From fa58b5bad7e4cbeb0cd73b53b955cf62e598eef5 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 13 May 2026 22:00:58 +0100 Subject: [PATCH 1/9] fix(deps): catalog-pin zod so trusted plugins typecheck Astro bundles its own Zod and re-exports it as 'astro/zod'. Trusted plugins like @emdash-cms/plugin-forms import their route schemas via 'astro/zod', then pass those schemas to definePlugin() in core. With emdash's 'zod: ^4.3.5' resolving independently of Astro's caret, pnpm kept two Zod 4 patches in the tree (e.g. 4.3.6 alongside 4.4.1). Zod 4 embeds its semver in the type system, so two patches of Zod 4 are not assignable to each other. The forms plugin's route schemas (ZodObject<..., $strip>) were rejected by PluginRoute['input'] (ZodType>) with 'Type "3" is not assignable to type "4"' on the internal version field. The native definePlugin overload silently failed, TS fell through to the StandardPluginDefinition overload, and reported a misleading 'id does not exist' error -- masking 8 cascading errors. Catalog-pinning Zod forces a single workspace-wide instance and restores normal overload resolution. No code changes needed in core or plugins/forms. Also adds a pnpm-workspace.yaml comment explaining the gotcha so the next person doesn't bump emdash's pin past Astro's range. --- .changeset/sharp-knives-invite.md | 6 +++++ packages/auth/package.json | 2 +- packages/core/package.json | 2 +- pnpm-lock.yaml | 44 +++++++++++++------------------ pnpm-workspace.yaml | 6 +++++ 5 files changed, 33 insertions(+), 27 deletions(-) create mode 100644 .changeset/sharp-knives-invite.md diff --git a/.changeset/sharp-knives-invite.md b/.changeset/sharp-knives-invite.md new file mode 100644 index 000000000..35da7a9b3 --- /dev/null +++ b/.changeset/sharp-knives-invite.md @@ -0,0 +1,6 @@ +--- +"emdash": patch +"@emdash-cms/auth": patch +--- + +Fixes a Zod type-incompatibility between trusted plugins and core. Without a workspace-level pin, emdash's `zod: ^4.3.5` could resolve to a different patch than Astro's bundled Zod, and Zod 4 embeds the version in the type — so schemas imported via `astro/zod` in trusted plugins (e.g. `@emdash-cms/plugin-forms`) were not assignable to `definePlugin`'s `PluginRoute['input']`. Pins Zod in the pnpm catalog so the entire workspace dedupes on one instance. diff --git a/packages/auth/package.json b/packages/auth/package.json index 9bc7e7d31..9eb14455b 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -42,7 +42,7 @@ "@oslojs/encoding": "catalog:", "@oslojs/webauthn": "catalog:", "ulidx": "^2.4.1", - "zod": "^4.3.5" + "zod": "catalog:" }, "peerDependencies": { "astro": ">=6.0.0-beta.0", diff --git a/packages/core/package.json b/packages/core/package.json index a8b5f8f2c..9ce8bd7b5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -207,7 +207,7 @@ "sax": "^1.4.1", "ulidx": "^2.4.1", "upng-js": "^2.1.0", - "zod": "^4.3.5" + "zod": "catalog:" }, "optionalDependencies": { "@libsql/kysely-libsql": "^0.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e65fe8c3..eeda756d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -231,6 +231,9 @@ catalogs: wrangler: specifier: ^4.83.0 version: 4.90.0 + zod: + specifier: ^4.3.6 + version: 4.4.1 importers: @@ -1080,8 +1083,8 @@ importers: specifier: ^2.4.1 version: 2.4.1 zod: - specifier: ^4.3.5 - version: 4.3.6 + specifier: 'catalog:' + version: 4.4.1 devDependencies: '@arethetypeswrong/cli': specifier: 'catalog:' @@ -1330,7 +1333,7 @@ importers: version: 0.27.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@modelcontextprotocol/sdk': specifier: ^1.26.0 - version: 1.26.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) + version: 1.26.0(@cfworker/json-schema@4.1.1)(zod@4.4.1) '@oslojs/crypto': specifier: 'catalog:' version: 1.0.1 @@ -1440,8 +1443,8 @@ importers: specifier: ^2.1.0 version: 2.1.0 zod: - specifier: ^4.3.5 - version: 4.3.6 + specifier: 'catalog:' + version: 4.4.1 devDependencies: '@apidevtools/swagger-parser': specifier: ^12.1.0 @@ -1487,7 +1490,7 @@ importers: version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) zod-openapi: specifier: ^5.4.6 - version: 5.4.6(zod@4.3.6) + version: 5.4.6(zod@4.4.1) optionalDependencies: '@libsql/kysely-libsql': specifier: ^0.4.0 @@ -10391,9 +10394,6 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - zod@4.4.1: resolution: {integrity: sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==} @@ -12625,7 +12625,7 @@ snapshots: dependencies: moo: 0.5.3 - '@modelcontextprotocol/sdk@1.26.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)': + '@modelcontextprotocol/sdk@1.26.0(@cfworker/json-schema@4.1.1)(zod@4.4.1)': dependencies: '@hono/node-server': 1.19.9(hono@4.12.4) ajv: 8.17.1 @@ -12642,8 +12642,8 @@ snapshots: json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.3.6 - zod-to-json-schema: 3.25.1(zod@4.3.6) + zod: 4.4.1 + zod-to-json-schema: 3.25.1(zod@4.4.1) optionalDependencies: '@cfworker/json-schema': 4.1.1 transitivePeerDependencies: @@ -15093,7 +15093,7 @@ snapshots: vitefu: 1.1.2(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 - zod: 4.3.6 + zod: 4.4.1 optionalDependencies: sharp: 0.34.5 transitivePeerDependencies: @@ -15187,7 +15187,7 @@ snapshots: vitefu: 1.1.2(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 - zod: 4.3.6 + zod: 4.4.1 optionalDependencies: sharp: 0.34.5 transitivePeerDependencies: @@ -15281,7 +15281,7 @@ snapshots: vitefu: 1.1.2(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 - zod: 4.3.6 + zod: 4.4.1 optionalDependencies: sharp: 0.34.5 transitivePeerDependencies: @@ -15468,7 +15468,7 @@ snapshots: vitefu: 1.1.2(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 - zod: 4.3.6 + zod: 4.4.1 optionalDependencies: sharp: 0.34.5 transitivePeerDependencies: @@ -16885,7 +16885,7 @@ snapshots: smol-toml: 1.6.0 strip-json-comments: 5.0.3 typescript: 6.0.0-beta - zod: 4.3.6 + zod: 4.4.1 kysely-d1@0.4.0(kysely@0.27.6): dependencies: @@ -20003,13 +20003,9 @@ snapshots: cookie: 1.1.1 youch-core: 0.3.3 - zod-openapi@5.4.6(zod@4.3.6): + zod-openapi@5.4.6(zod@4.4.1): dependencies: - zod: 4.3.6 - - zod-to-json-schema@3.25.1(zod@4.3.6): - dependencies: - zod: 4.3.6 + zod: 4.4.1 zod-to-json-schema@3.25.1(zod@4.4.1): dependencies: @@ -20017,8 +20013,6 @@ snapshots: zod@3.25.76: {} - zod@4.3.6: {} - zod@4.4.1: {} zrender@6.0.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c602d93df..470e011d2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -91,3 +91,9 @@ catalog: vite: ^8.0.11 vitest: ^4.1.5 wrangler: ^4.83.0 + # Catalog-pin Zod so the whole workspace dedupes on a single instance. + # Zod 4 embeds the version in the type, so even ^4.3.6 vs ^4.4.1 produce + # structurally incompatible ZodType across packages that mix astro/zod + # and emdash's zod (e.g. trusted plugins like @emdash-cms/plugin-forms + # importing 'z' from astro/zod and passing schemas to definePlugin). + zod: ^4.3.6 From eec3eb12f4ae363e7bfccb587dcbc29467ed556d Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 13 May 2026 11:09:38 +0100 Subject: [PATCH 2/9] feat(registry): experimental decentralized plugin registry Adds opt-in support for installing sandboxed plugins from the decentralized plugin registry described in RFC #694. Enabled via `experimental.registry.aggregatorUrl` in the EmDash integration options; when set, the admin UI replaces marketplace browse/install with the registry path. Server: new install handler (RFC verification chain), endpoint at POST /_emdash/api/admin/plugins/registry/install, migration 038 adds `source = 'registry'` plus `registry_publisher_did` / `registry_slug` columns on `_plugin_state`, runtime sync split into shared marketplace + registry tiers via a normalized opaque `r_` plugin id. Browser: aggregator XRPC calls go direct from the admin UI via @emdash-cms/registry-client. Install POST runs through the server. Includes a minimum-release-age policy with a per-publisher exclude allowlist, enforced both client-side (UX) and server-side (gate). Hardening (5 rounds of adversarial review): bundle id rewritten to the derived pluginId before storage, aggregator identity cross-checked, artifact and aggregator URLs validated for SSRF (https-only in prod, IPv6 brackets handled), per-request and total budgets on every outbound call, decompressed bundle capped at 256 KiB to match the RFC publish-time limit, migration 038 idempotent on both SQLite and Postgres. Known gaps tracked for follow-up: full MST signature verification against the publisher's PDS, multibase multihash decoding (hex SHA-256 is accepted today), registry plugin update + uninstall handlers. --- .changeset/real-plants-sell.md | 8 + packages/admin/package.json | 3 + .../admin/src/components/RegistryBrowse.tsx | 196 ++++ .../src/components/RegistryPluginDetail.tsx | 279 ++++++ packages/admin/src/lib/api/client.ts | 14 + packages/admin/src/lib/api/plugins.ts | 8 +- packages/admin/src/lib/api/registry.ts | 308 ++++++ packages/admin/src/router.tsx | 39 + packages/core/package.json | 4 +- packages/core/src/api/handlers/index.ts | 7 + packages/core/src/api/handlers/marketplace.ts | 33 +- packages/core/src/api/handlers/plugins.ts | 108 +- packages/core/src/api/handlers/registry.ts | 937 ++++++++++++++++++ packages/core/src/astro/integration/routes.ts | 6 + .../core/src/astro/integration/runtime.ts | 153 +++ packages/core/src/astro/middleware.ts | 3 + .../routes/api/admin/plugins/[id]/enable.ts | 10 + .../api/admin/plugins/registry/install.ts | 94 ++ packages/core/src/astro/types.ts | 38 + .../migrations/038_registry_plugin_state.ts | 123 +++ .../core/src/database/migrations/runner.ts | 2 + packages/core/src/database/types.ts | 6 +- packages/core/src/emdash-runtime.ts | 140 ++- packages/core/src/plugins/marketplace.ts | 67 +- packages/core/src/plugins/state.ts | 122 ++- packages/core/src/registry/config.ts | 245 +++++ packages/core/src/registry/plugin-id.ts | 112 +++ .../integration/database/migrations.test.ts | 1 + pnpm-lock.yaml | 123 ++- pnpm-workspace.yaml | 6 +- 30 files changed, 3078 insertions(+), 117 deletions(-) create mode 100644 .changeset/real-plants-sell.md create mode 100644 packages/admin/src/components/RegistryBrowse.tsx create mode 100644 packages/admin/src/components/RegistryPluginDetail.tsx create mode 100644 packages/admin/src/lib/api/registry.ts create mode 100644 packages/core/src/api/handlers/registry.ts create mode 100644 packages/core/src/astro/routes/api/admin/plugins/registry/install.ts create mode 100644 packages/core/src/database/migrations/038_registry_plugin_state.ts create mode 100644 packages/core/src/registry/config.ts create mode 100644 packages/core/src/registry/plugin-id.ts diff --git a/.changeset/real-plants-sell.md b/.changeset/real-plants-sell.md new file mode 100644 index 000000000..b7faf09c4 --- /dev/null +++ b/.changeset/real-plants-sell.md @@ -0,0 +1,8 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +--- + +Adds experimental support for the decentralized plugin registry (see RFC #694). Configure with `experimental.registry.aggregatorUrl` in `astro.config.mjs`; the admin UI then uses the registry instead of the centralized marketplace for browse and install. Marketplace behavior is unchanged when the option is not set. + +The experimental config accepts a `policy.minimumReleaseAge` duration (e.g. `"48h"`) that holds back releases below that age from install and update prompts, with a `policy.minimumReleaseAgeExclude` allowlist for trusted publishers or specific packages. The minimum-release-age check is enforced both client-side (for UX) and server-side (in the install endpoint), so stale browser tabs and deep links still hit the gate. diff --git a/packages/admin/package.json b/packages/admin/package.json index 28be79248..f6a4022b1 100644 --- a/packages/admin/package.json +++ b/packages/admin/package.json @@ -31,11 +31,14 @@ "locale:extract": "lingui extract --clean" }, "dependencies": { + "@atcute/lexicons": "catalog:", "@cloudflare/kumo": "^1.16.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@emdash-cms/blocks": "workspace:*", + "@emdash-cms/registry-client": "workspace:*", + "@emdash-cms/registry-lexicons": "workspace:*", "@floating-ui/react": "^0.27.16", "@lingui/core": "catalog:", "@lingui/react": "catalog:", diff --git a/packages/admin/src/components/RegistryBrowse.tsx b/packages/admin/src/components/RegistryBrowse.tsx new file mode 100644 index 000000000..bd5377a6b --- /dev/null +++ b/packages/admin/src/components/RegistryBrowse.tsx @@ -0,0 +1,196 @@ +/** + * Registry Browse + * + * Grid of plugin cards backed by the experimental decentralized plugin + * registry's aggregator. Search box debounces directly into the + * aggregator's `searchPackages` XRPC -- the aggregator is a public, + * read-only service, so no server proxy is involved. + * + * Cards navigate to `/plugins/marketplace/$pluginId` (the same path the + * marketplace browse uses); the router branches to the registry detail + * component when `manifest.registry` is configured. + */ + +import { Badge, Input } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import { MagnifyingGlass, PuzzlePiece, ShieldCheck } from "@phosphor-icons/react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import * as React from "react"; + +import { + searchRegistryPackages, + type RegistryClientConfig, + type RegistryPackageView, +} from "../lib/api/registry.js"; + +export interface RegistryBrowseProps { + /** Resolved manifest.registry block. Required -- caller checks. */ + config: RegistryClientConfig; + /** + * Plugin IDs already installed on this site (derived hashes for + * registry installs, see `makeRegistryPluginId`). The UI uses this + * only to show an "Installed" badge on browse cards; install gating + * happens server-side. + */ + installedRegistryUris?: Set; +} + +export function RegistryBrowse({ config, installedRegistryUris = new Set() }: RegistryBrowseProps) { + const { t } = useLingui(); + const [searchQuery, setSearchQuery] = React.useState(""); + const [debouncedQuery, setDebouncedQuery] = React.useState(""); + + // Debounce search input + React.useEffect(() => { + const timer = setTimeout(setDebouncedQuery, 300, searchQuery); + return () => clearTimeout(timer); + }, [searchQuery]); + + const { data, isLoading, error, fetchNextPage, hasNextPage, isFetchingNextPage } = + useInfiniteQuery({ + queryKey: ["registry", "search", config.aggregatorUrl, debouncedQuery], + queryFn: ({ pageParam }) => + searchRegistryPackages(config, { + q: debouncedQuery || undefined, + cursor: pageParam, + limit: 20, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.cursor, + }); + + const packages = data?.pages.flatMap((p) => p.packages); + + return ( +
+ {/* Header */} +
+

{t`Plugin Registry`}

+

{t`Browse and install plugins published to the decentralized registry.`}

+
+ + {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + className="ps-9" + aria-label={t`Search plugins`} + /> +
+
+ + {/* Error */} + {error ? ( +
+ {t`Failed to load plugins. The registry aggregator may be unreachable.`} +
+ ) : null} + + {/* Loading skeleton */} + {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ) : null} + + {/* Empty */} + {packages && packages.length === 0 ? ( +
+ {debouncedQuery + ? t`No plugins match "${debouncedQuery}".` + : t`No plugins have been published to this registry yet.`} +
+ ) : null} + + {/* Grid */} + {packages && packages.length > 0 ? ( +
+ {packages.map((pkg) => ( + + ))} +
+ ) : null} + + {/* Load more */} + {hasNextPage ? ( +
+ +
+ ) : null} +
+ ); +} + +interface RegistryPackageCardProps { + pkg: RegistryPackageView; + installed: boolean; +} + +function RegistryPackageCard({ pkg, installed }: RegistryPackageCardProps) { + const { t } = useLingui(); + const handle = pkg.handle ?? pkg.did; + // `profile` is a pass-through of the signed package profile record. + // We duck-type minimal display fields out of it. + const profile = pkg.profile as { name?: string; description?: string }; + const verified = (pkg.labels ?? []).some((l: { val?: string }) => l.val === "verified"); + + return ( + +
+
+ +
+
+
+

{profile.name ?? pkg.slug}

+ {verified ? ( + + ) : null} +
+

{handle}

+ {profile.description ? ( +

{profile.description}

+ ) : null} + {installed ? ( +
+ {t`Installed`} +
+ ) : null} +
+
+ + ); +} diff --git a/packages/admin/src/components/RegistryPluginDetail.tsx b/packages/admin/src/components/RegistryPluginDetail.tsx new file mode 100644 index 000000000..518fcf4fc --- /dev/null +++ b/packages/admin/src/components/RegistryPluginDetail.tsx @@ -0,0 +1,279 @@ +/** + * Registry Plugin Detail + * + * Detail view for a plugin from the experimental decentralized plugin + * registry. Resolves `(handle, slug)` directly against the configured + * aggregator; install routes through the EmDash server's + * `/_emdash/api/admin/plugins/registry/install` endpoint, which + * re-resolves and re-verifies before writing the install. + * + * Identified in the URL by a `pluginId` that is `${handle}/${slug}`. + * The router wraps this component when `manifest.registry` is set on + * the same route the marketplace detail uses, so existing bookmarks / + * sidebar entries stay stable. + */ + +import { Badge, Button } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import { ShieldCheck, Warning } from "@phosphor-icons/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import * as React from "react"; + +import { + getLatestRegistryRelease, + installRegistryPlugin, + releasePassesPolicy, + resolveRegistryPackage, + type RegistryClientConfig, +} from "../lib/api/registry.js"; +import { ArrowPrev } from "./ArrowIcons.js"; +import { CapabilityConsentDialog } from "./CapabilityConsentDialog.js"; +import { getMutationError } from "./DialogError.js"; + +export interface RegistryPluginDetailProps { + /** `${handle}/${slug}` -- the pluginId param from the route. */ + pluginId: string; + /** Resolved manifest.registry block. Caller is responsible for the null check. */ + config: RegistryClientConfig; +} + +export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailProps) { + const { t } = useLingui(); + const queryClient = useQueryClient(); + const [showConsent, setShowConsent] = React.useState(false); + + // Parse `handle/slug` out of the route param. Slugs themselves are + // `[A-Za-z][A-Za-z0-9_-]*` (no slashes), so the first `/` is the split. + const slashIdx = pluginId.indexOf("/"); + const handle = slashIdx > 0 ? pluginId.slice(0, slashIdx) : ""; + const slug = slashIdx > 0 ? pluginId.slice(slashIdx + 1) : ""; + + const { data: pkg, isLoading: isLoadingPkg } = useQuery({ + queryKey: ["registry", "package", config.aggregatorUrl, handle, slug], + queryFn: () => resolveRegistryPackage(config, handle, slug), + enabled: Boolean(handle && slug), + }); + + const { data: release } = useQuery({ + queryKey: ["registry", "latest-release", config.aggregatorUrl, pkg?.did, slug], + queryFn: () => getLatestRegistryRelease(config, pkg!.did, slug), + enabled: Boolean(pkg?.did && slug), + }); + + // `release.extensions[com.emdashcms.experimental.package.releaseExtension]` + // carries the structured `declaredAccess`. The EmDash bundle manifest + // uses the legacy `capabilities: string[]` shape that the sandbox + // enforces today, so we lift that from the release's extension when + // available and fall back to the structured declaredAccess flattened + // to a string list otherwise. This keeps `CapabilityConsentDialog` -- + // which only understands `capabilities` -- working unchanged. + const releaseDoc = release?.release as + | { + extensions?: Record; + } + | undefined; + const extensionEntries = releaseDoc?.extensions ? Object.entries(releaseDoc.extensions) : []; + const ext = extensionEntries.find(([k]) => + k.startsWith("com.emdashcms.experimental.package.releaseExtension"), + )?.[1]; + + const capabilities: string[] = Array.isArray(ext?.capabilities) + ? (ext?.capabilities as string[]) + : declaredAccessToCapabilityList(ext?.declaredAccess); + + const profile = pkg?.profile as { name?: string; description?: string } | undefined; + const verified = (pkg?.labels ?? []).some((l: { val?: string }) => l.val === "verified"); + + const policyOk = + release && pkg ? releasePassesPolicy(release, { did: pkg.did, slug }, config.policy) : true; + + const installMutation = useMutation({ + mutationFn: () => + installRegistryPlugin({ + handle, + slug, + version: release?.version, + acknowledgedDeclaredAccess: capabilities, + }), + onSuccess: () => { + setShowConsent(false); + void queryClient.invalidateQueries({ queryKey: ["plugins"] }); + void queryClient.invalidateQueries({ queryKey: ["manifest"] }); + void queryClient.invalidateQueries({ queryKey: ["registry"] }); + }, + }); + + if (isLoadingPkg) { + return ( +
+ +
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + if (!pkg) { + return ( +
+ +
+ {t`Plugin not found. The publisher handle or slug may be incorrect.`} +
+
+ ); + } + + return ( +
+ + + {/* Header */} +
+
+ +
+
+
+

{profile?.name ?? slug}

+ {verified ? ( + + ) : null} +
+

+ {t`Published by`} {pkg.handle ?? pkg.did} +

+ {release ? ( +

+ {t`Version ${release.version}`} · {t`indexed ${formatDate(release.indexedAt)}`} +

+ ) : null} +
+
+ +
+
+ + {/* Policy holdback notice */} + {release && !policyOk ? ( +
+ +
+

{t`Release is too new to install`}

+

+ {t`Your site requires releases to be at least ${formatHoldback(config.policy?.minimumReleaseAgeSeconds ?? 0)} old before they can be installed. This release will become installable later.`} +

+
+
+ ) : null} + + {/* Description */} + {profile?.description ? ( +

{profile.description}

+ ) : null} + + {/* Capabilities preview */} + {capabilities.length > 0 ? ( +
+

{t`Declared permissions`}

+
+ {capabilities.map((c) => ( + {c} + ))} +
+
+ ) : null} + + {/* Consent dialog */} + {showConsent && release ? ( + installMutation.mutate()} + onCancel={() => { + setShowConsent(false); + installMutation.reset(); + }} + /> + ) : null} +
+ ); +} + +function BackLink() { + const { t } = useLingui(); + return ( + + + {t`Back to plugins`} + + ); +} + +/** + * Flatten an RFC-0001 `declaredAccess` block (`{ content: { read: true }, + * email: { send: { allowedHosts: [...] } }, ... }`) into the legacy + * `capabilities: string[]` shape that the existing sandbox runtime + * enforces today. One entry per declared operation under each + * category. Unknown values are skipped silently -- the consent dialog + * shows only what the current runtime recognises. + */ +function declaredAccessToCapabilityList(declaredAccess: unknown): string[] { + if (!declaredAccess || typeof declaredAccess !== "object") return []; + const out: string[] = []; + for (const [category, value] of Object.entries(declaredAccess as Record)) { + if (!value || typeof value !== "object") continue; + for (const [operation, opValue] of Object.entries(value as Record)) { + // Skip operations explicitly opted out (`false`). + if (opValue === false) continue; + out.push(`${category}:${operation}`); + } + } + return out; +} + +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleDateString(); + } catch { + return iso; + } +} + +function formatHoldback(seconds: number): string { + if (seconds <= 0) return "0s"; + if (seconds < 60 * 60) return `${Math.round(seconds / 60)} min`; + if (seconds < 24 * 60 * 60) return `${Math.round(seconds / 60 / 60)} h`; + return `${Math.round(seconds / 60 / 60 / 24)} d`; +} diff --git a/packages/admin/src/lib/api/client.ts b/packages/admin/src/lib/api/client.ts index 151ddc5a7..a0eeca347 100644 --- a/packages/admin/src/lib/api/client.ts +++ b/packages/admin/src/lib/api/client.ts @@ -155,6 +155,20 @@ export interface AdminManifest { * in the EmDash integration. Enables marketplace features in the UI. */ marketplace?: string; + /** + * Experimental decentralized plugin registry. Present when + * `experimental.registry` is configured in the EmDash integration. + * When present, the admin UI uses the registry instead of the + * centralized marketplace for browse and install. + */ + registry?: { + aggregatorUrl: string; + acceptLabelers?: string; + policy?: { + minimumReleaseAgeSeconds?: number; + minimumReleaseAgeExclude?: string[]; + }; + }; /** * Admin branding overrides for white-labeling. * Set via the `admin` config in `astro.config.mjs`. diff --git a/packages/admin/src/lib/api/plugins.ts b/packages/admin/src/lib/api/plugins.ts index 6cbef65d8..ca632d5bf 100644 --- a/packages/admin/src/lib/api/plugins.ts +++ b/packages/admin/src/lib/api/plugins.ts @@ -21,10 +21,14 @@ export interface PluginInfo { installedAt?: string; activatedAt?: string; deactivatedAt?: string; - /** Plugin source: 'config' (declared in astro.config) or 'marketplace' */ - source?: "config" | "marketplace"; + /** Plugin source: 'config' (declared in astro.config), 'marketplace', or 'registry' */ + source?: "config" | "marketplace" | "registry"; /** Installed marketplace version (set when source = 'marketplace') */ marketplaceVersion?: string; + /** Publisher DID, for registry-source plugins. */ + registryPublisherDid?: string; + /** Publisher slug, for registry-source plugins. */ + registrySlug?: string; /** Description of what the plugin does */ description?: string; /** URL to the plugin icon (marketplace plugins use the icon proxy) */ diff --git a/packages/admin/src/lib/api/registry.ts b/packages/admin/src/lib/api/registry.ts new file mode 100644 index 000000000..649987ec9 --- /dev/null +++ b/packages/admin/src/lib/api/registry.ts @@ -0,0 +1,308 @@ +/** + * Registry API client + * + * The admin UI talks to two distinct services for registry features: + * + * - **Browse / search / detail**: directly to the configured aggregator + * via `@emdash-cms/registry-client`'s `DiscoveryClient`. The + * aggregator is a public, CORS-enabled atproto AppView; no server + * proxy is needed. + * - **Install**: POST to the EmDash server (which holds the sandbox, + * R2, and `_plugin_state` table). The server re-resolves the same + * `(handle, slug)` against the aggregator, re-verifies the bundle, + * and writes the install. The browser is the consent UI; the server + * is the install actor. + * + * The discovery client is constructed lazily so we only pull + * `@atcute/client` into the admin bundle when the registry path is + * actually exercised. Sites with no `experimental.registry` config never + * pay the cost (verified at ~2 KB gzip when it does load). + */ + +import type { Did, Handle } from "@atcute/lexicons"; +import { i18n } from "@lingui/core"; +import { msg } from "@lingui/core/macro"; + +import { API_BASE, apiFetch, throwResponseError } from "./client.js"; + +export type { Did, Handle }; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Registry configuration carried on the EmDash manifest. The browser + * reads this on app boot and passes the relevant fields into the + * DiscoveryClient and the latest-release policy filter. + */ +export interface RegistryClientConfig { + aggregatorUrl: string; + acceptLabelers?: string; + policy?: { + minimumReleaseAgeSeconds?: number; + minimumReleaseAgeExclude?: string[]; + }; +} + +/** + * Lightweight aliases for the lexicon-generated types. The hooks return + * the raw XRPC output -- callers narrow `profile` / `release` as needed + * (they're typed as `unknown` by the lexicon because the signed records + * are pass-through). + */ +export interface RegistryPackageView { + uri: string; + cid: string; + did: string; + handle?: string; + slug: string; + indexedAt: string; + latestVersion?: string; + profile: unknown; + labels?: Array<{ val: string; src?: string; uri?: string }>; +} + +export interface RegistryReleaseView { + uri: string; + cid: string; + did: string; + package: string; + version: string; + indexedAt: string; + mirrors?: string[]; + release: unknown; + labels?: Array<{ val: string; src?: string; uri?: string }>; +} + +export interface RegistrySearchResult { + packages: RegistryPackageView[]; + cursor?: string; +} + +export interface RegistrySearchOpts { + q?: string; + cursor?: string; + limit?: number; +} + +export interface RegistryInstallRequest { + handle: string; + slug: string; + version?: string; + acknowledgedDeclaredAccess?: unknown; +} + +export interface RegistryInstallResult { + pluginId: string; + publisherDid: string; + slug: string; + version: string; + capabilities: string[]; +} + +// --------------------------------------------------------------------------- +// Discovery client (lazy) +// --------------------------------------------------------------------------- + +interface WrappedDiscoveryClient { + searchPackages: (opts: RegistrySearchOpts) => Promise; + resolvePackage: (handle: string, slug: string) => Promise; + getLatestRelease: (did: string, slug: string) => Promise; + listReleases: ( + did: string, + slug: string, + cursor?: string, + ) => Promise<{ releases: RegistryReleaseView[]; cursor?: string }>; +} + +let cachedDiscovery: { + config: RegistryClientConfig; + client: WrappedDiscoveryClient; +} | null = null; + +async function getDiscoveryClient(config: RegistryClientConfig): Promise { + if ( + cachedDiscovery && + cachedDiscovery.config.aggregatorUrl === config.aggregatorUrl && + cachedDiscovery.config.acceptLabelers === config.acceptLabelers + ) { + return cachedDiscovery.client; + } + + const mod = await import("@emdash-cms/registry-client/discovery"); + const DiscoveryClient = mod.DiscoveryClient; + const discovery = new DiscoveryClient({ + aggregatorUrl: config.aggregatorUrl, + acceptLabelers: config.acceptLabelers, + }); + + const wrapped: WrappedDiscoveryClient = { + async searchPackages(opts: RegistrySearchOpts) { + const result = await discovery.searchPackages({ + q: opts.q, + cursor: opts.cursor, + limit: opts.limit, + }); + return result as RegistrySearchResult; + }, + async resolvePackage(handle: string, slug: string) { + const result = await discovery.resolvePackage({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- shape validated by aggregator + handle: handle as Handle, + slug, + }); + return result as RegistryPackageView; + }, + async getLatestRelease(did: string, slug: string) { + const result = await discovery.getLatestRelease({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- did shape validated by aggregator + did: did as Did, + package: slug, + }); + return result as RegistryReleaseView; + }, + async listReleases(did: string, slug: string, cursor?: string) { + const result = await discovery.listReleases({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- did shape validated by aggregator + did: did as Did, + package: slug, + cursor, + }); + return result as { releases: RegistryReleaseView[]; cursor?: string }; + }, + }; + + cachedDiscovery = { config, client: wrapped }; + return wrapped; +} + +// --------------------------------------------------------------------------- +// Latest-release policy filter +// --------------------------------------------------------------------------- + +/** + * Returns whether a release should be considered installable given the + * configured policy. Currently implements the minimum-release-age check + * described in RFC 0001's "Pre-label gap and launch tempo" section, + * plus the `minimumReleaseAgeExclude` allowlist. + * + * Returns `false` (release blocked) when the policy is configured but + * the release is missing a valid `indexedAt` -- we fail closed rather + * than silently letting unbounded-age releases through. + */ +export function releasePassesPolicy( + release: RegistryReleaseView, + pkg: { did: string; slug: string }, + policy: RegistryClientConfig["policy"], + now: number = Date.now(), +): boolean { + if (!policy?.minimumReleaseAgeSeconds) return true; + if (releaseExemptFromMinimumAge(policy.minimumReleaseAgeExclude, pkg.did, pkg.slug)) { + return true; + } + const indexedAt = Date.parse(release.indexedAt); + if (!Number.isFinite(indexedAt)) return false; + const ageSeconds = (now - indexedAt) / 1000; + return ageSeconds >= policy.minimumReleaseAgeSeconds; +} + +/** + * Matches a `(publisher_did, slug)` against the + * `minimumReleaseAgeExclude` allowlist. Mirrors the server-side helper + * of the same name in `packages/core/src/registry/config.ts`. + * + * DID-only on purpose: handles are aggregator-supplied envelope data + * and accepting them as a trust input would let a compromised + * aggregator bypass the holdback by claiming any handle for any + * package. DIDs are tied to the AT URI of the record itself. + * + * Entries from the config list have already been lowercased at + * manifest build time, so this only needs to lowercase the runtime + * values for comparison. + */ +export function releaseExemptFromMinimumAge( + exclude: readonly string[] | undefined, + publisherDid: string, + slug: string, +): boolean { + if (!exclude || exclude.length === 0) return false; + const didLower = publisherDid.toLowerCase(); + const slugLower = slug.toLowerCase(); + const fullDid = `${didLower}/${slugLower}`; + + for (const entry of exclude) { + if (entry === didLower) return true; + if (entry === fullDid) return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// Public discovery hooks (callable by React Query) +// --------------------------------------------------------------------------- + +export async function searchRegistryPackages( + config: RegistryClientConfig, + opts: RegistrySearchOpts, +): Promise { + const client = await getDiscoveryClient(config); + return client.searchPackages(opts); +} + +export async function resolveRegistryPackage( + config: RegistryClientConfig, + handle: string, + slug: string, +): Promise { + const client = await getDiscoveryClient(config); + return client.resolvePackage(handle, slug); +} + +export async function getLatestRegistryRelease( + config: RegistryClientConfig, + did: string, + slug: string, +): Promise { + const client = await getDiscoveryClient(config); + return client.getLatestRelease(did, slug); +} + +export async function listRegistryReleases( + config: RegistryClientConfig, + did: string, + slug: string, + cursor?: string, +): Promise<{ releases: RegistryReleaseView[]; cursor?: string }> { + const client = await getDiscoveryClient(config); + return client.listReleases(did, slug, cursor); +} + +// --------------------------------------------------------------------------- +// Install (server POST) +// --------------------------------------------------------------------------- + +const INSTALL_ENDPOINT = `${API_BASE}/admin/plugins/registry/install`; + +/** + * Install a plugin from the registry. + * + * Posts to the EmDash server, which re-resolves the same `(handle, + * slug)` against the aggregator, re-verifies the bundle's checksum + * against the signed release record, and writes the install. Surfaces + * structured error codes (`RELEASE_YANKED`, `CHECKSUM_MISMATCH`, + * `DECLARED_ACCESS_DRIFT`, etc.) that callers map to localized + * messages. + */ +export async function installRegistryPlugin( + body: RegistryInstallRequest, +): Promise { + const response = await apiFetch(INSTALL_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) await throwResponseError(response, i18n._(msg`Failed to install plugin`)); + const json = (await response.json()) as { data: RegistryInstallResult }; + return json.data; +} diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 80782968a..ad1409497 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -37,6 +37,8 @@ import { MenuEditor } from "./components/MenuEditor"; import { MenuList } from "./components/MenuList"; import { PluginManager } from "./components/PluginManager"; import { Redirects } from "./components/Redirects"; +import { RegistryBrowse } from "./components/RegistryBrowse"; +import { RegistryPluginDetail } from "./components/RegistryPluginDetail"; import { SandboxedPluginPage } from "./components/SandboxedPluginPage"; import { SectionEditor } from "./components/SectionEditor"; import { Sections } from "./components/Sections"; @@ -1265,6 +1267,11 @@ const marketplaceBrowseRoute = createRoute({ }); function MarketplaceBrowsePage() { + const { data: manifest } = useQuery({ + queryKey: ["manifest"], + queryFn: fetchManifest, + }); + const { data: plugins } = useQuery({ queryKey: ["plugins"], queryFn: async () => { @@ -1278,6 +1285,26 @@ function MarketplaceBrowsePage() { return new Set(plugins.map((p) => p.id)); }, [plugins]); + // When `experimental.registry` is configured, the registry browse + // replaces the centralized marketplace browse on this route. Existing + // sidebar / deep links stay valid; users see the registry without any + // path change. + if (manifest?.registry) { + // Map installed registry plugins to their AT URIs for the + // "Installed" badge on browse cards. + const installedRegistryUris = new Set( + (plugins ?? []) + .filter((p) => p.source === "registry" && p.registryPublisherDid && p.registrySlug) + .map( + (p) => + `at://${p.registryPublisherDid}/com.emdashcms.experimental.package.profile/${p.registrySlug}`, + ), + ); + return ( + + ); + } + return ; } @@ -1291,6 +1318,11 @@ const marketplaceDetailRoute = createRoute({ function MarketplaceDetailPage() { const { pluginId } = useParams({ from: "/_admin/plugins/marketplace/$pluginId" }); + const { data: manifest } = useQuery({ + queryKey: ["manifest"], + queryFn: fetchManifest, + }); + const { data: plugins } = useQuery({ queryKey: ["plugins"], queryFn: async () => { @@ -1304,6 +1336,13 @@ function MarketplaceDetailPage() { return new Set(plugins.map((p) => p.id)); }, [plugins]); + // Registry detail when configured. The `pluginId` route param carries + // `${handle}/${slug}` in the registry case; the slash is encoded once + // by the router and decoded back here. + if (manifest?.registry) { + return ; + } + return ; } diff --git a/packages/core/package.json b/packages/core/package.json index 9ce8bd7b5..42cf2eed3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -168,10 +168,12 @@ "test:integration": "vitest run --config vitest.integration.config.ts" }, "dependencies": { + "@atcute/lexicons": "catalog:", "@emdash-cms/admin": "workspace:*", "@emdash-cms/auth": "workspace:*", "@emdash-cms/gutenberg-to-portable-text": "workspace:*", "@emdash-cms/plugin-types": "workspace:*", + "@emdash-cms/registry-client": "workspace:*", "@floating-ui/react": "^0.27.16", "@modelcontextprotocol/sdk": "^1.26.0", "@oslojs/crypto": "catalog:", @@ -230,8 +232,8 @@ "@arethetypeswrong/cli": "catalog:", "@emdash-cms/blocks": "workspace:*", "@types/better-sqlite3": "^7.6.12", - "@types/react": "catalog:", "@types/pg": "^8.16.0", + "@types/react": "catalog:", "@types/sanitize-html": "^2.16.0", "@types/sax": "^1.2.7", "@vitest/ui": "^4.1.5", diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts index 89e352830..f33e8e0cb 100644 --- a/packages/core/src/api/handlers/index.ts +++ b/packages/core/src/api/handlers/index.ts @@ -168,3 +168,10 @@ export { type MarketplaceUpdateCheck, type MarketplaceUninstallResult, } from "./marketplace.js"; + +// Registry handlers (experimental) +export { + handleRegistryInstall, + type RegistryInstallInput, + type RegistryInstallResult, +} from "./registry.js"; diff --git a/packages/core/src/api/handlers/marketplace.ts b/packages/core/src/api/handlers/marketplace.ts index e3fdd870c..9e1e856e5 100644 --- a/packages/core/src/api/handlers/marketplace.ts +++ b/packages/core/src/api/handlers/marketplace.ts @@ -193,15 +193,27 @@ function validateBundleIdentity( } /** Store a plugin bundle's files in site-local R2 storage */ -async function storeBundleInR2( +/** + * Storage source for an installed plugin bundle. Determines the R2 + * key prefix and is used to keep marketplace and registry installs + * cleanly separated in object listings. + */ +export type PluginBundleSource = "marketplace" | "registry"; + +function bundlePrefix(source: PluginBundleSource, pluginId: string, version: string): string { + return `${source}/${pluginId}/${version}`; +} + +export async function storeBundleInR2( storage: Storage, pluginId: string, version: string, bundle: PluginBundle, + source: PluginBundleSource = "marketplace", ): Promise { validatePluginIdentifier(pluginId, "plugin ID"); validateVersion(version); - const prefix = `marketplace/${pluginId}/${version}`; + const prefix = bundlePrefix(source, pluginId, version); // Store manifest await storage.upload({ @@ -232,15 +244,23 @@ async function streamToText(stream: ReadableStream): Promise return new Response(stream).text(); } -/** Load a plugin bundle from site-local R2 storage */ +/** + * Load a plugin bundle from site-local R2 storage. + * + * `source` selects the R2 key prefix: marketplace plugins are stored + * under `marketplace///`, registry plugins under + * `registry///`. Defaults to `"marketplace"` for + * backwards compatibility with pre-registry call sites. + */ export async function loadBundleFromR2( storage: Storage, pluginId: string, version: string, + source: PluginBundleSource = "marketplace", ): Promise<{ manifest: PluginManifest; backendCode: string; adminCode?: string } | null> { validatePluginIdentifier(pluginId, "plugin ID"); validateVersion(version); - const prefix = `marketplace/${pluginId}/${version}`; + const prefix = bundlePrefix(source, pluginId, version); try { const manifestResult = await storage.download(`${prefix}/manifest.json`); @@ -272,14 +292,15 @@ export async function loadBundleFromR2( } /** Delete a plugin bundle from site-local R2 storage */ -async function deleteBundleFromR2( +export async function deleteBundleFromR2( storage: Storage, pluginId: string, version: string, + source: PluginBundleSource = "marketplace", ): Promise { validatePluginIdentifier(pluginId, "plugin ID"); validateVersion(version); - const prefix = `marketplace/${pluginId}/${version}`; + const prefix = bundlePrefix(source, pluginId, version); const files = ["manifest.json", "backend.js", "admin.js"]; for (const file of files) { diff --git a/packages/core/src/api/handlers/plugins.ts b/packages/core/src/api/handlers/plugins.ts index be628fe15..c029404df 100644 --- a/packages/core/src/api/handlers/plugins.ts +++ b/packages/core/src/api/handlers/plugins.ts @@ -16,8 +16,12 @@ export interface PluginInfo { package?: string; enabled: boolean; status: PluginStatus; - source?: "config" | "marketplace"; + source?: "config" | "marketplace" | "registry"; marketplaceVersion?: string; + /** Publisher DID, for registry-source plugins */ + registryPublisherDid?: string; + /** Publisher slug, for registry-source plugins */ + registrySlug?: string; capabilities: string[]; hasAdminPages: boolean; hasDashboardWidgets: boolean; @@ -65,6 +69,8 @@ function buildPluginInfo( status, source: state?.source ?? "config", marketplaceVersion: state?.marketplaceVersion ?? undefined, + registryPublisherDid: state?.registryPublisherDid ?? undefined, + registrySlug: state?.registrySlug ?? undefined, capabilities: plugin.capabilities, hasAdminPages: (plugin.admin.pages?.length ?? 0) > 0, hasDashboardWidgets: (plugin.admin.widgets?.length ?? 0) > 0, @@ -98,9 +104,10 @@ export async function handlePluginList( return buildPluginInfo(plugin, state, marketplaceUrl); }); - // Include marketplace-installed plugins that aren't in the configured plugins list + // Include runtime-installed plugins (marketplace or registry) that + // aren't in the configured plugins list. for (const state of allStates) { - if (state.source !== "marketplace") continue; + if (state.source !== "marketplace" && state.source !== "registry") continue; if (configuredIds.has(state.pluginId)) continue; items.push({ @@ -109,8 +116,10 @@ export async function handlePluginList( version: state.marketplaceVersion ?? state.version, enabled: state.status === "active", status: state.status, - source: "marketplace", + source: state.source, marketplaceVersion: state.marketplaceVersion ?? undefined, + registryPublisherDid: state.registryPublisherDid ?? undefined, + registrySlug: state.registrySlug ?? undefined, capabilities: [], hasAdminPages: false, hasDashboardWidgets: false, @@ -119,7 +128,10 @@ export async function handlePluginList( activatedAt: state.activatedAt?.toISOString() ?? undefined, deactivatedAt: state.deactivatedAt?.toISOString() ?? undefined, description: state.description ?? undefined, - iconUrl: marketplaceUrl ? marketplaceIconUrl(marketplaceUrl, state.pluginId) : undefined, + iconUrl: + state.source === "marketplace" && marketplaceUrl + ? marketplaceIconUrl(marketplaceUrl, state.pluginId) + : undefined, }); } @@ -177,6 +189,38 @@ export async function handlePluginGet( } } +/** + * Build a minimal `PluginInfo` for a plugin that exists only as a + * `_plugin_state` row (marketplace or registry install), with no + * matching `configuredPlugins` entry. Runtime-installed plugins don't + * have ResolvedPlugin metadata until they're loaded into the sandbox, + * so the enable/disable response surfaces the state-row view as a + * stable shape the admin UI already understands. + */ +function buildStateOnlyPluginInfo( + state: NonNullable>>, +): PluginInfo { + return { + id: state.pluginId, + name: state.displayName || state.pluginId, + version: state.marketplaceVersion ?? state.version, + enabled: state.status === "active", + status: state.status, + source: state.source, + marketplaceVersion: state.marketplaceVersion ?? undefined, + registryPublisherDid: state.registryPublisherDid ?? undefined, + registrySlug: state.registrySlug ?? undefined, + capabilities: [], + hasAdminPages: false, + hasDashboardWidgets: false, + hasHooks: false, + installedAt: state.installedAt?.toISOString(), + activatedAt: state.activatedAt?.toISOString() ?? undefined, + deactivatedAt: state.deactivatedAt?.toISOString() ?? undefined, + description: state.description ?? undefined, + }; +} + /** * Enable a plugin */ @@ -186,24 +230,27 @@ export async function handlePluginEnable( pluginId: string, ): Promise> { try { + const stateRepo = new PluginStateRepository(db); const plugin = configuredPlugins.find((p) => p.id === pluginId); - if (!plugin) { + + // Configured plugin: use its version as the source of truth. + if (plugin) { + const state = await stateRepo.enable(pluginId, plugin.version); + return { success: true, data: { item: buildPluginInfo(plugin, state) } }; + } + + // Runtime-installed plugin (marketplace or registry): only + // addressable through the state row. Fall back to the existing + // version recorded there. + const existing = await stateRepo.get(pluginId); + if (!existing || (existing.source !== "marketplace" && existing.source !== "registry")) { return { success: false, - error: { - code: "NOT_FOUND", - message: `Plugin not found: ${pluginId}`, - }, + error: { code: "NOT_FOUND", message: `Plugin not found: ${pluginId}` }, }; } - - const stateRepo = new PluginStateRepository(db); - const state = await stateRepo.enable(pluginId, plugin.version); - - return { - success: true, - data: { item: buildPluginInfo(plugin, state) }, - }; + const enabled = await stateRepo.enable(pluginId, existing.version); + return { success: true, data: { item: buildStateOnlyPluginInfo(enabled) } }; } catch { return { success: false, @@ -224,24 +271,23 @@ export async function handlePluginDisable( pluginId: string, ): Promise> { try { + const stateRepo = new PluginStateRepository(db); const plugin = configuredPlugins.find((p) => p.id === pluginId); - if (!plugin) { + + if (plugin) { + const state = await stateRepo.disable(pluginId, plugin.version); + return { success: true, data: { item: buildPluginInfo(plugin, state) } }; + } + + const existing = await stateRepo.get(pluginId); + if (!existing || (existing.source !== "marketplace" && existing.source !== "registry")) { return { success: false, - error: { - code: "NOT_FOUND", - message: `Plugin not found: ${pluginId}`, - }, + error: { code: "NOT_FOUND", message: `Plugin not found: ${pluginId}` }, }; } - - const stateRepo = new PluginStateRepository(db); - const state = await stateRepo.disable(pluginId, plugin.version); - - return { - success: true, - data: { item: buildPluginInfo(plugin, state) }, - }; + const disabled = await stateRepo.disable(pluginId, existing.version); + return { success: true, data: { item: buildStateOnlyPluginInfo(disabled) } }; } catch { return { success: false, diff --git a/packages/core/src/api/handlers/registry.ts b/packages/core/src/api/handlers/registry.ts new file mode 100644 index 000000000..adf8c4910 --- /dev/null +++ b/packages/core/src/api/handlers/registry.ts @@ -0,0 +1,937 @@ +/** + * Registry plugin install handler. + * + * Installs a plugin published to the experimental decentralized plugin + * registry described in RFC 0001. The install flow: + * + * 1. Resolve `(handle, slug)` to a publisher DID via the configured + * aggregator's `resolvePackage` XRPC. + * 2. Look up the requested release (or the policy-filtered latest one) + * via `getLatestRelease` / `listReleases`. + * 3. Reject the install if the aggregator surfaces a `security:yanked` + * hard-enforcement label or the release is below the configured + * minimum release age. + * 4. Fetch the bundle artifact, walking aggregator mirrors first and + * falling back to the publisher-declared URL. + * 5. Verify the artifact's multibase checksum against the signed + * release record's `artifacts.package.checksum`. + * 6. Extract `manifest.json` + `backend.js` + optional `admin.js` from + * the gzipped tar bundle. + * 7. Store the extracted files in site-local R2 under the + * `registry///` prefix. + * 8. Write a `plugin_states` row with `source = "registry"` and the + * `(publisher_did, slug)` pair so updates can be resolved later. + * 9. Sync the runtime so the plugin becomes active immediately. + * + * Known gaps (tracked separately): + * + * - The aggregator-supplied records are not yet cryptographically + * verified against the publisher's MST signature. The signed bytes + * and CIDs are passed through verbatim per the lexicon, but full + * PDS-direct verification with proof traversal is follow-up work. + * The artifact checksum is verified end-to-end against the value + * in the (aggregator-relayed) release record, which is the actual + * trust boundary for the bytes that end up in the sandbox. + * - `acceptLabelers` is forwarded as-is to the aggregator; this + * handler does not independently re-fetch and verify labels from + * each labeller's DID. Aggregator label envelope tampering is + * mitigated by the artifact checksum but not detected. + */ + +import type { Handle } from "@atcute/lexicons"; +import type { Kysely } from "kysely"; + +import type { RegistryConfig } from "../../astro/integration/runtime.js"; +import type { Database } from "../../database/types.js"; +import { extractBundle } from "../../plugins/marketplace.js"; +import type { PluginBundle } from "../../plugins/marketplace.js"; +import type { SandboxRunner } from "../../plugins/sandbox/types.js"; +import { PluginStateRepository } from "../../plugins/state.js"; +import { + parseDurationSeconds, + releaseExemptFromMinimumAge, + validateAggregatorUrl, +} from "../../registry/config.js"; +import { makeRegistryPluginId } from "../../registry/plugin-id.js"; +import { EmDashStorageError } from "../../storage/types.js"; +import type { Storage } from "../../storage/types.js"; +import type { ApiResult } from "../types.js"; +import { storeBundleInR2 } from "./marketplace.js"; + +// ── Types ────────────────────────────────────────────────────────── + +export interface RegistryInstallInput { + /** Publisher's atproto handle, e.g. `"example.dev"`. */ + handle: string; + /** Package slug (rkey of the publisher's profile record). */ + slug: string; + /** Optional explicit version. When omitted, the aggregator's latest. */ + version?: string; + /** + * Capabilities the admin acknowledged in the consent dialog, lifted + * from the release record's `declaredAccess` block. Compared against + * the bundle's `manifest.declaredAccess` to detect drift between + * what the admin agreed to and what the bundle actually requests. + * + * When omitted, drift detection is skipped -- callers that don't + * surface a consent UI before posting (e.g. CI scripts) opt out. + */ + acknowledgedDeclaredAccess?: unknown; +} + +export interface RegistryInstallResult { + /** Hashed, opaque plugin id used everywhere in the runtime. */ + pluginId: string; + /** Publisher DID resolved from the handle. */ + publisherDid: string; + /** Publisher slug (== the registry slug). */ + slug: string; + /** Installed version. */ + version: string; + /** Capabilities surfaced from the bundle's manifest. */ + capabilities: string[]; +} + +// ── Helpers ──────────────────────────────────────────────────────── + +/** Matches a bare 64-character lowercase/uppercase hex SHA-256 digest. */ +const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/i; + +/** Compute the SHA-256 of `bytes` as a lowercase hex string. */ +async function sha256Hex(bytes: Uint8Array): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Uint8Array is a valid BufferSource at runtime + const buf = await crypto.subtle.digest("SHA-256", bytes as unknown as BufferSource); + const arr = new Uint8Array(buf); + return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** + * Verify that a multibase multihash string from a release record's + * `artifact.checksum` field corresponds to the SHA-256 of the given + * bytes. + * + * The lexicon mandates support for sha2-256 (multihash code 0x12) and + * recommends base32 ('b' prefix) encoding. We accept the canonical + * `b` shape and reject anything we can't unambiguously verify. + * 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 { + // Bare hex-sha256 (no multibase prefix) -- accepted as a convenience + // because PluginBundle.checksum from extractBundle() is plain hex, + // and registries that haven't fully adopted multibase yet emit hex. + if (SHA256_HEX_PATTERN.test(checksum)) { + const actual = await sha256Hex(bytes); + return checksum.toLowerCase() === actual; + } + + // Multibase-base32 multihash with sha2-256: 'b' + base32(0x12, 0x20, <32 bytes>). + // The full decode pipeline (base32 → multihash header → digest bytes → + // hex) is more code than the trust boundary it gains us today, given + // the verification step is fundamentally bounded by what algorithm + // the upstream record chose. Leaving the multibase path for the + // followup that pairs with full MST verification. + // + // For now we fail closed on multibase strings rather than risk a + // false-positive verification. + return false; +} + +/** + * Bytes-per-artifact cap on the gzipped tarball we'll download before + * decompression. RFC 0001 caps a sandboxed plugin bundle at 256 KiB + * decompressed (see `MAX_BUNDLE_SIZE` in cli/commands/bundle-utils.ts); + * gzip on a mix of JSON manifest + JS code typically gives 0.3-0.6 + * ratio, so compressed bundles are well under 200 KiB in practice. + * 512 KiB leaves margin for unusual file mixes that compress poorly + * while still rejecting anything that's obviously not a legitimate + * plugin bundle. + */ +const MAX_ARTIFACT_BYTES = 512 * 1024; + +/** + * Maximum number of HTTP redirects followed during artifact download. + * Each hop is independently URL-validated, so a malicious server cannot + * redirect through a series of allowed-looking origins to reach a + * forbidden one. + */ +const MAX_REDIRECTS = 5; + +/** + * Wall-clock cap on any single artifact fetch attempt (per URL). + * Defends against slow-loris mirrors that accept the connection but + * never finish sending headers or body. + */ +const ARTIFACT_FETCH_TIMEOUT_MS = 15_000; + +/** + * Total wall-clock budget for the artifact-download phase across all + * mirrors and the declared URL. Even with the per-URL timeout, a + * malicious mirror list could otherwise tie up the install request for + * minutes; this caps total time at a budget interactive admins can + * tolerate. Tuned so a fast happy path takes <1s of budget per + * attempt and a worst case still completes in under a minute. + */ +const ARTIFACT_TOTAL_BUDGET_MS = 45_000; + +/** + * Cap on the number of mirror URLs we try before falling back to the + * publisher-declared URL. Matches the aggregator lexicon's + * `mirrors` array length cap (16) but enforced here independently so + * a misbehaving aggregator can't slow-loris us through hundreds of + * URLs. + */ +const MAX_MIRRORS = 16; + +/** + * Per-request timeout applied to every aggregator XRPC call + * (`resolvePackage`, `getLatestRelease`, `listReleases`). Matches the + * per-URL artifact-fetch cap. Without this, a slow-loris aggregator + * can stall the install before the artifact phase even starts. + */ +const AGGREGATOR_REQUEST_TIMEOUT_MS = 15_000; + +/** + * Total wall-clock budget for the aggregator-discovery phase + * (resolve + selected-release lookup). Mirrors the artifact-download + * budget. Worst case with the pinned-version path's 20-page cap is + * 20 + 1 calls; capping the total ensures any one stalled call + * still bounds the whole phase. + */ +const AGGREGATOR_TOTAL_BUDGET_MS = 30_000; + +/** Build a fetch function that enforces a per-request and per-budget timeout. */ +function timedFetch(totalDeadline: number): typeof fetch { + return (input: Parameters[0], init?: Parameters[1]) => { + const now = Date.now(); + const remaining = Math.max(0, totalDeadline - now); + if (remaining === 0) { + return Promise.reject(new Error("Aggregator request budget exhausted")); + } + const timeout = Math.min(AGGREGATOR_REQUEST_TIMEOUT_MS, remaining); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + const callerSignal = init?.signal; + if (callerSignal) { + if (callerSignal.aborted) controller.abort(callerSignal.reason); + else callerSignal.addEventListener("abort", () => controller.abort(callerSignal.reason)); + } + return fetch(input, { ...init, signal: controller.signal }).finally(() => { + clearTimeout(timer); + }); + }; +} + +/** + * IPv4 octets that resolve to non-routable or loopback addresses. The + * registry artifact fetcher refuses to make outbound HTTP requests to + * any host whose hostname is one of these literal addresses, because + * a compromised aggregator or publisher could otherwise use the + * EmDash worker as an SSRF stepping stone into the deploy environment + * (private networks, instance metadata, cloud-provider IMDS). + * + * Hostname-based DNS rebinding is not addressed here; the only + * mitigation that closes that gap is doing the address resolution + * ourselves and re-checking after connect. Out of scope for this + * iteration but documented as a follow-up. + */ +const FORBIDDEN_HOSTNAMES = new Set([ + "localhost", + "localhost.localdomain", + "ip6-localhost", + "ip6-loopback", +]); + +/** Matches a literal IPv4 address (four dotted decimal octets, 0-255). */ +const IPV4_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + +/** Trailing dot on a hostname, stripped before URL host comparisons. */ +const TRAILING_DOT = /\.$/; + +function isForbiddenIPv4(hostname: string): boolean { + const match = IPV4_PATTERN.exec(hostname); + if (!match) return false; + const octets = match.slice(1, 5).map((s) => Number(s)); + if (octets.some((o) => o < 0 || o > 255)) return true; // malformed + const [a, b] = octets; + // 127.0.0.0/8 loopback, 10.0.0.0/8 RFC1918, 172.16.0.0/12 RFC1918, + // 192.168.0.0/16 RFC1918, 169.254.0.0/16 link-local (incl. AWS IMDS), + // 100.64.0.0/10 CGNAT, 0.0.0.0/8 reserved, 224.0.0.0/4 multicast, + // 240.0.0.0/4 reserved. + if (a === 0 || a === 10 || a === 127) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b! >= 16 && b! <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 100 && b! >= 64 && b! <= 127) return true; + if (a! >= 224) return true; + return false; +} + +function isForbiddenIPv6(hostname: string): boolean { + // URL.hostname strips brackets, but a leading colon (`::1`) or + // IPv6 format is enough to identify. We err on the side of rejecting + // any literal IPv6 address rather than enumerating private ranges + // (fc00::/7, fe80::/10, ::1/128, etc.) -- legitimate registry + // artifacts are not served from raw IPv6 literals. + if (hostname.includes(":")) return true; + return false; +} + +/** Hostnames that resolve to the local machine; rejected outright in production. */ +function isLocalhostHostname(hostname: string): boolean { + // WHATWG URL preserves brackets on IPv6 hostnames; strip them before + // comparison so `[::1]` is recognised as localhost. + const stripped = hostname.toLowerCase().replace(TRAILING_DOT, ""); + const h = stripped.startsWith("[") && stripped.endsWith("]") ? stripped.slice(1, -1) : stripped; + if (FORBIDDEN_HOSTNAMES.has(h)) return true; + if (h === "localhost") return true; + if (h.endsWith(".localhost")) return true; + if (h === "127.0.0.1" || h === "::1") return true; + if (h.startsWith("::ffff:127.") || h.startsWith("::ffff:7f00:")) return true; + return false; +} + +/** + * Validate that `urlString` is a safe outbound target for artifact + * downloads. Rejects non-HTTPS (except localhost in dev), embedded + * credentials, and any host that's a loopback / private / link-local + * literal address. + * + * `import.meta.env.DEV` is a Vite/Astro compile-time constant, so + * production bundles cannot enable the dev escape hatch at runtime. + */ +function assertSafeArtifactUrl(urlString: string): URL { + let url: URL; + try { + url = new URL(urlString); + } catch { + throw new Error(`Invalid artifact URL: ${urlString}`); + } + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new Error(`Artifact URL protocol not allowed: ${url.protocol}`); + } + if (url.username || url.password) { + throw new Error("Artifact URL must not contain embedded credentials"); + } + + const rawHostname = url.hostname.toLowerCase().replace(TRAILING_DOT, ""); + // Strip brackets so the IPv4/IPv6 checks see the canonical form. + const hostname = + rawHostname.startsWith("[") && rawHostname.endsWith("]") + ? rawHostname.slice(1, -1) + : rawHostname; + const localhost = isLocalhostHostname(hostname); + + // In production: reject HTTP entirely and reject localhost over any + // protocol -- a publisher pointing at `https://localhost` is still + // trying to bounce the server through its own loopback interface. + if (!import.meta.env.DEV) { + if (url.protocol === "http:") { + throw new Error("Artifact URL must use https"); + } + if (localhost) { + throw new Error(`Artifact URL points to localhost: ${hostname}`); + } + } else if (url.protocol === "http:" && !localhost) { + // Dev mode: http allowed only for localhost. + throw new Error("Artifact URL must use https (http allowed only for localhost in dev)"); + } + + if (!localhost) { + if (isForbiddenIPv4(hostname) || isForbiddenIPv6(hostname)) { + throw new Error(`Artifact URL points to a non-routable address: ${hostname}`); + } + } + + return url; +} + +/** + * Fetch one URL with manual redirect handling so every hop is + * URL-validated, a hard byte cap so a malicious response body cannot + * exhaust memory before the checksum check rejects it, and a wall-clock + * timeout that covers connect, headers, and body together. The timeout + * is the minimum of the per-URL cap and the remaining total budget so + * a late-arriving mirror still respects the install's global budget. + */ +async function fetchWithLimits(initialUrl: string, totalDeadline: number): Promise { + const now = Date.now(); + const remaining = Math.max(0, totalDeadline - now); + if (remaining === 0) { + throw new Error("Artifact download budget exhausted"); + } + const perUrlTimeout = Math.min(ARTIFACT_FETCH_TIMEOUT_MS, remaining); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), perUrlTimeout); + try { + let current = assertSafeArtifactUrl(initialUrl); + let response: Response; + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + response = await fetch(current.href, { redirect: "manual", signal: controller.signal }); + if (response.status < 300 || response.status >= 400) break; + const location = response.headers.get("location"); + if (!location) break; + if (hop === MAX_REDIRECTS) { + throw new Error(`Too many redirects fetching artifact (>${MAX_REDIRECTS})`); + } + const next = new URL(location, current); + current = assertSafeArtifactUrl(next.href); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- response is assigned in the first loop iteration + const finalResponse = response!; + if (!finalResponse.ok) { + throw new Error(`HTTP ${finalResponse.status}`); + } + + // Check Content-Length up front when present. Untrusted servers can + // lie or omit it; the streaming cap below is the real defense. + const lengthHeader = finalResponse.headers.get("content-length"); + if (lengthHeader) { + const declared = Number(lengthHeader); + if (Number.isFinite(declared) && declared > MAX_ARTIFACT_BYTES) { + throw new Error( + `Artifact too large (declared ${declared} bytes, limit ${MAX_ARTIFACT_BYTES})`, + ); + } + } + + const body = finalResponse.body; + if (!body) { + // Workers can't return a null body for a normal GET; defensive fallback. + const buf = new Uint8Array(await finalResponse.arrayBuffer()); + if (buf.byteLength > MAX_ARTIFACT_BYTES) { + throw new Error(`Artifact too large (limit ${MAX_ARTIFACT_BYTES} bytes)`); + } + return buf; + } + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > MAX_ARTIFACT_BYTES) { + try { + await reader.cancel(); + } catch { + // nothing to do + } + throw new Error(`Artifact too large (limit ${MAX_ARTIFACT_BYTES} bytes)`); + } + chunks.push(value); + } + + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out; + } finally { + clearTimeout(timer); + } +} + +/** Walk artifact source URLs in priority order and return the first that fetches successfully. */ +async function fetchArtifact(mirrors: string[], declaredUrl: string): Promise { + // Clamp mirrors regardless of what the lexicon type says -- a buggy + // or malicious aggregator could return more than the spec'd limit + // and slow-loris each one. The declared URL is always tried last. + const clampedMirrors = mirrors.slice(0, MAX_MIRRORS); + const urls = [...clampedMirrors, declaredUrl]; + const errors: string[] = []; + + const totalDeadline = Date.now() + ARTIFACT_TOTAL_BUDGET_MS; + + for (const url of urls) { + if (Date.now() >= totalDeadline) { + errors.push("(total artifact download budget exhausted)"); + break; + } + try { + return await fetchWithLimits(url, totalDeadline); + } catch (err) { + errors.push(`${url}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + throw new Error(`Failed to download artifact from any source. Tried:\n ${errors.join("\n ")}`); +} + +// ── Install ──────────────────────────────────────────────────────── + +export async function handleRegistryInstall( + db: Kysely, + storage: Storage | null, + sandboxRunner: SandboxRunner | null, + registryConfig: RegistryConfig | undefined, + input: RegistryInstallInput, + opts?: { configuredPluginIds?: Set }, +): Promise> { + 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 installation", + }, + }; + } + + if (!sandboxRunner || !sandboxRunner.isAvailable()) { + return { + success: false, + error: { + code: "SANDBOX_NOT_AVAILABLE", + message: "Sandbox runner is required for registry plugins", + }, + }; + } + + // Defense in depth: validate the aggregator URL even though the same + // check runs at config-normalize time. Keeps every entrypoint into + // `handleRegistryInstall` safe regardless of how the caller obtained + // the config. + try { + validateAggregatorUrl(registryConfig.aggregatorUrl); + } catch (err) { + return { + success: false, + error: { + code: "REGISTRY_NOT_CONFIGURED", + message: err instanceof Error ? err.message : "Invalid aggregator URL", + }, + }; + } + + const { handle, slug, version: requestedVersion } = input; + + // Lazy-load the discovery client. Avoids pulling @atcute/client into + // every code path that imports core/api/handlers. + const { DiscoveryClient } = await import("@emdash-cms/registry-client/discovery"); + + // Every aggregator XRPC call passes through `timedFetch`, which + // enforces a per-request timeout and shares a single total-budget + // deadline. Defends against a slow-loris aggregator stalling the + // install before the artifact phase begins. + const aggregatorDeadline = Date.now() + AGGREGATOR_TOTAL_BUDGET_MS; + const discovery = new DiscoveryClient({ + aggregatorUrl: registryConfig.aggregatorUrl, + acceptLabelers: registryConfig.acceptLabelers, + fetch: timedFetch(aggregatorDeadline), + }); + + // Basic shape check on the handle. Aggregator's lexicon types the + // param as `${string}.${string}`, but the handler accepts a plain + // string from request bodies; reject malformed shapes here rather + // than letting the XRPC call fail opaquely. Full RFC 3986 handle + // validation is the aggregator's job. + if (!handle.includes(".")) { + return { + success: false, + error: { + code: "INVALID_HANDLE", + message: "Handle must be a domain-like identifier (e.g. example.dev)", + }, + }; + } + + try { + // Step 1: resolve (handle, slug) → (did, slug) + // Cast: the validation above ensures `handle` matches the lexicon's + // `${string}.${string}` shape. + const packageView = await discovery.resolvePackage({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- shape validated above + handle: handle as Handle, + slug, + }); + const publisherDid = packageView.did; + + // Step 2: select the target release. + // For an explicit version, page through listReleases until we find + // the matching record; the aggregator returns releases ordered by + // semver descending. For "latest", use the dedicated convenience + // endpoint which applies the aggregator's policy filter (yanked + // exclusion etc.) server-side. + // + // Pagination is bounded both by total pages and by repeated-cursor + // detection: a buggy or compromised aggregator could otherwise + // return endless distinct cursors that never include the + // requested version, hanging the install for the platform's + // request-time budget. + const MAX_LIST_PAGES = 20; // 20 * 50 limit = 1000 releases worth + const latestRelease = await (async () => { + if (!requestedVersion) { + 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 === requestedVersion) return r; + } + if (!result.cursor) break; + cursor = result.cursor; + } + return undefined; + })(); + const releaseView = latestRelease; + + if (!releaseView) { + return { + success: false, + error: { + code: "NO_RELEASE", + message: requestedVersion + ? `Version ${requestedVersion} not found for ${handle}/${slug}` + : `No installable release found for ${handle}/${slug}`, + }, + }; + } + + // Identity cross-check on every field the aggregator denormalises + // onto the package and release views. A buggy or compromised + // aggregator could otherwise return a release view for a + // different `(did, slug, version)` than we asked for; the + // handler would then fetch + checksum-verify + install bytes + // under the requested package's pluginId but for a different + // publisher's record. Checksum verification only proves the bytes + // match the *returned* record, not that the record belongs to + // the package we requested. + const signedRelease = releaseView.release as + | { package?: unknown; version?: unknown } + | null + | undefined; + if (packageView.did !== publisherDid || packageView.slug !== slug) { + return { + success: false, + error: { + code: "AGGREGATOR_IDENTITY_MISMATCH", + message: "Aggregator returned a package view for a different publisher or slug.", + }, + }; + } + if ( + releaseView.did !== publisherDid || + releaseView.package !== slug || + signedRelease?.package !== slug || + (requestedVersion !== undefined && releaseView.version !== requestedVersion) || + 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 version = releaseView.version; + + // Step 3: takedown label check (hard-enforced via aggregator's + // `atproto-accept-labelers` filtering, but we belt-and-suspenders + // the package-level labels too). + const yanked = (packageView.labels ?? []).some( + (l: { val?: string }) => l.val === "security:yanked", + ); + const releaseYanked = (releaseView.labels ?? []).some( + (l: { val?: string }) => l.val === "security:yanked", + ); + if (yanked || releaseYanked) { + return { + success: false, + error: { + code: "RELEASE_YANKED", + message: "This release has been withdrawn (security:yanked label).", + }, + }; + } + + // Step 3a: enforce the configured minimum release age. The browser + // applies the same check up front for UX, but the gate lives here + // -- a stale browser tab, a deep link, or a non-admin-UI caller + // must still hit the holdback. The `minimumReleaseAgeExclude` + // allowlist short-circuits the check for trusted publisher DIDs. + // + // Caveat: `releaseView.indexedAt` is aggregator-supplied envelope + // data, not a signed timestamp. A compromised aggregator can + // claim an arbitrary indexed-at date and bypass the holdback; + // closing this gap requires fetching the release record's + // signed createdAt from the publisher's PDS (deferred to the + // follow-up that adds full MST verification). If the timestamp + // is missing or malformed, we fail closed and reject the install. + const minimumReleaseAge = registryConfig.policy?.minimumReleaseAge; + const minimumReleaseAgeSeconds = + minimumReleaseAge !== undefined ? parseDurationSeconds(minimumReleaseAge) : 0; + if (minimumReleaseAgeSeconds > 0) { + const exclude = registryConfig.policy?.minimumReleaseAgeExclude?.map((e) => + e.trim().toLowerCase(), + ); + const exempt = releaseExemptFromMinimumAge(exclude, publisherDid, slug); + if (!exempt) { + const indexedAt = Date.parse(releaseView.indexedAt); + if (!Number.isFinite(indexedAt)) { + return { + success: false, + error: { + code: "RELEASE_TIMESTAMP_INVALID", + message: + "Release record is missing a valid indexed-at timestamp; cannot evaluate minimum release age policy.", + }, + }; + } + const ageSeconds = (Date.now() - indexedAt) / 1000; + if (ageSeconds < minimumReleaseAgeSeconds) { + const remaining = Math.ceil(minimumReleaseAgeSeconds - ageSeconds); + return { + success: false, + error: { + code: "RELEASE_TOO_NEW", + message: + `This release does not meet the configured minimum release age of ` + + `${minimumReleaseAgeSeconds}s. It will be installable in ~${remaining}s.`, + }, + }; + } + } + } + + // Derive the normalized opaque plugin id we'll use as the + // runtime-wide identifier from here on. The publisher_did + slug + // stay in the state row for update resolution and admin display. + const pluginId = await makeRegistryPluginId(publisherDid, slug); + + // Block installation if a configured (trusted) plugin shares this + // id. Mirrors the marketplace install's PLUGIN_ID_CONFLICT check. + if (opts?.configuredPluginIds?.has(pluginId)) { + return { + success: false, + error: { + code: "PLUGIN_ID_CONFLICT", + message: "A configured plugin with the same derived id already exists", + }, + }; + } + + // Check for an existing install (any source) under the derived id. + // We reject all pre-existing rows -- if the row is from a registry + // install of this same package, the caller should go through the + // (future) update flow; if it's from any other source, the + // pluginId collision means installing would silently mutate an + // unrelated plugin's lifecycle row. + const stateRepo = new PluginStateRepository(db); + const existing = await stateRepo.get(pluginId); + if (existing) { + if (existing.source === "registry") { + return { + success: false, + error: { + code: "ALREADY_INSTALLED", + message: `Plugin ${handle}/${slug} is already installed`, + }, + }; + } + return { + success: false, + error: { + code: "PLUGIN_ID_COLLISION", + message: + `A non-registry plugin already exists at the derived id ${pluginId}. ` + + "Uninstall it before installing this registry plugin.", + }, + }; + } + + // Step 4: fetch the artifact bytes. + // The signed release record is `releaseView.release`; the lexicon + // types it as `unknown` so we extract the package artifact via + // duck-typed access. Mirrors come from the envelope (aggregator + // operational data, not part of the signed record). + const release = releaseView.release as { + artifacts?: { + package?: { url?: string; checksum?: string }; + }; + }; + const declaredUrl = release.artifacts?.package?.url; + const declaredChecksum = release.artifacts?.package?.checksum; + + if (!declaredUrl || !declaredChecksum) { + return { + success: false, + error: { + code: "INVALID_RELEASE", + message: "Release record is missing artifact url or checksum", + }, + }; + } + + const mirrors = releaseView.mirrors ?? []; + const artifactBytes = await fetchArtifact(mirrors, declaredUrl); + + // Step 5: verify the bytes against the signed record's checksum. + const checksumOk = await verifyChecksum(artifactBytes, declaredChecksum); + if (!checksumOk) { + return { + success: false, + error: { + code: "CHECKSUM_MISMATCH", + message: + "Artifact bytes do not match the release record's checksum, or the checksum encoding is unsupported.", + }, + }; + } + + // Step 6: extract the bundle. + let bundle: PluginBundle; + try { + bundle = await extractBundle(artifactBytes); + } catch (err) { + return { + success: false, + error: { + code: "INVALID_BUNDLE", + message: err instanceof Error ? err.message : "Failed to extract plugin bundle", + }, + }; + } + + // Manifest sanity: declared version must match the release's version. + if (bundle.manifest.version !== version) { + return { + success: false, + error: { + code: "MANIFEST_VERSION_MISMATCH", + message: `Bundle manifest version (${bundle.manifest.version}) does not match release version (${version})`, + }, + }; + } + + // Manifest identity: the bundle's `manifest.id` is the publisher's + // natural plugin id (their slug). It MUST equal the slug the + // install was requested for; otherwise a malicious registry bundle + // could declare `manifest.id: "audit-log"` and confuse the sandbox + // bridge, which uses `manifest.id` as the trust key for + // per-plugin storage, cron schedules, and bridge-scoped + // operations. + if (bundle.manifest.id !== slug) { + return { + success: false, + error: { + code: "MANIFEST_ID_MISMATCH", + message: `Bundle manifest id (${bundle.manifest.id}) does not match registry slug (${slug})`, + }, + }; + } + + // Rewrite the manifest's id to the derived opaque pluginId before + // it reaches R2 storage or the sandbox loader. The sandbox uses + // `manifest.id` as its identity for per-plugin storage and bridge + // calls; addressing it by the same pluginId we use in the runtime + // cache, R2 prefix, and `_plugin_state` row keeps every layer + // in sync and prevents registry installs from colliding with + // marketplace plugins that happen to share the publisher's slug. + bundle.manifest = { ...bundle.manifest, id: pluginId }; + + // Drift check: capabilities the admin acknowledged must match + // what the bundle's manifest actually declares. Aggregator-side + // label envelope and release-record `declaredAccess` are + // independent assertions; this catches the case where they + // diverged between the consent dialog and the install POST. + if ( + input.acknowledgedDeclaredAccess !== undefined && + JSON.stringify(input.acknowledgedDeclaredAccess) !== + JSON.stringify(bundle.manifest.capabilities) + ) { + // We compare against the bundle's *capabilities* (the legacy + // shape) for v1 because EmDash's existing sandbox enforces + // capabilities, not the RFC's structured `declaredAccess`. + // Once the runtime starts enforcing `declaredAccess` natively, + // this comparison switches to that shape. Until then the + // admin UI lifts capabilities from the release record's + // extension data and the comparison is meaningful. + return { + success: false, + error: { + code: "DECLARED_ACCESS_DRIFT", + message: + "Plugin manifest has changed since you consented. Re-open the install dialog to review the new permissions.", + }, + }; + } + + // Step 7: store in R2 under the registry prefix. + await storeBundleInR2(storage, pluginId, version, bundle, "registry"); + + // Step 8: write plugin state. + // Display name and description come from the *package profile* + // (the signed record from the publisher's repo), not from the + // bundle manifest -- the manifest carries the trust contract, + // the profile carries the marketing copy. + const profile = packageView.profile as { name?: string; description?: string }; + await stateRepo.upsert(pluginId, version, "active", { + source: "registry", + displayName: profile.name ?? slug, + description: profile.description ?? undefined, + registryPublisherDid: publisherDid, + registrySlug: slug, + }); + + return { + success: true, + data: { + pluginId, + publisherDid, + slug, + version, + capabilities: bundle.manifest.capabilities, + }, + }; + } catch (err) { + if (err instanceof EmDashStorageError) { + return { + success: false, + error: { + code: err.code ?? "STORAGE_ERROR", + message: "Storage error while installing plugin", + }, + }; + } + console.error("[registry-install] Failed:", err); + return { + success: false, + error: { + code: "INSTALL_FAILED", + message: err instanceof Error ? err.message : "Failed to install plugin from registry", + }, + }; + } +} diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index f01f9a907..a5a2659cf 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -365,6 +365,12 @@ export function injectCoreRoutes(injectRoute: InjectRoute): void { entrypoint: resolveRoute("api/admin/plugins/marketplace/[id]/install.ts"), }); + // Experimental registry routes (see RFC 0001) + injectRoute({ + pattern: "/_emdash/api/admin/plugins/registry/install", + entrypoint: resolveRoute("api/admin/plugins/registry/install.ts"), + }); + injectRoute({ pattern: "/_emdash/api/admin/plugins/[id]/update", entrypoint: resolveRoute("api/admin/plugins/[id]/update.ts"), diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index 6de26de02..7d1cc352e 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -125,6 +125,133 @@ export interface PluginDescriptor> { export type SandboxedPluginDescriptor> = PluginDescriptor; +/** + * Experimental plugin registry configuration. + * + * See {@link ExperimentalConfig.registry}. + */ +export interface RegistryConfig { + /** + * Base URL of the registry aggregator (an atproto AppView that indexes + * the firehose for `pm.fair.package.*` and `com.emdashcms.*` records). + * + * Must be the origin where the aggregator's XRPC endpoints are mounted, + * such that `${aggregatorUrl}/xrpc/` resolves to a valid endpoint. + * + * Must be HTTPS in production; `http://localhost` or `http://127.0.0.1` + * are accepted in dev. + */ + aggregatorUrl: string; + + /** + * Optional comma-separated list of labeller DIDs forwarded as the + * `atproto-accept-labelers` header on every aggregator request. + * + * Format follows the atproto convention: + * `did:plc:abc;redact, did:plc:def` + * + * When unset, the aggregator applies its operator-default labeller set + * (typically the EmDash publisher-verification labeller and any + * additional trusted labellers the aggregator operator configured). + */ + acceptLabelers?: string; + + /** + * Site-level policy applied to the latest-release selection filter. + * + * These filters operate over the signed records the aggregator returns; + * they are not protocol-level constraints. See the RFC's + * "Update Discovery and Takedowns" section for the integration point. + */ + policy?: { + /** + * Hold back releases newer than this when computing the recommended + * install or update version. Mitigates "compromised publisher + * account pushes a malicious release of an established plugin" by + * giving the takedown labeller a detection window. + * + * Accepts a duration string (`"24h"`, `"48h"`, `"72h"`, `"7d"`) or a + * number of seconds. + * + * Currently applies uniformly to all releases. A future addition + * may exempt brand-new packages (those with no prior release + * history) so the holdback doesn't block first-time publishing, + * but that exemption is not implemented yet; use + * {@link minimumReleaseAgeExclude} to allowlist trusted publishers + * whose packages should install immediately. + * + * Defaults to `undefined` (no holdback). A future trust/moderation + * RFC will specify the recommended default. + */ + minimumReleaseAge?: string | number; + + /** + * Packages exempt from the {@link minimumReleaseAge} holdback. Use + * for publishers whose release tempo you've explicitly accepted -- + * your own first-party plugins, a trusted partner, etc. + * + * Each entry is either: + * - A bare publisher DID (e.g. `"did:plc:abc123"`) -- every + * package from that publisher is exempt. + * - A `/` pair (e.g. + * `"did:plc:abc123/hotfix-plugin"`) -- only that specific + * package is exempt. + * + * Whole-publisher exemptions are the common case: trust is + * naturally a property of the publisher, not of each individual + * package. Per-package exemptions exist for cases where a publisher + * has one plugin you want fast-track installs for and others you'd + * rather hold back. + * + * Only DIDs are accepted -- not handles. Handles are mutable + * aggregator-supplied envelope data, and accepting them as a + * trust input would let a compromised aggregator bypass the + * holdback by claiming any handle for any package. DIDs are + * tied to the AT URI of the package record itself, so even a + * compromised aggregator cannot lie about which DID published + * a release. + * + * Mirrors pnpm's `minimumReleaseAgeExclude`. + * + * @example + * ```ts + * minimumReleaseAgeExclude: [ + * "did:plc:emdashfirstparty", // every package from this publisher + * "did:plc:abc123/hotfix-plugin", // just this one package + * ] + * ``` + */ + minimumReleaseAgeExclude?: readonly string[]; + }; +} + +/** + * Experimental EmDash features. See {@link EmDashConfig.experimental}. + * + * Each field is independently opt-in. Fields may be promoted out of + * `experimental` (becoming top-level `EmDashConfig` options) or removed + * in minor releases; check the changelog when upgrading. + */ +export interface ExperimentalConfig { + /** + * Decentralized plugin registry. + * + * When set, replaces the centralized `marketplace` for the admin UI's + * browse and install flows. The registry is an atproto-backed + * federation: package metadata lives in each publisher's PDS, an + * aggregator (the `aggregatorUrl`) indexes the firehose and exposes + * read-only XRPC endpoints for discovery, and EmDash verifies each + * release against the publisher's signed records before installing. + * + * See [RFC 0001](https://github.com/emdash-cms/emdash/pull/694) for + * the protocol design and threat model. + * + * Requires `sandboxRunner` to be configured -- registry plugins always + * run sandboxed. + */ + registry?: RegistryConfig; +} + export interface EmDashConfig { /** * Database configuration @@ -264,6 +391,10 @@ export interface EmDashConfig { * Must be an HTTPS URL in production, or localhost/127.0.0.1 in dev. * Requires `sandboxRunner` to be configured (marketplace plugins run sandboxed). * + * When `registry` is also configured, the registry replaces the marketplace + * for the admin UI's browse and install flows. Existing marketplace-installed + * plugins continue to work; new installs and updates come from the registry. + * * @example * ```ts * emdash({ @@ -274,6 +405,28 @@ export interface EmDashConfig { */ marketplace?: string; + /** + * Experimental features. + * + * These options are not yet stable. Shape, defaults, and behavior may + * change between minor versions. Use only if you're comfortable + * tracking the release notes and updating your config when an + * experimental feature graduates or changes. + * + * @example + * ```ts + * emdash({ + * experimental: { + * registry: { + * aggregatorUrl: "https://registry.emdashcms.com", + * }, + * }, + * sandboxRunner: "@emdash-cms/sandbox-cloudflare", + * }) + * ``` + */ + experimental?: ExperimentalConfig; + /** * Maximum allowed media file upload size in bytes. * diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index 488bf91d6..bdbd628da 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -527,6 +527,9 @@ export const onRequest = defineMiddleware(async (context, next) => { // Sync marketplace plugin states (after install/update/uninstall) syncMarketplacePlugins: runtime.syncMarketplacePlugins.bind(runtime), + // Sync registry plugin states (after install/update/uninstall) + syncRegistryPlugins: runtime.syncRegistryPlugins.bind(runtime), + // Update plugin enabled/disabled status and rebuild hook pipeline setPluginStatus: runtime.setPluginStatus.bind(runtime), }; diff --git a/packages/core/src/astro/routes/api/admin/plugins/[id]/enable.ts b/packages/core/src/astro/routes/api/admin/plugins/[id]/enable.ts index 4feb22c25..5b9b1994e 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/[id]/enable.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/[id]/enable.ts @@ -32,6 +32,16 @@ export const POST: APIRoute = async ({ params, locals }) => { if (!result.success) return unwrapResult(result); + // If this is a runtime-installed plugin (marketplace or registry), + // the sandbox bundle may not be in memory yet -- a sync reloads it + // from R2 so the just-enabled plugin can actually run hooks. + const source = result.data.item.source; + if (source === "registry") { + await emdash.syncRegistryPlugins(); + } else if (source === "marketplace") { + await emdash.syncMarketplacePlugins(); + } + await emdash.setPluginStatus(id, "active"); await setCronTasksEnabled(emdash.db, id, true); diff --git a/packages/core/src/astro/routes/api/admin/plugins/registry/install.ts b/packages/core/src/astro/routes/api/admin/plugins/registry/install.ts new file mode 100644 index 000000000..1b85a87ca --- /dev/null +++ b/packages/core/src/astro/routes/api/admin/plugins/registry/install.ts @@ -0,0 +1,94 @@ +/** + * Registry plugin install endpoint + * + * POST /_emdash/api/admin/plugins/registry/install + * + * Installs a plugin from the experimental decentralized plugin registry + * (see RFC 0001). The browser passes the publisher handle and slug it + * resolved through the aggregator's `searchPackages` / `resolvePackage` + * endpoints; the server re-resolves and re-verifies on its side before + * fetching the artifact and handing it to the sandbox loader. + */ + +import type { APIRoute } from "astro"; +import { z } from "zod"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError, handleError, unwrapResult } from "#api/error.js"; +import { handleRegistryInstall } from "#api/index.js"; +import { isParseError, parseBody } from "#api/parse.js"; + +export const prerender = false; + +const installBodySchema = z.object({ + /** Publisher's atproto handle (e.g. `"example.dev"`). */ + handle: z.string().min(1).max(253), + /** Package slug. */ + slug: z + .string() + .min(1) + .max(64) + // Mirrors the lexicon's slug grammar: ASCII letter followed by + // letters / digits / `-` / `_`. Rejects anything that could + // confuse the R2 prefix or the URL. + .regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/, "Invalid slug"), + /** Optional explicit version. Defaults to the aggregator's latest. */ + version: z.string().min(1).max(64).optional(), + /** + * Capabilities the admin acknowledged in the consent dialog, lifted + * from the release record's declaredAccess block at browse time. + * Compared against the bundle's manifest to detect drift between the + * dialog and the install POST. + */ + acknowledgedDeclaredAccess: z.unknown().optional(), +}); + +export const POST: APIRoute = async ({ request, locals }) => { + try { + const { emdash, user } = locals; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + const denied = requirePerm(user, "plugins:manage"); + if (denied) return denied; + + const body = await parseBody(request, installBodySchema); + if (isParseError(body)) return body; + + // Block registry installs whose derived `pluginId` collides with + // any build-time-reserved id: configured (in-process) plugins, and + // sandboxed plugins declared in `config.sandboxed`. The runtime + // caches sandboxed plugins by id; a registry install at the same + // id would silently shadow or coexist with the build-time entry. + const reservedPluginIds = new Set([ + ...emdash.configuredPlugins.map((p: { id: string }) => p.id), + ...(emdash.config.sandboxed ?? []).map((p: { id: string }) => p.id), + ]); + + const result = await handleRegistryInstall( + emdash.db, + emdash.storage, + emdash.getSandboxRunner(), + emdash.config.experimental?.registry, + { + handle: body.handle, + slug: body.slug, + version: body.version, + acknowledgedDeclaredAccess: body.acknowledgedDeclaredAccess, + }, + { configuredPluginIds: reservedPluginIds }, + ); + + if (!result.success) return unwrapResult(result); + + // Sync runtime so the new plugin becomes active without a worker restart. + await emdash.syncRegistryPlugins(); + + return unwrapResult(result, 201); + } catch (error) { + console.error("[registry-install] Unhandled error:", error); + return handleError(error, "Failed to install plugin from registry", "INSTALL_FAILED"); + } +}; diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index c14f20626..16dd2c7df 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -144,8 +144,43 @@ export interface EmDashManifest { /** * Whether the plugin marketplace is configured. * When true, the admin UI can show marketplace browse/install features. + * + * When `registry` is also present, the registry replaces the marketplace + * for the admin UI's browse and install flows. Existing marketplace-installed + * plugins continue to work; new installs and updates use the registry. */ marketplace?: boolean; + /** + * Decentralized plugin registry configuration. + * + * When present, the admin UI uses the registry instead of the + * centralized marketplace for browse and install. The aggregator URL + * and policy fields are read by the browser; the `acceptLabelers` + * header value is forwarded with every aggregator request. + * + * See the `registry` integration option in `astro.config.mjs`. + */ + registry?: { + aggregatorUrl: string; + acceptLabelers?: string; + policy?: { + /** + * Minimum release age in seconds. The admin UI's + * latest-release selection filter holds back releases younger + * than this when computing the recommended install/update. + * + * Normalized from the integration option's duration string + * (`"48h"`) to seconds at manifest build time so the browser + * doesn't need a duration parser. + */ + minimumReleaseAgeSeconds?: number; + /** + * Publishers / packages exempt from {@link minimumReleaseAgeSeconds}. + * See `RegistryConfig.policy.minimumReleaseAgeExclude`. + */ + minimumReleaseAgeExclude?: string[]; + }; + }; /** * Admin branding overrides for white-labeling. * Set via the `admin` config in `astro.config.mjs`. @@ -395,6 +430,9 @@ export interface EmDashHandlers { // Sync marketplace plugin states (after install/update/uninstall) syncMarketplacePlugins: () => Promise; + // Sync registry plugin states (after install/update/uninstall) + syncRegistryPlugins: () => Promise; + // Update plugin enabled/disabled status and rebuild hook pipeline setPluginStatus: (pluginId: string, status: "active" | "inactive") => Promise; diff --git a/packages/core/src/database/migrations/038_registry_plugin_state.ts b/packages/core/src/database/migrations/038_registry_plugin_state.ts new file mode 100644 index 000000000..0b45a19d1 --- /dev/null +++ b/packages/core/src/database/migrations/038_registry_plugin_state.ts @@ -0,0 +1,123 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +import { isSqlite } from "../dialect-helpers.js"; + +interface ColumnInfo { + name: string; +} + +interface IndexInfo { + name: string; +} + +/** + * Migration: Add registry fields to _plugin_state + * + * Extends the marketplace columns added in 022 to support the + * experimental decentralized plugin registry (see RFC #694). Rather + * than introducing a separate `_registry_plugin_state` table, we + * reuse the same row shape and distinguish registry installs via the + * existing `source` column (now `'config' | 'marketplace' | 'registry'`). + * + * Registry plugins are addressed by `(publisher_did, slug)` in their + * lexicon records but stored under a hashed, opaque `plugin_id` for + * runtime compatibility -- see `packages/core/src/registry/plugin-id.ts`. + * The `(publisher_did, slug)` pair is preserved here for update + * resolution against the currently configured aggregator and for admin + * UI rendering ("by @example.dev"). + * + * All new columns are nullable; existing marketplace and config rows + * keep working unchanged. + * + * Idempotency: D1 and SQLite don't honor the migration runner's + * advisory lock, so a partial re-apply (cold start race between two + * isolates, retry after a connection drop) can re-enter this `up` + * function with the columns or index already in place. Each step + * checks before adding to keep the migration safe under partial + * re-application. The same pattern is used in 019_i18n.ts. + */ +export async function up(db: Kysely): Promise { + if (isSqlite(db)) { + await upSqlite(db); + } else { + await upPostgres(db); + } +} + +async function upSqlite(db: Kysely): Promise { + const cols = await sql`PRAGMA table_info(_plugin_state)`.execute(db); + const colNames = new Set(cols.rows.map((c) => c.name)); + + if (!colNames.has("registry_publisher_did")) { + await sql` + ALTER TABLE _plugin_state + ADD COLUMN registry_publisher_did TEXT + `.execute(db); + } + + if (!colNames.has("registry_slug")) { + await sql` + ALTER TABLE _plugin_state + ADD COLUMN registry_slug TEXT + `.execute(db); + } + + const indexes = await sql`PRAGMA index_list(_plugin_state)`.execute(db); + const indexNames = new Set(indexes.rows.map((i) => i.name)); + + if (!indexNames.has("idx_plugin_state_registry")) { + await sql` + CREATE INDEX idx_plugin_state_registry + ON _plugin_state (source) + WHERE source = 'registry' + `.execute(db); + } +} + +async function upPostgres(db: Kysely): Promise { + const cols = await sql<{ column_name: string }>` + SELECT column_name FROM information_schema.columns + WHERE table_name = '_plugin_state' + `.execute(db); + const colNames = new Set(cols.rows.map((c) => c.column_name)); + + if (!colNames.has("registry_publisher_did")) { + await sql` + ALTER TABLE _plugin_state + ADD COLUMN registry_publisher_did TEXT + `.execute(db); + } + + if (!colNames.has("registry_slug")) { + await sql` + ALTER TABLE _plugin_state + ADD COLUMN registry_slug TEXT + `.execute(db); + } + + // pg's CREATE INDEX IF NOT EXISTS handles the race natively; partial + // index syntax differs from SQLite (`WHERE` is supported), so the + // statement is otherwise identical. + await sql` + CREATE INDEX IF NOT EXISTS idx_plugin_state_registry + ON _plugin_state (source) + WHERE source = 'registry' + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql` + DROP INDEX IF EXISTS idx_plugin_state_registry + `.execute(db); + + await sql` + ALTER TABLE _plugin_state + DROP COLUMN registry_slug + `.execute(db); + + await sql` + ALTER TABLE _plugin_state + DROP COLUMN registry_publisher_did + `.execute(db); +} diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index b1c3b384a..81102b200 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -38,6 +38,7 @@ import * as m034 from "./034_published_at_index.js"; import * as m035 from "./035_bounded_404_log.js"; import * as m036 from "./036_i18n_menus_and_taxonomies.js"; import * as m037 from "./037_credential_algorithm.js"; +import * as m038 from "./038_registry_plugin_state.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -76,6 +77,7 @@ const MIGRATIONS: Readonly> = Object.freeze({ "035_bounded_404_log": m035, "036_i18n_menus_and_taxonomies": m036, "037_credential_algorithm": m037, + "038_registry_plugin_state": m038, }); /** Total number of registered migrations. Exported for use in tests. */ diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts index f37da6c97..acce67ca6 100644 --- a/packages/core/src/database/types.ts +++ b/packages/core/src/database/types.ts @@ -275,10 +275,14 @@ export interface PluginStateTable { activated_at: string | null; deactivated_at: string | null; data: string | null; // JSON - source: Generated; // 'config' | 'marketplace' + source: Generated; // 'config' | 'marketplace' | 'registry' marketplace_version: string | null; display_name: string | null; description: string | null; + // Registry-specific columns (added by migration 038). Always null for + // `source = 'config' | 'marketplace'`; populated for `source = 'registry'`. + registry_publisher_did: string | null; + registry_slug: string | null; } export interface PluginIndexTable { diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index b846eb85b..dc4ace5c9 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -161,6 +161,7 @@ import { NodeCronScheduler } from "./plugins/scheduler/node.js"; import { PiggybackScheduler } from "./plugins/scheduler/piggyback.js"; import type { CronScheduler } from "./plugins/scheduler/types.js"; import { PluginStateRepository } from "./plugins/state.js"; +import { normalizeRegistryConfig } from "./registry/config.js"; import { requestCached } from "./request-cache.js"; import { getRequestContext } from "./request-context.js"; import { FTSManager } from "./search/fts-manager.js"; @@ -304,8 +305,19 @@ const dbCache = new Map>(); let dbInitPromise: Promise> | null = null; const storageCache = new Map(); const sandboxedPluginCache = new Map(); +/** + * Per-tier sets of `${pluginId}:${version}` keys present in + * `sandboxedPluginCache`. Used during sync to know which entries belong + * to which install source so we can invalidate only what belongs to the + * tier currently being synced. + */ const marketplacePluginKeys = new Set(); -/** Manifest metadata for marketplace plugins: pluginId -> manifest admin config */ +const registryPluginKeys = new Set(); +/** + * Manifest metadata for runtime-installed sandboxed plugins (marketplace + * and registry both). Keyed by `pluginId`; readers don't care which + * source the plugin came from. Named `marketplace*` for legacy reasons. + */ const marketplaceManifestCache = new Map< string, { @@ -506,15 +518,46 @@ export class EmDashRuntime { * current worker: loads newly active plugins and removes uninstalled ones. */ async syncMarketplacePlugins(): Promise { - if (!this.config.marketplace || !this.storage) return; + if (!this.config.marketplace) return; + await this.syncSandboxedSourcePlugins("marketplace"); + } + + /** + * Synchronize registry plugin runtime state with DB + storage. + * + * Mirrors {@link syncMarketplacePlugins} for plugins installed via the + * experimental decentralized plugin registry. Called after install, + * update, and uninstall handlers complete. + */ + async syncRegistryPlugins(): Promise { + if (!this.config.experimental?.registry) return; + await this.syncSandboxedSourcePlugins("registry"); + } + + /** + * Internal: reconcile in-memory sandboxed-plugin state with the + * `_plugin_state` table for the given source tier. Shared + * implementation behind `syncMarketplacePlugins` and + * `syncRegistryPlugins`. + * + * Each source tier has its own key set in `${source}PluginKeys` so a + * sync for one tier doesn't invalidate the other. + */ + private async syncSandboxedSourcePlugins(source: "marketplace" | "registry"): Promise { + if (!this.storage) return; if (!sandboxRunner || !sandboxRunner.isAvailable()) return; + const keySet = source === "marketplace" ? marketplacePluginKeys : registryPluginKeys; + try { const stateRepo = new PluginStateRepository(this.db); - const marketplaceStates = await stateRepo.getMarketplacePlugins(); + const states = + source === "marketplace" + ? await stateRepo.getMarketplacePlugins() + : await stateRepo.getRegistryPlugins(); const desired = new Map(); - for (const state of marketplaceStates) { + for (const state of states) { this.pluginStates.set(state.pluginId, state.status); if (state.status === "active") { this.enabledPlugins.add(state.pluginId); @@ -522,12 +565,16 @@ export class EmDashRuntime { this.enabledPlugins.delete(state.pluginId); } if (state.status !== "active") continue; - desired.set(state.pluginId, state.marketplaceVersion ?? state.version); + // Marketplace plugins use `marketplaceVersion` when present; + // registry plugins always use `version`. + const desiredVersion = + source === "marketplace" ? (state.marketplaceVersion ?? state.version) : state.version; + desired.set(state.pluginId, desiredVersion); } - // Remove uninstalled or no-longer-active marketplace plugins from memory. + // Remove uninstalled or no-longer-active plugins from memory. const keysToRemove: string[] = []; - for (const key of marketplacePluginKeys) { + for (const key of keySet) { const [pluginId] = key.split(":"); if (!pluginId) continue; const desiredVersion = desired.get(pluginId); @@ -555,31 +602,31 @@ export class EmDashRuntime { sandboxedPluginCache.delete(key); this.sandboxedPlugins.delete(key); - marketplacePluginKeys.delete(key); + keySet.delete(key); if (pluginId) { sandboxedRouteMetaCache.delete(pluginId); marketplaceManifestCache.delete(pluginId); } } - // Load newly active marketplace plugins. + // Load newly active plugins. for (const [pluginId, version] of desired) { const key = `${pluginId}:${version}`; if (sandboxedPluginCache.has(key)) { - marketplacePluginKeys.add(key); + keySet.add(key); continue; } - const bundle = await loadBundleFromR2(this.storage, pluginId, version); + const bundle = await loadBundleFromR2(this.storage, pluginId, version, source); if (!bundle) { - console.warn(`EmDash: Marketplace plugin ${pluginId}@${version} not found in R2`); + console.warn(`EmDash: ${source} plugin ${pluginId}@${version} not found in R2`); continue; } const loaded = await sandboxRunner.load(bundle.manifest, bundle.backendCode); sandboxedPluginCache.set(key, loaded); this.sandboxedPlugins.set(key, loaded); - marketplacePluginKeys.add(key); + keySet.add(key); // Cache manifest admin config for getManifest() marketplaceManifestCache.set(pluginId, { @@ -601,7 +648,7 @@ export class EmDashRuntime { } } } catch (error) { - console.error("EmDash: Failed to sync marketplace plugins:", error); + console.error(`EmDash: Failed to sync ${source} plugins:`, error); } } @@ -756,7 +803,26 @@ export class EmDashRuntime { // Cold-start: load marketplace-installed plugins from site R2 if (deps.config.marketplace && storage) { await phase("rt.market", "Marketplace plugins", () => - EmDashRuntime.loadMarketplacePlugins(db, storage, deps, sandboxedPlugins), + EmDashRuntime.loadInstalledSandboxedPlugins( + "marketplace", + db, + storage, + deps, + sandboxedPlugins, + ), + ); + } + + // Cold-start: load registry-installed plugins from site R2 + if (deps.config.experimental?.registry && storage) { + await phase("rt.registry", "Registry plugins", () => + EmDashRuntime.loadInstalledSandboxedPlugins( + "registry", + db, + storage, + deps, + sandboxedPlugins, + ), ); } @@ -1120,7 +1186,16 @@ export class EmDashRuntime { * Queries _plugin_state for source='marketplace' rows, fetches each bundle * from R2, and loads via SandboxRunner. */ - private static async loadMarketplacePlugins( + /** + * Cold-start load of all active sandboxed plugins for one install + * tier (marketplace or registry) from site-local R2. + * + * Mirrors {@link syncSandboxedSourcePlugins} but runs once at runtime + * creation, before request traffic arrives; the sync method runs on + * demand after install / update / uninstall handlers. + */ + private static async loadInstalledSandboxedPlugins( + source: "marketplace" | "registry", db: Kysely, storage: Storage, deps: RuntimeDependencies, @@ -1134,31 +1209,37 @@ export class EmDashRuntime { return; } + const keySet = source === "marketplace" ? marketplacePluginKeys : registryPluginKeys; + try { const stateRepo = new PluginStateRepository(db); - const marketplacePlugins = await stateRepo.getMarketplacePlugins(); + const plugins = + source === "marketplace" + ? await stateRepo.getMarketplacePlugins() + : await stateRepo.getRegistryPlugins(); - for (const plugin of marketplacePlugins) { + for (const plugin of plugins) { if (plugin.status !== "active") continue; - const version = plugin.marketplaceVersion ?? plugin.version; + // Marketplace plugins record the live version in + // `marketplaceVersion`; registry plugins use `version` directly. + const version = + source === "marketplace" ? (plugin.marketplaceVersion ?? plugin.version) : plugin.version; const pluginKey = `${plugin.pluginId}:${version}`; // Skip if already loaded (shouldn't happen, but guard) if (cache.has(pluginKey)) continue; try { - const bundle = await loadBundleFromR2(storage, plugin.pluginId, version); + const bundle = await loadBundleFromR2(storage, plugin.pluginId, version, source); if (!bundle) { - console.warn( - `EmDash: Marketplace plugin ${plugin.pluginId}@${version} not found in R2`, - ); + console.warn(`EmDash: ${source} plugin ${plugin.pluginId}@${version} not found in R2`); continue; } const loaded = await sandboxRunner.load(bundle.manifest, bundle.backendCode); cache.set(pluginKey, loaded); - marketplacePluginKeys.add(pluginKey); + keySet.add(pluginKey); // Cache manifest admin config for getManifest() marketplaceManifestCache.set(plugin.pluginId, { @@ -1178,10 +1259,10 @@ export class EmDashRuntime { } console.log( - `EmDash: Loaded marketplace plugin ${pluginKey} with capabilities: [${bundle.manifest.capabilities.join(", ")}]`, + `EmDash: Loaded ${source} plugin ${pluginKey} with capabilities: [${bundle.manifest.capabilities.join(", ")}]`, ); } catch (error) { - console.error(`EmDash: Failed to load marketplace plugin ${plugin.pluginId}:`, error); + console.error(`EmDash: Failed to load ${source} plugin ${plugin.pluginId}:`, error); } } } catch { @@ -1486,6 +1567,12 @@ export class EmDashRuntime { ? { defaultLocale: i18nConfig.defaultLocale, locales: i18nConfig.locales } : undefined; + // Normalize the experimental registry config for browser consumption. + // Validation errors here surface as 500s from the manifest endpoint + // rather than being silently dropped -- a misconfigured registry + // should be loud, not invisible. + const registry = normalizeRegistryConfig(this.config.experimental?.registry) ?? undefined; + return { version: VERSION, commit: COMMIT, @@ -1496,6 +1583,7 @@ export class EmDashRuntime { authMode: authModeValue, i18n, marketplace: !!this.config.marketplace, + registry, }; } diff --git a/packages/core/src/plugins/marketplace.ts b/packages/core/src/plugins/marketplace.ts index 809a4ebfb..921615f0c 100644 --- a/packages/core/src/plugins/marketplace.ts +++ b/packages/core/src/plugins/marketplace.ts @@ -376,7 +376,26 @@ class MarketplaceClientImpl implements MarketplaceClient { * * We use a minimal tar parser since we only need to read a few small files. */ -async function extractBundle(tarballBytes: Uint8Array): Promise { +/** + * Exported so the experimental registry install handler can reuse the + * same parse / validate / hash primitive. Despite the file name, this + * function predates the marketplace-vs-registry split and is generic + * over plugin bundle tarballs regardless of distribution channel. + */ +// Aligns with RFC 0001 §"Bundle size limits" (256 KiB decompressed, +// 20 files). Matches `MAX_BUNDLE_SIZE` in cli/commands/bundle-utils.ts +// (the publish-side cap). We don't import that constant to keep this +// runtime module independent of the CLI; the two values are +// load-bearing identical and must stay in sync. +// +// Tar adds per-file headers (~512 bytes each) plus directory entries, +// so the entry count cap is set comfortably above RFC's 20-file limit. +// Going over either is a strong signal the bundle isn't a legitimate +// sandboxed plugin. +const MAX_DECOMPRESSED_BUNDLE_BYTES = 256 * 1024; +const MAX_BUNDLE_TAR_ENTRIES = 32; + +export async function extractBundle(tarballBytes: Uint8Array): Promise { // Decompress fully into memory first, then parse the tar. // Passing a pipeThrough() stream directly to unpackTar causes a backpressure // deadlock in workerd: the tar decoder's body-stream pull() needs more @@ -389,9 +408,42 @@ async function extractBundle(tarballBytes: Uint8Array): Promise { }, }).pipeThrough(createGzipDecoder()); - // Collect decompressed bytes fully before parsing - const decompressedBuf = await new Response(decompressedStream).arrayBuffer(); - const decompressedBytes = new Uint8Array(decompressedBuf); + // Collect decompressed bytes with a hard cap. A gzip-bomb -- a small + // tarball that decompresses to gigabytes -- otherwise exhausts + // worker / Node memory before we know to reject it. The cap is + // generous (32 MiB) relative to any plausible plugin bundle and + // strict enough to bound the worst case. + const reader = decompressedStream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > MAX_DECOMPRESSED_BUNDLE_BYTES) { + try { + await reader.cancel(); + } catch { + // nothing to do + } + throw new MarketplaceError( + `Bundle decompressed size exceeds limit (${MAX_DECOMPRESSED_BUNDLE_BYTES} bytes)`, + undefined, + "INVALID_BUNDLE", + ); + } + chunks.push(value); + } + const decompressedBytes = new Uint8Array(total); + { + let offset = 0; + for (const chunk of chunks) { + decompressedBytes.set(chunk, offset); + offset += chunk.byteLength; + } + } + const decompressed = new ReadableStream({ start(controller) { controller.enqueue(decompressedBytes); @@ -400,6 +452,13 @@ async function extractBundle(tarballBytes: Uint8Array): Promise { }); const entries = await unpackTar(decompressed); + if (entries.length > MAX_BUNDLE_TAR_ENTRIES) { + throw new MarketplaceError( + `Bundle has too many tar entries (${entries.length} > ${MAX_BUNDLE_TAR_ENTRIES})`, + undefined, + "INVALID_BUNDLE", + ); + } const decoder = new TextDecoder(); const files = new Map(); diff --git a/packages/core/src/plugins/state.ts b/packages/core/src/plugins/state.ts index 27a78bee7..a75817469 100644 --- a/packages/core/src/plugins/state.ts +++ b/packages/core/src/plugins/state.ts @@ -10,7 +10,7 @@ import type { Kysely } from "kysely"; import type { Database } from "../database/types.js"; export type PluginStatus = "active" | "inactive"; -export type PluginSource = "config" | "marketplace"; +export type PluginSource = "config" | "marketplace" | "registry"; function toPluginStatus(value: string): PluginStatus { if (value === "active") return "active"; @@ -19,6 +19,7 @@ function toPluginStatus(value: string): PluginStatus { function toPluginSource(value: string | undefined | null): PluginSource { if (value === "marketplace") return "marketplace"; + if (value === "registry") return "registry"; return "config"; } @@ -33,6 +34,21 @@ export interface PluginState { marketplaceVersion: string | null; displayName: string | null; description: string | null; + /** + * Publisher DID this plugin was published under. Populated only when + * `source === "registry"`; null otherwise. + */ + registryPublisherDid: string | null; + /** + * Slug under which the plugin was published in the publisher's repo + * (the rkey of the `pm.fair.package.profile` record). Populated only + * when `source === "registry"`; null otherwise. + * + * The opaque `pluginId` for registry installs is derived from + * `(registryPublisherDid, registrySlug)` -- see + * `packages/core/src/registry/plugin-id.ts`. + */ + registrySlug: string | null; } /** @@ -53,18 +69,7 @@ export class PluginStateRepository { if (!row) return null; - return { - pluginId: row.plugin_id, - status: toPluginStatus(row.status), - version: row.version, - installedAt: new Date(row.installed_at), - activatedAt: row.activated_at ? new Date(row.activated_at) : null, - deactivatedAt: row.deactivated_at ? new Date(row.deactivated_at) : null, - source: toPluginSource(row.source), - marketplaceVersion: row.marketplace_version ?? null, - displayName: row.display_name ?? null, - description: row.description ?? null, - }; + return rowToPluginState(row); } /** @@ -72,19 +77,7 @@ export class PluginStateRepository { */ async getAll(): Promise { const rows = await this.db.selectFrom("_plugin_state").selectAll().execute(); - - return rows.map((row) => ({ - pluginId: row.plugin_id, - status: toPluginStatus(row.status), - version: row.version, - installedAt: new Date(row.installed_at), - activatedAt: row.activated_at ? new Date(row.activated_at) : null, - deactivatedAt: row.deactivated_at ? new Date(row.deactivated_at) : null, - source: toPluginSource(row.source), - marketplaceVersion: row.marketplace_version ?? null, - displayName: row.display_name ?? null, - description: row.description ?? null, - })); + return rows.map(rowToPluginState); } /** @@ -96,19 +89,22 @@ export class PluginStateRepository { .selectAll() .where("source", "=", "marketplace") .execute(); + return rows.map(rowToPluginState); + } - return rows.map((row) => ({ - pluginId: row.plugin_id, - status: toPluginStatus(row.status), - version: row.version, - installedAt: new Date(row.installed_at), - activatedAt: row.activated_at ? new Date(row.activated_at) : null, - deactivatedAt: row.deactivated_at ? new Date(row.deactivated_at) : null, - source: toPluginSource(row.source), - marketplaceVersion: row.marketplace_version ?? null, - displayName: row.display_name ?? null, - description: row.description ?? null, - })); + /** + * Get all registry-installed plugin states. + * + * The runtime's registry sync path uses this to discover which + * registry plugins should be loaded into the sandbox on this worker. + */ + async getRegistryPlugins(): Promise { + const rows = await this.db + .selectFrom("_plugin_state") + .selectAll() + .where("source", "=", "registry") + .execute(); + return rows.map(rowToPluginState); } /** @@ -123,6 +119,8 @@ export class PluginStateRepository { marketplaceVersion?: string; displayName?: string; description?: string; + registryPublisherDid?: string; + registrySlug?: string; }, ): Promise { const now = new Date().toISOString(); @@ -151,6 +149,12 @@ export class PluginStateRepository { if (opts?.description !== undefined) { updates.description = opts.description; } + if (opts?.registryPublisherDid !== undefined) { + updates.registry_publisher_did = opts.registryPublisherDid; + } + if (opts?.registrySlug !== undefined) { + updates.registry_slug = opts.registrySlug; + } await this.db .updateTable("_plugin_state") @@ -173,6 +177,8 @@ export class PluginStateRepository { marketplace_version: opts?.marketplaceVersion ?? null, display_name: opts?.displayName ?? null, description: opts?.description ?? null, + registry_publisher_did: opts?.registryPublisherDid ?? null, + registry_slug: opts?.registrySlug ?? null, }) .execute(); } @@ -206,3 +212,43 @@ export class PluginStateRepository { return (result.numDeletedRows ?? 0) > 0; } } + +/** + * Internal: map a `_plugin_state` row to the public `PluginState` shape. + * + * Kept at module scope so the three select paths (`get`, `getAll`, + * `getMarketplacePlugins`, `getRegistryPlugins`) stay byte-identical in + * their handling of nullable columns -- adding a new column to the table + * means changing this function and nothing else. + */ +interface PluginStateRow { + plugin_id: string; + status: string; + version: string; + installed_at: string; + activated_at: string | null; + deactivated_at: string | null; + source: string; + marketplace_version: string | null; + display_name: string | null; + description: string | null; + registry_publisher_did: string | null; + registry_slug: string | null; +} + +function rowToPluginState(row: PluginStateRow): PluginState { + return { + pluginId: row.plugin_id, + status: toPluginStatus(row.status), + version: row.version, + installedAt: new Date(row.installed_at), + activatedAt: row.activated_at ? new Date(row.activated_at) : null, + deactivatedAt: row.deactivated_at ? new Date(row.deactivated_at) : null, + source: toPluginSource(row.source), + marketplaceVersion: row.marketplace_version ?? null, + displayName: row.display_name ?? null, + description: row.description ?? null, + registryPublisherDid: row.registry_publisher_did ?? null, + registrySlug: row.registry_slug ?? null, + }; +} diff --git a/packages/core/src/registry/config.ts b/packages/core/src/registry/config.ts new file mode 100644 index 000000000..f0613176f --- /dev/null +++ b/packages/core/src/registry/config.ts @@ -0,0 +1,245 @@ +/** + * Helpers for normalizing the experimental registry integration option + * (`config.experimental.registry` in `astro.config.mjs`) into the shape + * exposed on the admin manifest. + * + * The integration option accepts a human-friendly duration string for + * `policy.minimumReleaseAge` (`"48h"`, `"7d"`); the manifest exposes + * seconds so the browser doesn't need a duration parser. + */ + +import type { RegistryConfig } from "../astro/integration/runtime.js"; + +/** + * Shape returned in the admin manifest's `registry` field. The browser + * consumes this directly -- all duration normalization and aggregator URL + * validation has already happened by the time it gets here. + */ +export interface ManifestRegistryConfig { + aggregatorUrl: string; + acceptLabelers?: string; + policy?: { + minimumReleaseAgeSeconds?: number; + /** + * Allowlist of publishers / packages exempt from the + * {@link minimumReleaseAgeSeconds} holdback. Each entry is either: + * + * - A bare publisher identifier: `"did:plc:abc123"` or a handle + * like `"example.dev"`. Every package from that publisher is + * exempt. + * - A `publisher/slug` pair: only that specific package is exempt. + * + * Normalized to lowercase strings at config load time so the + * browser does case-insensitive comparison. See + * {@link releaseExemptFromMinimumAge}. + */ + minimumReleaseAgeExclude?: string[]; + }; +} + +/** + * Returns whether a `(publisher_did, slug)` pair is on the + * minimum-release-age exemption list. Exported so the same matcher is + * used by the browser policy filter and the server-side install + * enforcement. + * + * Matching is DID-only. Handles are aggregator-supplied envelope data + * (mutable, controlled by an attacker who compromises the aggregator) + * and cannot be used as a trust input -- a compromised aggregator + * could claim any handle for any package and bypass the holdback. DIDs + * are part of the AT URI of the package record and are independently + * resolvable, so even a compromised aggregator can't lie about the + * publisher DID without also breaking checksum verification downstream. + * + * Entries from config are already lowercased at manifest-build time. + * Runtime values are lowercased here at compare time. + */ +export function releaseExemptFromMinimumAge( + exclude: readonly string[] | undefined, + publisherDid: string, + slug: string, +): boolean { + if (!exclude || exclude.length === 0) return false; + const didLower = publisherDid.toLowerCase(); + const slugLower = slug.toLowerCase(); + const fullDid = `${didLower}/${slugLower}`; + + for (const entry of exclude) { + if (entry === didLower) return true; + if (entry === fullDid) return true; + } + return false; +} + +const DURATION_PATTERN = /^(\d+)(s|m|h|d|w)$/; + +/** Trailing slashes on the aggregator URL, stripped during normalization. */ +const TRAILING_SLASHES = /\/+$/; + +/** Trailing dot on a hostname, stripped before URL host comparisons. */ +const TRAILING_DOT = /\.$/; + +/** + * Parse a duration string or raw second count into a non-negative + * integer count of seconds. Throws on unrecognised input so config + * mistakes fail at startup rather than silently disabling the policy. + */ +export function parseDurationSeconds(duration: string | number): number { + if (typeof duration === "number") { + if (!Number.isFinite(duration) || duration < 0) { + throw new Error(`Invalid duration: ${duration} (must be a non-negative finite number)`); + } + return Math.floor(duration); + } + + const match = duration.match(DURATION_PATTERN); + if (!match) { + throw new Error( + `Invalid duration format: "${duration}". Use a duration string like "48h", "7d", "30m", or a number of seconds.`, + ); + } + + const value = parseInt(match[1]!, 10); + const unit = match[2]; + + switch (unit) { + case "s": + return value; + case "m": + return value * 60; + case "h": + return value * 60 * 60; + case "d": + return value * 24 * 60 * 60; + case "w": + return value * 7 * 24 * 60 * 60; + default: + // Unreachable given the regex, but keep the exhaustive arm for + // future maintainers who add a unit to the pattern. + throw new Error(`Unknown duration unit: ${unit}`); + } +} + +/** + * Validate that `aggregatorUrl` is a safe outbound target for the + * registry's XRPC calls. Same posture as artifact downloads: HTTPS + * required in production; `http://localhost` allowed only in dev. + * + * The aggregator's responses are the trust source for release records, + * checksums, labels, mirrors, and `indexedAt` (until full MST + * verification lands). Allowing plain HTTP here would let a network + * attacker swap a release record and point the artifact URL at their + * own HTTPS bundle, defeating the checksum trust chain because the + * attacker controls the unsigned transport that supplied the checksum. + */ +export function validateAggregatorUrl(aggregatorUrl: string): URL { + let parsed: URL; + try { + parsed = new URL(aggregatorUrl); + } catch { + throw new Error(`registry.aggregatorUrl is not a valid URL: ${aggregatorUrl}`); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`registry.aggregatorUrl must use http or https: ${aggregatorUrl}`); + } + + // WHATWG URL preserves the brackets on IPv6 hostnames -- strip them + // before any comparison so `https://[::1]/` is recognised as localhost + // and not treated as a generic domain string. + const rawHostname = parsed.hostname.toLowerCase().replace(TRAILING_DOT, ""); + const hostname = + rawHostname.startsWith("[") && rawHostname.endsWith("]") + ? rawHostname.slice(1, -1) + : rawHostname; + const isLocalhost = + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname === "127.0.0.1" || + hostname === "::1" || + // IPv4-mapped IPv6 forms of loopback, e.g. `::ffff:127.0.0.1` and `::ffff:7f00:1`. + hostname.startsWith("::ffff:127.") || + hostname.startsWith("::ffff:7f00:"); + + if (!import.meta.env.DEV) { + if (parsed.protocol === "http:") { + throw new Error(`registry.aggregatorUrl must use https in production: ${aggregatorUrl}`); + } + if (isLocalhost) { + throw new Error( + `registry.aggregatorUrl points at localhost; allowed only in dev: ${aggregatorUrl}`, + ); + } + } else if (parsed.protocol === "http:" && !isLocalhost) { + throw new Error( + `registry.aggregatorUrl must use https (http allowed only for localhost in dev): ${aggregatorUrl}`, + ); + } + + return parsed; +} + +/** + * Normalize the user-supplied `RegistryConfig` into the shape that ships + * to the admin browser via the manifest endpoint. + * + * Returns `null` when `config` is undefined so callers can spread the + * result directly into the manifest object. + * + * Throws if the aggregator URL is malformed, points at a forbidden host, + * or `policy.minimumReleaseAge` is unparseable. Both surface at runtime + * startup as 500s from the manifest endpoint -- intended, because the + * alternative is silently disabling the registry on misconfigured sites. + */ +export function normalizeRegistryConfig( + config: RegistryConfig | undefined, +): ManifestRegistryConfig | null { + if (!config) return null; + + const aggregatorUrl = config.aggregatorUrl?.trim(); + if (!aggregatorUrl) { + throw new Error("registry.aggregatorUrl is required when registry is configured"); + } + + validateAggregatorUrl(aggregatorUrl); + + const out: ManifestRegistryConfig = { + // Strip any trailing slash so `${aggregatorUrl}/xrpc/...` works + // regardless of how the user wrote it. + aggregatorUrl: aggregatorUrl.replace(TRAILING_SLASHES, ""), + }; + + if (config.acceptLabelers) { + out.acceptLabelers = config.acceptLabelers; + } + + const policy: ManifestRegistryConfig["policy"] = {}; + let hasPolicy = false; + + if (config.policy?.minimumReleaseAge !== undefined) { + policy.minimumReleaseAgeSeconds = parseDurationSeconds(config.policy.minimumReleaseAge); + hasPolicy = true; + } + + if (config.policy?.minimumReleaseAgeExclude !== undefined) { + // Normalize at load time so callers (browser and server) can do + // plain string compares without each one re-implementing the + // case-folding rule. + const list = config.policy.minimumReleaseAgeExclude.map((entry) => { + const trimmed = entry.trim(); + if (!trimmed) { + throw new Error("registry.policy.minimumReleaseAgeExclude entries cannot be empty"); + } + return trimmed.toLowerCase(); + }); + if (list.length > 0) { + policy.minimumReleaseAgeExclude = list; + hasPolicy = true; + } + } + + if (hasPolicy) { + out.policy = policy; + } + + return out; +} diff --git a/packages/core/src/registry/plugin-id.ts b/packages/core/src/registry/plugin-id.ts new file mode 100644 index 000000000..f3d254453 --- /dev/null +++ b/packages/core/src/registry/plugin-id.ts @@ -0,0 +1,112 @@ +/** + * Plugin identifier helpers for the experimental decentralized plugin + * registry. + * + * Registry plugins are addressed by `(publisher_did, slug)`, but the + * EmDash runtime threads a single `pluginId: string` through every + * install primitive (R2 storage keys, `PluginStateRepository`, + * `syncMarketplacePlugins`, sandbox cache keys). Rather than refactor + * everything to carry a composite identifier, we normalize the registry + * tuple to an opaque content-addressed id that satisfies the existing + * `validatePluginIdentifier` shape (`/^[a-z][a-z0-9_]*$/`). + * + * The normalized id is: + * + * `r_` + base32-encoded SHA-256(publisher_did + "\n" + slug), truncated. + * + * Properties: + * + * - Deterministic. The same `(publisher, slug)` always produces the + * same id, so re-resolving an installed plugin's metadata against + * the aggregator is a straightforward lookup keyed by the columns + * stored alongside `plugin_id` in `plugin_states`. + * - Collision-resistant. 80 bits of truncated hash; a 50% birthday + * collision happens around 2^40 distinct plugins, well beyond what + * this registry will ever index. + * - R2-safe. Lowercase alphanumerics + underscores, no `:` or `/`. + * Existing sandbox cache keys (`${pluginId}:${version}`) keep + * working because the id contains no `:`. + * - Cannot collide with marketplace plugin ids. Marketplace ids must + * start with an ASCII letter (`/^[a-z]/`); the `r_` prefix means + * registry ids never look like marketplace ids. + * + * Reverse lookup (id → publisher + slug) requires the `plugin_states` + * row -- the hash is one-way. That's intentional: any code path that + * needs the human-meaningful pair already has the state row in hand. + */ + +/** Length (in base32 characters) of the truncated hash portion of the id. */ +const HASH_LENGTH = 16; + +/** Total expected length of a registry plugin id. */ +export const REGISTRY_PLUGIN_ID_LENGTH = 2 /* "r_" */ + HASH_LENGTH; + +/** + * Regex matching a well-formed registry plugin id. Used by call sites + * that need to distinguish registry installs from marketplace installs + * without consulting the `source` column on `plugin_states`. + * + * The base32 alphabet here uses RFC 4648 lowercase without padding, + * matching {@link base32Encode}'s output. + */ +export const REGISTRY_PLUGIN_ID_PATTERN = /^r_[a-z2-7]{16}$/; + +const BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"; + +/** + * RFC 4648 base32 encoding without padding, lowercase. Implemented inline + * rather than depending on a multibase library because (a) we only need + * lowercase base32 here, (b) we need it to run identically in workerd, + * Node, and the browser, and (c) the implementation is fewer lines than + * the import statement would be. + */ +function base32Encode(bytes: Uint8Array): string { + let bits = 0; + let value = 0; + let out = ""; + for (const byte of bytes) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + bits -= 5; + out += BASE32_ALPHABET[(value >>> bits) & 0x1f]; + } + } + if (bits > 0) { + out += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f]; + } + return out; +} + +/** + * Derive the normalized plugin id for a registry-published plugin. + * + * Throws if either input is empty or whitespace-only -- a missing DID + * or slug is always a programming error in the install path, not a + * recoverable runtime condition. + */ +export async function makeRegistryPluginId(publisherDid: string, slug: string): Promise { + const did = publisherDid.trim(); + const s = slug.trim(); + if (!did) throw new Error("makeRegistryPluginId: publisherDid is required"); + if (!s) throw new Error("makeRegistryPluginId: slug is required"); + + // `\n` separator avoids ambiguity: no canonical did:plc / did:web form + // contains a literal newline, so `("a", "b\nc")` cannot hash to the + // same bytes as `("a\nb", "c")`. + const input = `${did}\n${s}`; + const hashBuffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); + const encoded = base32Encode(new Uint8Array(hashBuffer)); + return `r_${encoded.slice(0, HASH_LENGTH)}`; +} + +/** + * Return whether `pluginId` is a well-formed registry plugin id. + * + * This is a syntactic check, not a database lookup -- it answers + * "could this id have come from `makeRegistryPluginId`?", not "is this + * plugin installed?". + */ +export function isRegistryPluginId(pluginId: string): boolean { + return REGISTRY_PLUGIN_ID_PATTERN.test(pluginId); +} diff --git a/packages/core/tests/integration/database/migrations.test.ts b/packages/core/tests/integration/database/migrations.test.ts index dfdefbe30..72d6e1bc2 100644 --- a/packages/core/tests/integration/database/migrations.test.ts +++ b/packages/core/tests/integration/database/migrations.test.ts @@ -117,6 +117,7 @@ describe("Database Migrations (Integration)", () => { "035_bounded_404_log", "036_i18n_menus_and_taxonomies", "037_credential_algorithm", + "038_registry_plugin_state", ]; await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eeda756d9..859cc17f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -837,6 +837,9 @@ importers: packages/admin: dependencies: + '@atcute/lexicons': + specifier: 'catalog:' + version: 1.3.0 '@cloudflare/kumo': specifier: ^1.16.0 version: 1.16.0(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1) @@ -852,6 +855,12 @@ importers: '@emdash-cms/blocks': specifier: workspace:* version: link:../blocks + '@emdash-cms/registry-client': + specifier: workspace:* + version: link:../registry-client + '@emdash-cms/registry-lexicons': + specifier: workspace:* + version: link:../registry-lexicons '@floating-ui/react': specifier: ^0.27.16 version: 0.27.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1313,6 +1322,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)(tsx@4.21.0)(yaml@2.8.2) + '@atcute/lexicons': + specifier: 'catalog:' + version: 1.3.0 '@emdash-cms/admin': specifier: workspace:* version: link:../admin @@ -1328,6 +1340,9 @@ importers: '@emdash-cms/plugin-types': specifier: workspace:* version: link:../plugin-types + '@emdash-cms/registry-client': + specifier: workspace:* + version: link:../registry-client '@floating-ui/react': specifier: ^0.27.16 version: 0.27.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1384,10 +1399,10 @@ importers: version: 3.7.0 astro: specifier: '>=6.0.0-beta.0' - version: 6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 6.1.7(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) astro-portabletext: specifier: ^0.11.0 - version: 0.11.4(astro@6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)) + version: 0.11.4(astro@6.1.7(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)) better-sqlite3: specifier: 'catalog:' version: 12.8.0 @@ -6418,6 +6433,11 @@ packages: engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} hasBin: true + astro@6.1.7: + resolution: {integrity: sha512-pvZysIUV2C2nRv8N7cXAkCLcfDQz/axAxF09SqiTz1B+xnvbhy6KzL2I6J15ZBXk8k0TfMD75dJ151QyQmAqZA==} + engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + astro@https://pkg.pr.new/astro@94d342d: resolution: {tarball: https://pkg.pr.new/astro@94d342d} version: 6.1.7 @@ -14780,7 +14800,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/utils@4.1.5': dependencies: @@ -15031,11 +15051,11 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - astro-portabletext@0.11.4(astro@6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)): + astro-portabletext@0.11.4(astro@6.1.7(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)): dependencies: '@portabletext/toolkit': 3.0.3 '@portabletext/types': 2.0.15 - astro: 6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + astro: 6.1.7(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) astro@6.0.0-beta.20(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): dependencies: @@ -15413,6 +15433,99 @@ snapshots: - uploadthing - yaml + astro@6.1.7(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): + dependencies: + '@astrojs/compiler': 3.0.1 + '@astrojs/internal-helpers': 0.8.0 + '@astrojs/markdown-remark': 7.1.0 + '@astrojs/telemetry': 3.3.0 + '@capsizecss/unpack': 4.0.0 + '@clack/prompts': 1.1.0 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.3.0(rollup@4.55.2) + aria-query: 5.3.2 + axobject-query: 4.1.0 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 2.0.0 + cookie: 1.1.1 + devalue: 5.6.3 + diff: 8.0.3 + dset: 3.1.4 + es-module-lexer: 2.0.0 + esbuild: 0.27.3 + flattie: 1.1.1 + fontace: 0.4.1 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + js-yaml: 4.1.1 + magic-string: 0.30.21 + magicast: 0.5.2 + mrmime: 2.0.1 + neotraverse: 0.6.18 + obug: 2.1.1 + p-limit: 7.3.0 + p-queue: 9.1.0 + package-manager-detector: 1.6.0 + piccolore: 0.1.3 + picomatch: 4.0.4 + rehype: 13.0.2 + semver: 7.7.4 + shiki: 4.0.2 + smol-toml: 1.6.0 + svgo: 4.0.1 + tinyclip: 0.1.12 + tinyexec: 1.0.4 + tinyglobby: 0.2.16 + tsconfck: 3.1.6(typescript@5.9.3) + ultrahtml: 1.6.0 + unifont: 0.7.4 + unist-util-visit: 5.1.0 + unstorage: 1.17.4 + vfile: 6.0.3 + vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) + vitefu: 1.1.2(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) + xxhash-wasm: 1.1.0 + yargs-parser: 22.0.0 + zod: 4.4.1 + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - uploadthing + - yaml + astro@https://pkg.pr.new/astro@94d342d(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(tsx@4.21.0)(typescript@6.0.0-beta)(yaml@2.8.2): dependencies: '@astrojs/compiler': 3.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 470e011d2..7a566bdfc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,8 +21,6 @@ catalog: "@astrojs/node": ^10.0.0 "@astrojs/react": ^5.0.0 "@atcute/atproto": ^3.1.11 - "@atproto/crypto": ^0.4.5 - "@atproto/repo": ^0.9.1 "@atcute/car": ^6.0.0 "@atcute/cbor": ^2.3.3 "@atcute/cid": ^2.4.1 @@ -40,16 +38,18 @@ catalog: "@atcute/repo": ^1.0.0 "@atcute/xrpc-server": ^2.0.0 "@atcute/xrpc-server-cloudflare": ^2.0.0 + "@atproto/crypto": ^0.4.5 + "@atproto/repo": ^0.9.1 "@cloudflare/vite-plugin": ^1.36.3 "@cloudflare/vitest-pool-workers": ^0.16.3 "@cloudflare/workers-types": ^4.20260305.1 + "@iconify-json/ph": ^1.2.2 "@lingui/babel-plugin-lingui-macro": ^5.9.4 "@lingui/cli": ^5.9.4 "@lingui/conf": ^5.9.4 "@lingui/core": ^5.9.4 "@lingui/macro": ^5.9.4 "@lingui/react": ^5.9.4 - "@iconify-json/ph": ^1.2.2 "@oslojs/crypto": ^1.0.1 "@oslojs/encoding": ^1.1.0 "@oslojs/webauthn": ^1.0.0 From 5329a46c7110a94e0b7a322a65ffbc7cbb409eb6 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 13 May 2026 11:27:24 +0100 Subject: [PATCH 3/9] fix(registry-lexicons): drop codegen from build script The generated lexicon types are committed to git so consumers don't need the codegen toolchain. Running lex-cli generate as part of the default build pipeline broke Cloudflare Pages builds for sites that pull registry-lexicons in transitively, because lex-cli imports lex.config.ts directly and Node in the CF Pages build environment can't load .ts natively. Codegen moves to a separate `regen` script (`pnpm regen` runs codegen + full build). Maintainers run it when they edit the lexicons; consumers just consume the committed output. --- packages/registry-lexicons/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/registry-lexicons/package.json b/packages/registry-lexicons/package.json index 6e77c6f21..73340b1df 100644 --- a/packages/registry-lexicons/package.json +++ b/packages/registry-lexicons/package.json @@ -31,7 +31,8 @@ "codegen": "lex-cli generate", "build:lexicons": "node scripts/copy-lexicons.mjs", "build:types": "tsdown", - "build": "pnpm run build:lexicons && pnpm run codegen && pnpm run build:types", + "build": "pnpm run build:lexicons && pnpm run build:types", + "regen": "pnpm run codegen && pnpm run build", "prepublishOnly": "node --run build", "typecheck": "tsgo --noEmit", "test": "vitest run", From 6632d8cc1103efe613511eab16c61badd7c10d4f Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 13 May 2026 12:24:40 +0100 Subject: [PATCH 4/9] fix(registry): copilot review fixes - Drift check normalizes capabilities (filter strings, dedupe, sort) on both browser and server so reorderings or junk entries can't trigger spurious rejection. Adds a shared normalizeCapabilities helper in registry/config.ts and a mirror in admin/lib/api/registry.ts. - RegistryPluginDetail no longer trusts the aggregator-supplied ext?.capabilities as already-validated string[]; runs it through normalizeCapabilities before display and before send. - Fix stale '32 MiB' docstring on extractBundle (cap is actually MAX_DECOMPRESSED_BUNDLE_BYTES = 256 KiB). - Fix plugin-id.ts JSDoc: validatePluginIdentifier regex is /^[a-z][a-z0-9_-]*$/ (allows hyphens); the prior 'cannot collide with marketplace ids' claim was too strong and is now framed as 'syntactically distinct, plus an explicit pre-existing-row check in the install handler.' --- .../src/components/RegistryPluginDetail.tsx | 11 +++-- packages/admin/src/lib/api/registry.ts | 20 +++++++++ packages/core/src/api/handlers/registry.ts | 45 ++++++++++--------- packages/core/src/plugins/marketplace.ts | 6 +-- packages/core/src/registry/config.ts | 27 +++++++++++ packages/core/src/registry/plugin-id.ts | 18 +++++--- 6 files changed, 94 insertions(+), 33 deletions(-) diff --git a/packages/admin/src/components/RegistryPluginDetail.tsx b/packages/admin/src/components/RegistryPluginDetail.tsx index 518fcf4fc..3615bfd20 100644 --- a/packages/admin/src/components/RegistryPluginDetail.tsx +++ b/packages/admin/src/components/RegistryPluginDetail.tsx @@ -23,6 +23,7 @@ import * as React from "react"; import { getLatestRegistryRelease, installRegistryPlugin, + normalizeCapabilities, releasePassesPolicy, resolveRegistryPackage, type RegistryClientConfig, @@ -68,9 +69,13 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP // available and fall back to the structured declaredAccess flattened // to a string list otherwise. This keeps `CapabilityConsentDialog` -- // which only understands `capabilities` -- working unchanged. + // + // `normalizeCapabilities` filters non-strings, dedupes, and sorts so + // an aggregator-supplied array with unstable order or junk entries + // can't trigger a spurious server-side drift rejection later. const releaseDoc = release?.release as | { - extensions?: Record; + extensions?: Record; } | undefined; const extensionEntries = releaseDoc?.extensions ? Object.entries(releaseDoc.extensions) : []; @@ -79,8 +84,8 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP )?.[1]; const capabilities: string[] = Array.isArray(ext?.capabilities) - ? (ext?.capabilities as string[]) - : declaredAccessToCapabilityList(ext?.declaredAccess); + ? normalizeCapabilities(ext?.capabilities) + : normalizeCapabilities(declaredAccessToCapabilityList(ext?.declaredAccess)); const profile = pkg?.profile as { name?: string; description?: string } | undefined; const verified = (pkg?.labels ?? []).some((l: { val?: string }) => l.val === "verified"); diff --git a/packages/admin/src/lib/api/registry.ts b/packages/admin/src/lib/api/registry.ts index 649987ec9..3e6499b17 100644 --- a/packages/admin/src/lib/api/registry.ts +++ b/packages/admin/src/lib/api/registry.ts @@ -207,6 +207,26 @@ export function releasePassesPolicy( return ageSeconds >= policy.minimumReleaseAgeSeconds; } +/** + * Normalize a capabilities list for set-style comparison. Mirrors the + * server-side helper of the same name in + * `packages/core/src/registry/config.ts` -- both sides must produce + * the same canonical shape so the install handler's drift check is + * stable across reorderings, duplicates, and junk entries. + * + * Filters non-strings, deduplicates, and sorts lexically. + */ +export function normalizeCapabilities(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + for (const entry of value) { + if (typeof entry === "string" && entry.length > 0) { + seen.add(entry); + } + } + return Array.from(seen).sort(); +} + /** * Matches a `(publisher_did, slug)` against the * `minimumReleaseAgeExclude` allowlist. Mirrors the server-side helper diff --git a/packages/core/src/api/handlers/registry.ts b/packages/core/src/api/handlers/registry.ts index adf8c4910..132613c20 100644 --- a/packages/core/src/api/handlers/registry.ts +++ b/packages/core/src/api/handlers/registry.ts @@ -48,6 +48,7 @@ import type { PluginBundle } from "../../plugins/marketplace.js"; import type { SandboxRunner } from "../../plugins/sandbox/types.js"; import { PluginStateRepository } from "../../plugins/state.js"; import { + normalizeCapabilities, parseDurationSeconds, releaseExemptFromMinimumAge, validateAggregatorUrl, @@ -866,26 +867,30 @@ export async function handleRegistryInstall( // label envelope and release-record `declaredAccess` are // independent assertions; this catches the case where they // diverged between the consent dialog and the install POST. - if ( - input.acknowledgedDeclaredAccess !== undefined && - JSON.stringify(input.acknowledgedDeclaredAccess) !== - JSON.stringify(bundle.manifest.capabilities) - ) { - // We compare against the bundle's *capabilities* (the legacy - // shape) for v1 because EmDash's existing sandbox enforces - // capabilities, not the RFC's structured `declaredAccess`. - // Once the runtime starts enforcing `declaredAccess` natively, - // this comparison switches to that shape. Until then the - // admin UI lifts capabilities from the release record's - // extension data and the comparison is meaningful. - return { - success: false, - error: { - code: "DECLARED_ACCESS_DRIFT", - message: - "Plugin manifest has changed since you consented. Re-open the install dialog to review the new permissions.", - }, - }; + // + // Both sides are normalised (filter to strings, dedupe, sort) so + // reorderings or junk entries don't trigger spurious rejections. + // We compare against the bundle's *capabilities* (the legacy + // shape) for v1 because EmDash's existing sandbox enforces + // capabilities, not the RFC's structured `declaredAccess`. Once + // the runtime starts enforcing `declaredAccess` natively, this + // comparison switches to that shape. + if (input.acknowledgedDeclaredAccess !== undefined) { + const acknowledged = normalizeCapabilities(input.acknowledgedDeclaredAccess); + const actual = normalizeCapabilities(bundle.manifest.capabilities); + if ( + acknowledged.length !== actual.length || + acknowledged.some((cap, i) => cap !== actual[i]) + ) { + return { + success: false, + error: { + code: "DECLARED_ACCESS_DRIFT", + message: + "Plugin manifest has changed since you consented. Re-open the install dialog to review the new permissions.", + }, + }; + } } // Step 7: store in R2 under the registry prefix. diff --git a/packages/core/src/plugins/marketplace.ts b/packages/core/src/plugins/marketplace.ts index 921615f0c..073c80d48 100644 --- a/packages/core/src/plugins/marketplace.ts +++ b/packages/core/src/plugins/marketplace.ts @@ -410,9 +410,9 @@ export async function extractBundle(tarballBytes: Uint8Array): Promise(); + for (const entry of value) { + if (typeof entry === "string" && entry.length > 0) { + seen.add(entry); + } + } + return Array.from(seen).sort(); +} + /** * Returns whether a `(publisher_did, slug)` pair is on the * minimum-release-age exemption list. Exported so the same matcher is diff --git a/packages/core/src/registry/plugin-id.ts b/packages/core/src/registry/plugin-id.ts index f3d254453..91ca8d498 100644 --- a/packages/core/src/registry/plugin-id.ts +++ b/packages/core/src/registry/plugin-id.ts @@ -8,7 +8,7 @@ * `syncMarketplacePlugins`, sandbox cache keys). Rather than refactor * everything to carry a composite identifier, we normalize the registry * tuple to an opaque content-addressed id that satisfies the existing - * `validatePluginIdentifier` shape (`/^[a-z][a-z0-9_]*$/`). + * `validatePluginIdentifier` shape (`/^[a-z][a-z0-9_-]*$/`). * * The normalized id is: * @@ -23,12 +23,16 @@ * - Collision-resistant. 80 bits of truncated hash; a 50% birthday * collision happens around 2^40 distinct plugins, well beyond what * this registry will ever index. - * - R2-safe. Lowercase alphanumerics + underscores, no `:` or `/`. - * Existing sandbox cache keys (`${pluginId}:${version}`) keep - * working because the id contains no `:`. - * - Cannot collide with marketplace plugin ids. Marketplace ids must - * start with an ASCII letter (`/^[a-z]/`); the `r_` prefix means - * registry ids never look like marketplace ids. + * - R2-safe. Lowercase alphanumerics + underscore (no hyphens), no + * `:` or `/`. Existing sandbox cache keys (`${pluginId}:${version}`) + * keep working because the id contains no `:`. + * - Syntactically distinct from typical marketplace plugin ids: the + * `r_` prefix plus exactly 16 base32 characters is unlikely to be + * chosen as a marketplace id. Not formally guaranteed by the + * validator -- marketplace ids may begin with `r_` and contain + * hyphens -- so the install handler also performs an explicit + * pre-existing-row check at the derived id and rejects any cross- + * source collision (`PLUGIN_ID_COLLISION`). * * Reverse lookup (id → publisher + slug) requires the `plugin_states` * row -- the hash is one-way. That's intentional: any code path that From 0aefaded8d96e8e28cf3a570583961839a59d2bb Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 13 May 2026 13:55:13 +0100 Subject: [PATCH 5/9] fix(registry): address review findings + CI failures CI fixes: - Rename normalizeCapabilities -> canonicalCapabilitiesForDriftCheck to avoid namespace clash with the existing capability normalizer exported from @emdash-cms/plugin-types via core's index. The old name shadowed plugin-types' helper at the top level of core's dist, which made the definePlugin() overload set look ambiguous to TS in plugins/forms and caused a typecheck cascade there. - [...seen].toSorted() instead of [...seen].sort() to clear the e18e/prefer-spread-syntax + unicorn/no-array-sort lint errors. Review findings (ask-bonk[bot]): - HIGH: drift check tripped on every install when the release record's extension was empty. The browser now omits acknowledgedDeclaredAccess when capabilities is empty, opting out of the server-side drift gate for the (currently common) case where publishers haven't filled in the extension block. The bundle's real capabilities are still bound to the checksum-verified bytes. - HIGH: DID-only publishers (no resolvable handle) could be linked from the browse grid but never installed because the server rejects handles without a '.'. Cards now render as non-interactive with a 'Publisher handle unresolved' badge; the detail page surfaces a matching warning and disables Install. - MEDIUM: registry-enabled sites were unconditionally routing existing marketplace plugin detail URLs to RegistryPluginDetail, breaking deep links. Detail-route selection now discriminates by param shape (pluginId.includes('/')) rather than the manifest flag. - MEDIUM: state-row write failure after storeBundleInR2 left orphan bundles. Best-effort cleanup in the catch via deleteBundleFromR2. - LOW: parseDurationSeconds runs on the user-supplied integration option per install (not the already-normalized manifest shape). Wrap in try/catch and surface as REGISTRY_POLICY_INVALID rather than letting it bubble to a generic INSTALL_FAILED. - LOW: validator-pattern doc drift in plugin-id.ts (already fixed in the prior commit). --- .../admin/src/components/RegistryBrowse.tsx | 80 ++++++++++++------- .../src/components/RegistryPluginDetail.tsx | 43 ++++++++-- packages/admin/src/lib/api/registry.ts | 8 +- packages/admin/src/router.tsx | 12 ++- packages/core/src/api/handlers/registry.ts | 66 ++++++++++++--- packages/core/src/registry/config.ts | 18 +++-- 6 files changed, 167 insertions(+), 60 deletions(-) diff --git a/packages/admin/src/components/RegistryBrowse.tsx b/packages/admin/src/components/RegistryBrowse.tsx index bd5377a6b..3e9fa013b 100644 --- a/packages/admin/src/components/RegistryBrowse.tsx +++ b/packages/admin/src/components/RegistryBrowse.tsx @@ -154,43 +154,69 @@ interface RegistryPackageCardProps { function RegistryPackageCard({ pkg, installed }: RegistryPackageCardProps) { const { t } = useLingui(); - const handle = pkg.handle ?? pkg.did; + // Only domain-like handles are installable; the server's handle + // validator rejects DID strings. Cards for packages whose handle the + // aggregator couldn't resolve render the same content but without a + // link, so the user understands the package isn't actionable. + const installable = Boolean(pkg.handle && pkg.handle.includes(".")); + const handleDisplay = pkg.handle ?? pkg.did; // `profile` is a pass-through of the signed package profile record. // We duck-type minimal display fields out of it. const profile = pkg.profile as { name?: string; description?: string }; const verified = (pkg.labels ?? []).some((l: { val?: string }) => l.val === "verified"); + const inner = ( +
+
+ +
+
+
+

{profile.name ?? pkg.slug}

+ {verified ? ( + + ) : null} +
+

{handleDisplay}

+ {profile.description ? ( +

{profile.description}

+ ) : null} + {installed ? ( +
+ {t`Installed`} +
+ ) : null} + {!installable ? ( +
+ {t`Publisher handle unresolved`} +
+ ) : null} +
+
+ ); + + if (!installable) { + return ( +
+ {inner} +
+ ); + } + return ( -
-
- -
-
-
-

{profile.name ?? pkg.slug}

- {verified ? ( - - ) : null} -
-

{handle}

- {profile.description ? ( -

{profile.description}

- ) : null} - {installed ? ( -
- {t`Installed`} -
- ) : null} -
-
+ {inner} ); } diff --git a/packages/admin/src/components/RegistryPluginDetail.tsx b/packages/admin/src/components/RegistryPluginDetail.tsx index 3615bfd20..7befc1d49 100644 --- a/packages/admin/src/components/RegistryPluginDetail.tsx +++ b/packages/admin/src/components/RegistryPluginDetail.tsx @@ -21,9 +21,9 @@ import { Link } from "@tanstack/react-router"; import * as React from "react"; import { + canonicalCapabilitiesForDriftCheck, getLatestRegistryRelease, installRegistryPlugin, - normalizeCapabilities, releasePassesPolicy, resolveRegistryPackage, type RegistryClientConfig, @@ -84,14 +84,21 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP )?.[1]; const capabilities: string[] = Array.isArray(ext?.capabilities) - ? normalizeCapabilities(ext?.capabilities) - : normalizeCapabilities(declaredAccessToCapabilityList(ext?.declaredAccess)); + ? canonicalCapabilitiesForDriftCheck(ext?.capabilities) + : canonicalCapabilitiesForDriftCheck(declaredAccessToCapabilityList(ext?.declaredAccess)); const profile = pkg?.profile as { name?: string; description?: string } | undefined; const verified = (pkg?.labels ?? []).some((l: { val?: string }) => l.val === "verified"); const policyOk = release && pkg ? releasePassesPolicy(release, { did: pkg.did, slug }, config.policy) : true; + // Install requires a resolvable handle: the server validates handles + // with at least one `.`, which DID strings (`did:plc:abc`) don't + // satisfy. Publishers whose handle the aggregator couldn't resolve + // (or who haven't claimed one yet) can't be installed today. Surface + // the limitation in the UI rather than letting the user click into + // `INVALID_HANDLE` from the server. + const hasResolvableHandle = Boolean(pkg?.handle && pkg.handle.includes(".")); const installMutation = useMutation({ mutationFn: () => @@ -99,7 +106,17 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP handle, slug, version: release?.version, - acknowledgedDeclaredAccess: capabilities, + // Only send the acknowledgement when the dialog had real + // capability data to display. The server's drift check is + // gated on `acknowledgedDeclaredAccess !== undefined`, so + // omitting the field opts out of the check entirely -- + // correct behaviour for the (currently common) case where + // the publisher's release record doesn't yet carry an + // extension block. The bundle's actual capabilities are + // still bound to the checksum-verified bytes; the drift + // check is a UX sanity belt for already-displayed + // consent, not an authorization gate. + acknowledgedDeclaredAccess: capabilities.length > 0 ? capabilities : undefined, }), onSuccess: () => { setShowConsent(false); @@ -173,7 +190,7 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
+ {/* Unresolvable handle notice */} + {pkg && !hasResolvableHandle ? ( +
+ +
+

{t`Publisher handle is not resolvable`}

+

+ {t`This package's publisher hasn't claimed a handle the aggregator can resolve, so it can't be installed yet. The publisher needs to set up a handle (any domain they control) before this plugin is installable.`} +

+
+
+ ) : null} + {/* Policy holdback notice */} {release && !policyOk ? (
(); for (const entry of value) { @@ -224,7 +224,7 @@ export function normalizeCapabilities(value: unknown): string[] { seen.add(entry); } } - return Array.from(seen).sort(); + return [...seen].toSorted(); } /** diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index ad1409497..810cfb897 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -1336,10 +1336,14 @@ function MarketplaceDetailPage() { return new Set(plugins.map((p) => p.id)); }, [plugins]); - // Registry detail when configured. The `pluginId` route param carries - // `${handle}/${slug}` in the registry case; the slash is encoded once - // by the router and decoded back here. - if (manifest?.registry) { + // Discriminate by param shape, not by the manifest flag. A registry + // pluginId is always `${handle}/${slug}` and contains exactly one `/`; + // a marketplace pluginId is a single segment with no `/`. This keeps + // deep links to marketplace-installed plugins working on sites that + // later opt into the registry, instead of unconditionally routing + // every visit to RegistryPluginDetail. + const looksLikeRegistryId = pluginId.includes("/"); + if (manifest?.registry && looksLikeRegistryId) { return ; } diff --git a/packages/core/src/api/handlers/registry.ts b/packages/core/src/api/handlers/registry.ts index 132613c20..c589436d5 100644 --- a/packages/core/src/api/handlers/registry.ts +++ b/packages/core/src/api/handlers/registry.ts @@ -48,7 +48,7 @@ import type { PluginBundle } from "../../plugins/marketplace.js"; import type { SandboxRunner } from "../../plugins/sandbox/types.js"; import { PluginStateRepository } from "../../plugins/state.js"; import { - normalizeCapabilities, + canonicalCapabilitiesForDriftCheck, parseDurationSeconds, releaseExemptFromMinimumAge, validateAggregatorUrl, @@ -57,7 +57,7 @@ import { makeRegistryPluginId } from "../../registry/plugin-id.js"; import { EmDashStorageError } from "../../storage/types.js"; import type { Storage } from "../../storage/types.js"; import type { ApiResult } from "../types.js"; -import { storeBundleInR2 } from "./marketplace.js"; +import { deleteBundleFromR2, storeBundleInR2 } from "./marketplace.js"; // ── Types ────────────────────────────────────────────────────────── @@ -690,9 +690,31 @@ export async function handleRegistryInstall( // signed createdAt from the publisher's PDS (deferred to the // follow-up that adds full MST verification). If the timestamp // is missing or malformed, we fail closed and reject the install. + // `registryConfig` is the user-supplied integration option, not + // the normalized manifest shape, so the duration parse runs once + // per install. Catch a malformed value here -- normally caught at + // `normalizeRegistryConfig` time, but a future config-mutation + // path could re-enter with a bad value -- and surface it as a + // structured error rather than letting it bubble out as a generic + // 500. const minimumReleaseAge = registryConfig.policy?.minimumReleaseAge; - const minimumReleaseAgeSeconds = - minimumReleaseAge !== undefined ? parseDurationSeconds(minimumReleaseAge) : 0; + let minimumReleaseAgeSeconds = 0; + if (minimumReleaseAge !== undefined) { + try { + minimumReleaseAgeSeconds = parseDurationSeconds(minimumReleaseAge); + } catch (err) { + return { + success: false, + error: { + code: "REGISTRY_POLICY_INVALID", + message: + err instanceof Error + ? err.message + : "Invalid minimumReleaseAge value in registry config", + }, + }; + } + } if (minimumReleaseAgeSeconds > 0) { const exclude = registryConfig.policy?.minimumReleaseAgeExclude?.map((e) => e.trim().toLowerCase(), @@ -876,8 +898,8 @@ export async function handleRegistryInstall( // the runtime starts enforcing `declaredAccess` natively, this // comparison switches to that shape. if (input.acknowledgedDeclaredAccess !== undefined) { - const acknowledged = normalizeCapabilities(input.acknowledgedDeclaredAccess); - const actual = normalizeCapabilities(bundle.manifest.capabilities); + const acknowledged = canonicalCapabilitiesForDriftCheck(input.acknowledgedDeclaredAccess); + const actual = canonicalCapabilitiesForDriftCheck(bundle.manifest.capabilities); if ( acknowledged.length !== actual.length || acknowledged.some((cap, i) => cap !== actual[i]) @@ -901,14 +923,32 @@ export async function handleRegistryInstall( // (the signed record from the publisher's repo), not from the // bundle manifest -- the manifest carries the trust contract, // the profile carries the marketing copy. + // + // If the state-row write fails (DB error, PK race against a + // concurrent install of the same package), clean up the R2 bundle + // we just wrote so we don't leave orphans. The cleanup is + // best-effort; if it also fails, the row failure still surfaces + // to the caller. const profile = packageView.profile as { name?: string; description?: string }; - await stateRepo.upsert(pluginId, version, "active", { - source: "registry", - displayName: profile.name ?? slug, - description: profile.description ?? undefined, - registryPublisherDid: publisherDid, - registrySlug: slug, - }); + try { + await stateRepo.upsert(pluginId, version, "active", { + source: "registry", + displayName: profile.name ?? slug, + description: profile.description ?? undefined, + registryPublisherDid: publisherDid, + registrySlug: slug, + }); + } catch (stateErr) { + try { + await deleteBundleFromR2(storage, pluginId, version, "registry"); + } catch (cleanupErr) { + console.warn( + `[registry-install] Failed to clean up R2 bundle for ${pluginId}@${version} after state-row write failure:`, + cleanupErr, + ); + } + throw stateErr; + } return { success: true, diff --git a/packages/core/src/registry/config.ts b/packages/core/src/registry/config.ts index 7ae03d70c..189f71b27 100644 --- a/packages/core/src/registry/config.ts +++ b/packages/core/src/registry/config.ts @@ -38,22 +38,26 @@ export interface ManifestRegistryConfig { } /** - * Normalize a capabilities list for set-style comparison. + * Canonicalize a capabilities list for set-style comparison. * * Capabilities (the legacy declared-access shape used by the current * sandbox enforcer) are conceptually a *set*: order, duplicates, and * non-string entries don't carry meaning. The install handler's drift * check compares the admin's acknowledged set against the bundle - * manifest's set; both sides pass through this normalizer first so + * manifest's set; both sides pass through this canonicalizer first so * an aggregator-supplied array with unstable order or junk entries * can't cause a spurious drift rejection. * - * Filters non-strings, deduplicates, and sorts lexically. Exported so - * the same shape is produced by the browser before sending the - * `acknowledgedDeclaredAccess` payload and by the server before + * Filters non-strings, deduplicates, and sorts lexically. Named to + * avoid shadowing `@emdash-cms/plugin-types`'s existing + * `normalizeCapabilities` (which dedupes + applies the deprecated → + * current alias map but does not filter junk or sort). + * + * Exported so the same shape is produced by the browser before sending + * the `acknowledgedDeclaredAccess` payload and by the server before * comparing against the bundle. */ -export function normalizeCapabilities(value: unknown): string[] { +export function canonicalCapabilitiesForDriftCheck(value: unknown): string[] { if (!Array.isArray(value)) return []; const seen = new Set(); for (const entry of value) { @@ -61,7 +65,7 @@ export function normalizeCapabilities(value: unknown): string[] { seen.add(entry); } } - return Array.from(seen).sort(); + return [...seen].toSorted(); } /** From 02cb42061589bc48d9c6e61c96e2b754795a3e5a Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 13 May 2026 15:52:36 +0100 Subject: [PATCH 6/9] fix(registry): move registry config types to their own module The new RegistryConfig + ExperimentalConfig interfaces lived alongside definePlugin's overloads in astro/integration/runtime.ts. tsdown + rolldown's chunking decided to inline a bigger subset of plugin-related types into the entry chunk as a result, which broke definePlugin() overload resolution for trusted plugins building against core's dist on CI (plugins/forms failed with 'id does not exist in type StandardPluginDefinition'). Move both types to packages/core/src/registry/types.ts (still re-exported from runtime.ts for backwards compatibility) so the chunking matches main's layout and definePlugin's overloads resolve as before. --- packages/core/src/api/handlers/registry.ts | 2 +- .../core/src/astro/integration/runtime.ts | 130 +---------------- packages/core/src/registry/config.ts | 2 +- packages/core/src/registry/types.ts | 138 ++++++++++++++++++ 4 files changed, 143 insertions(+), 129 deletions(-) create mode 100644 packages/core/src/registry/types.ts diff --git a/packages/core/src/api/handlers/registry.ts b/packages/core/src/api/handlers/registry.ts index c589436d5..2f6d820d0 100644 --- a/packages/core/src/api/handlers/registry.ts +++ b/packages/core/src/api/handlers/registry.ts @@ -41,7 +41,6 @@ import type { Handle } from "@atcute/lexicons"; import type { Kysely } from "kysely"; -import type { RegistryConfig } from "../../astro/integration/runtime.js"; import type { Database } from "../../database/types.js"; import { extractBundle } from "../../plugins/marketplace.js"; import type { PluginBundle } from "../../plugins/marketplace.js"; @@ -54,6 +53,7 @@ import { validateAggregatorUrl, } from "../../registry/config.js"; import { makeRegistryPluginId } from "../../registry/plugin-id.js"; +import type { RegistryConfig } from "../../registry/types.js"; import { EmDashStorageError } from "../../storage/types.js"; import type { Storage } from "../../storage/types.js"; import type { ApiResult } from "../types.js"; diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index 7d1cc352e..e82ded719 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -11,8 +11,11 @@ import type { AuthDescriptor, AuthProviderDescriptor } from "../../auth/types.js import type { DatabaseDescriptor } from "../../db/adapters.js"; import type { MediaProviderDescriptor } from "../../media/types.js"; import type { ResolvedPlugin } from "../../plugins/types.js"; +import type { ExperimentalConfig } from "../../registry/types.js"; import type { StorageDescriptor } from "../storage/types.js"; +export type { ExperimentalConfig, RegistryConfig } from "../../registry/types.js"; + export type { ResolvedPlugin }; export type { MediaProviderDescriptor }; @@ -125,133 +128,6 @@ export interface PluginDescriptor> { export type SandboxedPluginDescriptor> = PluginDescriptor; -/** - * Experimental plugin registry configuration. - * - * See {@link ExperimentalConfig.registry}. - */ -export interface RegistryConfig { - /** - * Base URL of the registry aggregator (an atproto AppView that indexes - * the firehose for `pm.fair.package.*` and `com.emdashcms.*` records). - * - * Must be the origin where the aggregator's XRPC endpoints are mounted, - * such that `${aggregatorUrl}/xrpc/` resolves to a valid endpoint. - * - * Must be HTTPS in production; `http://localhost` or `http://127.0.0.1` - * are accepted in dev. - */ - aggregatorUrl: string; - - /** - * Optional comma-separated list of labeller DIDs forwarded as the - * `atproto-accept-labelers` header on every aggregator request. - * - * Format follows the atproto convention: - * `did:plc:abc;redact, did:plc:def` - * - * When unset, the aggregator applies its operator-default labeller set - * (typically the EmDash publisher-verification labeller and any - * additional trusted labellers the aggregator operator configured). - */ - acceptLabelers?: string; - - /** - * Site-level policy applied to the latest-release selection filter. - * - * These filters operate over the signed records the aggregator returns; - * they are not protocol-level constraints. See the RFC's - * "Update Discovery and Takedowns" section for the integration point. - */ - policy?: { - /** - * Hold back releases newer than this when computing the recommended - * install or update version. Mitigates "compromised publisher - * account pushes a malicious release of an established plugin" by - * giving the takedown labeller a detection window. - * - * Accepts a duration string (`"24h"`, `"48h"`, `"72h"`, `"7d"`) or a - * number of seconds. - * - * Currently applies uniformly to all releases. A future addition - * may exempt brand-new packages (those with no prior release - * history) so the holdback doesn't block first-time publishing, - * but that exemption is not implemented yet; use - * {@link minimumReleaseAgeExclude} to allowlist trusted publishers - * whose packages should install immediately. - * - * Defaults to `undefined` (no holdback). A future trust/moderation - * RFC will specify the recommended default. - */ - minimumReleaseAge?: string | number; - - /** - * Packages exempt from the {@link minimumReleaseAge} holdback. Use - * for publishers whose release tempo you've explicitly accepted -- - * your own first-party plugins, a trusted partner, etc. - * - * Each entry is either: - * - A bare publisher DID (e.g. `"did:plc:abc123"`) -- every - * package from that publisher is exempt. - * - A `/` pair (e.g. - * `"did:plc:abc123/hotfix-plugin"`) -- only that specific - * package is exempt. - * - * Whole-publisher exemptions are the common case: trust is - * naturally a property of the publisher, not of each individual - * package. Per-package exemptions exist for cases where a publisher - * has one plugin you want fast-track installs for and others you'd - * rather hold back. - * - * Only DIDs are accepted -- not handles. Handles are mutable - * aggregator-supplied envelope data, and accepting them as a - * trust input would let a compromised aggregator bypass the - * holdback by claiming any handle for any package. DIDs are - * tied to the AT URI of the package record itself, so even a - * compromised aggregator cannot lie about which DID published - * a release. - * - * Mirrors pnpm's `minimumReleaseAgeExclude`. - * - * @example - * ```ts - * minimumReleaseAgeExclude: [ - * "did:plc:emdashfirstparty", // every package from this publisher - * "did:plc:abc123/hotfix-plugin", // just this one package - * ] - * ``` - */ - minimumReleaseAgeExclude?: readonly string[]; - }; -} - -/** - * Experimental EmDash features. See {@link EmDashConfig.experimental}. - * - * Each field is independently opt-in. Fields may be promoted out of - * `experimental` (becoming top-level `EmDashConfig` options) or removed - * in minor releases; check the changelog when upgrading. - */ -export interface ExperimentalConfig { - /** - * Decentralized plugin registry. - * - * When set, replaces the centralized `marketplace` for the admin UI's - * browse and install flows. The registry is an atproto-backed - * federation: package metadata lives in each publisher's PDS, an - * aggregator (the `aggregatorUrl`) indexes the firehose and exposes - * read-only XRPC endpoints for discovery, and EmDash verifies each - * release against the publisher's signed records before installing. - * - * See [RFC 0001](https://github.com/emdash-cms/emdash/pull/694) for - * the protocol design and threat model. - * - * Requires `sandboxRunner` to be configured -- registry plugins always - * run sandboxed. - */ - registry?: RegistryConfig; -} - export interface EmDashConfig { /** * Database configuration diff --git a/packages/core/src/registry/config.ts b/packages/core/src/registry/config.ts index 189f71b27..d7e73067f 100644 --- a/packages/core/src/registry/config.ts +++ b/packages/core/src/registry/config.ts @@ -8,7 +8,7 @@ * seconds so the browser doesn't need a duration parser. */ -import type { RegistryConfig } from "../astro/integration/runtime.js"; +import type { RegistryConfig } from "./types.js"; /** * Shape returned in the admin manifest's `registry` field. The browser diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts new file mode 100644 index 000000000..31a80baf7 --- /dev/null +++ b/packages/core/src/registry/types.ts @@ -0,0 +1,138 @@ +/** + * Public types for the experimental plugin registry. + * + * Kept in their own module so they don't get re-bundled into the + * `astro/integration/runtime.ts` chunk's dist output. tsdown / rolldown + * are sensitive to which top-level types live alongside `definePlugin`'s + * overloads, and pulling these types into the integration module + * affected downstream `definePlugin()` overload resolution for trusted + * plugins built against core's dist (see commit history for the + * detailed write-up). + */ + +/** + * Experimental plugin registry configuration. + * + * See {@link ExperimentalConfig.registry}. + */ +export interface RegistryConfig { + /** + * Base URL of the registry aggregator (an atproto AppView that indexes + * the firehose for `pm.fair.package.*` and `com.emdashcms.*` records). + * + * Must be the origin where the aggregator's XRPC endpoints are mounted, + * such that `${aggregatorUrl}/xrpc/` resolves to a valid endpoint. + * + * Must be HTTPS in production; `http://localhost` or `http://127.0.0.1` + * are accepted in dev. + */ + aggregatorUrl: string; + + /** + * Optional comma-separated list of labeller DIDs forwarded as the + * `atproto-accept-labelers` header on every aggregator request. + * + * Format follows the atproto convention: + * `did:plc:abc;redact, did:plc:def` + * + * When unset, the aggregator applies its operator-default labeller set + * (typically the EmDash publisher-verification labeller and any + * additional trusted labellers the aggregator operator configured). + */ + acceptLabelers?: string; + + /** + * Site-level policy applied to the latest-release selection filter. + * + * These filters operate over the signed records the aggregator returns; + * they are not protocol-level constraints. See the RFC's + * "Update Discovery and Takedowns" section for the integration point. + */ + policy?: { + /** + * Hold back releases newer than this when computing the recommended + * install or update version. Mitigates "compromised publisher + * account pushes a malicious release of an established plugin" by + * giving the takedown labeller a detection window. + * + * Accepts a duration string (`"24h"`, `"48h"`, `"72h"`, `"7d"`) or a + * number of seconds. + * + * Currently applies uniformly to all releases. A future addition + * may exempt brand-new packages (those with no prior release + * history) so the holdback doesn't block first-time publishing, + * but that exemption is not implemented yet; use + * {@link minimumReleaseAgeExclude} to allowlist trusted publishers + * whose packages should install immediately. + * + * Defaults to `undefined` (no holdback). A future trust/moderation + * RFC will specify the recommended default. + */ + minimumReleaseAge?: string | number; + + /** + * Packages exempt from the {@link minimumReleaseAge} holdback. Use + * for publishers whose release tempo you've explicitly accepted -- + * your own first-party plugins, a trusted partner, etc. + * + * Each entry is either: + * - A bare publisher DID (e.g. `"did:plc:abc123"`) -- every + * package from that publisher is exempt. + * - A `/` pair (e.g. + * `"did:plc:abc123/hotfix-plugin"`) -- only that specific + * package is exempt. + * + * Whole-publisher exemptions are the common case: trust is + * naturally a property of the publisher, not of each individual + * package. Per-package exemptions exist for cases where a publisher + * has one plugin you want fast-track installs for and others you'd + * rather hold back. + * + * Only DIDs are accepted -- not handles. Handles are mutable + * aggregator-supplied envelope data, and accepting them as a + * trust input would let a compromised aggregator bypass the + * holdback by claiming any handle for any package. DIDs are + * tied to the AT URI of the package record itself, so even a + * compromised aggregator cannot lie about which DID published + * a release. + * + * Mirrors pnpm's `minimumReleaseAgeExclude`. + * + * @example + * ```ts + * minimumReleaseAgeExclude: [ + * "did:plc:emdashfirstparty", // every package from this publisher + * "did:plc:abc123/hotfix-plugin", // just this one package + * ] + * ``` + */ + minimumReleaseAgeExclude?: readonly string[]; + }; +} + +/** + * Experimental EmDash features. See `EmDashConfig.experimental`. + * + * Each field is independently opt-in. Fields may be promoted out of + * `experimental` (becoming top-level `EmDashConfig` options) or removed + * in minor releases; check the changelog when upgrading. + */ +export interface ExperimentalConfig { + /** + * Decentralized plugin registry. + * + * When set, replaces the centralized `marketplace` for the admin UI's + * browse and install flows. The registry is an atproto-backed + * federation: package metadata lives in each publisher's PDS, an + * aggregator (the `aggregatorUrl`) indexes the firehose and exposes + * read-only XRPC endpoints for discovery, and EmDash verifies each + * release against the publisher's signed records before installing. + * + * See [RFC 0001](https://github.com/emdash-cms/emdash/pull/694) for + * the protocol design and threat model. + * + * Requires `sandboxRunner` to be configured -- registry plugins always + * run sandboxed. + */ + registry?: RegistryConfig; +} From 7fb0d7ea3522b32ac129af00acafc89d1d11289f Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 13 May 2026 21:41:01 +0100 Subject: [PATCH 7/9] fix(registry): wire up real-world install + display polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggregator (apps/aggregator): - Add CORS to /xrpc/* so the admin UI can call it from any origin (preflight 204, response headers on every method). Aggregator is a public read-only service; * is correct here. Core (packages/core): - Implement multibase-multihash checksum verification by re-encoding our SHA-256 digest in the same 'b' shape the registry CLI produces, rather than decoding the publisher's checksum. Same trust contract, no base32 decoder needed. Bare hex SHA-256 still accepted as a convenience fallback. - Switch install handler to take 'did' (not handle) so packages whose handle the aggregator couldn't resolve are still installable. The browser resolves handle→DID via the aggregator before posting and sends DID directly; the server skips resolvePackage and goes straight to getPackage. - Coerce 'experimental.registry' bare-string shorthand into the full RegistryConfig object via 'coerceRegistryConfig'. 'registry: "..."' is now equivalent to 'registry: { aggregatorUrl: "..." }'. - Plumb 'experimental' through the integration's serializableConfig so the manifest endpoint actually sees the user's registry block. Previously it was being stripped, so the admin UI never branched to the registry path. - Split RegistryConfig + ExperimentalConfig types into their own module (registry/types.ts) so they don't get bundled into the astro/integration/runtime.ts dist chunk -- the wider inlining was breaking definePlugin overload resolution for trusted plugins building against core's dist. Admin (packages/admin): - New component + usePublisherHandle hook with tri-state result ('ok' / 'invalid' / 'missing'). Renders @handle, 'Unverified publisher' (red), or DID respectively. Uses @atcute/identity-resolver's LocalActorResolver for bidirectional handle verification, localStorage-cached for 24h. - Detail page disables install on 'invalid' status (publisher claims a handle that doesn't round-trip back to its DID -- impersonation risk). Surfaces 'We couldn't verify this publisher's identity' alert in plain language. - Detail page reads installed state from fetchPlugins() and swaps the Install button to 'Installed' (disabled) when the package already has a 'source = "registry"' row matching its DID + slug. React Query's existing ['plugins'] invalidation handles the post-install UI update. - Browse cards reuse (variant='card') and link by handle when available, DID otherwise. Detail page parses either form from the URL. - Browser sends 'did' (not handle) in the install POST. Workspace: - '@cloudflare/kumo' moved to the pnpm catalog and bumped to ^1.16.0 workspace-wide. Older 1.10.0 was missing Sidebar export and being hoisted into the admin via packages/blocks's transitive dep. - Add '@atcute/multibase' to core (for checksum encoding) and '@atcute/identity-resolver' to admin (for DID->handle resolution). - Update DEFAULT_AGGREGATOR_URL + DiscoveryClient doc example from 'experimental-registry.emdashcms.com' to 'registry.emdashcms.com' (the actual production host). --- apps/aggregator/src/routes/xrpc/router.ts | 48 +++++- infra/blog-demo/astro.config.mjs | 4 +- infra/blog-demo/emdash-env.d.ts | 2 +- packages/admin/package.json | 3 +- .../admin/src/components/PublisherHandle.tsx | 112 ++++++++++++++ .../admin/src/components/RegistryBrowse.tsx | 85 ++++------- .../src/components/RegistryPluginDetail.tsx | 122 +++++++++++---- packages/admin/src/lib/api/registry.ts | 143 +++++++++++++++++- packages/auth-atproto/package.json | 2 +- packages/blocks/package.json | 2 +- packages/blocks/playground/package.json | 2 +- packages/core/package.json | 1 + packages/core/src/api/handlers/registry.ts | 131 +++++++++++----- packages/core/src/astro/integration/index.ts | 1 + .../api/admin/plugins/registry/install.ts | 27 +++- packages/core/src/registry/config.ts | 45 ++++-- packages/core/src/registry/types.ts | 28 +++- packages/plugins/ai-moderation/package.json | 2 +- packages/plugins/field-kit/package.json | 2 +- packages/plugins/forms/package.json | 2 +- packages/registry-cli/src/config.ts | 2 +- .../registry-client/src/discovery/index.ts | 2 +- pnpm-lock.yaml | 68 +++------ pnpm-workspace.yaml | 1 + 24 files changed, 635 insertions(+), 202 deletions(-) create mode 100644 packages/admin/src/components/PublisherHandle.tsx diff --git a/apps/aggregator/src/routes/xrpc/router.ts b/apps/aggregator/src/routes/xrpc/router.ts index d90e7f678..a38cb78be 100644 --- a/apps/aggregator/src/routes/xrpc/router.ts +++ b/apps/aggregator/src/routes/xrpc/router.ts @@ -40,6 +40,35 @@ import { syncGetRecord } from "./sync-get-record.js"; const NO_STORE = "private, no-store"; const SYNC_GET_RECORD_PATH = "/xrpc/com.atproto.sync.getRecord"; +/** + * CORS for the aggregator's XRPC surface. + * + * The aggregator is a public read-only service: admin UIs running on + * arbitrary EmDash sites call it directly from the browser. The atproto + * spec doesn't standardize CORS for XRPC services, but browser clients + * need `Access-Control-Allow-Origin` to access the JSON responses. + * + * `*` is correct here because nothing in our responses depends on the + * caller's origin or credentials -- there are no cookies, no auth, no + * per-origin policy. We allow `atproto-accept-labelers` and + * `content-type` as request headers (the only two clients send), echo + * back the labellers header for symmetry with atproto's labeller-aware + * clients, and cap preflight cache at 24h. + */ +const CORS_HEADERS: Record = { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-allow-headers": "content-type, atproto-accept-labelers", + "access-control-expose-headers": "atproto-accept-labelers, content-language", + "access-control-max-age": "86400", +}; + +function applyCorsHeaders(headers: Headers): void { + for (const [name, value] of Object.entries(CORS_HEADERS)) { + headers.set(name, value); + } +} + /** * Dispatch any `/xrpc/*` request. Returns null when the path isn't an * XRPC route (caller falls through to other route matching). @@ -48,8 +77,24 @@ export async function handleXrpc(env: Env, request: Request): Promise }; content?: PortableTextBlock[]; excerpt?: string; createdAt: Date; diff --git a/packages/admin/package.json b/packages/admin/package.json index f6a4022b1..e8e27e5b3 100644 --- a/packages/admin/package.json +++ b/packages/admin/package.json @@ -31,8 +31,9 @@ "locale:extract": "lingui extract --clean" }, "dependencies": { + "@atcute/identity-resolver": "catalog:", "@atcute/lexicons": "catalog:", - "@cloudflare/kumo": "^1.16.0", + "@cloudflare/kumo": "catalog:", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/packages/admin/src/components/PublisherHandle.tsx b/packages/admin/src/components/PublisherHandle.tsx new file mode 100644 index 000000000..743883adb --- /dev/null +++ b/packages/admin/src/components/PublisherHandle.tsx @@ -0,0 +1,112 @@ +/** + * Renders an atproto publisher's identity, with three branches: + * + * - **Verified handle**: shows `@handle`. Either the aggregator + * already resolved the handle at ingest (we trust that), or our + * local `LocalActorResolver` round-tripped the DID document's + * `alsoKnownAs` back to the same DID. + * - **Unverified publisher**: DID document claims a handle but the + * handle's domain doesn't point back to the same DID. Treat as + * untrusted -- the publisher might be impersonating someone else. + * Surface as `Unverified publisher` in error styling. Callers + * should also disable destructive actions (install, etc.). + * - **Missing handle**: no claimed handle at all (or DID document + * resolution failed entirely). Fall back to the raw DID. + * + * `aggregatorHandle` is what the registry's `searchPackages` / + * `resolvePackage` endpoint returned for this DID -- best-effort, may + * be `null`. When absent, this component falls back to a per-DID + * `LocalActorResolver` lookup via `resolveDidToHandle`, cached in + * localStorage for 24h so repeat renders don't refetch. + */ + +import { useLingui } from "@lingui/react/macro"; +import { useQuery } from "@tanstack/react-query"; +import * as React from "react"; + +import { resolveDidToHandle } from "../lib/api/registry.js"; + +export type PublisherHandleStatus = "ok" | "invalid" | "missing"; + +export interface PublisherHandleResult { + status: PublisherHandleStatus; + /** Verified handle (only present when `status === "ok"`). */ + handle?: string; +} + +export interface PublisherHandleProps { + did: string; + aggregatorHandle?: string | null; + /** + * Called every time the resolution status changes, so callers can + * gate install buttons or other side effects on + * `status === "invalid"`. Optional. + */ + onResolved?: (result: PublisherHandleResult) => void; + /** Visual variant. `card` is the smaller list-item form. */ + variant?: "card" | "detail"; + className?: string; +} + +/** + * Hook form: returns the same tri-state result without rendering. Use + * when a parent needs to coordinate UI (e.g. disable install) based on + * the resolution. + */ +export function usePublisherHandle( + did: string, + aggregatorHandle?: string | null, +): PublisherHandleResult { + const { data: didHandleResolution } = useQuery({ + queryKey: ["registry", "did-handle", did], + queryFn: () => resolveDidToHandle(did), + enabled: Boolean(did) && !aggregatorHandle, + staleTime: 5 * 60 * 1000, + }); + + if (aggregatorHandle) return { status: "ok", handle: aggregatorHandle }; + if (!didHandleResolution) return { status: "missing" }; + if (didHandleResolution.status === "ok") { + return { status: "ok", handle: didHandleResolution.handle }; + } + return { status: didHandleResolution.status }; +} + +export function PublisherHandle({ + did, + aggregatorHandle, + onResolved, + variant = "card", + className, +}: PublisherHandleProps) { + const { t } = useLingui(); + const result = usePublisherHandle(did, aggregatorHandle); + + // Notify the caller every time the result changes. Effect (not + // inline) so we don't re-fire on every parent re-render. + const onResolvedRef = React.useRef(onResolved); + onResolvedRef.current = onResolved; + React.useEffect(() => { + onResolvedRef.current?.(result); + }, [result.status, result.handle]); + + const textClass = variant === "card" ? "text-xs" : "text-sm"; + + if (result.status === "ok" && result.handle) { + return ( + + @{result.handle} + + ); + } + + if (result.status === "invalid") { + return ( + + {t`Unverified publisher`} + + ); + } + + return {did}; +} diff --git a/packages/admin/src/components/RegistryBrowse.tsx b/packages/admin/src/components/RegistryBrowse.tsx index 3e9fa013b..618fa3896 100644 --- a/packages/admin/src/components/RegistryBrowse.tsx +++ b/packages/admin/src/components/RegistryBrowse.tsx @@ -23,6 +23,7 @@ import { type RegistryClientConfig, type RegistryPackageView, } from "../lib/api/registry.js"; +import { PublisherHandle, usePublisherHandle } from "./PublisherHandle.js"; export interface RegistryBrowseProps { /** Resolved manifest.registry block. Required -- caller checks. */ @@ -154,69 +155,47 @@ interface RegistryPackageCardProps { function RegistryPackageCard({ pkg, installed }: RegistryPackageCardProps) { const { t } = useLingui(); - // Only domain-like handles are installable; the server's handle - // validator rejects DID strings. Cards for packages whose handle the - // aggregator couldn't resolve render the same content but without a - // link, so the user understands the package isn't actionable. - const installable = Boolean(pkg.handle && pkg.handle.includes(".")); - const handleDisplay = pkg.handle ?? pkg.did; + const handleResult = usePublisherHandle(pkg.did, pkg.handle); + // Always link by handle when we have one (cleaner URL), DID + // otherwise. The detail page accepts either. + const linkSegment = handleResult.handle ?? pkg.did; // `profile` is a pass-through of the signed package profile record. // We duck-type minimal display fields out of it. const profile = pkg.profile as { name?: string; description?: string }; const verified = (pkg.labels ?? []).some((l: { val?: string }) => l.val === "verified"); - const inner = ( -
-
- -
-
-
-

{profile.name ?? pkg.slug}

- {verified ? ( - - ) : null} -
-

{handleDisplay}

- {profile.description ? ( -

{profile.description}

- ) : null} - {installed ? ( -
- {t`Installed`} -
- ) : null} - {!installable ? ( -
- {t`Publisher handle unresolved`} -
- ) : null} -
-
- ); - - if (!installable) { - return ( -
- {inner} -
- ); - } - return ( - {inner} +
+
+ +
+
+
+

{profile.name ?? pkg.slug}

+ {verified ? ( + + ) : null} +
+ + + {profile.description ? ( +

{profile.description}

+ ) : null} + {installed ? ( +
+ {t`Installed`} +
+ ) : null} +
+
); } diff --git a/packages/admin/src/components/RegistryPluginDetail.tsx b/packages/admin/src/components/RegistryPluginDetail.tsx index 7befc1d49..1827783a5 100644 --- a/packages/admin/src/components/RegistryPluginDetail.tsx +++ b/packages/admin/src/components/RegistryPluginDetail.tsx @@ -23,6 +23,7 @@ import * as React from "react"; import { canonicalCapabilitiesForDriftCheck, getLatestRegistryRelease, + getRegistryPackage, installRegistryPlugin, releasePassesPolicy, resolveRegistryPackage, @@ -31,6 +32,7 @@ import { import { ArrowPrev } from "./ArrowIcons.js"; import { CapabilityConsentDialog } from "./CapabilityConsentDialog.js"; import { getMutationError } from "./DialogError.js"; +import { PublisherHandle, usePublisherHandle } from "./PublisherHandle.js"; export interface RegistryPluginDetailProps { /** `${handle}/${slug}` -- the pluginId param from the route. */ @@ -44,18 +46,47 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP const queryClient = useQueryClient(); const [showConsent, setShowConsent] = React.useState(false); - // Parse `handle/slug` out of the route param. Slugs themselves are - // `[A-Za-z][A-Za-z0-9_-]*` (no slashes), so the first `/` is the split. - const slashIdx = pluginId.indexOf("/"); - const handle = slashIdx > 0 ? pluginId.slice(0, slashIdx) : ""; + // Plugins list — used to compute whether this package is already + // installed. Same query key as elsewhere so the install mutation's + // invalidate hook updates the install button without a manual + // refresh. + const { data: installedPlugins } = useQuery({ + queryKey: ["plugins"], + queryFn: async () => { + const { fetchPlugins } = await import("../lib/api/plugins.js"); + return fetchPlugins(); + }, + }); + + // Parse `/` out of the route param. The publisher + // segment is either a handle (`example.dev`) or a DID + // (`did:plc:abc...`). Slugs are `[A-Za-z][A-Za-z0-9_-]*` (no `/`), + // so the *last* `/` is the split (a handle could contain a `/` + // historically, though atproto handles don't; the DID form + // definitely doesn't). + const slashIdx = pluginId.lastIndexOf("/"); + const publisher = slashIdx > 0 ? pluginId.slice(0, slashIdx) : ""; const slug = slashIdx > 0 ? pluginId.slice(slashIdx + 1) : ""; + const isDid = publisher.startsWith("did:"); + // When linked by handle, resolve via `resolvePackage(handle, slug)`. + // When linked by DID, go straight to `getPackage(did, slug)`. Either + // way we end up with the same `RegistryPackageView` shape. const { data: pkg, isLoading: isLoadingPkg } = useQuery({ - queryKey: ["registry", "package", config.aggregatorUrl, handle, slug], - queryFn: () => resolveRegistryPackage(config, handle, slug), - enabled: Boolean(handle && slug), + queryKey: ["registry", "package", config.aggregatorUrl, publisher, slug, isDid], + queryFn: () => + isDid + ? getRegistryPackage(config, publisher, slug) + : resolveRegistryPackage(config, publisher, slug), + enabled: Boolean(publisher && slug), }); + // Resolve the publisher's handle for display (and for the install + // gate -- we block install on an "invalid" status, where the + // publisher claims a handle that doesn't round-trip back to this + // DID, because that's an impersonation risk). + const handleResult = usePublisherHandle(pkg?.did ?? "", pkg?.handle); + const { data: release } = useQuery({ queryKey: ["registry", "latest-release", config.aggregatorUrl, pkg?.did, slug], queryFn: () => getLatestRegistryRelease(config, pkg!.did, slug), @@ -92,18 +123,28 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP const policyOk = release && pkg ? releasePassesPolicy(release, { did: pkg.did, slug }, config.policy) : true; - // Install requires a resolvable handle: the server validates handles - // with at least one `.`, which DID strings (`did:plc:abc`) don't - // satisfy. Publishers whose handle the aggregator couldn't resolve - // (or who haven't claimed one yet) can't be installed today. Surface - // the limitation in the UI rather than letting the user click into - // `INVALID_HANDLE` from the server. - const hasResolvableHandle = Boolean(pkg?.handle && pkg.handle.includes(".")); + // Handle resolution affects display only -- installs are addressed + // by DID, so an unverified or missing handle doesn't block install. + // A handle that *claims* a value but doesn't verify (`status: + // "invalid"`) is a publisher misconfiguration we surface as a + // warning but don't gate on. + + // Is this package already installed? Match on (publisher DID, + // slug) -- the same key the install handler writes to plugin_states. + const installedEntry = React.useMemo(() => { + if (!pkg || !installedPlugins) return undefined; + return installedPlugins.find( + (p) => + p.source === "registry" && p.registryPublisherDid === pkg.did && p.registrySlug === slug, + ); + }, [pkg, installedPlugins, slug]); + const isInstalled = Boolean(installedEntry); const installMutation = useMutation({ - mutationFn: () => - installRegistryPlugin({ - handle, + mutationFn: () => { + if (!pkg) throw new Error("Package not loaded"); + return installRegistryPlugin({ + did: pkg.did, slug, version: release?.version, // Only send the acknowledgement when the dialog had real @@ -117,7 +158,8 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP // check is a UX sanity belt for already-displayed // consent, not an authorization gate. acknowledgedDeclaredAccess: capabilities.length > 0 ? capabilities : undefined, - }), + }); + }, onSuccess: () => { setShowConsent(false); void queryClient.invalidateQueries({ queryKey: ["plugins"] }); @@ -179,7 +221,8 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP ) : null}

- {t`Published by`} {pkg.handle ?? pkg.did} + {t`Published by`}{" "} +

{release ? (

@@ -188,27 +231,42 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP ) : null}

- + {isInstalled ? ( + + ) : ( + + )}
- {/* Unresolvable handle notice */} - {pkg && !hasResolvableHandle ? ( + {/* Invalid-handle notice. The publisher's DID document claims a + handle but the handle's domain doesn't point back to this + DID. Possible causes: an expired DNS record or stale + .well-known/atproto-did file on the publisher's side + (legitimate but misconfigured), OR an active impersonation + attempt -- somebody publishing under a DID that claims to + be `stripe.com` etc. We can't tell the two apart from this + side, so we treat the claim as untrusted and block + install. Don't display the spoofed handle string -- it + might be exactly what the attacker wants the admin to see. */} + {handleResult.status === "invalid" ? (
-

{t`Publisher handle is not resolvable`}

+

{t`We couldn't verify this publisher's identity`}

- {t`This package's publisher hasn't claimed a handle the aggregator can resolve, so it can't be installed yet. The publisher needs to set up a handle (any domain they control) before this plugin is installable.`} + {t`This publisher claims a name they couldn't prove they own — possibly impersonating someone else. Install is disabled. If you know the publisher and trust them, ask them to fix their identity setup before retrying.`}

diff --git a/packages/admin/src/lib/api/registry.ts b/packages/admin/src/lib/api/registry.ts index f5a8e71f1..49cf471a5 100644 --- a/packages/admin/src/lib/api/registry.ts +++ b/packages/admin/src/lib/api/registry.ts @@ -87,7 +87,7 @@ export interface RegistrySearchOpts { } export interface RegistryInstallRequest { - handle: string; + did: string; slug: string; version?: string; acknowledgedDeclaredAccess?: unknown; @@ -108,6 +108,7 @@ export interface RegistryInstallResult { interface WrappedDiscoveryClient { searchPackages: (opts: RegistrySearchOpts) => Promise; resolvePackage: (handle: string, slug: string) => Promise; + getPackage: (did: string, slug: string) => Promise; getLatestRelease: (did: string, slug: string) => Promise; listReleases: ( did: string, @@ -154,6 +155,14 @@ async function getDiscoveryClient(config: RegistryClientConfig): Promise { + const client = await getDiscoveryClient(config); + return client.getPackage(did, slug); +} + export async function getLatestRegistryRelease( config: RegistryClientConfig, did: string, @@ -298,6 +316,129 @@ export async function listRegistryReleases( return client.listReleases(did, slug, cursor); } +/** + * Resolve a publisher DID to its claimed handle using the same + * `LocalActorResolver` pattern as `@emdash-cms/registry-cli` and + * `@emdash-cms/auth-atproto`. Bidirectional verification (handle's + * domain points back to the same DID) is part of the resolver -- + * `LocalActorResolver` returns the sentinel `"handle.invalid"` when + * the `alsoKnownAs` handle is present but doesn't round-trip. + * + * Three distinct outcomes the UI can render: + * + * - `{ status: "ok", handle }` — verified handle, round-trip OK. + * - `{ status: "invalid" }` — DID claims a handle but it doesn't + * resolve back. The publisher's handle setup is broken; the admin + * should see a clear "Invalid handle" indicator rather than the + * raw DID. + * - `{ status: "missing" }` — no handle claimed at all (no + * `alsoKnownAs`), or the DID document couldn't be fetched (network + * error, unsupported DID method). + */ +let actorResolver: import("@atcute/identity-resolver").LocalActorResolver | null = null; +async function getActorResolver(): Promise { + if (actorResolver) return actorResolver; + const { + CompositeDidDocumentResolver, + CompositeHandleResolver, + DohJsonHandleResolver, + LocalActorResolver, + PlcDidDocumentResolver, + WebDidDocumentResolver, + WellKnownHandleResolver, + } = await import("@atcute/identity-resolver"); + actorResolver = new LocalActorResolver({ + handleResolver: new CompositeHandleResolver({ + methods: { + dns: new DohJsonHandleResolver({ dohUrl: "https://cloudflare-dns.com/dns-query" }), + http: new WellKnownHandleResolver(), + }, + }), + didDocumentResolver: new CompositeDidDocumentResolver({ + methods: { + plc: new PlcDidDocumentResolver(), + web: new WebDidDocumentResolver(), + }, + }), + }); + return actorResolver; +} + +export type DidHandleResolution = + | { status: "ok"; handle: string } + | { status: "invalid" } + | { status: "missing" }; + +/** + * localStorage-backed cache for DID→handle resolutions. Handles are + * stable for hours-to-days in practice, but bound the cache so a + * compromised handle eventually flips back to "invalid" without a + * forced refresh. 24h matches the typical atproto handle TTL. + * + * Failures (network errors, unsupported DID method) are *not* cached -- + * those should retry on the next render. + */ +const HANDLE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const HANDLE_CACHE_KEY_PREFIX = "emdash:did-handle:"; + +interface CachedResolution { + resolution: DidHandleResolution; + expiresAt: number; +} + +function readHandleCache(did: string): DidHandleResolution | null { + if (typeof localStorage === "undefined") return null; + try { + const raw = localStorage.getItem(`${HANDLE_CACHE_KEY_PREFIX}${did}`); + if (!raw) return null; + const parsed = JSON.parse(raw) as CachedResolution; + if (!parsed || typeof parsed.expiresAt !== "number" || parsed.expiresAt < Date.now()) { + return null; + } + return parsed.resolution; + } catch { + return null; + } +} + +function writeHandleCache(did: string, resolution: DidHandleResolution): void { + if (typeof localStorage === "undefined") return; + try { + const entry: CachedResolution = { resolution, expiresAt: Date.now() + HANDLE_CACHE_TTL_MS }; + localStorage.setItem(`${HANDLE_CACHE_KEY_PREFIX}${did}`, JSON.stringify(entry)); + } catch { + // quota exceeded or storage disabled; drop silently + } +} + +export async function resolveDidToHandle(did: string): Promise { + const cached = readHandleCache(did); + if (cached) return cached; + + let result: DidHandleResolution; + try { + const resolver = await getActorResolver(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- caller's DID has the right shape + const resolved = await resolver.resolve(did as Did); + if (resolved.handle === "handle.invalid") { + result = { status: "invalid" }; + } else if (resolved.handle) { + result = { status: "ok", handle: resolved.handle }; + } else { + result = { status: "missing" }; + } + } catch (err) { + // Network / DID-method failure: don't cache, so a transient + // outage doesn't poison the cache for 24h. Log so a publisher + // debugging "why is my handle not resolving?" can see the cause. + console.warn(`[registry] DID->handle resolution failed for ${did}:`, err); + return { status: "missing" }; + } + + writeHandleCache(did, result); + return result; +} + // --------------------------------------------------------------------------- // Install (server POST) // --------------------------------------------------------------------------- diff --git a/packages/auth-atproto/package.json b/packages/auth-atproto/package.json index 0bc411e14..37767a72f 100644 --- a/packages/auth-atproto/package.json +++ b/packages/auth-atproto/package.json @@ -31,7 +31,7 @@ }, "devDependencies": { "@atcute/lexicons": "^1.2.10", - "@cloudflare/kumo": "^1.16.0", + "@cloudflare/kumo": "catalog:", "@types/react": "^19.0.0", "vitest": "catalog:" }, diff --git a/packages/blocks/package.json b/packages/blocks/package.json index de47e3de4..6be35e214 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -26,7 +26,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "@cloudflare/kumo": "^1.10.0", + "@cloudflare/kumo": "catalog:", "@phosphor-icons/react": "catalog:", "clsx": "^2.1.1", "echarts": "^6.0.0", diff --git a/packages/blocks/playground/package.json b/packages/blocks/playground/package.json index 0bcfe5532..b2f88076d 100644 --- a/packages/blocks/playground/package.json +++ b/packages/blocks/playground/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@emdash-cms/blocks": "workspace:*", - "@cloudflare/kumo": "^1.1.0", + "@cloudflare/kumo": "catalog:", "@phosphor-icons/react": "catalog:", "react": "catalog:", "react-dom": "catalog:" diff --git a/packages/core/package.json b/packages/core/package.json index 42cf2eed3..e0d082fef 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -169,6 +169,7 @@ }, "dependencies": { "@atcute/lexicons": "catalog:", + "@atcute/multibase": "catalog:", "@emdash-cms/admin": "workspace:*", "@emdash-cms/auth": "workspace:*", "@emdash-cms/gutenberg-to-portable-text": "workspace:*", diff --git a/packages/core/src/api/handlers/registry.ts b/packages/core/src/api/handlers/registry.ts index 2f6d820d0..e7d222f56 100644 --- a/packages/core/src/api/handlers/registry.ts +++ b/packages/core/src/api/handlers/registry.ts @@ -38,7 +38,7 @@ * mitigated by the artifact checksum but not detected. */ -import type { Handle } from "@atcute/lexicons"; +import type { Did } from "@atcute/lexicons"; import type { Kysely } from "kysely"; import type { Database } from "../../database/types.js"; @@ -48,12 +48,13 @@ import type { SandboxRunner } from "../../plugins/sandbox/types.js"; import { PluginStateRepository } from "../../plugins/state.js"; import { canonicalCapabilitiesForDriftCheck, + coerceRegistryConfig, parseDurationSeconds, releaseExemptFromMinimumAge, validateAggregatorUrl, } from "../../registry/config.js"; import { makeRegistryPluginId } from "../../registry/plugin-id.js"; -import type { RegistryConfig } from "../../registry/types.js"; +import type { RegistryConfigInput } from "../../registry/types.js"; import { EmDashStorageError } from "../../storage/types.js"; import type { Storage } from "../../storage/types.js"; import type { ApiResult } from "../types.js"; @@ -62,8 +63,18 @@ import { deleteBundleFromR2, storeBundleInR2 } from "./marketplace.js"; // ── Types ────────────────────────────────────────────────────────── export interface RegistryInstallInput { - /** Publisher's atproto handle, e.g. `"example.dev"`. */ - handle: string; + /** + * Publisher DID. Required. The browser is expected to resolve + * `(handle, slug) → (did, slug)` via the aggregator's + * `resolvePackage` XRPC before posting -- the server then skips that + * round-trip and looks up the package directly. + * + * Passing DID rather than handle here means installs work for + * publishers whose handle the aggregator couldn't resolve at view + * time (handle is "best-effort" per the lexicon -- absent for any + * publisher whose DID document didn't resolve cleanly at ingest). + */ + did: string; /** Package slug (rkey of the publisher's profile record). */ slug: string; /** Optional explicit version. When omitted, the aggregator's latest. */ @@ -106,35 +117,68 @@ async function sha256Hex(bytes: Uint8Array): Promise { return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join(""); } +/** multihash code for sha2-256 (single-byte varint). */ +const MULTIHASH_SHA256_CODE = 0x12; +/** sha2-256 digest length in bytes (single-byte varint). */ +const MULTIHASH_SHA256_LENGTH = 0x20; + +/** + * Compute the multibase-multihash sha2-256 checksum of `bytes`, in the + * same `b` shape the registry CLI publishes + * (`packages/registry-cli/src/multihash.ts`). Returns a 56-character + * string starting with `b`. + * + * The trust contract is: if both sides produce the same string for + * the same bytes, the bytes are unchanged. We don't decode the + * publisher-supplied checksum -- we just re-encode our own and compare, + * which is equivalent and avoids needing a base32 decoder. + */ +async function sha256MultibaseMultihash(bytes: Uint8Array): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Uint8Array is a valid BufferSource at runtime + const digestBuf = await crypto.subtle.digest("SHA-256", bytes as unknown as BufferSource); + const digest = new Uint8Array(digestBuf); + const multihash = new Uint8Array(2 + digest.length); + multihash[0] = MULTIHASH_SHA256_CODE; + multihash[1] = MULTIHASH_SHA256_LENGTH; + multihash.set(digest, 2); + const { toBase32 } = await import("@atcute/multibase"); + return `b${toBase32(multihash)}`; +} + /** - * Verify that a multibase multihash string from a release record's + * Verify that a checksum string from a release record's * `artifact.checksum` field corresponds to the SHA-256 of the given * bytes. * - * The lexicon mandates support for sha2-256 (multihash code 0x12) and - * recommends base32 ('b' prefix) encoding. We accept the canonical - * `b` shape and reject anything we can't unambiguously verify. - * Hash functions other than sha2-256 are out of scope for this initial - * release; the install fails closed. + * Accepts two formats: + * + * - Bare lowercase/uppercase hex SHA-256 (64 chars). Convenience for + * publishers / tools that emit hex rather than multibase. + * - Multibase-multihash with the `b` (base32) prefix and sha2-256. + * This is the format RFC 0001 mandates and the registry CLI emits + * (see `packages/registry-cli/src/multihash.ts`). + * + * 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 { - // Bare hex-sha256 (no multibase prefix) -- accepted as a convenience - // because PluginBundle.checksum from extractBundle() is plain hex, - // and registries that haven't fully adopted multibase yet emit hex. if (SHA256_HEX_PATTERN.test(checksum)) { const actual = await sha256Hex(bytes); return checksum.toLowerCase() === actual; } - // Multibase-base32 multihash with sha2-256: 'b' + base32(0x12, 0x20, <32 bytes>). - // The full decode pipeline (base32 → multihash header → digest bytes → - // hex) is more code than the trust boundary it gains us today, given - // the verification step is fundamentally bounded by what algorithm - // the upstream record chose. Leaving the multibase path for the - // followup that pairs with full MST verification. - // - // For now we fail closed on multibase strings rather than risk a - // false-positive verification. + // Multibase-base32 multihash with sha2-256. We re-encode our own + // digest in the same shape and compare strings -- equivalent to + // decoding and comparing bytes, but doesn't need a base32 decoder. + // 56 chars = 'b' + base32(34 bytes) = 'b' + 55 chars. + if (checksum.length === 56 && checksum.startsWith("b")) { + const actual = await sha256MultibaseMultihash(bytes); + // Case-insensitive: multibase 'b' is lowercase by convention but + // some emitters use uppercase. RFC 4648 base32 alphabets are + // case-insensitive. + return actual.toLowerCase() === checksum.toLowerCase(); + } + return false; } @@ -469,10 +513,13 @@ export async function handleRegistryInstall( db: Kysely, storage: Storage | null, sandboxRunner: SandboxRunner | null, - registryConfig: RegistryConfig | undefined, + registryConfigInput: RegistryConfigInput | undefined, input: RegistryInstallInput, opts?: { configuredPluginIds?: Set }, ): Promise> { + // Accept either the bare-string shorthand or the full + // `RegistryConfig` object (see `RegistryConfigInput`). + const registryConfig = coerceRegistryConfig(registryConfigInput); if (!registryConfig) { return { success: false, @@ -519,7 +566,7 @@ export async function handleRegistryInstall( }; } - const { handle, slug, version: requestedVersion } = input; + const { did, slug, version: requestedVersion } = input; // Lazy-load the discovery client. Avoids pulling @atcute/client into // every code path that imports core/api/handlers. @@ -536,31 +583,31 @@ export async function handleRegistryInstall( fetch: timedFetch(aggregatorDeadline), }); - // Basic shape check on the handle. Aggregator's lexicon types the - // param as `${string}.${string}`, but the handler accepts a plain - // string from request bodies; reject malformed shapes here rather - // than letting the XRPC call fail opaquely. Full RFC 3986 handle - // validation is the aggregator's job. - if (!handle.includes(".")) { + // Basic shape check on the DID. The browser is expected to send a + // DID resolved via the aggregator's `resolvePackage`; reject obvious + // malformations here rather than letting the XRPC call fail + // opaquely. The lexicon's `did:${string}:${string}` template is the + // authoritative check. + if (!did.startsWith("did:") || did.split(":").length < 3) { return { success: false, error: { - code: "INVALID_HANDLE", - message: "Handle must be a domain-like identifier (e.g. example.dev)", + code: "INVALID_DID", + message: "DID must be a valid atproto DID (e.g. did:plc:abc123)", }, }; } try { - // Step 1: resolve (handle, slug) → (did, slug) - // Cast: the validation above ensures `handle` matches the lexicon's - // `${string}.${string}` shape. - const packageView = await discovery.resolvePackage({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- shape validated above - handle: handle as Handle, + // Step 1: look up the package by DID + slug. The browser already + // resolved any handle to a DID via `resolvePackage`; we skip that + // round-trip and go straight to `getPackage`. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- validated above + const publisherDid = did as Did; + const packageView = await discovery.getPackage({ + did: publisherDid, slug, }); - const publisherDid = packageView.did; // Step 2: select the target release. // For an explicit version, page through listReleases until we find @@ -611,8 +658,8 @@ export async function handleRegistryInstall( error: { code: "NO_RELEASE", message: requestedVersion - ? `Version ${requestedVersion} not found for ${handle}/${slug}` - : `No installable release found for ${handle}/${slug}`, + ? `Version ${requestedVersion} not found for ${publisherDid}/${slug}` + : `No installable release found for ${publisherDid}/${slug}`, }, }; } @@ -779,7 +826,7 @@ export async function handleRegistryInstall( success: false, error: { code: "ALREADY_INSTALLED", - message: `Plugin ${handle}/${slug} is already installed`, + message: `Plugin ${publisherDid}/${slug} is already installed`, }, }; } diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts index 98a69f5d1..6b415125d 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -181,6 +181,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { auth: resolvedConfig.auth, authProviders: resolvedConfig.authProviders, marketplace: resolvedConfig.marketplace, + experimental: resolvedConfig.experimental, siteUrl: resolvedConfig.siteUrl, trustedProxyHeaders: resolvedConfig.trustedProxyHeaders, maxUploadSize: resolvedConfig.maxUploadSize, diff --git a/packages/core/src/astro/routes/api/admin/plugins/registry/install.ts b/packages/core/src/astro/routes/api/admin/plugins/registry/install.ts index 1b85a87ca..25aa9d08e 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/registry/install.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/registry/install.ts @@ -4,10 +4,12 @@ * POST /_emdash/api/admin/plugins/registry/install * * Installs a plugin from the experimental decentralized plugin registry - * (see RFC 0001). The browser passes the publisher handle and slug it - * resolved through the aggregator's `searchPackages` / `resolvePackage` - * endpoints; the server re-resolves and re-verifies on its side before - * fetching the artifact and handing it to the sandbox loader. + * (see RFC 0001). The browser resolves `(handle, slug) → (did, slug)` + * via the aggregator before posting and sends the publisher DID + * directly; the server skips the resolvePackage round-trip and looks + * up the package by DID. Sending DID rather than handle means installs + * work for publishers whose handle the aggregator couldn't resolve at + * view time (handle is best-effort per the lexicon). */ import type { APIRoute } from "astro"; @@ -21,8 +23,19 @@ import { isParseError, parseBody } from "#api/parse.js"; export const prerender = false; const installBodySchema = z.object({ - /** Publisher's atproto handle (e.g. `"example.dev"`). */ - handle: z.string().min(1).max(253), + /** + * Publisher DID. Required. Browser is expected to resolve + * `(handle, slug) → did` against the aggregator before posting. + */ + did: z + .string() + .min(1) + .max(2048) + // Loose match -- atproto DID specs allow `did:plc:*` and + // `did:web:*` plus future methods. Reject anything that + // doesn't even start with `did:` rather than enumerating + // methods here; downstream lexicon validation tightens. + .regex(/^did:[a-z]+:/, "Invalid DID"), /** Package slug. */ slug: z .string() @@ -73,7 +86,7 @@ export const POST: APIRoute = async ({ request, locals }) => { emdash.getSandboxRunner(), emdash.config.experimental?.registry, { - handle: body.handle, + did: body.did, slug: body.slug, version: body.version, acknowledgedDeclaredAccess: body.acknowledgedDeclaredAccess, diff --git a/packages/core/src/registry/config.ts b/packages/core/src/registry/config.ts index d7e73067f..e68571e26 100644 --- a/packages/core/src/registry/config.ts +++ b/packages/core/src/registry/config.ts @@ -8,7 +8,7 @@ * seconds so the browser doesn't need a duration parser. */ -import type { RegistryConfig } from "./types.js"; +import type { RegistryConfig, RegistryConfigInput } from "./types.js"; /** * Shape returned in the admin manifest's `registry` field. The browser @@ -210,20 +210,47 @@ export function validateAggregatorUrl(aggregatorUrl: string): URL { } /** - * Normalize the user-supplied `RegistryConfig` into the shape that ships - * to the admin browser via the manifest endpoint. + * Expand the `RegistryConfigInput` shorthand into the full + * `RegistryConfig` object shape. * - * Returns `null` when `config` is undefined so callers can spread the - * result directly into the manifest object. + * Users can pass a bare aggregator URL string for the common case + * (`experimental.registry: "https://registry.emdashcms.com"`); the + * normalizer handles either form transparently. + * + * Returns `undefined` for `undefined` input so callers can chain with + * optional chaining. + */ +export function coerceRegistryConfig( + input: RegistryConfigInput | undefined, +): RegistryConfig | undefined { + if (input === undefined) return undefined; + if (typeof input === "string") return { aggregatorUrl: input }; + return input; +} + +/** + * Normalize the user-supplied `RegistryConfigInput` into the shape that + * ships to the admin browser via the manifest endpoint. + * + * Accepts either the shorthand string form + * (`"https://registry.emdashcms.com"`) or the full `RegistryConfig` + * object. Returns `null` when `input` is undefined so callers can + * spread the result directly into the manifest object. * * Throws if the aggregator URL is malformed, points at a forbidden host, - * or `policy.minimumReleaseAge` is unparseable. Both surface at runtime - * startup as 500s from the manifest endpoint -- intended, because the - * alternative is silently disabling the registry on misconfigured sites. + * or `policy.minimumReleaseAge` is unparseable. These surface at + * runtime startup as 500s from the manifest endpoint -- intended, + * because the alternative is silently disabling the registry on + * misconfigured sites. + * + * TODO: switch to a Zod schema for richer per-field error messages and + * to surface misconfigurations to the admin UI as a banner instead of + * a manifest 500. */ export function normalizeRegistryConfig( - config: RegistryConfig | undefined, + input: RegistryConfigInput | undefined, ): ManifestRegistryConfig | null { + const config = coerceRegistryConfig(input); if (!config) return null; const aggregatorUrl = config.aggregatorUrl?.trim(); diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 31a80baf7..26ada48ef 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -110,6 +110,28 @@ export interface RegistryConfig { }; } +/** + * Shorthand: pass a bare aggregator URL string in place of a full + * `RegistryConfig` object when you don't need `acceptLabelers` or + * `policy`. The normalizer expands the string into + * `{ aggregatorUrl: }` before any downstream code sees it. + * + * @example + * ```ts + * experimental: { + * registry: "https://registry.emdashcms.com", + * } + * ``` + * + * Equivalent to: + * ```ts + * experimental: { + * registry: { aggregatorUrl: "https://registry.emdashcms.com" }, + * } + * ``` + */ +export type RegistryConfigInput = string | RegistryConfig; + /** * Experimental EmDash features. See `EmDashConfig.experimental`. * @@ -133,6 +155,10 @@ export interface ExperimentalConfig { * * Requires `sandboxRunner` to be configured -- registry plugins always * run sandboxed. + * + * Accepts a bare URL string as shorthand for + * `{ aggregatorUrl: "..." }`. Use the full object form when you need + * `acceptLabelers` or `policy`. */ - registry?: RegistryConfig; + registry?: RegistryConfigInput; } diff --git a/packages/plugins/ai-moderation/package.json b/packages/plugins/ai-moderation/package.json index 9d700a6ac..e5a66f64a 100644 --- a/packages/plugins/ai-moderation/package.json +++ b/packages/plugins/ai-moderation/package.json @@ -27,7 +27,7 @@ "emdash": "workspace:>=0.9.0", "react": "^18.0.0 || ^19.0.0", "@phosphor-icons/react": "^2.1.10", - "@cloudflare/kumo": "^1.0.0" + "@cloudflare/kumo": "catalog:" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250224.0", diff --git a/packages/plugins/field-kit/package.json b/packages/plugins/field-kit/package.json index 1d93ee231..0ad60f2c5 100644 --- a/packages/plugins/field-kit/package.json +++ b/packages/plugins/field-kit/package.json @@ -21,7 +21,7 @@ "author": "Filip Ilic", "license": "MIT", "peerDependencies": { - "@cloudflare/kumo": "^1.0.0", + "@cloudflare/kumo": "catalog:", "@phosphor-icons/react": "^2.1.10", "emdash": "workspace:>=0.9.0", "react": "^18.0.0 || ^19.0.0" diff --git a/packages/plugins/forms/package.json b/packages/plugins/forms/package.json index bb7e2d40a..e53349a44 100644 --- a/packages/plugins/forms/package.json +++ b/packages/plugins/forms/package.json @@ -29,7 +29,7 @@ "emdash": "workspace:>=0.11.0", "react": "^18.0.0 || ^19.0.0", "@phosphor-icons/react": "^2.1.10", - "@cloudflare/kumo": "^1.0.0" + "@cloudflare/kumo": "catalog:" }, "devDependencies": { "vitest": "catalog:" diff --git a/packages/registry-cli/src/config.ts b/packages/registry-cli/src/config.ts index 278a7f576..c2447b593 100644 --- a/packages/registry-cli/src/config.ts +++ b/packages/registry-cli/src/config.ts @@ -19,7 +19,7 @@ import { join } from "node:path"; * aggregator. See `.opencode/plans/plugin-registry-implementation.md` * § "Open questions". */ -export const DEFAULT_AGGREGATOR_URL = "https://experimental-registry.emdashcms.com"; +export const DEFAULT_AGGREGATOR_URL = "https://registry.emdashcms.com"; /** * Default directory for OAuth state (sessions, in-flight authorize states). diff --git a/packages/registry-client/src/discovery/index.ts b/packages/registry-client/src/discovery/index.ts index a3787dc4f..2f9f15f6a 100644 --- a/packages/registry-client/src/discovery/index.ts +++ b/packages/registry-client/src/discovery/index.ts @@ -68,7 +68,7 @@ export interface DiscoveryClientOptions { * @example * ```ts * const discovery = new DiscoveryClient({ - * aggregatorUrl: "https://experimental-registry.emdashcms.com", + * aggregatorUrl: "https://registry.emdashcms.com", * }); * const result = await discovery.searchPackages({ q: "gallery", limit: 10 }); * for (const pkg of result.packages) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 859cc17f9..1978a7350 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,9 @@ catalogs: '@atproto/repo': specifier: ^0.9.1 version: 0.9.1 + '@cloudflare/kumo': + specifier: ^1.16.0 + version: 1.16.0 '@cloudflare/vite-plugin': specifier: ^1.36.3 version: 1.36.3 @@ -837,11 +840,14 @@ importers: packages/admin: dependencies: + '@atcute/identity-resolver': + specifier: 'catalog:' + version: 2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@1.3.0)(typescript@5.9.3))(@atcute/lexicons@1.3.0)(typescript@5.9.3) '@atcute/lexicons': specifier: 'catalog:' version: 1.3.0 '@cloudflare/kumo': - specifier: ^1.16.0 + specifier: 'catalog:' version: 1.16.0(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1) '@dnd-kit/core': specifier: ^6.3.1 @@ -1145,7 +1151,7 @@ importers: specifier: ^1.2.10 version: 1.2.10 '@cloudflare/kumo': - specifier: ^1.16.0 + specifier: 'catalog:' version: 1.16.0(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1) '@types/react': specifier: ^19.0.0 @@ -1157,8 +1163,8 @@ importers: packages/blocks: dependencies: '@cloudflare/kumo': - specifier: ^1.10.0 - version: 1.10.0(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1) + specifier: 'catalog:' + version: 1.16.0(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1) '@phosphor-icons/react': specifier: 'catalog:' version: 2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1212,8 +1218,8 @@ importers: packages/blocks/playground: dependencies: '@cloudflare/kumo': - specifier: ^1.1.0 - version: 1.10.0(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1) + specifier: 'catalog:' + version: 1.16.0(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1) '@emdash-cms/blocks': specifier: workspace:* version: link:.. @@ -1325,6 +1331,9 @@ importers: '@atcute/lexicons': specifier: 'catalog:' version: 1.3.0 + '@atcute/multibase': + specifier: 'catalog:' + version: 1.2.0 '@emdash-cms/admin': specifier: workspace:* version: link:../admin @@ -1619,8 +1628,8 @@ importers: packages/plugins/ai-moderation: dependencies: '@cloudflare/kumo': - specifier: ^1.0.0 - version: 1.10.0(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1) + specifier: 'catalog:' + version: 1.16.0(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1) '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1710,7 +1719,7 @@ importers: packages/plugins/field-kit: dependencies: '@cloudflare/kumo': - specifier: ^1.0.0 + specifier: 'catalog:' version: 1.16.0(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1) '@phosphor-icons/react': specifier: ^2.1.10 @@ -1747,8 +1756,8 @@ importers: packages/plugins/forms: dependencies: '@cloudflare/kumo': - specifier: ^1.0.0 - version: 1.10.0(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1) + specifier: 'catalog:' + version: 1.16.0(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1) '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -2930,19 +2939,6 @@ packages: '@clack/prompts@1.1.0': resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==} - '@cloudflare/kumo@1.10.0': - resolution: {integrity: sha512-6Q89+LqUsBUxEmFe6mBPruKsIFviUqkXYi5DvRaIWDgSeFjLKEISVosHeu8Ufecs9MLg6vBV3xtYjjTSddqMOg==} - hasBin: true - peerDependencies: - '@phosphor-icons/react': ^2.1.10 - echarts: ^6.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - zod: ^4.0.0 - peerDependenciesMeta: - zod: - optional: true - '@cloudflare/kumo@1.16.0': resolution: {integrity: sha512-uCrj7jGPvdXj8lrdQBfMGKzV3JTDi7hUBsLf4jpirD7QHvZMsGe6XuU+KKvQFqDTmj5ELXQVES4YVoducxZ7Tg==} hasBin: true @@ -11707,37 +11703,19 @@ snapshots: '@clack/core': 1.1.0 sisteransi: 1.0.5 - '@cloudflare/kumo@1.10.0(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1)': - dependencies: - '@base-ui/react': 1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@phosphor-icons/react': 2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - clsx: 2.1.1 - echarts: 6.0.0 - motion: 12.35.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-day-picker: 9.14.0(react@19.2.4) - react-dom: 19.2.4(react@19.2.4) - shiki: 4.0.1 - tailwind-merge: 3.4.0 - optionalDependencies: - zod: 4.4.1 - transitivePeerDependencies: - - '@emotion/is-prop-valid' - - '@types/react' - '@cloudflare/kumo@1.16.0(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1)': dependencies: '@base-ui/react': 1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@phosphor-icons/react': 2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@shikijs/langs': 4.0.1 - '@shikijs/themes': 4.0.1 + '@shikijs/langs': 4.0.2 + '@shikijs/themes': 4.0.2 clsx: 2.1.1 echarts: 6.0.0 motion: 12.35.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-day-picker: 9.14.0(react@19.2.4) react-dom: 19.2.4(react@19.2.4) - shiki: 4.0.1 + shiki: 4.0.2 tailwind-merge: 3.4.0 optionalDependencies: zod: 4.4.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7a566bdfc..afc762847 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -40,6 +40,7 @@ catalog: "@atcute/xrpc-server-cloudflare": ^2.0.0 "@atproto/crypto": ^0.4.5 "@atproto/repo": ^0.9.1 + "@cloudflare/kumo": ^1.16.0 "@cloudflare/vite-plugin": ^1.36.3 "@cloudflare/vitest-pool-workers": ^0.16.3 "@cloudflare/workers-types": ^4.20260305.1 From ddb3a495f7adfb97392781aa99335cf3ee7ff06a Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 13 May 2026 23:02:33 +0100 Subject: [PATCH 8/9] fix(registry): adversarial review round 6 findings Addresses 7 findings from the round-6 adversarial review and documents the eighth. #1 (high) Capability consent bypass [registry.ts, RegistryPluginDetail.tsx] The drift check was gated on the client sending acknowledgedDeclaredAccess. If the publisher's release record had no extension, the admin saw an empty permission dialog, omitted the acknowledgement, and the server skipped the check entirely -- letting a bundle whose manifest declares real capabilities slip through behind an empty consent UI. Server now extracts capabilities from the bundle manifest after download and refuses with DECLARED_ACCESS_REQUIRED if the bundle declares any capabilities and no acknowledgement was sent. Client always sends the list (empty when no extension) so the new server check is always armed. #2 (high) Concurrent install bundle deletion [registry.ts] Two parallel installs of the same (did, slug, version) both passed the pre-existing-row check, both uploaded to the same deterministic R2 prefix, and one then won the state-row PK race. The loser's catch block deleted the R2 bundle the winner had just written. On state-write failure we now re-query the state row: if a winner exists, we lost the race and must not touch the R2 bundle. Cleanup runs only when the failure is a real DB error, not a lost concurrent install. #3 (high) SSRF via DNS-resolving public hostnames [registry.ts, ssrf.ts moved] Literal-IP blocklist alone left a DNS-rebinding gap: any public DNS service resolving an attacker-chosen hostname to loopback / RFC1918 / 169.254.169.254 passed the URL check. The import pipeline already shipped resolveAndValidateExternalUrl which does Cloudflare DoH resolution and rejects on any forbidden resolved address; reuse it for artifact downloads. Move src/import/ssrf.ts to src/security/ssrf.ts to reflect that it's not import-specific. Leave a re-export shim at the old path so 13 existing callers keep working unchanged. Add #security/* path alias. #5 (high) Aggregator-supplied handles treated as verified [PublisherHandle.tsx] usePublisherHandle returned status: 'ok' with the aggregator-supplied handle whenever one was present, skipping local DID->handle round-trip. A compromised aggregator could label an attacker DID as e.g. 'stripe.com' and the UI would render it as verified. Always run LocalActorResolver via resolveDidToHandle; use the aggregator handle only for a cross-check. If the aggregator's claim differs from the verified handle, mark the publisher invalid. #6 (medium) Postgres migration 038 schema-qualification [038_registry_plugin_state.ts] The columns probe queried information_schema.columns without filtering by table_schema. A _plugin_state table in another schema (multi-tenant Postgres, per-test schemas) could make the migration skip the column adds. Filter by table_schema = current_schema(). #7 (medium) Install errors leak full artifact URLs [registry.ts] fetchArtifact recorded each full URL in the joined error message that bubbled up to the admin client. Artifacts hosted on storage backends often carry presigned tokens in the query string; failed installs were leaking those into HTTP responses and logs. Strip query and fragment when building client-visible errors (origin + path only); log the full URL server-side for debugging. #8 (medium) Credentialed aggregator URLs accepted [config.ts] validateAggregatorUrl accepted https://user:pass@example.com. The normalized URL ends up in the admin manifest and is shipped to every admin browser; browser fetch() also rejects credentialed URLs outright. Reject them at config-validation time. #4 (high, documented not fixed) Aggregator-trust-root scope [types.ts] Full MST proof / publisher signature verification is not in this PR; the server still trusts the aggregator-supplied (did, slug, checksum, artifact URL). Expand the JSDoc on EmDashConfig.experimental.registry to spell out exactly what the v1 trust contract is, what EmDash does verify independently (checksum, manifest id/version/capabilities), and what it doesn't (release-record signatures, replay). Recommendation: point aggregatorUrl only at an aggregator you operate or trust at centralized-source level until signature verification lands. --- .../admin/src/components/PublisherHandle.tsx | 81 ++- .../src/components/RegistryPluginDetail.tsx | 29 +- packages/core/package.json | 1 + packages/core/src/api/handlers/registry.ts | 211 ++++--- .../migrations/038_registry_plugin_state.ts | 7 + packages/core/src/import/ssrf.ts | 520 +----------------- packages/core/src/registry/config.ts | 8 + packages/core/src/registry/types.ts | 60 +- packages/core/src/security/ssrf.ts | 501 +++++++++++++++++ 9 files changed, 800 insertions(+), 618 deletions(-) create mode 100644 packages/core/src/security/ssrf.ts diff --git a/packages/admin/src/components/PublisherHandle.tsx b/packages/admin/src/components/PublisherHandle.tsx index 743883adb..23a7ada8a 100644 --- a/packages/admin/src/components/PublisherHandle.tsx +++ b/packages/admin/src/components/PublisherHandle.tsx @@ -1,23 +1,30 @@ /** * Renders an atproto publisher's identity, with three branches: * - * - **Verified handle**: shows `@handle`. Either the aggregator - * already resolved the handle at ingest (we trust that), or our - * local `LocalActorResolver` round-tripped the DID document's - * `alsoKnownAs` back to the same DID. + * - **Verified handle**: shows `@handle`. Our local + * `LocalActorResolver` round-tripped the DID document's + * `alsoKnownAs` back to the same DID (verified by DNS TXT or + * `.well-known`, not by the aggregator). * - **Unverified publisher**: DID document claims a handle but the - * handle's domain doesn't point back to the same DID. Treat as - * untrusted -- the publisher might be impersonating someone else. - * Surface as `Unverified publisher` in error styling. Callers - * should also disable destructive actions (install, etc.). - * - **Missing handle**: no claimed handle at all (or DID document - * resolution failed entirely). Fall back to the raw DID. + * handle's domain doesn't point back to the same DID, OR the + * aggregator's claimed handle doesn't match the bidirectionally + * verified one. Treat as untrusted -- the publisher might be + * impersonating someone else, or the aggregator might be lying + * about a handle. Surface as `Unverified publisher` in error + * styling. Callers should also disable destructive actions + * (install, etc.). + * - **Missing handle**: no handle claimed in the DID document (no + * `alsoKnownAs`), or the DID document couldn't be fetched + * (network error, unsupported DID method). * * `aggregatorHandle` is what the registry's `searchPackages` / - * `resolvePackage` endpoint returned for this DID -- best-effort, may - * be `null`. When absent, this component falls back to a per-DID - * `LocalActorResolver` lookup via `resolveDidToHandle`, cached in - * localStorage for 24h so repeat renders don't refetch. + * `resolvePackage` endpoint returned for this DID. It is NEVER trusted + * on its own -- the aggregator is an untrusted indexer that could be + * compromised or buggy. We always run our own DID->handle round-trip + * via `LocalActorResolver` (cached in localStorage for 24h) and use + * the aggregator's value only to *cross-check*: if the aggregator + * claims a handle that differs from what the DID document + * bidirectionally verifies, the publisher is marked invalid. */ import { useLingui } from "@lingui/react/macro"; @@ -26,6 +33,9 @@ import * as React from "react"; import { resolveDidToHandle } from "../lib/api/registry.js"; +/** Trailing dot(s) on an FQDN, stripped before handle comparison. */ +const TRAILING_DOT = /\.+$/; + export type PublisherHandleStatus = "ok" | "invalid" | "missing"; export interface PublisherHandleResult { @@ -57,19 +67,48 @@ export function usePublisherHandle( did: string, aggregatorHandle?: string | null, ): PublisherHandleResult { - const { data: didHandleResolution } = useQuery({ + // Always run the local DID->handle round-trip. We never trust the + // aggregator's `aggregatorHandle` on its own: a compromised + // aggregator could label an attacker DID as `stripe.com` and any + // shortcut that returns the aggregator's value as verified would + // let the impersonation through unchecked. + const { data: didHandleResolution, isPending } = useQuery({ queryKey: ["registry", "did-handle", did], queryFn: () => resolveDidToHandle(did), - enabled: Boolean(did) && !aggregatorHandle, + enabled: Boolean(did), staleTime: 5 * 60 * 1000, }); - if (aggregatorHandle) return { status: "ok", handle: aggregatorHandle }; - if (!didHandleResolution) return { status: "missing" }; - if (didHandleResolution.status === "ok") { - return { status: "ok", handle: didHandleResolution.handle }; + if (isPending || !didHandleResolution) return { status: "missing" }; + + // DID document didn't claim a handle (or the document was + // unreachable). The aggregator might have one, but without our own + // verification we can't display it. + if (didHandleResolution.status === "missing") { + return { status: "missing" }; + } + + // DID document claims a handle but it doesn't round-trip. + // `invalid` always wins over an aggregator-supplied handle. + if (didHandleResolution.status === "invalid") { + return { status: "invalid" }; } - return { status: didHandleResolution.status }; + + // Bidirectionally verified handle. Cross-check against the + // aggregator's claim: if they differ, flag the publisher as + // invalid. The aggregator may simply be stale, but we shouldn't + // silently disagree with our own verification by showing the + // aggregator's value -- the conservative read is "something is + // off, surface it to the admin". + const verifiedHandle = didHandleResolution.handle.toLowerCase(); + if (aggregatorHandle) { + const claimed = aggregatorHandle.toLowerCase().replace(TRAILING_DOT, ""); + if (claimed !== verifiedHandle) { + return { status: "invalid" }; + } + } + + return { status: "ok", handle: didHandleResolution.handle }; } export function PublisherHandle({ diff --git a/packages/admin/src/components/RegistryPluginDetail.tsx b/packages/admin/src/components/RegistryPluginDetail.tsx index 1827783a5..c7be39db0 100644 --- a/packages/admin/src/components/RegistryPluginDetail.tsx +++ b/packages/admin/src/components/RegistryPluginDetail.tsx @@ -147,17 +147,24 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP did: pkg.did, slug, version: release?.version, - // Only send the acknowledgement when the dialog had real - // capability data to display. The server's drift check is - // gated on `acknowledgedDeclaredAccess !== undefined`, so - // omitting the field opts out of the check entirely -- - // correct behaviour for the (currently common) case where - // the publisher's release record doesn't yet carry an - // extension block. The bundle's actual capabilities are - // still bound to the checksum-verified bytes; the drift - // check is a UX sanity belt for already-displayed - // consent, not an authorization gate. - acknowledgedDeclaredAccess: capabilities.length > 0 ? capabilities : undefined, + // Always send the acknowledgement, even when the dialog + // showed no permissions. The server compares this list + // against the bundle's actual `manifest.capabilities` + // after download: + // + // - If the bundle has capabilities, the server + // requires us to send a matching list (the consent + // dialog is the only place the admin sees what + // they're agreeing to). + // - If the bundle has no capabilities, no consent is + // required and the server ignores this field. + // + // Sending the empty list when the release extension was + // missing means a publisher who ships a bundle with + // permissions but no extension block can't sneak the + // permissions past an empty consent dialog -- the + // server will refuse with `DECLARED_ACCESS_REQUIRED`. + acknowledgedDeclaredAccess: capabilities, }); }, onSuccess: () => { diff --git a/packages/core/package.json b/packages/core/package.json index e0d082fef..276e77207 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -143,6 +143,7 @@ "#menus/*": "./src/menus/*", "#widgets/*": "./src/widgets/*", "#import/*": "./src/import/*", + "#security/*": "./src/security/*", "#utils/*": "./src/utils/*", "#preview/*": "./src/preview/*", "#seed/*": "./src/seed/*", diff --git a/packages/core/src/api/handlers/registry.ts b/packages/core/src/api/handlers/registry.ts index e7d222f56..dec415532 100644 --- a/packages/core/src/api/handlers/registry.ts +++ b/packages/core/src/api/handlers/registry.ts @@ -55,6 +55,7 @@ import { } from "../../registry/config.js"; import { makeRegistryPluginId } from "../../registry/plugin-id.js"; import type { RegistryConfigInput } from "../../registry/types.js"; +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"; @@ -268,17 +269,11 @@ function timedFetch(totalDeadline: number): typeof fetch { } /** - * IPv4 octets that resolve to non-routable or loopback addresses. The - * registry artifact fetcher refuses to make outbound HTTP requests to - * any host whose hostname is one of these literal addresses, because - * a compromised aggregator or publisher could otherwise use the - * EmDash worker as an SSRF stepping stone into the deploy environment - * (private networks, instance metadata, cloud-provider IMDS). - * - * Hostname-based DNS rebinding is not addressed here; the only - * mitigation that closes that gap is doing the address resolution - * ourselves and re-checking after connect. Out of scope for this - * iteration but documented as a follow-up. + * Localhost-equivalent hostnames the artifact fetcher rejects in + * production. The full literal-IP / DNS-rebinding blocklist lives in + * `#security/ssrf.js` and is invoked via `resolveAndValidateExternalUrl` + * below; this small set exists only because the artifact handler has + * a dev-mode escape hatch that lets `http://localhost` through. */ const FORBIDDEN_HOSTNAMES = new Set([ "localhost", @@ -287,41 +282,9 @@ const FORBIDDEN_HOSTNAMES = new Set([ "ip6-loopback", ]); -/** Matches a literal IPv4 address (four dotted decimal octets, 0-255). */ -const IPV4_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; - /** Trailing dot on a hostname, stripped before URL host comparisons. */ const TRAILING_DOT = /\.$/; -function isForbiddenIPv4(hostname: string): boolean { - const match = IPV4_PATTERN.exec(hostname); - if (!match) return false; - const octets = match.slice(1, 5).map((s) => Number(s)); - if (octets.some((o) => o < 0 || o > 255)) return true; // malformed - const [a, b] = octets; - // 127.0.0.0/8 loopback, 10.0.0.0/8 RFC1918, 172.16.0.0/12 RFC1918, - // 192.168.0.0/16 RFC1918, 169.254.0.0/16 link-local (incl. AWS IMDS), - // 100.64.0.0/10 CGNAT, 0.0.0.0/8 reserved, 224.0.0.0/4 multicast, - // 240.0.0.0/4 reserved. - if (a === 0 || a === 10 || a === 127) return true; - if (a === 169 && b === 254) return true; - if (a === 172 && b! >= 16 && b! <= 31) return true; - if (a === 192 && b === 168) return true; - if (a === 100 && b! >= 64 && b! <= 127) return true; - if (a! >= 224) return true; - return false; -} - -function isForbiddenIPv6(hostname: string): boolean { - // URL.hostname strips brackets, but a leading colon (`::1`) or - // IPv6 format is enough to identify. We err on the side of rejecting - // any literal IPv6 address rather than enumerating private ranges - // (fc00::/7, fe80::/10, ::1/128, etc.) -- legitimate registry - // artifacts are not served from raw IPv6 literals. - if (hostname.includes(":")) return true; - return false; -} - /** Hostnames that resolve to the local machine; rejected outright in production. */ function isLocalhostHostname(hostname: string): boolean { // WHATWG URL preserves brackets on IPv6 hostnames; strip them before @@ -339,13 +302,19 @@ function isLocalhostHostname(hostname: string): boolean { /** * Validate that `urlString` is a safe outbound target for artifact * downloads. Rejects non-HTTPS (except localhost in dev), embedded - * credentials, and any host that's a loopback / private / link-local - * literal address. + * credentials, any host that's a loopback / private / link-local + * literal address, and any hostname whose resolved A or AAAA records + * point at one of those addresses (closes the DNS-rebinding gap). + * + * Wraps `resolveAndValidateExternalUrl` from the import-pipeline SSRF + * module so both code paths share one DoH cache, one resolver, one + * blocklist, and one set of regression tests. Layers an + * artifact-specific protocol/dev-localhost policy on top. * * `import.meta.env.DEV` is a Vite/Astro compile-time constant, so * production bundles cannot enable the dev escape hatch at runtime. */ -function assertSafeArtifactUrl(urlString: string): URL { +async function assertSafeArtifactUrl(urlString: string): Promise { let url: URL; try { url = new URL(urlString); @@ -382,13 +351,23 @@ function assertSafeArtifactUrl(urlString: string): URL { throw new Error("Artifact URL must use https (http allowed only for localhost in dev)"); } - if (!localhost) { - if (isForbiddenIPv4(hostname) || isForbiddenIPv6(hostname)) { - throw new Error(`Artifact URL points to a non-routable address: ${hostname}`); - } + if (localhost) { + // Dev-only path; nothing to resolve. + return url; } - return url; + // Delegate IP-literal + DNS-rebinding validation to the import + // pipeline's SSRF helper. Adapts the SsrfError to the existing + // artifact-URL error vocabulary so callers keep their current + // catch shape. + try { + return await resolveAndValidateExternalUrl(url.href); + } catch (err) { + if (err instanceof SsrfError) { + throw new Error(`Artifact URL rejected: ${err.message}`); + } + throw err; + } } /** @@ -409,7 +388,7 @@ async function fetchWithLimits(initialUrl: string, totalDeadline: number): Promi const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), perUrlTimeout); try { - let current = assertSafeArtifactUrl(initialUrl); + let current = await assertSafeArtifactUrl(initialUrl); let response: Response; for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { response = await fetch(current.href, { redirect: "manual", signal: controller.signal }); @@ -420,7 +399,7 @@ async function fetchWithLimits(initialUrl: string, totalDeadline: number): Promi throw new Error(`Too many redirects fetching artifact (>${MAX_REDIRECTS})`); } const next = new URL(location, current); - current = assertSafeArtifactUrl(next.href); + current = await assertSafeArtifactUrl(next.href); } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- response is assigned in the first loop iteration const finalResponse = response!; @@ -481,6 +460,26 @@ async function fetchWithLimits(initialUrl: string, totalDeadline: number): Promi } } +/** + * Strip query string and fragment from a URL for use in + * client-visible error messages. Registry artifacts are often hosted + * on storage backends that include presigned tokens in the query + * string; surfacing the raw URL on a failed install leaks those + * tokens into the admin's HTTP response and any log drain that + * captures the error chain. Origin + pathname is enough to identify + * the host and resource without exposing credentials. + * + * Falls back to a generic placeholder when the URL is malformed. + */ +function redactUrlForError(raw: string): string { + try { + const u = new URL(raw); + return `${u.origin}${u.pathname}`; + } catch { + return ""; + } +} + /** Walk artifact source URLs in priority order and return the first that fetches successfully. */ async function fetchArtifact(mirrors: string[], declaredUrl: string): Promise { // Clamp mirrors regardless of what the lexicon type says -- a buggy @@ -488,23 +487,30 @@ async function fetchArtifact(mirrors: string[], declaredUrl: string): Promise= totalDeadline) { - errors.push("(total artifact download budget exhausted)"); + clientErrors.push("(total artifact download budget exhausted)"); break; } try { return await fetchWithLimits(url, totalDeadline); } catch (err) { - errors.push(`${url}: ${err instanceof Error ? err.message : String(err)}`); + const message = err instanceof Error ? err.message : String(err); + console.warn(`[registry-install] Artifact fetch failed from ${url}:`, message); + clientErrors.push(`${redactUrlForError(url)}: ${message}`); } } - throw new Error(`Failed to download artifact from any source. Tried:\n ${errors.join("\n ")}`); + throw new Error( + `Failed to download artifact from any source. Tried:\n ${clientErrors.join("\n ")}`, + ); } // ── Install ──────────────────────────────────────────────────────── @@ -931,25 +937,46 @@ export async function handleRegistryInstall( // marketplace plugins that happen to share the publisher's slug. bundle.manifest = { ...bundle.manifest, id: pluginId }; - // Drift check: capabilities the admin acknowledged must match - // what the bundle's manifest actually declares. Aggregator-side - // label envelope and release-record `declaredAccess` are - // independent assertions; this catches the case where they - // diverged between the consent dialog and the install POST. + // Capability consent gate: the admin MUST acknowledge the + // capabilities the bundle's manifest actually declares before we + // install it. The bundle manifest is the only source of truth + // the runtime sandbox enforces -- the release record's + // `declaredAccess` extension is an aggregator-supplied + // assertion that the publisher may or may not have included, + // and trusting it would let a malicious publisher (or a + // compromised aggregator) ship a bundle whose manifest + // requests `content:*` etc. behind an empty consent dialog. + // + // Two outcomes after normalization (filter to strings, dedupe, + // sort): + // + // 1. The bundle declares no capabilities: install is allowed + // without any acknowledgement (nothing to consent to). + // 2. The bundle declares capabilities: install requires the + // caller to send `acknowledgedDeclaredAccess`, and the + // sorted lists must match exactly. // - // Both sides are normalised (filter to strings, dedupe, sort) so - // reorderings or junk entries don't trigger spurious rejections. // We compare against the bundle's *capabilities* (the legacy // shape) for v1 because EmDash's existing sandbox enforces // capabilities, not the RFC's structured `declaredAccess`. Once // the runtime starts enforcing `declaredAccess` natively, this // comparison switches to that shape. - if (input.acknowledgedDeclaredAccess !== undefined) { + const actualCapabilities = canonicalCapabilitiesForDriftCheck(bundle.manifest.capabilities); + if (actualCapabilities.length > 0) { + if (input.acknowledgedDeclaredAccess === undefined) { + return { + success: false, + error: { + code: "DECLARED_ACCESS_REQUIRED", + message: + "This plugin declares capabilities that require consent. Re-open the install dialog to review and acknowledge them.", + }, + }; + } const acknowledged = canonicalCapabilitiesForDriftCheck(input.acknowledgedDeclaredAccess); - const actual = canonicalCapabilitiesForDriftCheck(bundle.manifest.capabilities); if ( - acknowledged.length !== actual.length || - acknowledged.some((cap, i) => cap !== actual[i]) + acknowledged.length !== actualCapabilities.length || + acknowledged.some((cap, i) => cap !== actualCapabilities[i]) ) { return { success: false, @@ -971,11 +998,29 @@ export async function handleRegistryInstall( // bundle manifest -- the manifest carries the trust contract, // the profile carries the marketing copy. // - // If the state-row write fails (DB error, PK race against a - // concurrent install of the same package), clean up the R2 bundle - // we just wrote so we don't leave orphans. The cleanup is - // best-effort; if it also fails, the row failure still surfaces - // to the caller. + // On failure, we may need to clean up the R2 bundle we just + // wrote. But two parallel installs of the same (did, slug, + // version) both pass the earlier `existing` check at line 822 + // (the read is not transactional with the insert), both upload + // to the same deterministic R2 prefix (overwrites are + // content-identical because R2 keys include the version and + // the bundle is checksum-verified upstream), and then one wins + // the insert while the other fails with a PK constraint + // violation. + // + // If we blindly clean up R2 on every state-write failure, the + // loser of that race would delete the winner's bundle and the + // runtime would fail to load the plugin on the next sync. + // + // Instead: on state-write failure, re-query the state row. If + // a row now exists for this pluginId, we lost the race -- the + // winner owns the R2 bundle and we must not touch it. If the + // row doesn't exist, the failure was a real DB error and the + // R2 bytes are orphans; clean them up. + // + // Cleanup is best-effort; if it also fails, the row failure + // still surfaces to the caller and the orphan R2 bundle costs + // only the storage of a single checksum-verified zip. const profile = packageView.profile as { name?: string; description?: string }; try { await stateRepo.upsert(pluginId, version, "active", { @@ -986,14 +1031,26 @@ export async function handleRegistryInstall( registrySlug: slug, }); } catch (stateErr) { + let lostRace = false; try { - await deleteBundleFromR2(storage, pluginId, version, "registry"); - } catch (cleanupErr) { + const winner = await stateRepo.get(pluginId); + lostRace = winner !== undefined && winner !== null; + } catch (probeErr) { console.warn( - `[registry-install] Failed to clean up R2 bundle for ${pluginId}@${version} after state-row write failure:`, - cleanupErr, + `[registry-install] Failed to probe state row for ${pluginId} after state-write failure; treating as orphan:`, + probeErr, ); } + if (!lostRace) { + try { + await deleteBundleFromR2(storage, pluginId, version, "registry"); + } catch (cleanupErr) { + console.warn( + `[registry-install] Failed to clean up R2 bundle for ${pluginId}@${version} after state-row write failure:`, + cleanupErr, + ); + } + } throw stateErr; } diff --git a/packages/core/src/database/migrations/038_registry_plugin_state.ts b/packages/core/src/database/migrations/038_registry_plugin_state.ts index 0b45a19d1..f15ecfd83 100644 --- a/packages/core/src/database/migrations/038_registry_plugin_state.ts +++ b/packages/core/src/database/migrations/038_registry_plugin_state.ts @@ -76,9 +76,16 @@ async function upSqlite(db: Kysely): Promise { } async function upPostgres(db: Kysely): Promise { + // Scope the column check to the connection's current schema. + // Without `table_schema = current_schema()`, a `_plugin_state` table + // in another schema (per-tenant Postgres, shared Postgres clusters, + // per-test schemas) makes this query see columns from the wrong + // table and skip the ALTERs entirely, leaving the active schema's + // `_plugin_state` missing the registry columns. const cols = await sql<{ column_name: string }>` SELECT column_name FROM information_schema.columns WHERE table_name = '_plugin_state' + AND table_schema = current_schema() `.execute(db); const colNames = new Set(cols.rows.map((c) => c.column_name)); diff --git a/packages/core/src/import/ssrf.ts b/packages/core/src/import/ssrf.ts index 86f3185b1..919f5e90e 100644 --- a/packages/core/src/import/ssrf.ts +++ b/packages/core/src/import/ssrf.ts @@ -1,501 +1,21 @@ /** - * SSRF protection for import URLs. - * - * Validates that URLs don't target internal/private network addresses. - * Applied before any fetch() call in the import pipeline. - */ - -const IPV4_MAPPED_IPV6_DOTTED_PATTERN = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i; -const IPV4_MAPPED_IPV6_HEX_PATTERN = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i; -const IPV4_TRANSLATED_HEX_PATTERN = /^::ffff:0:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i; -const IPV6_EXPANDED_MAPPED_PATTERN = - /^0{0,4}:0{0,4}:0{0,4}:0{0,4}:0{0,4}:ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i; - -/** - * IPv4-compatible (deprecated) addresses: ::XXXX:XXXX - * - * The WHATWG URL parser normalizes [::127.0.0.1] to [::7f00:1] (no ffff prefix). - * These are deprecated but still parsed, and bypass the ffff-based checks. - */ -const IPV4_COMPATIBLE_HEX_PATTERN = /^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i; - -/** - * NAT64 prefix (RFC 6052): 64:ff9b::XXXX:XXXX - * - * Used by NAT64 gateways to embed IPv4 addresses in IPv6. - * [64:ff9b::127.0.0.1] normalizes to [64:ff9b::7f00:1]. - */ -const NAT64_HEX_PATTERN = /^64:ff9b::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i; - -const IPV6_BRACKET_PATTERN = /^\[|\]$/g; - -/** Match fc00::/7 ULA — first byte 0xfc or 0xfd followed by any byte. */ -const IPV6_ULA_FC_PATTERN = /^fc[0-9a-f]{2}:/; -const IPV6_ULA_FD_PATTERN = /^fd[0-9a-f]{2}:/; - -/** Strip trailing dots from an FQDN-form hostname ("localhost." -> "localhost"). */ -const TRAILING_DOT_PATTERN = /\.+$/; - -/** - * Private and reserved IP ranges that should never be fetched. - * - * Includes: - * - Loopback (127.0.0.0/8) - * - Private (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) - * - Link-local (169.254.0.0/16) - * - Cloud metadata (169.254.169.254 — AWS/GCP/Azure) - * - IPv6 loopback and link-local - */ -const BLOCKED_PATTERNS: Array<{ start: number; end: number }> = [ - // 127.0.0.0/8 — loopback - { start: ip4ToNum(127, 0, 0, 0), end: ip4ToNum(127, 255, 255, 255) }, - // 10.0.0.0/8 — private - { start: ip4ToNum(10, 0, 0, 0), end: ip4ToNum(10, 255, 255, 255) }, - // 172.16.0.0/12 — private - { start: ip4ToNum(172, 16, 0, 0), end: ip4ToNum(172, 31, 255, 255) }, - // 192.168.0.0/16 — private - { start: ip4ToNum(192, 168, 0, 0), end: ip4ToNum(192, 168, 255, 255) }, - // 169.254.0.0/16 — link-local (includes cloud metadata endpoint) - { start: ip4ToNum(169, 254, 0, 0), end: ip4ToNum(169, 254, 255, 255) }, - // 0.0.0.0/8 — current network - { start: ip4ToNum(0, 0, 0, 0), end: ip4ToNum(0, 255, 255, 255) }, -]; - -// Bracket-stripped form is used for lookups (validateExternalUrl strips -// brackets from parsed.hostname before checking), so "::1" appears here -// without brackets. The "::1" case is already covered by isPrivateIp, but -// keeping it here makes the intent explicit and gives a clearer error -// message for the common `http://[::1]/` form. -const BLOCKED_HOSTNAMES = new Set([ - "localhost", - "metadata.google.internal", - "metadata.google", - "::1", -]); - -/** - * Wildcard DNS services that publicly resolve arbitrary IPs embedded in the - * hostname. Commonly used in local dev and by SSRF exploit tooling to bypass - * hostname-only blocklists (e.g. 127.0.0.1.nip.io -> 127.0.0.1). - * - * Matched case-insensitively as a suffix, so both the apex and any subdomain - * are blocked. - */ -const BLOCKED_HOSTNAME_SUFFIXES = [ - "nip.io", - "sslip.io", - "xip.io", - "traefik.me", - "lvh.me", - "localtest.me", -]; - -/** Blocked URL schemes */ -const ALLOWED_SCHEMES = new Set(["http:", "https:"]); - -function ip4ToNum(a: number, b: number, c: number, d: number): number { - return ((a << 24) | (b << 16) | (c << 8) | d) >>> 0; -} - -function parseIpv4(ip: string): number | null { - const parts = ip.split("."); - if (parts.length !== 4) return null; - - const nums = parts.map(Number); - if (nums.some((n) => isNaN(n) || n < 0 || n > 255)) return null; - - return ip4ToNum(nums[0], nums[1], nums[2], nums[3]); -} - -/** - * Convert IPv4-mapped/translated IPv6 addresses from hex form back to IPv4. - * - * The WHATWG URL parser normalizes dotted-decimal to hex: - * [::ffff:127.0.0.1] -> [::ffff:7f00:1] - * [::ffff:169.254.169.254] -> [::ffff:a9fe:a9fe] - * - * Without this conversion, the hex forms bypass isPrivateIp() regex checks. - */ -export function normalizeIPv6MappedToIPv4(ip: string): string | null { - // Match hex-form IPv4-mapped IPv6: ::ffff:XXXX:XXXX - let match = ip.match(IPV4_MAPPED_IPV6_HEX_PATTERN); - if (!match) { - // Match IPv4-translated (RFC 6052): ::ffff:0:XXXX:XXXX - match = ip.match(IPV4_TRANSLATED_HEX_PATTERN); - } - if (!match) { - // Match fully expanded form: 0000:0000:0000:0000:0000:ffff:XXXX:XXXX - match = ip.match(IPV6_EXPANDED_MAPPED_PATTERN); - } - if (!match) { - // Match IPv4-compatible (deprecated) form: ::XXXX:XXXX (no ffff prefix) - match = ip.match(IPV4_COMPATIBLE_HEX_PATTERN); - } - if (!match) { - // Match NAT64 prefix (RFC 6052): 64:ff9b::XXXX:XXXX - match = ip.match(NAT64_HEX_PATTERN); - } - if (match) { - const high = parseInt(match[1] ?? "", 16); - const low = parseInt(match[2] ?? "", 16); - return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`; - } - return null; -} - -function isPrivateIp(ip: string): boolean { - // Normalize IPv6 strings to lowercase. `new URL().hostname` already - // lowercases, but resolver output (from DoH or an injected resolver) may - // not. Without this, "FE80::1" bypasses the link-local check. - const normalized = ip.toLowerCase(); - - // Handle IPv6 loopback - if (normalized === "::1" || normalized === "::ffff:127.0.0.1") return true; - - // Handle IPv4-mapped IPv6 in hex form (WHATWG URL parser normalizes to this) - // e.g. ::ffff:7f00:1 -> 127.0.0.1, ::ffff:a9fe:a9fe -> 169.254.169.254 - const hexIpv4 = normalizeIPv6MappedToIPv4(normalized); - if (hexIpv4) return isPrivateIp(hexIpv4); - - // Handle IPv4-mapped IPv6 in dotted-decimal form - const v4Match = normalized.match(IPV4_MAPPED_IPV6_DOTTED_PATTERN); - const ipv4 = v4Match ? v4Match[1] : normalized; - - const num = parseIpv4(ipv4); - if (num === null) { - // If we can't parse it, block IPv6 addresses that look internal. - // fc00::/7 is Unique Local (first byte 0xfc or 0xfd), fe80::/10 is - // link-local. Only match when followed by hex digit + colon to avoid - // collisions with hypothetical non-address strings. - return ( - normalized.startsWith("fe80:") || - IPV6_ULA_FC_PATTERN.test(normalized) || - IPV6_ULA_FD_PATTERN.test(normalized) - ); - } - - return BLOCKED_PATTERNS.some((range) => num >= range.start && num <= range.end); -} - -/** - * Error thrown when SSRF protection blocks a URL. - */ -export class SsrfError extends Error { - code = "SSRF_BLOCKED" as const; - - constructor(message: string) { - super(message); - this.name = "SsrfError"; - } -} - -/** - * Validate that a URL is safe to fetch (not targeting internal networks). - * - * Checks: - * 1. URL is well-formed with http/https scheme - * 2. Hostname is not a known internal name (localhost, metadata endpoints) - * 3. If hostname is an IP literal, it's not in a private range - * - * Note: DNS rebinding attacks are not fully mitigated (hostname could resolve - * to a private IP). Full protection requires resolving DNS and checking the IP - * before connecting, which needs a custom fetch implementation. This covers - * the most common SSRF vectors. - * - * @throws SsrfError if the URL targets an internal address - */ -/** Maximum number of redirects to follow in ssrfSafeFetch */ -const MAX_REDIRECTS = 5; - -export function validateExternalUrl(url: string): URL { - let parsed: URL; - try { - parsed = new URL(url); - } catch { - throw new SsrfError("Invalid URL"); - } - - // Only allow http/https - if (!ALLOWED_SCHEMES.has(parsed.protocol)) { - throw new SsrfError(`Scheme '${parsed.protocol}' is not allowed`); - } - - // Strip brackets from IPv6 hostname - const hostname = parsed.hostname.replace(IPV6_BRACKET_PATTERN, ""); - - // Normalize the hostname for blocklist matching: lowercase + strip any - // trailing dots. WHATWG preserves trailing dots on .hostname, so without - // this normalization "localhost." and "nip.io." bypass the checks. - const normalizedHost = hostname.toLowerCase().replace(TRAILING_DOT_PATTERN, ""); - - // Check against known internal hostnames - if (BLOCKED_HOSTNAMES.has(normalizedHost)) { - throw new SsrfError("URLs targeting internal hosts are not allowed"); - } - - // Check against wildcard DNS services used by SSRF tooling to bypass - // hostname-only checks. Match the apex and any subdomain. - for (const suffix of BLOCKED_HOSTNAME_SUFFIXES) { - if (normalizedHost === suffix || normalizedHost.endsWith(`.${suffix}`)) { - throw new SsrfError("URLs targeting wildcard DNS services are not allowed"); - } - } - - // Check if hostname is an IP address in a private range. Use the - // normalized form so "127.0.0.1.." and friends don't bypass parseIpv4 - // (which rejects extra trailing dots). - if (isPrivateIp(normalizedHost)) { - throw new SsrfError("URLs targeting private IP addresses are not allowed"); - } - - return parsed; -} - -// --------------------------------------------------------------------------- -// DNS-aware validation -// --------------------------------------------------------------------------- - -/** - * A resolver that maps a hostname to a list of IPv4/IPv6 addresses. - * Injectable so callers can swap in OS-level DNS on Node, stub it in tests, - * or point to a different DoH endpoint. - */ -export type DnsResolver = (hostname: string) => Promise; - -/** - * Module-level default resolver. Tests can swap this with a stub so fetch - * mocks don't see unexpected DoH round-trips. Production code should leave - * it alone. - */ -let defaultResolver: DnsResolver | null = null; - -/** Override the default DNS resolver. Returns the previous value. */ -export function setDefaultDnsResolver(resolver: DnsResolver | null): DnsResolver | null { - const previous = defaultResolver; - defaultResolver = resolver; - return previous; -} - -/** Timeout for a single DoH request, in milliseconds. */ -const DOH_TIMEOUT_MS = 3000; - -/** Default DoH endpoint — Cloudflare's public resolver. */ -const DEFAULT_DOH_URL = "https://cloudflare-dns.com/dns-query"; - -interface DohAnswer { - data: string; -} - -interface DohResponse { - Status: number; - Answer: DohAnswer[]; -} - -function hasProperty(obj: unknown, key: K): obj is Record { - return typeof obj === "object" && obj !== null && key in obj; -} - -/** - * Narrow an unknown JSON body to a DohResponse shape we can read safely. - * Throws if the body doesn't look like a DoH response — a malformed body is - * indistinguishable from a failure and must not be silently treated as empty. - */ -function parseDohResponse(raw: unknown): DohResponse { - if (!hasProperty(raw, "Status") || typeof raw.Status !== "number") { - throw new Error("DoH response missing Status field"); - } - const answers: DohAnswer[] = []; - if (hasProperty(raw, "Answer") && Array.isArray(raw.Answer)) { - for (const entry of raw.Answer) { - if (hasProperty(entry, "data") && typeof entry.data === "string") { - answers.push({ data: entry.data }); - } - } - } - return { Status: raw.Status, Answer: answers }; -} - -/** - * Resolve a hostname via DNS over HTTPS (Cloudflare). Returns all A and AAAA - * records. Works in both Workers and Node without requiring node:dns. - * - * Fails closed: any network error, non-2xx response, or DNS rcode != 0 - * causes a rejected promise so the calling validator treats it as a block. - */ -export const cloudflareDohResolver: DnsResolver = async (hostname) => { - async function query(type: "A" | "AAAA"): Promise { - const params = new URLSearchParams({ name: hostname, type }); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), DOH_TIMEOUT_MS); - try { - const response = await globalThis.fetch(`${DEFAULT_DOH_URL}?${params.toString()}`, { - headers: { Accept: "application/dns-json" }, - signal: controller.signal, - }); - if (!response.ok) { - throw new Error(`DoH lookup failed: ${response.status}`); - } - const raw = await response.json(); - const body = parseDohResponse(raw); - // NXDOMAIN (3) is a legitimate "does not exist" — treat as empty. - // Any other non-zero status (SERVFAIL=2, REFUSED=5, etc.) is - // ambiguous and could be a split-view attacker hiding records - // from our resolver. Fail closed. - if (body.Status === 3) return []; - if (body.Status !== 0) { - throw new Error(`DoH ${type} lookup failed: rcode=${body.Status}`); - } - // DoH Answer arrays often include CNAME records alongside A/AAAA - // records. Their `data` is a hostname, not an IP. Filter to just - // IP literals so isPrivateIp sees real addresses. - return body.Answer.map((a) => a.data).filter(isIpLiteral); - } finally { - clearTimeout(timeout); - } - } - - const [a, aaaa] = await Promise.all([query("A"), query("AAAA")]); - return [...a, ...aaaa]; -}; - -/** - * Validate a URL and resolve its hostname to check the actual IPs against - * the private-range blocklist. This catches DNS rebinding attacks using - * attacker-controlled domains that publicly resolve to private addresses, - * and wildcard DNS services like nip.io used by exploit tooling. - * - * Runs `validateExternalUrl` first for cheap pre-flight checks (scheme, - * literal IP, known-bad hostnames). Then resolves the hostname and rejects - * if ANY returned address is private. - * - * Fails closed: if resolution fails or returns no records, throws SsrfError. - * - * **Caveats.** This does NOT fully close the TOCTOU between check and - * connect. Attacks that still work against this layer include: - * - * - TTL=0 rebind: authoritative server returns public IP to the check, then - * private IP to the subsequent fetch() a few milliseconds later. - * - Split-view via EDNS Client Subnet or source-IP inspection: the - * authoritative server returns public IP to Cloudflare's DoH resolver and - * private IP to the victim's own resolver (used by fetch()). - * - Host-file overrides or split-horizon corporate DNS on self-hosted Node. - * - Attacker-controlled rebinding services the caller has allowlisted. - * - * The only complete defense is a network-layer egress firewall. On - * Cloudflare Workers, the platform fetch pipeline provides most of that. - * On self-hosted Node, operators must restrict egress themselves. - */ -export async function resolveAndValidateExternalUrl( - url: string, - options?: { resolver?: DnsResolver }, -): Promise { - const parsed = validateExternalUrl(url); - - // Strip brackets from IPv6 hostnames - const hostname = parsed.hostname.replace(IPV6_BRACKET_PATTERN, ""); - - // If the hostname is already an IP literal, validateExternalUrl has - // already checked it against the private-range list. Skip DNS. - if (isIpLiteral(hostname)) { - return parsed; - } - - const resolver = options?.resolver ?? defaultResolver ?? cloudflareDohResolver; - - let addresses: string[]; - try { - addresses = await resolver(hostname); - } catch (error) { - throw new SsrfError( - `Could not resolve hostname: ${error instanceof Error ? error.message : String(error)}`, - ); - } - - if (addresses.length === 0) { - throw new SsrfError("Hostname resolved to no addresses"); - } - - for (const ip of addresses) { - if (isPrivateIp(ip)) { - throw new SsrfError("Hostname resolves to a private IP address"); - } - } - - return parsed; -} - -/** True when a string looks like an IPv4 or IPv6 literal. */ -function isIpLiteral(host: string): boolean { - if (parseIpv4(host) !== null) return true; - // Very loose IPv6 heuristic — matches anything with a colon, which is - // never valid in DNS hostnames, so this is safe. - return host.includes(":"); -} - -/** - * Fetch a URL with SSRF protection on redirects. - * - * Uses `redirect: "manual"` to intercept redirects and re-validate each - * redirect target against SSRF rules before following it. This prevents - * an attacker from setting up an allowed external URL that redirects to - * an internal IP (e.g. 169.254.169.254 for cloud metadata). - * - * @throws SsrfError if the initial URL or any redirect target is internal - */ -/** Headers that must be stripped when a redirect crosses origins */ -const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization"]; - -export async function ssrfSafeFetch( - url: string, - init?: RequestInit, - options?: { resolver?: DnsResolver }, -): Promise { - let currentUrl = url; - let currentInit = init; - - for (let i = 0; i <= MAX_REDIRECTS; i++) { - await resolveAndValidateExternalUrl(currentUrl, options); - - const response = await globalThis.fetch(currentUrl, { - ...currentInit, - redirect: "manual", - }); - - // Not a redirect -- return directly - if (response.status < 300 || response.status >= 400) { - return response; - } - - // Extract redirect target - const location = response.headers.get("Location"); - if (!location) { - return response; - } - - // Resolve relative redirects against the current URL - const previousOrigin = new URL(currentUrl).origin; - currentUrl = new URL(location, currentUrl).href; - const nextOrigin = new URL(currentUrl).origin; - - // Strip credential headers on cross-origin redirects - if (previousOrigin !== nextOrigin && currentInit) { - currentInit = stripCredentialHeaders(currentInit); - } - } - - throw new SsrfError(`Too many redirects (max ${MAX_REDIRECTS})`); -} - -/** - * Return a copy of init with credential headers removed. - */ -export function stripCredentialHeaders(init: RequestInit): RequestInit { - if (!init.headers) return init; - - const headers = new Headers(init.headers); - for (const name of CREDENTIAL_HEADERS) { - headers.delete(name); - } - - return { ...init, headers }; -} + * @deprecated Re-export shim. The SSRF helpers moved to + * `packages/core/src/security/ssrf.ts` because they're now used outside + * the import pipeline (registry installs, future trusted-fetch use + * cases). New code should import from `#security/ssrf.js` directly. + * + * Existing import-pipeline callers keep working unchanged through this + * shim. Remove once all callers have migrated. + */ + +export { + cloudflareDohResolver, + resolveAndValidateExternalUrl, + setDefaultDnsResolver, + SsrfError, + ssrfSafeFetch, + stripCredentialHeaders, + validateExternalUrl, + normalizeIPv6MappedToIPv4, + type DnsResolver, +} from "../security/ssrf.js"; diff --git a/packages/core/src/registry/config.ts b/packages/core/src/registry/config.ts index e68571e26..75cbde80c 100644 --- a/packages/core/src/registry/config.ts +++ b/packages/core/src/registry/config.ts @@ -173,6 +173,14 @@ export function validateAggregatorUrl(aggregatorUrl: string): URL { if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new Error(`registry.aggregatorUrl must use http or https: ${aggregatorUrl}`); } + // Reject embedded credentials. The normalized aggregator URL ends + // up in the admin manifest and is shipped to every admin browser; + // browser `fetch()` also outright rejects URLs with `user:pass@`, + // so leaving them in would both leak the credentials and break the + // registry UI at runtime. + if (parsed.username || parsed.password) { + throw new Error("registry.aggregatorUrl must not contain embedded credentials (user:pass@)"); + } // WHATWG URL preserves the brackets on IPv6 hostnames -- strip them // before any comparison so `https://[::1]/` is recognised as localhost diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 26ada48ef..e0257688a 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -145,20 +145,62 @@ export interface ExperimentalConfig { * * When set, replaces the centralized `marketplace` for the admin UI's * browse and install flows. The registry is an atproto-backed - * federation: package metadata lives in each publisher's PDS, an - * aggregator (the `aggregatorUrl`) indexes the firehose and exposes - * read-only XRPC endpoints for discovery, and EmDash verifies each - * release against the publisher's signed records before installing. + * federation: package metadata lives in each publisher's PDS and + * an aggregator (the `aggregatorUrl`) indexes the firehose and + * exposes read-only XRPC endpoints for discovery. * * See [RFC 0001](https://github.com/emdash-cms/emdash/pull/694) for - * the protocol design and threat model. + * the protocol design. * - * Requires `sandboxRunner` to be configured -- registry plugins always - * run sandboxed. + * **Trust model (v1, experimental).** Today EmDash trusts the + * configured aggregator with these claims, per package and per + * release: + * + * - The publisher DID associated with a `(did, slug)` pair. + * - The artifact `url`, the artifact `checksum`, and any mirror + * URLs returned for a release. + * - The published handle for a DID (used for display only; + * EmDash separately verifies the DID->handle round-trip in the + * admin UI before treating a handle as confirmed). + * + * What EmDash verifies independently before activating an + * installed plugin: + * + * - The artifact bytes hash to the checksum the aggregator + * returned (so a malicious mirror or in-transit tamper can't + * swap the bundle). + * - The bundle's `manifest.id` matches the requested slug, and + * its `manifest.version` matches the release version (so an + * attacker who controls the aggregator can't trick the + * sandbox into addressing the wrong plugin id). + * - The bundle's `manifest.capabilities` matches what the admin + * acknowledged in the consent dialog (so a publisher can't + * ship a bundle that requests more permissions than the + * dialog displayed). + * + * What's NOT yet verified: + * + * - Full MST proof / publisher signature on the release record. + * A compromised aggregator can forge a release for any DID + * and slug, and the install will succeed as long as the + * bundle matches the (forged) checksum. + * - Per-release replay / rollback: the aggregator chooses which + * release version is "latest". + * + * **Recommendation.** Until full signature verification lands, + * point `aggregatorUrl` only at an aggregator you operate + * yourself or one you trust with the same level of authority as + * a centralized plugin source. The `policy.minimumReleaseAge` and + * `acceptLabelers` knobs partially mitigate by widening the + * detection window for takedowns, but they assume the labeller + * system is operating. + * + * Requires `sandboxRunner` to be configured -- registry plugins + * always run sandboxed. * * Accepts a bare URL string as shorthand for - * `{ aggregatorUrl: "..." }`. Use the full object form when you need - * `acceptLabelers` or `policy`. + * `{ aggregatorUrl: "..." }`. Use the full object form when you + * need `acceptLabelers` or `policy`. */ registry?: RegistryConfigInput; } diff --git a/packages/core/src/security/ssrf.ts b/packages/core/src/security/ssrf.ts new file mode 100644 index 000000000..86f3185b1 --- /dev/null +++ b/packages/core/src/security/ssrf.ts @@ -0,0 +1,501 @@ +/** + * SSRF protection for import URLs. + * + * Validates that URLs don't target internal/private network addresses. + * Applied before any fetch() call in the import pipeline. + */ + +const IPV4_MAPPED_IPV6_DOTTED_PATTERN = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i; +const IPV4_MAPPED_IPV6_HEX_PATTERN = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i; +const IPV4_TRANSLATED_HEX_PATTERN = /^::ffff:0:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i; +const IPV6_EXPANDED_MAPPED_PATTERN = + /^0{0,4}:0{0,4}:0{0,4}:0{0,4}:0{0,4}:ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i; + +/** + * IPv4-compatible (deprecated) addresses: ::XXXX:XXXX + * + * The WHATWG URL parser normalizes [::127.0.0.1] to [::7f00:1] (no ffff prefix). + * These are deprecated but still parsed, and bypass the ffff-based checks. + */ +const IPV4_COMPATIBLE_HEX_PATTERN = /^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i; + +/** + * NAT64 prefix (RFC 6052): 64:ff9b::XXXX:XXXX + * + * Used by NAT64 gateways to embed IPv4 addresses in IPv6. + * [64:ff9b::127.0.0.1] normalizes to [64:ff9b::7f00:1]. + */ +const NAT64_HEX_PATTERN = /^64:ff9b::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i; + +const IPV6_BRACKET_PATTERN = /^\[|\]$/g; + +/** Match fc00::/7 ULA — first byte 0xfc or 0xfd followed by any byte. */ +const IPV6_ULA_FC_PATTERN = /^fc[0-9a-f]{2}:/; +const IPV6_ULA_FD_PATTERN = /^fd[0-9a-f]{2}:/; + +/** Strip trailing dots from an FQDN-form hostname ("localhost." -> "localhost"). */ +const TRAILING_DOT_PATTERN = /\.+$/; + +/** + * Private and reserved IP ranges that should never be fetched. + * + * Includes: + * - Loopback (127.0.0.0/8) + * - Private (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) + * - Link-local (169.254.0.0/16) + * - Cloud metadata (169.254.169.254 — AWS/GCP/Azure) + * - IPv6 loopback and link-local + */ +const BLOCKED_PATTERNS: Array<{ start: number; end: number }> = [ + // 127.0.0.0/8 — loopback + { start: ip4ToNum(127, 0, 0, 0), end: ip4ToNum(127, 255, 255, 255) }, + // 10.0.0.0/8 — private + { start: ip4ToNum(10, 0, 0, 0), end: ip4ToNum(10, 255, 255, 255) }, + // 172.16.0.0/12 — private + { start: ip4ToNum(172, 16, 0, 0), end: ip4ToNum(172, 31, 255, 255) }, + // 192.168.0.0/16 — private + { start: ip4ToNum(192, 168, 0, 0), end: ip4ToNum(192, 168, 255, 255) }, + // 169.254.0.0/16 — link-local (includes cloud metadata endpoint) + { start: ip4ToNum(169, 254, 0, 0), end: ip4ToNum(169, 254, 255, 255) }, + // 0.0.0.0/8 — current network + { start: ip4ToNum(0, 0, 0, 0), end: ip4ToNum(0, 255, 255, 255) }, +]; + +// Bracket-stripped form is used for lookups (validateExternalUrl strips +// brackets from parsed.hostname before checking), so "::1" appears here +// without brackets. The "::1" case is already covered by isPrivateIp, but +// keeping it here makes the intent explicit and gives a clearer error +// message for the common `http://[::1]/` form. +const BLOCKED_HOSTNAMES = new Set([ + "localhost", + "metadata.google.internal", + "metadata.google", + "::1", +]); + +/** + * Wildcard DNS services that publicly resolve arbitrary IPs embedded in the + * hostname. Commonly used in local dev and by SSRF exploit tooling to bypass + * hostname-only blocklists (e.g. 127.0.0.1.nip.io -> 127.0.0.1). + * + * Matched case-insensitively as a suffix, so both the apex and any subdomain + * are blocked. + */ +const BLOCKED_HOSTNAME_SUFFIXES = [ + "nip.io", + "sslip.io", + "xip.io", + "traefik.me", + "lvh.me", + "localtest.me", +]; + +/** Blocked URL schemes */ +const ALLOWED_SCHEMES = new Set(["http:", "https:"]); + +function ip4ToNum(a: number, b: number, c: number, d: number): number { + return ((a << 24) | (b << 16) | (c << 8) | d) >>> 0; +} + +function parseIpv4(ip: string): number | null { + const parts = ip.split("."); + if (parts.length !== 4) return null; + + const nums = parts.map(Number); + if (nums.some((n) => isNaN(n) || n < 0 || n > 255)) return null; + + return ip4ToNum(nums[0], nums[1], nums[2], nums[3]); +} + +/** + * Convert IPv4-mapped/translated IPv6 addresses from hex form back to IPv4. + * + * The WHATWG URL parser normalizes dotted-decimal to hex: + * [::ffff:127.0.0.1] -> [::ffff:7f00:1] + * [::ffff:169.254.169.254] -> [::ffff:a9fe:a9fe] + * + * Without this conversion, the hex forms bypass isPrivateIp() regex checks. + */ +export function normalizeIPv6MappedToIPv4(ip: string): string | null { + // Match hex-form IPv4-mapped IPv6: ::ffff:XXXX:XXXX + let match = ip.match(IPV4_MAPPED_IPV6_HEX_PATTERN); + if (!match) { + // Match IPv4-translated (RFC 6052): ::ffff:0:XXXX:XXXX + match = ip.match(IPV4_TRANSLATED_HEX_PATTERN); + } + if (!match) { + // Match fully expanded form: 0000:0000:0000:0000:0000:ffff:XXXX:XXXX + match = ip.match(IPV6_EXPANDED_MAPPED_PATTERN); + } + if (!match) { + // Match IPv4-compatible (deprecated) form: ::XXXX:XXXX (no ffff prefix) + match = ip.match(IPV4_COMPATIBLE_HEX_PATTERN); + } + if (!match) { + // Match NAT64 prefix (RFC 6052): 64:ff9b::XXXX:XXXX + match = ip.match(NAT64_HEX_PATTERN); + } + if (match) { + const high = parseInt(match[1] ?? "", 16); + const low = parseInt(match[2] ?? "", 16); + return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`; + } + return null; +} + +function isPrivateIp(ip: string): boolean { + // Normalize IPv6 strings to lowercase. `new URL().hostname` already + // lowercases, but resolver output (from DoH or an injected resolver) may + // not. Without this, "FE80::1" bypasses the link-local check. + const normalized = ip.toLowerCase(); + + // Handle IPv6 loopback + if (normalized === "::1" || normalized === "::ffff:127.0.0.1") return true; + + // Handle IPv4-mapped IPv6 in hex form (WHATWG URL parser normalizes to this) + // e.g. ::ffff:7f00:1 -> 127.0.0.1, ::ffff:a9fe:a9fe -> 169.254.169.254 + const hexIpv4 = normalizeIPv6MappedToIPv4(normalized); + if (hexIpv4) return isPrivateIp(hexIpv4); + + // Handle IPv4-mapped IPv6 in dotted-decimal form + const v4Match = normalized.match(IPV4_MAPPED_IPV6_DOTTED_PATTERN); + const ipv4 = v4Match ? v4Match[1] : normalized; + + const num = parseIpv4(ipv4); + if (num === null) { + // If we can't parse it, block IPv6 addresses that look internal. + // fc00::/7 is Unique Local (first byte 0xfc or 0xfd), fe80::/10 is + // link-local. Only match when followed by hex digit + colon to avoid + // collisions with hypothetical non-address strings. + return ( + normalized.startsWith("fe80:") || + IPV6_ULA_FC_PATTERN.test(normalized) || + IPV6_ULA_FD_PATTERN.test(normalized) + ); + } + + return BLOCKED_PATTERNS.some((range) => num >= range.start && num <= range.end); +} + +/** + * Error thrown when SSRF protection blocks a URL. + */ +export class SsrfError extends Error { + code = "SSRF_BLOCKED" as const; + + constructor(message: string) { + super(message); + this.name = "SsrfError"; + } +} + +/** + * Validate that a URL is safe to fetch (not targeting internal networks). + * + * Checks: + * 1. URL is well-formed with http/https scheme + * 2. Hostname is not a known internal name (localhost, metadata endpoints) + * 3. If hostname is an IP literal, it's not in a private range + * + * Note: DNS rebinding attacks are not fully mitigated (hostname could resolve + * to a private IP). Full protection requires resolving DNS and checking the IP + * before connecting, which needs a custom fetch implementation. This covers + * the most common SSRF vectors. + * + * @throws SsrfError if the URL targets an internal address + */ +/** Maximum number of redirects to follow in ssrfSafeFetch */ +const MAX_REDIRECTS = 5; + +export function validateExternalUrl(url: string): URL { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new SsrfError("Invalid URL"); + } + + // Only allow http/https + if (!ALLOWED_SCHEMES.has(parsed.protocol)) { + throw new SsrfError(`Scheme '${parsed.protocol}' is not allowed`); + } + + // Strip brackets from IPv6 hostname + const hostname = parsed.hostname.replace(IPV6_BRACKET_PATTERN, ""); + + // Normalize the hostname for blocklist matching: lowercase + strip any + // trailing dots. WHATWG preserves trailing dots on .hostname, so without + // this normalization "localhost." and "nip.io." bypass the checks. + const normalizedHost = hostname.toLowerCase().replace(TRAILING_DOT_PATTERN, ""); + + // Check against known internal hostnames + if (BLOCKED_HOSTNAMES.has(normalizedHost)) { + throw new SsrfError("URLs targeting internal hosts are not allowed"); + } + + // Check against wildcard DNS services used by SSRF tooling to bypass + // hostname-only checks. Match the apex and any subdomain. + for (const suffix of BLOCKED_HOSTNAME_SUFFIXES) { + if (normalizedHost === suffix || normalizedHost.endsWith(`.${suffix}`)) { + throw new SsrfError("URLs targeting wildcard DNS services are not allowed"); + } + } + + // Check if hostname is an IP address in a private range. Use the + // normalized form so "127.0.0.1.." and friends don't bypass parseIpv4 + // (which rejects extra trailing dots). + if (isPrivateIp(normalizedHost)) { + throw new SsrfError("URLs targeting private IP addresses are not allowed"); + } + + return parsed; +} + +// --------------------------------------------------------------------------- +// DNS-aware validation +// --------------------------------------------------------------------------- + +/** + * A resolver that maps a hostname to a list of IPv4/IPv6 addresses. + * Injectable so callers can swap in OS-level DNS on Node, stub it in tests, + * or point to a different DoH endpoint. + */ +export type DnsResolver = (hostname: string) => Promise; + +/** + * Module-level default resolver. Tests can swap this with a stub so fetch + * mocks don't see unexpected DoH round-trips. Production code should leave + * it alone. + */ +let defaultResolver: DnsResolver | null = null; + +/** Override the default DNS resolver. Returns the previous value. */ +export function setDefaultDnsResolver(resolver: DnsResolver | null): DnsResolver | null { + const previous = defaultResolver; + defaultResolver = resolver; + return previous; +} + +/** Timeout for a single DoH request, in milliseconds. */ +const DOH_TIMEOUT_MS = 3000; + +/** Default DoH endpoint — Cloudflare's public resolver. */ +const DEFAULT_DOH_URL = "https://cloudflare-dns.com/dns-query"; + +interface DohAnswer { + data: string; +} + +interface DohResponse { + Status: number; + Answer: DohAnswer[]; +} + +function hasProperty(obj: unknown, key: K): obj is Record { + return typeof obj === "object" && obj !== null && key in obj; +} + +/** + * Narrow an unknown JSON body to a DohResponse shape we can read safely. + * Throws if the body doesn't look like a DoH response — a malformed body is + * indistinguishable from a failure and must not be silently treated as empty. + */ +function parseDohResponse(raw: unknown): DohResponse { + if (!hasProperty(raw, "Status") || typeof raw.Status !== "number") { + throw new Error("DoH response missing Status field"); + } + const answers: DohAnswer[] = []; + if (hasProperty(raw, "Answer") && Array.isArray(raw.Answer)) { + for (const entry of raw.Answer) { + if (hasProperty(entry, "data") && typeof entry.data === "string") { + answers.push({ data: entry.data }); + } + } + } + return { Status: raw.Status, Answer: answers }; +} + +/** + * Resolve a hostname via DNS over HTTPS (Cloudflare). Returns all A and AAAA + * records. Works in both Workers and Node without requiring node:dns. + * + * Fails closed: any network error, non-2xx response, or DNS rcode != 0 + * causes a rejected promise so the calling validator treats it as a block. + */ +export const cloudflareDohResolver: DnsResolver = async (hostname) => { + async function query(type: "A" | "AAAA"): Promise { + const params = new URLSearchParams({ name: hostname, type }); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), DOH_TIMEOUT_MS); + try { + const response = await globalThis.fetch(`${DEFAULT_DOH_URL}?${params.toString()}`, { + headers: { Accept: "application/dns-json" }, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`DoH lookup failed: ${response.status}`); + } + const raw = await response.json(); + const body = parseDohResponse(raw); + // NXDOMAIN (3) is a legitimate "does not exist" — treat as empty. + // Any other non-zero status (SERVFAIL=2, REFUSED=5, etc.) is + // ambiguous and could be a split-view attacker hiding records + // from our resolver. Fail closed. + if (body.Status === 3) return []; + if (body.Status !== 0) { + throw new Error(`DoH ${type} lookup failed: rcode=${body.Status}`); + } + // DoH Answer arrays often include CNAME records alongside A/AAAA + // records. Their `data` is a hostname, not an IP. Filter to just + // IP literals so isPrivateIp sees real addresses. + return body.Answer.map((a) => a.data).filter(isIpLiteral); + } finally { + clearTimeout(timeout); + } + } + + const [a, aaaa] = await Promise.all([query("A"), query("AAAA")]); + return [...a, ...aaaa]; +}; + +/** + * Validate a URL and resolve its hostname to check the actual IPs against + * the private-range blocklist. This catches DNS rebinding attacks using + * attacker-controlled domains that publicly resolve to private addresses, + * and wildcard DNS services like nip.io used by exploit tooling. + * + * Runs `validateExternalUrl` first for cheap pre-flight checks (scheme, + * literal IP, known-bad hostnames). Then resolves the hostname and rejects + * if ANY returned address is private. + * + * Fails closed: if resolution fails or returns no records, throws SsrfError. + * + * **Caveats.** This does NOT fully close the TOCTOU between check and + * connect. Attacks that still work against this layer include: + * + * - TTL=0 rebind: authoritative server returns public IP to the check, then + * private IP to the subsequent fetch() a few milliseconds later. + * - Split-view via EDNS Client Subnet or source-IP inspection: the + * authoritative server returns public IP to Cloudflare's DoH resolver and + * private IP to the victim's own resolver (used by fetch()). + * - Host-file overrides or split-horizon corporate DNS on self-hosted Node. + * - Attacker-controlled rebinding services the caller has allowlisted. + * + * The only complete defense is a network-layer egress firewall. On + * Cloudflare Workers, the platform fetch pipeline provides most of that. + * On self-hosted Node, operators must restrict egress themselves. + */ +export async function resolveAndValidateExternalUrl( + url: string, + options?: { resolver?: DnsResolver }, +): Promise { + const parsed = validateExternalUrl(url); + + // Strip brackets from IPv6 hostnames + const hostname = parsed.hostname.replace(IPV6_BRACKET_PATTERN, ""); + + // If the hostname is already an IP literal, validateExternalUrl has + // already checked it against the private-range list. Skip DNS. + if (isIpLiteral(hostname)) { + return parsed; + } + + const resolver = options?.resolver ?? defaultResolver ?? cloudflareDohResolver; + + let addresses: string[]; + try { + addresses = await resolver(hostname); + } catch (error) { + throw new SsrfError( + `Could not resolve hostname: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (addresses.length === 0) { + throw new SsrfError("Hostname resolved to no addresses"); + } + + for (const ip of addresses) { + if (isPrivateIp(ip)) { + throw new SsrfError("Hostname resolves to a private IP address"); + } + } + + return parsed; +} + +/** True when a string looks like an IPv4 or IPv6 literal. */ +function isIpLiteral(host: string): boolean { + if (parseIpv4(host) !== null) return true; + // Very loose IPv6 heuristic — matches anything with a colon, which is + // never valid in DNS hostnames, so this is safe. + return host.includes(":"); +} + +/** + * Fetch a URL with SSRF protection on redirects. + * + * Uses `redirect: "manual"` to intercept redirects and re-validate each + * redirect target against SSRF rules before following it. This prevents + * an attacker from setting up an allowed external URL that redirects to + * an internal IP (e.g. 169.254.169.254 for cloud metadata). + * + * @throws SsrfError if the initial URL or any redirect target is internal + */ +/** Headers that must be stripped when a redirect crosses origins */ +const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization"]; + +export async function ssrfSafeFetch( + url: string, + init?: RequestInit, + options?: { resolver?: DnsResolver }, +): Promise { + let currentUrl = url; + let currentInit = init; + + for (let i = 0; i <= MAX_REDIRECTS; i++) { + await resolveAndValidateExternalUrl(currentUrl, options); + + const response = await globalThis.fetch(currentUrl, { + ...currentInit, + redirect: "manual", + }); + + // Not a redirect -- return directly + if (response.status < 300 || response.status >= 400) { + return response; + } + + // Extract redirect target + const location = response.headers.get("Location"); + if (!location) { + return response; + } + + // Resolve relative redirects against the current URL + const previousOrigin = new URL(currentUrl).origin; + currentUrl = new URL(location, currentUrl).href; + const nextOrigin = new URL(currentUrl).origin; + + // Strip credential headers on cross-origin redirects + if (previousOrigin !== nextOrigin && currentInit) { + currentInit = stripCredentialHeaders(currentInit); + } + } + + throw new SsrfError(`Too many redirects (max ${MAX_REDIRECTS})`); +} + +/** + * Return a copy of init with credential headers removed. + */ +export function stripCredentialHeaders(init: RequestInit): RequestInit { + if (!init.headers) return init; + + const headers = new Headers(init.headers); + for (const name of CREDENTIAL_HEADERS) { + headers.delete(name); + } + + return { ...init, headers }; +} From 56fb2b421266301330f6979c2a3bd3d5e5c71a50 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 14 May 2026 07:02:22 +0100 Subject: [PATCH 9/9] fix(registry): adversarial review round 7 followups Two LOW findings from the round-7 review (PR #1011 comment). NSID exact-match in RegistryPluginDetail.tsx Round-6 left a startsWith() match on the release-extension key. RFC 0001 fixes the NSID for the release extension; accepting prefix variants (...releaseExtensionV2, ...releaseExtension.deprecated) would let a publisher render a different capability list than the canonical key would. Use exact-equality keyed lookup. Registry plugin uninstall affordance in PluginManager.tsx Registry-installed plugins appear in PluginManager but the Uninstall button is gated on isMarketplace. Admins see a permanent-looking install with no way to remove it short of editing the DB and R2 by hand. Add an inline note for source === 'registry' rows that says uninstall isn't available yet and points the admin at the disable toggle. Full uninstall handler lands in a follow-up PR. --- .../admin/src/components/PluginManager.tsx | 10 ++++++++++ .../src/components/RegistryPluginDetail.tsx | 19 ++++++++++++------- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/admin/src/components/PluginManager.tsx b/packages/admin/src/components/PluginManager.tsx index 16650006a..8e8427736 100644 --- a/packages/admin/src/components/PluginManager.tsx +++ b/packages/admin/src/components/PluginManager.tsx @@ -229,6 +229,7 @@ function PluginCard({ const toastManager = Toast.useToastManager(); const isMarketplace = plugin.source === "marketplace"; + const isRegistry = plugin.source === "registry"; const hasUpdate = !!updateInfo && updateInfo.installed !== updateInfo.latest; const updateMutation = useMutation({ @@ -495,6 +496,15 @@ function PluginCard({
)} + + {/* 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/components/RegistryPluginDetail.tsx b/packages/admin/src/components/RegistryPluginDetail.tsx index c7be39db0..8f2ec8df8 100644 --- a/packages/admin/src/components/RegistryPluginDetail.tsx +++ b/packages/admin/src/components/RegistryPluginDetail.tsx @@ -101,18 +101,23 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP // to a string list otherwise. This keeps `CapabilityConsentDialog` -- // which only understands `capabilities` -- working unchanged. // - // `normalizeCapabilities` filters non-strings, dedupes, and sorts so - // an aggregator-supplied array with unstable order or junk entries - // can't trigger a spurious server-side drift rejection later. + // `canonicalCapabilitiesForDriftCheck` filters non-strings, dedupes, + // and sorts so an aggregator-supplied array with unstable order or + // junk entries can't trigger a spurious server-side drift rejection + // later. + // + // NSID is exact-matched, not prefix-matched. RFC 0001 fixes the NSID + // for this extension; accepting variants like `…releaseExtensionV2` + // or `…releaseExtension.deprecated` would let a publisher render a + // different permissions list than another publisher would for the + // same RFC-0001 fields. + const RELEASE_EXTENSION_NSID = "com.emdashcms.experimental.package.releaseExtension"; const releaseDoc = release?.release as | { extensions?: Record; } | undefined; - const extensionEntries = releaseDoc?.extensions ? Object.entries(releaseDoc.extensions) : []; - const ext = extensionEntries.find(([k]) => - k.startsWith("com.emdashcms.experimental.package.releaseExtension"), - )?.[1]; + const ext = releaseDoc?.extensions?.[RELEASE_EXTENSION_NSID]; const capabilities: string[] = Array.isArray(ext?.capabilities) ? canonicalCapabilitiesForDriftCheck(ext?.capabilities)