Skip to content

Commit b0eb848

Browse files
committed
fix(registry): wire up real-world install + display polish
Aggregator (apps/aggregator): - Add CORS to /xrpc/* so the admin UI can call it from any origin (preflight 204, response headers on every method). Aggregator is a public read-only service; * is correct here. Core (packages/core): - Implement multibase-multihash checksum verification by re-encoding our SHA-256 digest in the same 'b<base32>' shape the registry CLI produces, rather than decoding the publisher's checksum. Same trust contract, no base32 decoder needed. Bare hex SHA-256 still accepted as a convenience fallback. - Switch install handler to take 'did' (not handle) so packages whose handle the aggregator couldn't resolve are still installable. The browser resolves handle→DID via the aggregator before posting and sends DID directly; the server skips resolvePackage and goes straight to getPackage. - Coerce 'experimental.registry' bare-string shorthand into the full RegistryConfig object via 'coerceRegistryConfig'. 'registry: "..."' is now equivalent to 'registry: { aggregatorUrl: "..." }'. - Plumb 'experimental' through the integration's serializableConfig so the manifest endpoint actually sees the user's registry block. Previously it was being stripped, so the admin UI never branched to the registry path. - Split RegistryConfig + ExperimentalConfig types into their own module (registry/types.ts) so they don't get bundled into the astro/integration/runtime.ts dist chunk -- the wider inlining was breaking definePlugin overload resolution for trusted plugins building against core's dist. Admin (packages/admin): - New <PublisherHandle> component + usePublisherHandle hook with tri-state result ('ok' / 'invalid' / 'missing'). Renders @handle, 'Unverified publisher' (red), or DID respectively. Uses @atcute/identity-resolver's LocalActorResolver for bidirectional handle verification, localStorage-cached for 24h. - Detail page disables install on 'invalid' status (publisher claims a handle that doesn't round-trip back to its DID -- impersonation risk). Surfaces 'We couldn't verify this publisher's identity' alert in plain language. - Detail page reads installed state from fetchPlugins() and swaps the Install button to 'Installed' (disabled) when the package already has a 'source = "registry"' row matching its DID + slug. React Query's existing ['plugins'] invalidation handles the post-install UI update. - Browse cards reuse <PublisherHandle> (variant='card') and link by handle when available, DID otherwise. Detail page parses either form from the URL. - Browser sends 'did' (not handle) in the install POST. Workspace: - '@cloudflare/kumo' moved to the pnpm catalog and bumped to ^1.16.0 workspace-wide. Older 1.10.0 was missing Sidebar export and being hoisted into the admin via packages/blocks's transitive dep. - Add '@atcute/multibase' to core (for checksum encoding) and '@atcute/identity-resolver' to admin (for DID->handle resolution). - Update DEFAULT_AGGREGATOR_URL + DiscoveryClient doc example from 'experimental-registry.emdashcms.com' to 'registry.emdashcms.com' (the actual production host).
1 parent d6c4dc5 commit b0eb848

24 files changed

Lines changed: 635 additions & 202 deletions

File tree

apps/aggregator/src/routes/xrpc/router.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,35 @@ import { syncGetRecord } from "./sync-get-record.js";
4040
const NO_STORE = "private, no-store";
4141
const SYNC_GET_RECORD_PATH = "/xrpc/com.atproto.sync.getRecord";
4242

43+
/**
44+
* CORS for the aggregator's XRPC surface.
45+
*
46+
* The aggregator is a public read-only service: admin UIs running on
47+
* arbitrary EmDash sites call it directly from the browser. The atproto
48+
* spec doesn't standardize CORS for XRPC services, but browser clients
49+
* need `Access-Control-Allow-Origin` to access the JSON responses.
50+
*
51+
* `*` is correct here because nothing in our responses depends on the
52+
* caller's origin or credentials -- there are no cookies, no auth, no
53+
* per-origin policy. We allow `atproto-accept-labelers` and
54+
* `content-type` as request headers (the only two clients send), echo
55+
* back the labellers header for symmetry with atproto's labeller-aware
56+
* clients, and cap preflight cache at 24h.
57+
*/
58+
const CORS_HEADERS: Record<string, string> = {
59+
"access-control-allow-origin": "*",
60+
"access-control-allow-methods": "GET, POST, OPTIONS",
61+
"access-control-allow-headers": "content-type, atproto-accept-labelers",
62+
"access-control-expose-headers": "atproto-accept-labelers, content-language",
63+
"access-control-max-age": "86400",
64+
};
65+
66+
function applyCorsHeaders(headers: Headers): void {
67+
for (const [name, value] of Object.entries(CORS_HEADERS)) {
68+
headers.set(name, value);
69+
}
70+
}
71+
4372
/**
4473
* Dispatch any `/xrpc/*` request. Returns null when the path isn't an
4574
* XRPC route (caller falls through to other route matching).
@@ -48,8 +77,24 @@ export async function handleXrpc(env: Env, request: Request): Promise<Response |
4877
const url = new URL(request.url);
4978
if (!url.pathname.startsWith("/xrpc/")) return null;
5079

80+
// CORS preflight. Browsers send OPTIONS before any cross-origin XRPC
81+
// call; we answer with the same allow-list as the actual response
82+
// so the real request goes through.
83+
if (request.method === "OPTIONS") {
84+
const headers = new Headers();
85+
applyCorsHeaders(headers);
86+
return new Response(null, { status: 204, headers });
87+
}
88+
5189
if (url.pathname === SYNC_GET_RECORD_PATH) {
52-
return syncGetRecord(env, request);
90+
const response = await syncGetRecord(env, request);
91+
const headers = new Headers(response.headers);
92+
applyCorsHeaders(headers);
93+
return new Response(response.body, {
94+
status: response.status,
95+
statusText: response.statusText,
96+
headers,
97+
});
5398
}
5499

55100
const router = getRouter(env);
@@ -63,6 +108,7 @@ export async function handleXrpc(env: Env, request: Request): Promise<Response |
63108
// frozen Response from `json()`.
64109
const headers = new Headers(response.headers);
65110
headers.set("cache-control", NO_STORE);
111+
applyCorsHeaders(headers);
66112
return new Response(response.body, {
67113
status: response.status,
68114
statusText: response.statusText,

infra/blog-demo/astro.config.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ export default defineConfig({
2121
plugins: [formsPlugin()],
2222
sandboxed: [webhookNotifierPlugin()],
2323
sandboxRunner: sandbox(),
24-
marketplace: "https://marketplace.emdashcms.com",
24+
experimental: {
25+
registry: "https://registry.emdashcms.com",
26+
},
2527
}),
2628
],
2729
fonts: [

infra/blog-demo/emdash-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export interface Post {
2222
slug: string | null;
2323
status: string;
2424
title: string;
25-
featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number };
25+
featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record<string, unknown> };
2626
content?: PortableTextBlock[];
2727
excerpt?: string;
2828
createdAt: Date;

packages/admin/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@
3131
"locale:extract": "lingui extract --clean"
3232
},
3333
"dependencies": {
34+
"@atcute/identity-resolver": "catalog:",
3435
"@atcute/lexicons": "catalog:",
35-
"@cloudflare/kumo": "^1.16.0",
36+
"@cloudflare/kumo": "catalog:",
3637
"@dnd-kit/core": "^6.3.1",
3738
"@dnd-kit/sortable": "^10.0.0",
3839
"@dnd-kit/utilities": "^3.2.2",
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* Renders an atproto publisher's identity, with three branches:
3+
*
4+
* - **Verified handle**: shows `@handle`. Either the aggregator
5+
* already resolved the handle at ingest (we trust that), or our
6+
* local `LocalActorResolver` round-tripped the DID document's
7+
* `alsoKnownAs` back to the same DID.
8+
* - **Unverified publisher**: DID document claims a handle but the
9+
* handle's domain doesn't point back to the same DID. Treat as
10+
* untrusted -- the publisher might be impersonating someone else.
11+
* Surface as `Unverified publisher` in error styling. Callers
12+
* should also disable destructive actions (install, etc.).
13+
* - **Missing handle**: no claimed handle at all (or DID document
14+
* resolution failed entirely). Fall back to the raw DID.
15+
*
16+
* `aggregatorHandle` is what the registry's `searchPackages` /
17+
* `resolvePackage` endpoint returned for this DID -- best-effort, may
18+
* be `null`. When absent, this component falls back to a per-DID
19+
* `LocalActorResolver` lookup via `resolveDidToHandle`, cached in
20+
* localStorage for 24h so repeat renders don't refetch.
21+
*/
22+
23+
import { useLingui } from "@lingui/react/macro";
24+
import { useQuery } from "@tanstack/react-query";
25+
import * as React from "react";
26+
27+
import { resolveDidToHandle } from "../lib/api/registry.js";
28+
29+
export type PublisherHandleStatus = "ok" | "invalid" | "missing";
30+
31+
export interface PublisherHandleResult {
32+
status: PublisherHandleStatus;
33+
/** Verified handle (only present when `status === "ok"`). */
34+
handle?: string;
35+
}
36+
37+
export interface PublisherHandleProps {
38+
did: string;
39+
aggregatorHandle?: string | null;
40+
/**
41+
* Called every time the resolution status changes, so callers can
42+
* gate install buttons or other side effects on
43+
* `status === "invalid"`. Optional.
44+
*/
45+
onResolved?: (result: PublisherHandleResult) => void;
46+
/** Visual variant. `card` is the smaller list-item form. */
47+
variant?: "card" | "detail";
48+
className?: string;
49+
}
50+
51+
/**
52+
* Hook form: returns the same tri-state result without rendering. Use
53+
* when a parent needs to coordinate UI (e.g. disable install) based on
54+
* the resolution.
55+
*/
56+
export function usePublisherHandle(
57+
did: string,
58+
aggregatorHandle?: string | null,
59+
): PublisherHandleResult {
60+
const { data: didHandleResolution } = useQuery({
61+
queryKey: ["registry", "did-handle", did],
62+
queryFn: () => resolveDidToHandle(did),
63+
enabled: Boolean(did) && !aggregatorHandle,
64+
staleTime: 5 * 60 * 1000,
65+
});
66+
67+
if (aggregatorHandle) return { status: "ok", handle: aggregatorHandle };
68+
if (!didHandleResolution) return { status: "missing" };
69+
if (didHandleResolution.status === "ok") {
70+
return { status: "ok", handle: didHandleResolution.handle };
71+
}
72+
return { status: didHandleResolution.status };
73+
}
74+
75+
export function PublisherHandle({
76+
did,
77+
aggregatorHandle,
78+
onResolved,
79+
variant = "card",
80+
className,
81+
}: PublisherHandleProps) {
82+
const { t } = useLingui();
83+
const result = usePublisherHandle(did, aggregatorHandle);
84+
85+
// Notify the caller every time the result changes. Effect (not
86+
// inline) so we don't re-fire on every parent re-render.
87+
const onResolvedRef = React.useRef(onResolved);
88+
onResolvedRef.current = onResolved;
89+
React.useEffect(() => {
90+
onResolvedRef.current?.(result);
91+
}, [result.status, result.handle]);
92+
93+
const textClass = variant === "card" ? "text-xs" : "text-sm";
94+
95+
if (result.status === "ok" && result.handle) {
96+
return (
97+
<span className={`truncate ${textClass} text-kumo-subtle ${className ?? ""}`}>
98+
@{result.handle}
99+
</span>
100+
);
101+
}
102+
103+
if (result.status === "invalid") {
104+
return (
105+
<span className={`truncate ${textClass} font-medium text-kumo-error ${className ?? ""}`}>
106+
{t`Unverified publisher`}
107+
</span>
108+
);
109+
}
110+
111+
return <span className={`truncate ${textClass} text-kumo-subtle ${className ?? ""}`}>{did}</span>;
112+
}

packages/admin/src/components/RegistryBrowse.tsx

Lines changed: 32 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
type RegistryClientConfig,
2424
type RegistryPackageView,
2525
} from "../lib/api/registry.js";
26+
import { PublisherHandle, usePublisherHandle } from "./PublisherHandle.js";
2627

2728
export interface RegistryBrowseProps {
2829
/** Resolved manifest.registry block. Required -- caller checks. */
@@ -154,69 +155,47 @@ interface RegistryPackageCardProps {
154155

155156
function RegistryPackageCard({ pkg, installed }: RegistryPackageCardProps) {
156157
const { t } = useLingui();
157-
// Only domain-like handles are installable; the server's handle
158-
// validator rejects DID strings. Cards for packages whose handle the
159-
// aggregator couldn't resolve render the same content but without a
160-
// link, so the user understands the package isn't actionable.
161-
const installable = Boolean(pkg.handle && pkg.handle.includes("."));
162-
const handleDisplay = pkg.handle ?? pkg.did;
158+
const handleResult = usePublisherHandle(pkg.did, pkg.handle);
159+
// Always link by handle when we have one (cleaner URL), DID
160+
// otherwise. The detail page accepts either.
161+
const linkSegment = handleResult.handle ?? pkg.did;
163162
// `profile` is a pass-through of the signed package profile record.
164163
// We duck-type minimal display fields out of it.
165164
const profile = pkg.profile as { name?: string; description?: string };
166165
const verified = (pkg.labels ?? []).some((l: { val?: string }) => l.val === "verified");
167166

168-
const inner = (
169-
<div className="flex items-start gap-3">
170-
<div className="mt-1 rounded-md bg-kumo-subtle p-2 text-kumo-subtle">
171-
<PuzzlePiece className="h-5 w-5" />
172-
</div>
173-
<div className="min-w-0 flex-1">
174-
<div className="flex items-center gap-2">
175-
<h2 className="truncate font-semibold">{profile.name ?? pkg.slug}</h2>
176-
{verified ? (
177-
<ShieldCheck
178-
className="h-4 w-4 shrink-0 text-kumo-brand"
179-
aria-label={t`Verified publisher`}
180-
/>
181-
) : null}
182-
</div>
183-
<p className="truncate text-xs text-kumo-subtle">{handleDisplay}</p>
184-
{profile.description ? (
185-
<p className="mt-2 line-clamp-2 text-sm text-kumo-default">{profile.description}</p>
186-
) : null}
187-
{installed ? (
188-
<div className="mt-3">
189-
<Badge variant="success">{t`Installed`}</Badge>
190-
</div>
191-
) : null}
192-
{!installable ? (
193-
<div className="mt-3">
194-
<Badge variant="outline">{t`Publisher handle unresolved`}</Badge>
195-
</div>
196-
) : null}
197-
</div>
198-
</div>
199-
);
200-
201-
if (!installable) {
202-
return (
203-
<div
204-
className="block rounded-md border border-kumo-border bg-kumo-surface p-4 opacity-60"
205-
aria-disabled="true"
206-
>
207-
{inner}
208-
</div>
209-
);
210-
}
211-
212167
return (
213168
<Link
214169
to="/plugins/marketplace/$pluginId"
215-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- installable check above ensures pkg.handle is defined
216-
params={{ pluginId: `${pkg.handle!}/${pkg.slug}` }}
170+
params={{ pluginId: `${linkSegment}/${pkg.slug}` }}
217171
className="block rounded-md border border-kumo-border bg-kumo-surface p-4 transition-colors hover:bg-kumo-subtle focus:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand"
218172
>
219-
{inner}
173+
<div className="flex items-start gap-3">
174+
<div className="mt-1 rounded-md bg-kumo-subtle p-2 text-kumo-subtle">
175+
<PuzzlePiece className="h-5 w-5" />
176+
</div>
177+
<div className="min-w-0 flex-1">
178+
<div className="flex items-center gap-2">
179+
<h2 className="truncate font-semibold">{profile.name ?? pkg.slug}</h2>
180+
{verified ? (
181+
<ShieldCheck
182+
className="h-4 w-4 shrink-0 text-kumo-brand"
183+
aria-label={t`Verified publisher`}
184+
/>
185+
) : null}
186+
</div>
187+
<PublisherHandle did={pkg.did} aggregatorHandle={pkg.handle} variant="card" />
188+
189+
{profile.description ? (
190+
<p className="mt-2 line-clamp-2 text-sm text-kumo-default">{profile.description}</p>
191+
) : null}
192+
{installed ? (
193+
<div className="mt-3">
194+
<Badge variant="success">{t`Installed`}</Badge>
195+
</div>
196+
) : null}
197+
</div>
198+
</div>
220199
</Link>
221200
);
222201
}

0 commit comments

Comments
 (0)