Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/real-plants-sell.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 47 additions & 1 deletion apps/aggregator/src/routes/xrpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
"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).
Expand All @@ -48,8 +77,24 @@ export async function handleXrpc(env: Env, request: Request): Promise<Response |
const url = new URL(request.url);
if (!url.pathname.startsWith("/xrpc/")) return null;

// CORS preflight. Browsers send OPTIONS before any cross-origin XRPC
// call; we answer with the same allow-list as the actual response
// so the real request goes through.
if (request.method === "OPTIONS") {
const headers = new Headers();
applyCorsHeaders(headers);
return new Response(null, { status: 204, headers });
}

if (url.pathname === SYNC_GET_RECORD_PATH) {
return syncGetRecord(env, request);
const response = await syncGetRecord(env, request);
const headers = new Headers(response.headers);
applyCorsHeaders(headers);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}

const router = getRouter(env);
Expand All @@ -63,6 +108,7 @@ export async function handleXrpc(env: Env, request: Request): Promise<Response |
// frozen Response from `json()`.
const headers = new Headers(response.headers);
headers.set("cache-control", NO_STORE);
applyCorsHeaders(headers);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
Expand Down
4 changes: 3 additions & 1 deletion infra/blog-demo/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ export default defineConfig({
plugins: [formsPlugin()],
sandboxed: [webhookNotifierPlugin()],
sandboxRunner: sandbox(),
marketplace: "https://marketplace.emdashcms.com",
experimental: {
registry: "https://registry.emdashcms.com",
},
}),
],
fonts: [
Expand Down
2 changes: 1 addition & 1 deletion infra/blog-demo/emdash-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export interface Post {
slug: string | null;
status: string;
title: string;
featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number };
featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record<string, unknown> };
content?: PortableTextBlock[];
excerpt?: string;
createdAt: Date;
Expand Down
6 changes: 5 additions & 1 deletion packages/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
10 changes: 10 additions & 0 deletions packages/admin/src/components/PluginManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -495,6 +496,15 @@ function PluginCard({
</Button>
</div>
)}

{/* Registry plugins have an install path but no uninstall
handler yet. Tell the admin so they don't think the
plugin is permanent or fall back to editing the DB. */}
{isRegistry && (
<div className="pt-2 border-t text-xs text-kumo-subtle">
{t`Uninstall is not yet available for registry plugins. Disable the plugin to stop it from running; full uninstall will land in a follow-up.`}
</div>
)}
</div>
)}
</div>
Expand Down
151 changes: 151 additions & 0 deletions packages/admin/src/components/PublisherHandle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className={`truncate ${textClass} text-kumo-subtle ${className ?? ""}`}>
@{result.handle}
</span>
);
}

if (result.status === "invalid") {
return (
<span className={`truncate ${textClass} font-medium text-kumo-error ${className ?? ""}`}>
{t`Unverified publisher`}
</span>
);
}

return <span className={`truncate ${textClass} text-kumo-subtle ${className ?? ""}`}>{did}</span>;
}
Loading
Loading