Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
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