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/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 28be79248..e8e27e5b3 100644 --- a/packages/admin/package.json +++ b/packages/admin/package.json @@ -31,11 +31,15 @@ "locale:extract": "lingui extract --clean" }, "dependencies": { - "@cloudflare/kumo": "^1.16.0", + "@atcute/identity-resolver": "catalog:", + "@atcute/lexicons": "catalog:", + "@cloudflare/kumo": "catalog:", "@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/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/PublisherHandle.tsx b/packages/admin/src/components/PublisherHandle.tsx new file mode 100644 index 000000000..23a7ada8a --- /dev/null +++ b/packages/admin/src/components/PublisherHandle.tsx @@ -0,0 +1,151 @@ +/** + * Renders an atproto publisher's identity, with three branches: + * + * - **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, 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. 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"; +import { useQuery } from "@tanstack/react-query"; +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 { + 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 { + // 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), + staleTime: 5 * 60 * 1000, + }); + + 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" }; + } + + // 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({ + 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 new file mode 100644 index 000000000..618fa3896 --- /dev/null +++ b/packages/admin/src/components/RegistryBrowse.tsx @@ -0,0 +1,201 @@ +/** + * 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"; +import { PublisherHandle, usePublisherHandle } from "./PublisherHandle.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 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"); + + return ( + +
+
+ +
+
+
+

{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 new file mode 100644 index 000000000..8f2ec8df8 --- /dev/null +++ b/packages/admin/src/components/RegistryPluginDetail.tsx @@ -0,0 +1,387 @@ +/** + * 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 { + canonicalCapabilitiesForDriftCheck, + getLatestRegistryRelease, + getRegistryPackage, + 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"; +import { PublisherHandle, usePublisherHandle } from "./PublisherHandle.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); + + // 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, 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), + 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. + // + // `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 ext = releaseDoc?.extensions?.[RELEASE_EXTENSION_NSID]; + + const capabilities: string[] = Array.isArray(ext?.capabilities) + ? 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; + // 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: () => { + if (!pkg) throw new Error("Package not loaded"); + return installRegistryPlugin({ + did: pkg.did, + slug, + version: release?.version, + // 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: () => { + 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`}{" "} + +

+ {release ? ( +

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

+ ) : null} +
+
+ {isInstalled ? ( + + ) : ( + + )} +
+
+ + {/* 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`We couldn't verify this publisher's identity`}

+

+ {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.`} +

+
+
+ ) : 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..49cf471a5 --- /dev/null +++ b/packages/admin/src/lib/api/registry.ts @@ -0,0 +1,469 @@ +/** + * 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 { + did: 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; + getPackage: (did: 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 getPackage(did: string, slug: string) { + const result = await discovery.getPackage({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- did shape validated by aggregator + did: did as Did, + 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; +} + +/** + * Canonicalize a capabilities list for set-style comparison. Mirrors + * the server-side helper `canonicalCapabilitiesForDriftCheck` 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 canonicalCapabilitiesForDriftCheck(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 [...seen].toSorted(); +} + +/** + * 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 getRegistryPackage( + config: RegistryClientConfig, + did: string, + slug: string, +): Promise { + const client = await getDiscoveryClient(config); + return client.getPackage(did, 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); +} + +/** + * 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) +// --------------------------------------------------------------------------- + +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..810cfb897 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,17 @@ function MarketplaceDetailPage() { return new Set(plugins.map((p) => p.id)); }, [plugins]); + // 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 ; + } + return ; } 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 9ce8bd7b5..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/*", @@ -168,10 +169,13 @@ "test:integration": "vitest run --config vitest.integration.config.ts" }, "dependencies": { + "@atcute/lexicons": "catalog:", + "@atcute/multibase": "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 +234,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..dec415532 --- /dev/null +++ b/packages/core/src/api/handlers/registry.ts @@ -0,0 +1,1086 @@ +/** + * 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 { Did } from "@atcute/lexicons"; +import type { Kysely } from "kysely"; + +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 { + canonicalCapabilitiesForDriftCheck, + coerceRegistryConfig, + parseDurationSeconds, + releaseExemptFromMinimumAge, + validateAggregatorUrl, +} 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"; +import { deleteBundleFromR2, storeBundleInR2 } from "./marketplace.js"; + +// ── Types ────────────────────────────────────────────────────────── + +export interface RegistryInstallInput { + /** + * 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. */ + 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(""); +} + +/** 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 checksum string from a release record's + * `artifact.checksum` field corresponds to the SHA-256 of the given + * bytes. + * + * 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 { + if (SHA256_HEX_PATTERN.test(checksum)) { + const actual = await sha256Hex(bytes); + return checksum.toLowerCase() === actual; + } + + // 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; +} + +/** + * 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); + }); + }; +} + +/** + * 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", + "localhost.localdomain", + "ip6-localhost", + "ip6-loopback", +]); + +/** Trailing dot on a hostname, stripped before URL host comparisons. */ +const TRAILING_DOT = /\.$/; + +/** 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, 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. + */ +async function assertSafeArtifactUrl(urlString: string): Promise { + 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) { + // Dev-only path; nothing to resolve. + 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; + } +} + +/** + * 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 = await 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 = 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!; + 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); + } +} + +/** + * 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 + // 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]; + // Client-visible errors carry redacted URLs (origin + path only). + // The full URL with any query-string token is logged server-side + // so operators can still debug delivery failures. + const clientErrors: string[] = []; + + const totalDeadline = Date.now() + ARTIFACT_TOTAL_BUDGET_MS; + + for (const url of urls) { + if (Date.now() >= totalDeadline) { + clientErrors.push("(total artifact download budget exhausted)"); + break; + } + try { + return await fetchWithLimits(url, totalDeadline); + } catch (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 ${clientErrors.join("\n ")}`, + ); +} + +// ── Install ──────────────────────────────────────────────────────── + +export async function handleRegistryInstall( + db: Kysely, + storage: Storage | null, + sandboxRunner: SandboxRunner | null, + 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, + 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 { did, 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 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_DID", + message: "DID must be a valid atproto DID (e.g. did:plc:abc123)", + }, + }; + } + + try { + // 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, + }); + + // 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 ${publisherDid}/${slug}` + : `No installable release found for ${publisherDid}/${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. + // `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; + 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(), + ); + 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 ${publisherDid}/${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 }; + + // 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. + // + // 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. + 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); + if ( + acknowledged.length !== actualCapabilities.length || + acknowledged.some((cap, i) => cap !== actualCapabilities[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. + 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. + // + // 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", { + source: "registry", + displayName: profile.name ?? slug, + description: profile.description ?? undefined, + registryPublisherDid: publisherDid, + registrySlug: slug, + }); + } catch (stateErr) { + let lostRace = false; + try { + const winner = await stateRepo.get(pluginId); + lostRace = winner !== undefined && winner !== null; + } catch (probeErr) { + console.warn( + `[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; + } + + 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/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/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..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 }; @@ -264,6 +267,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 +281,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..25aa9d08e --- /dev/null +++ b/packages/core/src/astro/routes/api/admin/plugins/registry/install.ts @@ -0,0 +1,107 @@ +/** + * 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 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"; +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 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() + .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, + { + did: body.did, + 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..f15ecfd83 --- /dev/null +++ b/packages/core/src/database/migrations/038_registry_plugin_state.ts @@ -0,0 +1,130 @@ +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 { + // 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)); + + 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/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/plugins/marketplace.ts b/packages/core/src/plugins/marketplace.ts index 809a4ebfb..073c80d48 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 matches + // RFC 0001's publish-time bundle size limit (MAX_DECOMPRESSED_BUNDLE_BYTES); + // anything past that isn't a legitimate sandboxed plugin. + 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..75cbde80c --- /dev/null +++ b/packages/core/src/registry/config.ts @@ -0,0 +1,311 @@ +/** + * 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, RegistryConfigInput } from "./types.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[]; + }; +} + +/** + * 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 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. 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 canonicalCapabilitiesForDriftCheck(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 [...seen].toSorted(); +} + +/** + * 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}`); + } + // 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 + // 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; +} + +/** + * Expand the `RegistryConfigInput` shorthand into the full + * `RegistryConfig` object shape. + * + * 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. 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( + input: RegistryConfigInput | undefined, +): ManifestRegistryConfig | null { + const config = coerceRegistryConfig(input); + 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..91ca8d498 --- /dev/null +++ b/packages/core/src/registry/plugin-id.ts @@ -0,0 +1,116 @@ +/** + * 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 + 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 + * 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/src/registry/types.ts b/packages/core/src/registry/types.ts new file mode 100644 index 000000000..e0257688a --- /dev/null +++ b/packages/core/src/registry/types.ts @@ -0,0 +1,206 @@ +/** + * 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[]; + }; +} + +/** + * 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`. + * + * 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 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. + * + * **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`. + */ + 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 }; +} 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/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/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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eeda756d9..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,8 +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 @@ -852,6 +861,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) @@ -1136,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 @@ -1148,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) @@ -1203,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:.. @@ -1313,6 +1328,12 @@ 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 + '@atcute/multibase': + specifier: 'catalog:' + version: 1.2.0 '@emdash-cms/admin': specifier: workspace:* version: link:../admin @@ -1328,6 +1349,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 +1408,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 @@ -1604,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) @@ -1695,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 @@ -1732,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) @@ -2915,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 @@ -6418,6 +6429,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 @@ -11687,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 @@ -14780,7 +14778,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 +15029,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 +15411,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..afc762847 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,19 @@ 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/kumo": ^1.16.0 "@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