Skip to content

Commit dbaea9c

Browse files
authored
feat(admin): install sandboxed plugins from the experimental registry (#1011)
* fix(deps): catalog-pin zod so trusted plugins typecheck Astro bundles its own Zod and re-exports it as 'astro/zod'. Trusted plugins like @emdash-cms/plugin-forms import their route schemas via 'astro/zod', then pass those schemas to definePlugin() in core. With emdash's 'zod: ^4.3.5' resolving independently of Astro's caret, pnpm kept two Zod 4 patches in the tree (e.g. 4.3.6 alongside 4.4.1). Zod 4 embeds its semver in the type system, so two patches of Zod 4 are not assignable to each other. The forms plugin's route schemas (ZodObject<..., $strip>) were rejected by PluginRoute<TInput>['input'] (ZodType<unknown, unknown, $ZodTypeInternals<unknown, unknown>>) with 'Type "3" is not assignable to type "4"' on the internal version field. The native definePlugin overload silently failed, TS fell through to the StandardPluginDefinition overload, and reported a misleading 'id does not exist' error -- masking 8 cascading errors. Catalog-pinning Zod forces a single workspace-wide instance and restores normal overload resolution. No code changes needed in core or plugins/forms. Also adds a pnpm-workspace.yaml comment explaining the gotcha so the next person doesn't bump emdash's pin past Astro's range. * feat(registry): experimental decentralized plugin registry Adds opt-in support for installing sandboxed plugins from the decentralized plugin registry described in RFC #694. Enabled via `experimental.registry.aggregatorUrl` in the EmDash integration options; when set, the admin UI replaces marketplace browse/install with the registry path. Server: new install handler (RFC verification chain), endpoint at POST /_emdash/api/admin/plugins/registry/install, migration 038 adds `source = 'registry'` plus `registry_publisher_did` / `registry_slug` columns on `_plugin_state`, runtime sync split into shared marketplace + registry tiers via a normalized opaque `r_<hash>` plugin id. Browser: aggregator XRPC calls go direct from the admin UI via @emdash-cms/registry-client. Install POST runs through the server. Includes a minimum-release-age policy with a per-publisher exclude allowlist, enforced both client-side (UX) and server-side (gate). Hardening (5 rounds of adversarial review): bundle id rewritten to the derived pluginId before storage, aggregator identity cross-checked, artifact and aggregator URLs validated for SSRF (https-only in prod, IPv6 brackets handled), per-request and total budgets on every outbound call, decompressed bundle capped at 256 KiB to match the RFC publish-time limit, migration 038 idempotent on both SQLite and Postgres. Known gaps tracked for follow-up: full MST signature verification against the publisher's PDS, multibase multihash decoding (hex SHA-256 is accepted today), registry plugin update + uninstall handlers. * fix(registry-lexicons): drop codegen from build script The generated lexicon types are committed to git so consumers don't need the codegen toolchain. Running lex-cli generate as part of the default build pipeline broke Cloudflare Pages builds for sites that pull registry-lexicons in transitively, because lex-cli imports lex.config.ts directly and Node in the CF Pages build environment can't load .ts natively. Codegen moves to a separate `regen` script (`pnpm regen` runs codegen + full build). Maintainers run it when they edit the lexicons; consumers just consume the committed output. * fix(registry): copilot review fixes - Drift check normalizes capabilities (filter strings, dedupe, sort) on both browser and server so reorderings or junk entries can't trigger spurious rejection. Adds a shared normalizeCapabilities helper in registry/config.ts and a mirror in admin/lib/api/registry.ts. - RegistryPluginDetail no longer trusts the aggregator-supplied ext?.capabilities as already-validated string[]; runs it through normalizeCapabilities before display and before send. - Fix stale '32 MiB' docstring on extractBundle (cap is actually MAX_DECOMPRESSED_BUNDLE_BYTES = 256 KiB). - Fix plugin-id.ts JSDoc: validatePluginIdentifier regex is /^[a-z][a-z0-9_-]*$/ (allows hyphens); the prior 'cannot collide with marketplace ids' claim was too strong and is now framed as 'syntactically distinct, plus an explicit pre-existing-row check in the install handler.' * fix(registry): address review findings + CI failures CI fixes: - Rename normalizeCapabilities -> canonicalCapabilitiesForDriftCheck to avoid namespace clash with the existing capability normalizer exported from @emdash-cms/plugin-types via core's index. The old name shadowed plugin-types' helper at the top level of core's dist, which made the definePlugin() overload set look ambiguous to TS in plugins/forms and caused a typecheck cascade there. - [...seen].toSorted() instead of [...seen].sort() to clear the e18e/prefer-spread-syntax + unicorn/no-array-sort lint errors. Review findings (ask-bonk[bot]): - HIGH: drift check tripped on every install when the release record's extension was empty. The browser now omits acknowledgedDeclaredAccess when capabilities is empty, opting out of the server-side drift gate for the (currently common) case where publishers haven't filled in the extension block. The bundle's real capabilities are still bound to the checksum-verified bytes. - HIGH: DID-only publishers (no resolvable handle) could be linked from the browse grid but never installed because the server rejects handles without a '.'. Cards now render as non-interactive with a 'Publisher handle unresolved' badge; the detail page surfaces a matching warning and disables Install. - MEDIUM: registry-enabled sites were unconditionally routing existing marketplace plugin detail URLs to RegistryPluginDetail, breaking deep links. Detail-route selection now discriminates by param shape (pluginId.includes('/')) rather than the manifest flag. - MEDIUM: state-row write failure after storeBundleInR2 left orphan bundles. Best-effort cleanup in the catch via deleteBundleFromR2. - LOW: parseDurationSeconds runs on the user-supplied integration option per install (not the already-normalized manifest shape). Wrap in try/catch and surface as REGISTRY_POLICY_INVALID rather than letting it bubble to a generic INSTALL_FAILED. - LOW: validator-pattern doc drift in plugin-id.ts (already fixed in the prior commit). * fix(registry): move registry config types to their own module The new RegistryConfig + ExperimentalConfig interfaces lived alongside definePlugin's overloads in astro/integration/runtime.ts. tsdown + rolldown's chunking decided to inline a bigger subset of plugin-related types into the entry chunk as a result, which broke definePlugin() overload resolution for trusted plugins building against core's dist on CI (plugins/forms failed with 'id does not exist in type StandardPluginDefinition'). Move both types to packages/core/src/registry/types.ts (still re-exported from runtime.ts for backwards compatibility) so the chunking matches main's layout and definePlugin's overloads resolve as before. * 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). * fix(registry): adversarial review round 6 findings Addresses 7 findings from the round-6 adversarial review and documents the eighth. #1 (high) Capability consent bypass [registry.ts, RegistryPluginDetail.tsx] The drift check was gated on the client sending acknowledgedDeclaredAccess. If the publisher's release record had no extension, the admin saw an empty permission dialog, omitted the acknowledgement, and the server skipped the check entirely -- letting a bundle whose manifest declares real capabilities slip through behind an empty consent UI. Server now extracts capabilities from the bundle manifest after download and refuses with DECLARED_ACCESS_REQUIRED if the bundle declares any capabilities and no acknowledgement was sent. Client always sends the list (empty when no extension) so the new server check is always armed. #2 (high) Concurrent install bundle deletion [registry.ts] Two parallel installs of the same (did, slug, version) both passed the pre-existing-row check, both uploaded to the same deterministic R2 prefix, and one then won the state-row PK race. The loser's catch block deleted the R2 bundle the winner had just written. On state-write failure we now re-query the state row: if a winner exists, we lost the race and must not touch the R2 bundle. Cleanup runs only when the failure is a real DB error, not a lost concurrent install. #3 (high) SSRF via DNS-resolving public hostnames [registry.ts, ssrf.ts moved] Literal-IP blocklist alone left a DNS-rebinding gap: any public DNS service resolving an attacker-chosen hostname to loopback / RFC1918 / 169.254.169.254 passed the URL check. The import pipeline already shipped resolveAndValidateExternalUrl which does Cloudflare DoH resolution and rejects on any forbidden resolved address; reuse it for artifact downloads. Move src/import/ssrf.ts to src/security/ssrf.ts to reflect that it's not import-specific. Leave a re-export shim at the old path so 13 existing callers keep working unchanged. Add #security/* path alias. #5 (high) Aggregator-supplied handles treated as verified [PublisherHandle.tsx] usePublisherHandle returned status: 'ok' with the aggregator-supplied handle whenever one was present, skipping local DID->handle round-trip. A compromised aggregator could label an attacker DID as e.g. 'stripe.com' and the UI would render it as verified. Always run LocalActorResolver via resolveDidToHandle; use the aggregator handle only for a cross-check. If the aggregator's claim differs from the verified handle, mark the publisher invalid. #6 (medium) Postgres migration 038 schema-qualification [038_registry_plugin_state.ts] The columns probe queried information_schema.columns without filtering by table_schema. A _plugin_state table in another schema (multi-tenant Postgres, per-test schemas) could make the migration skip the column adds. Filter by table_schema = current_schema(). #7 (medium) Install errors leak full artifact URLs [registry.ts] fetchArtifact recorded each full URL in the joined error message that bubbled up to the admin client. Artifacts hosted on storage backends often carry presigned tokens in the query string; failed installs were leaking those into HTTP responses and logs. Strip query and fragment when building client-visible errors (origin + path only); log the full URL server-side for debugging. #8 (medium) Credentialed aggregator URLs accepted [config.ts] validateAggregatorUrl accepted https://user:pass@example.com. The normalized URL ends up in the admin manifest and is shipped to every admin browser; browser fetch() also rejects credentialed URLs outright. Reject them at config-validation time. #4 (high, documented not fixed) Aggregator-trust-root scope [types.ts] Full MST proof / publisher signature verification is not in this PR; the server still trusts the aggregator-supplied (did, slug, checksum, artifact URL). Expand the JSDoc on EmDashConfig.experimental.registry to spell out exactly what the v1 trust contract is, what EmDash does verify independently (checksum, manifest id/version/capabilities), and what it doesn't (release-record signatures, replay). Recommendation: point aggregatorUrl only at an aggregator you operate or trust at centralized-source level until signature verification lands. * fix(registry): adversarial review round 7 followups Two LOW findings from the round-7 review (PR #1011 comment). NSID exact-match in RegistryPluginDetail.tsx Round-6 left a startsWith() match on the release-extension key. RFC 0001 fixes the NSID for the release extension; accepting prefix variants (...releaseExtensionV2, ...releaseExtension.deprecated) would let a publisher render a different capability list than the canonical key would. Use exact-equality keyed lookup. Registry plugin uninstall affordance in PluginManager.tsx Registry-installed plugins appear in PluginManager but the Uninstall button is gated on isMarketplace. Admins see a permanent-looking install with no way to remove it short of editing the DB and R2 by hand. Add an inline note for source === 'registry' rows that says uninstall isn't available yet and points the admin at the disable toggle. Full uninstall handler lands in a follow-up PR.
1 parent 5681eb2 commit dbaea9c

48 files changed

Lines changed: 4449 additions & 675 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/real-plants-sell.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"emdash": minor
3+
"@emdash-cms/admin": minor
4+
---
5+
6+
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.
7+
8+
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.

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: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,15 @@
3131
"locale:extract": "lingui extract --clean"
3232
},
3333
"dependencies": {
34-
"@cloudflare/kumo": "^1.16.0",
34+
"@atcute/identity-resolver": "catalog:",
35+
"@atcute/lexicons": "catalog:",
36+
"@cloudflare/kumo": "catalog:",
3537
"@dnd-kit/core": "^6.3.1",
3638
"@dnd-kit/sortable": "^10.0.0",
3739
"@dnd-kit/utilities": "^3.2.2",
3840
"@emdash-cms/blocks": "workspace:*",
41+
"@emdash-cms/registry-client": "workspace:*",
42+
"@emdash-cms/registry-lexicons": "workspace:*",
3943
"@floating-ui/react": "^0.27.16",
4044
"@lingui/core": "catalog:",
4145
"@lingui/react": "catalog:",

packages/admin/src/components/PluginManager.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ function PluginCard({
229229
const toastManager = Toast.useToastManager();
230230

231231
const isMarketplace = plugin.source === "marketplace";
232+
const isRegistry = plugin.source === "registry";
232233
const hasUpdate = !!updateInfo && updateInfo.installed !== updateInfo.latest;
233234

234235
const updateMutation = useMutation({
@@ -495,6 +496,15 @@ function PluginCard({
495496
</Button>
496497
</div>
497498
)}
499+
500+
{/* Registry plugins have an install path but no uninstall
501+
handler yet. Tell the admin so they don't think the
502+
plugin is permanent or fall back to editing the DB. */}
503+
{isRegistry && (
504+
<div className="pt-2 border-t text-xs text-kumo-subtle">
505+
{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.`}
506+
</div>
507+
)}
498508
</div>
499509
)}
500510
</div>
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* Renders an atproto publisher's identity, with three branches:
3+
*
4+
* - **Verified handle**: shows `@handle`. Our local
5+
* `LocalActorResolver` round-tripped the DID document's
6+
* `alsoKnownAs` back to the same DID (verified by DNS TXT or
7+
* `.well-known`, not by the aggregator).
8+
* - **Unverified publisher**: DID document claims a handle but the
9+
* handle's domain doesn't point back to the same DID, OR the
10+
* aggregator's claimed handle doesn't match the bidirectionally
11+
* verified one. Treat as untrusted -- the publisher might be
12+
* impersonating someone else, or the aggregator might be lying
13+
* about a handle. Surface as `Unverified publisher` in error
14+
* styling. Callers should also disable destructive actions
15+
* (install, etc.).
16+
* - **Missing handle**: no handle claimed in the DID document (no
17+
* `alsoKnownAs`), or the DID document couldn't be fetched
18+
* (network error, unsupported DID method).
19+
*
20+
* `aggregatorHandle` is what the registry's `searchPackages` /
21+
* `resolvePackage` endpoint returned for this DID. It is NEVER trusted
22+
* on its own -- the aggregator is an untrusted indexer that could be
23+
* compromised or buggy. We always run our own DID->handle round-trip
24+
* via `LocalActorResolver` (cached in localStorage for 24h) and use
25+
* the aggregator's value only to *cross-check*: if the aggregator
26+
* claims a handle that differs from what the DID document
27+
* bidirectionally verifies, the publisher is marked invalid.
28+
*/
29+
30+
import { useLingui } from "@lingui/react/macro";
31+
import { useQuery } from "@tanstack/react-query";
32+
import * as React from "react";
33+
34+
import { resolveDidToHandle } from "../lib/api/registry.js";
35+
36+
/** Trailing dot(s) on an FQDN, stripped before handle comparison. */
37+
const TRAILING_DOT = /\.+$/;
38+
39+
export type PublisherHandleStatus = "ok" | "invalid" | "missing";
40+
41+
export interface PublisherHandleResult {
42+
status: PublisherHandleStatus;
43+
/** Verified handle (only present when `status === "ok"`). */
44+
handle?: string;
45+
}
46+
47+
export interface PublisherHandleProps {
48+
did: string;
49+
aggregatorHandle?: string | null;
50+
/**
51+
* Called every time the resolution status changes, so callers can
52+
* gate install buttons or other side effects on
53+
* `status === "invalid"`. Optional.
54+
*/
55+
onResolved?: (result: PublisherHandleResult) => void;
56+
/** Visual variant. `card` is the smaller list-item form. */
57+
variant?: "card" | "detail";
58+
className?: string;
59+
}
60+
61+
/**
62+
* Hook form: returns the same tri-state result without rendering. Use
63+
* when a parent needs to coordinate UI (e.g. disable install) based on
64+
* the resolution.
65+
*/
66+
export function usePublisherHandle(
67+
did: string,
68+
aggregatorHandle?: string | null,
69+
): PublisherHandleResult {
70+
// Always run the local DID->handle round-trip. We never trust the
71+
// aggregator's `aggregatorHandle` on its own: a compromised
72+
// aggregator could label an attacker DID as `stripe.com` and any
73+
// shortcut that returns the aggregator's value as verified would
74+
// let the impersonation through unchecked.
75+
const { data: didHandleResolution, isPending } = useQuery({
76+
queryKey: ["registry", "did-handle", did],
77+
queryFn: () => resolveDidToHandle(did),
78+
enabled: Boolean(did),
79+
staleTime: 5 * 60 * 1000,
80+
});
81+
82+
if (isPending || !didHandleResolution) return { status: "missing" };
83+
84+
// DID document didn't claim a handle (or the document was
85+
// unreachable). The aggregator might have one, but without our own
86+
// verification we can't display it.
87+
if (didHandleResolution.status === "missing") {
88+
return { status: "missing" };
89+
}
90+
91+
// DID document claims a handle but it doesn't round-trip.
92+
// `invalid` always wins over an aggregator-supplied handle.
93+
if (didHandleResolution.status === "invalid") {
94+
return { status: "invalid" };
95+
}
96+
97+
// Bidirectionally verified handle. Cross-check against the
98+
// aggregator's claim: if they differ, flag the publisher as
99+
// invalid. The aggregator may simply be stale, but we shouldn't
100+
// silently disagree with our own verification by showing the
101+
// aggregator's value -- the conservative read is "something is
102+
// off, surface it to the admin".
103+
const verifiedHandle = didHandleResolution.handle.toLowerCase();
104+
if (aggregatorHandle) {
105+
const claimed = aggregatorHandle.toLowerCase().replace(TRAILING_DOT, "");
106+
if (claimed !== verifiedHandle) {
107+
return { status: "invalid" };
108+
}
109+
}
110+
111+
return { status: "ok", handle: didHandleResolution.handle };
112+
}
113+
114+
export function PublisherHandle({
115+
did,
116+
aggregatorHandle,
117+
onResolved,
118+
variant = "card",
119+
className,
120+
}: PublisherHandleProps) {
121+
const { t } = useLingui();
122+
const result = usePublisherHandle(did, aggregatorHandle);
123+
124+
// Notify the caller every time the result changes. Effect (not
125+
// inline) so we don't re-fire on every parent re-render.
126+
const onResolvedRef = React.useRef(onResolved);
127+
onResolvedRef.current = onResolved;
128+
React.useEffect(() => {
129+
onResolvedRef.current?.(result);
130+
}, [result.status, result.handle]);
131+
132+
const textClass = variant === "card" ? "text-xs" : "text-sm";
133+
134+
if (result.status === "ok" && result.handle) {
135+
return (
136+
<span className={`truncate ${textClass} text-kumo-subtle ${className ?? ""}`}>
137+
@{result.handle}
138+
</span>
139+
);
140+
}
141+
142+
if (result.status === "invalid") {
143+
return (
144+
<span className={`truncate ${textClass} font-medium text-kumo-error ${className ?? ""}`}>
145+
{t`Unverified publisher`}
146+
</span>
147+
);
148+
}
149+
150+
return <span className={`truncate ${textClass} text-kumo-subtle ${className ?? ""}`}>{did}</span>;
151+
}

0 commit comments

Comments
 (0)