From 820e1daaa0bec493fe8537e182a55e81498f5c3f Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 12 Sep 2026 07:33:28 +0100 Subject: [PATCH 1/2] fix: surface registry configuration errors --- .changeset/tame-registry-config-errors.md | 6 + .../RegistryConfigurationBanner.tsx | 32 ++++ packages/admin/src/components/Shell.tsx | 8 + packages/admin/src/lib/api/client.ts | 13 ++ .../RegistryConfigurationBanner.test.tsx | 47 ++++++ packages/core/src/astro/integration/index.ts | 9 ++ packages/core/src/astro/types.ts | 3 + packages/core/src/emdash-runtime.ts | 16 +- packages/core/src/registry/config.ts | 150 ++++++++++++++---- .../astro/integration/registry-config.test.ts | 43 +++++ .../core/tests/unit/registry/config.test.ts | 44 ++++- .../tests/unit/runtime/manifest-build.test.ts | 21 ++- 12 files changed, 356 insertions(+), 36 deletions(-) create mode 100644 .changeset/tame-registry-config-errors.md create mode 100644 packages/admin/src/components/RegistryConfigurationBanner.tsx create mode 100644 packages/admin/tests/components/RegistryConfigurationBanner.test.tsx create mode 100644 packages/core/tests/unit/astro/integration/registry-config.test.ts diff --git a/.changeset/tame-registry-config-errors.md b/.changeset/tame-registry-config-errors.md new file mode 100644 index 0000000000..cc5843ed58 --- /dev/null +++ b/.changeset/tame-registry-config-errors.md @@ -0,0 +1,6 @@ +--- +"emdash": patch +"@emdash-cms/admin": patch +--- + +Fixes invalid plugin registry settings causing the admin manifest to fail with a generic server error. EmDash reports malformed `experimental.registry` fields while Astro loads the site configuration. If invalid registry settings reach the runtime, the admin remains available and shows which field to correct in `astro.config.mjs`. diff --git a/packages/admin/src/components/RegistryConfigurationBanner.tsx b/packages/admin/src/components/RegistryConfigurationBanner.tsx new file mode 100644 index 0000000000..a34ead7c3c --- /dev/null +++ b/packages/admin/src/components/RegistryConfigurationBanner.tsx @@ -0,0 +1,32 @@ +import { Banner } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; + +import type { AdminManifest } from "../lib/api/client.js"; + +type RegistryConfigurationError = NonNullable; + +export function RegistryConfigurationBanner({ error }: { error: RegistryConfigurationError }) { + const { t } = useLingui(); + let description: string; + + switch (error.field) { + case "experimental.registry.policy.minimumReleaseAge": + description = t`Check experimental.registry.policy.minimumReleaseAge in astro.config.mjs, then restart EmDash.`; + break; + case "experimental.registry.policy.minimumReleaseAgeExclude": + description = t`Check experimental.registry.policy.minimumReleaseAgeExclude in astro.config.mjs, then restart EmDash.`; + break; + default: + description = t`Check experimental.registry.aggregatorUrl in astro.config.mjs, then restart EmDash.`; + } + + return ( + + ); +} diff --git a/packages/admin/src/components/Shell.tsx b/packages/admin/src/components/Shell.tsx index 5fbd2d419d..2607b890d0 100644 --- a/packages/admin/src/components/Shell.tsx +++ b/packages/admin/src/components/Shell.tsx @@ -1,11 +1,13 @@ import { useMatches } from "@tanstack/react-router"; import * as React from "react"; +import type { AdminManifest } from "../lib/api/client.js"; import { useCurrentUser } from "../lib/api/current-user"; import { getLocaleDir } from "../locales/config.js"; import { useLocale } from "../locales/useLocale.js"; import { AdminCommandPalette } from "./AdminCommandPalette"; import { Header } from "./Header"; +import { RegistryConfigurationBanner } from "./RegistryConfigurationBanner.js"; import { Sidebar, SidebarNav } from "./Sidebar"; import { WelcomeModal } from "./WelcomeModal"; @@ -39,6 +41,7 @@ export interface ShellProps { }>; i18n?: { defaultLocale: string; locales: string[] }; version?: string; + registryConfigurationError?: AdminManifest["registryConfigurationError"]; }; } @@ -104,6 +107,11 @@ export function Shell({ children, manifest }: ShellProps) { {/* Main content area — scrolls independently so sidebar stays full height */}
+ {manifest.registryConfigurationError && ( +
+ +
+ )}
{ + it("directs administrators to the invalid aggregator URL setting", async () => { + const screen = await render( + , + ); + + await expect + .element(screen.getByRole("alert", { name: "Plugin registry configuration error" })) + .toBeInTheDocument(); + await expect + .element( + screen.getByText( + "Check experimental.registry.aggregatorUrl in astro.config.mjs, then restart EmDash.", + ), + ) + .toBeInTheDocument(); + }); + + it("directs administrators to the invalid release policy setting", async () => { + const screen = await render( + , + ); + + await expect + .element( + screen.getByText( + "Check experimental.registry.policy.minimumReleaseAge in astro.config.mjs, then restart EmDash.", + ), + ) + .toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts index 721a514a63..03dce485bf 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -27,6 +27,7 @@ import { import { buildMigrationManifest } from "../../migrations/manifest-builder.js"; import { writeMigrationManifest } from "../../migrations/manifest-writer.js"; import type { ResolvedPlugin } from "../../plugins/types.js"; +import { normalizeRegistryConfig } from "../../registry/config.js"; import { VERSION } from "../../version.js"; import { setDevTypegenRefresh } from "../dev-typegen.js"; import { local } from "../storage/adapters.js"; @@ -328,6 +329,11 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { migrations: normalizeMigrationConfig(config.migrations), }; + // Validate environment-independent registry settings while Astro is still + // evaluating its config. The command-aware check in astro:config:setup + // applies the stricter production localhost policy. + normalizeRegistryConfig(resolvedConfig.experimental?.registry, { allowLocalhost: true }); + // Validate marketplace URL if (resolvedConfig.marketplace) { const url = resolvedConfig.marketplace; @@ -463,6 +469,9 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { command, }) => { astroCommand = command; + normalizeRegistryConfig(resolvedConfig.experimental?.registry, { + allowLocalhost: command === "dev" || command === "sync", + }); printBanner(logger); // Capture the host's Astro version so the runtime can expose it // to the admin and the registry install gate for `env:astro` diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index 540968d0d2..df208468bb 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -10,6 +10,7 @@ import type { Kysely } from "kysely"; import type { ContentFieldFilters } from "../content-list-query.js"; import type { RouteCallerInput, RouteMeta } from "../plugins/routes.js"; +import type { ManifestRegistryConfigurationError } from "../registry/config.js"; // Re-export core types export type { @@ -214,6 +215,8 @@ export interface EmDashManifest { minimumReleaseAgeExclude?: string[]; }; }; + /** Safe field-level diagnostic when the registry configuration cannot be normalized. */ + registryConfigurationError?: ManifestRegistryConfigurationError; /** * Admin branding overrides for white-labeling. * Set via the `admin` config in `astro.config.mjs`. diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 50a35fad0f..3ef767cc7b 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -227,7 +227,7 @@ import { isContentSaveRejection } from "./plugins/save-rejection.js"; import type { CronScheduler } from "./plugins/scheduler/types.js"; import { PluginStateRepository } from "./plugins/state.js"; import { syncDeclaredStorageIndexes } from "./plugins/storage-indexes.js"; -import { normalizeRegistryConfig } from "./registry/config.js"; +import { resolveManifestRegistryConfig } from "./registry/config.js"; import { requestCached } from "./request-cache.js"; import { getRequestContext } from "./request-context.js"; import { publishDueContent, type PublishedRef } from "./scheduled-publish.js"; @@ -2606,11 +2606,14 @@ export class EmDashRuntime { } : 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; + const { registry, error: registryConfigurationError } = resolveManifestRegistryConfig( + this.config.experimental?.registry, + ); + if (registryConfigurationError) { + console.error( + `EmDash registry configuration error in ${registryConfigurationError.field} (${registryConfigurationError.code})`, + ); + } return { version: VERSION, @@ -2628,6 +2631,7 @@ export class EmDashRuntime { }, marketplace: !!this.config.marketplace, registry, + registryConfigurationError, }; } diff --git a/packages/core/src/registry/config.ts b/packages/core/src/registry/config.ts index f3a8170a49..c1a0f37353 100644 --- a/packages/core/src/registry/config.ts +++ b/packages/core/src/registry/config.ts @@ -41,6 +41,39 @@ export interface ManifestRegistryConfig { }; } +export type RegistryConfigurationErrorCode = + | "REGISTRY_AGGREGATOR_URL_REQUIRED" + | "REGISTRY_AGGREGATOR_URL_INVALID" + | "REGISTRY_AGGREGATOR_URL_FORBIDDEN" + | "REGISTRY_MINIMUM_RELEASE_AGE_INVALID" + | "REGISTRY_MINIMUM_RELEASE_AGE_EXCLUDE_INVALID"; + +export type RegistryConfigurationField = + | "experimental.registry.aggregatorUrl" + | "experimental.registry.policy.minimumReleaseAge" + | "experimental.registry.policy.minimumReleaseAgeExclude"; + +export interface ManifestRegistryConfigurationError { + code: RegistryConfigurationErrorCode; + field: RegistryConfigurationField; +} + +class RegistryConfigurationError extends Error { + constructor( + public readonly code: RegistryConfigurationErrorCode, + public readonly field: RegistryConfigurationField, + message: string, + options?: ErrorOptions, + ) { + super(`EmDash registry configuration error in ${field}: ${message}`, options); + this.name = "RegistryConfigurationError"; + } +} + +interface RegistryConfigurationValidationOptions { + allowLocalhost?: boolean; +} + /** * Canonicalize a capabilities list for set-style comparison. * @@ -169,15 +202,27 @@ export function parseDurationSeconds(duration: string | number): number { * 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 { +export function validateAggregatorUrl( + aggregatorUrl: string, + options: RegistryConfigurationValidationOptions = {}, +): URL { let parsed: URL; try { parsed = new URL(aggregatorUrl); - } catch { - throw new Error(`registry.aggregatorUrl is not a valid URL: ${aggregatorUrl}`); + } catch (cause) { + throw new RegistryConfigurationError( + "REGISTRY_AGGREGATOR_URL_INVALID", + "experimental.registry.aggregatorUrl", + "must be a valid URL", + { cause }, + ); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error(`registry.aggregatorUrl must use http or https: ${aggregatorUrl}`); + throw new RegistryConfigurationError( + "REGISTRY_AGGREGATOR_URL_FORBIDDEN", + "experimental.registry.aggregatorUrl", + "must use HTTP or HTTPS", + ); } // Reject embedded credentials. The normalized aggregator URL ends // up in the admin manifest and is shipped to every admin browser; @@ -185,7 +230,11 @@ export function validateAggregatorUrl(aggregatorUrl: string): URL { // 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@)"); + throw new RegistryConfigurationError( + "REGISTRY_AGGREGATOR_URL_FORBIDDEN", + "experimental.registry.aggregatorUrl", + "must not contain embedded credentials", + ); } // WHATWG URL preserves the brackets on IPv6 hostnames -- strip them @@ -205,18 +254,27 @@ export function validateAggregatorUrl(aggregatorUrl: string): URL { hostname.startsWith("::ffff:127.") || hostname.startsWith("::ffff:7f00:"); - if (!import.meta.env.DEV) { + const allowLocalhost = options.allowLocalhost ?? import.meta.env.DEV; + if (!allowLocalhost) { if (parsed.protocol === "http:") { - throw new Error(`registry.aggregatorUrl must use https in production: ${aggregatorUrl}`); + throw new RegistryConfigurationError( + "REGISTRY_AGGREGATOR_URL_FORBIDDEN", + "experimental.registry.aggregatorUrl", + "must use HTTPS outside development", + ); } if (isLocalhost) { - throw new Error( - `registry.aggregatorUrl points at localhost; allowed only in dev: ${aggregatorUrl}`, + throw new RegistryConfigurationError( + "REGISTRY_AGGREGATOR_URL_FORBIDDEN", + "experimental.registry.aggregatorUrl", + "must not point at localhost outside development", ); } } else if (parsed.protocol === "http:" && !isLocalhost) { - throw new Error( - `registry.aggregatorUrl must use https (http allowed only for localhost in dev): ${aggregatorUrl}`, + throw new RegistryConfigurationError( + "REGISTRY_AGGREGATOR_URL_FORBIDDEN", + "experimental.registry.aggregatorUrl", + "must use HTTPS unless it points at localhost in development", ); } @@ -251,28 +309,30 @@ export function coerceRegistryConfig( * 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. + * Throws a field-specific configuration error if the aggregator URL is + * malformed or forbidden, or a registry policy value cannot be normalized. + * The Astro integration uses this to fail during config loading. Runtime + * manifest generation uses {@link resolveManifestRegistryConfig} to expose + * recognized failures without exposing the configured value. */ export function normalizeRegistryConfig( input: RegistryConfigInput | undefined, + options: RegistryConfigurationValidationOptions = {}, ): ManifestRegistryConfig | null { const config = coerceRegistryConfig(input); if (!config) return null; - const aggregatorUrl = config.aggregatorUrl?.trim(); + const aggregatorUrl = + typeof config.aggregatorUrl === "string" ? config.aggregatorUrl.trim() : undefined; if (!aggregatorUrl) { - throw new Error("registry.aggregatorUrl is required when registry is configured"); + throw new RegistryConfigurationError( + "REGISTRY_AGGREGATOR_URL_REQUIRED", + "experimental.registry.aggregatorUrl", + "is required when the registry is configured", + ); } - validateAggregatorUrl(aggregatorUrl); + validateAggregatorUrl(aggregatorUrl, options); const out: ManifestRegistryConfig = { // Strip any trailing slash so `${aggregatorUrl}/xrpc/...` works @@ -288,7 +348,16 @@ export function normalizeRegistryConfig( let hasPolicy = false; if (config.policy?.minimumReleaseAge !== undefined) { - policy.minimumReleaseAgeSeconds = parseDurationSeconds(config.policy.minimumReleaseAge); + try { + policy.minimumReleaseAgeSeconds = parseDurationSeconds(config.policy.minimumReleaseAge); + } catch (cause) { + throw new RegistryConfigurationError( + "REGISTRY_MINIMUM_RELEASE_AGE_INVALID", + "experimental.registry.policy.minimumReleaseAge", + 'must be a duration such as "48h", "7d", or a non-negative number of seconds', + { cause }, + ); + } hasPolicy = true; } @@ -297,9 +366,20 @@ export function normalizeRegistryConfig( // plain string compares without each one re-implementing the // case-folding rule. const list = config.policy.minimumReleaseAgeExclude.map((entry) => { + if (typeof entry !== "string") { + throw new RegistryConfigurationError( + "REGISTRY_MINIMUM_RELEASE_AGE_EXCLUDE_INVALID", + "experimental.registry.policy.minimumReleaseAgeExclude", + "minimumReleaseAgeExclude entry must be a DID or /", + ); + } const trimmed = entry.trim(); if (!trimmed) { - throw new Error("registry.policy.minimumReleaseAgeExclude entries cannot be empty"); + throw new RegistryConfigurationError( + "REGISTRY_MINIMUM_RELEASE_AGE_EXCLUDE_INVALID", + "experimental.registry.policy.minimumReleaseAgeExclude", + "entries cannot be empty", + ); } const lower = trimmed.toLowerCase(); const [did, slug, ...extra] = lower.split("/"); @@ -309,8 +389,10 @@ export function normalizeRegistryConfig( extra.length > 0 || (slug !== undefined && !REGISTRY_PACKAGE_SLUG_PATTERN.test(slug)) ) { - throw new Error( - `registry.policy.minimumReleaseAgeExclude entry must be a DID or /: ${trimmed}`, + throw new RegistryConfigurationError( + "REGISTRY_MINIMUM_RELEASE_AGE_EXCLUDE_INVALID", + "experimental.registry.policy.minimumReleaseAgeExclude", + "minimumReleaseAgeExclude entry must be a DID or /", ); } return lower; @@ -327,3 +409,17 @@ export function normalizeRegistryConfig( return out; } + +/** Normalize registry config without allowing a known config error to hide the admin. */ +export function resolveManifestRegistryConfig(input: RegistryConfigInput | undefined): { + registry?: ManifestRegistryConfig; + error?: ManifestRegistryConfigurationError; +} { + try { + const registry = normalizeRegistryConfig(input); + return registry ? { registry } : {}; + } catch (error) { + if (!(error instanceof RegistryConfigurationError)) throw error; + return { error: { code: error.code, field: error.field } }; + } +} diff --git a/packages/core/tests/unit/astro/integration/registry-config.test.ts b/packages/core/tests/unit/astro/integration/registry-config.test.ts new file mode 100644 index 0000000000..7bbc4f91ed --- /dev/null +++ b/packages/core/tests/unit/astro/integration/registry-config.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import emdash from "../../../../src/astro/integration/index.js"; + +describe("registry integration configuration", () => { + it.each([ + ["a malformed aggregator URL", { aggregatorUrl: "not a URL" }, "aggregatorUrl"], + [ + "an insecure non-local aggregator", + { aggregatorUrl: "http://registry.example.com" }, + "aggregatorUrl", + ], + [ + "an invalid minimum release age", + { + aggregatorUrl: "https://registry.example.com", + policy: { minimumReleaseAge: "tomorrow" }, + }, + "minimumReleaseAge", + ], + ] as const)("fails during integration creation for %s", (_label, registry, field) => { + expect(() => emdash({ experimental: { registry } })).toThrow( + new RegExp(`EmDash registry configuration error.*${field}`), + ); + }); + + it("accepts shorthand and full registry configuration", () => { + expect(() => + emdash({ experimental: { registry: "https://registry.example.com" } }), + ).not.toThrow(); + expect(() => + emdash({ + experimental: { + registry: { + aggregatorUrl: "https://registry.example.com/", + acceptLabelers: "did:web:labeler.example", + policy: { minimumReleaseAge: "48h" }, + }, + }, + }), + ).not.toThrow(); + }); +}); diff --git a/packages/core/tests/unit/registry/config.test.ts b/packages/core/tests/unit/registry/config.test.ts index 9b4a30270a..f6805c53ef 100644 --- a/packages/core/tests/unit/registry/config.test.ts +++ b/packages/core/tests/unit/registry/config.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { normalizeRegistryConfig } from "../../../src/registry/config.js"; +import { + normalizeRegistryConfig, + resolveManifestRegistryConfig, +} from "../../../src/registry/config.js"; describe("normalizeRegistryConfig", () => { it("rejects mutable handles in the minimum release age exemption list", () => { @@ -26,4 +29,43 @@ describe("normalizeRegistryConfig", () => { }, }); }); + + it.each([ + ["a malformed URL", "not a URL", "REGISTRY_AGGREGATOR_URL_INVALID"], + ["a forbidden target", "http://registry.example.com", "REGISTRY_AGGREGATOR_URL_FORBIDDEN"], + ] as const)("returns a safe manifest diagnostic for %s", (_label, aggregatorUrl, code) => { + expect(resolveManifestRegistryConfig({ aggregatorUrl })).toEqual({ + error: { + code, + field: "experimental.registry.aggregatorUrl", + }, + }); + }); + + it("returns a safe manifest diagnostic for an invalid minimum release age", () => { + expect( + resolveManifestRegistryConfig({ + aggregatorUrl: "https://registry.example.com", + policy: { minimumReleaseAge: "tomorrow" }, + }), + ).toEqual({ + error: { + code: "REGISTRY_MINIMUM_RELEASE_AGE_INVALID", + field: "experimental.registry.policy.minimumReleaseAge", + }, + }); + }); + + it("does not turn unexpected failures into configuration diagnostics", () => { + const input = { + aggregatorUrl: "https://registry.example.com", + policy: { + get minimumReleaseAge(): string { + throw new Error("unexpected getter failure"); + }, + }, + }; + + expect(() => resolveManifestRegistryConfig(input)).toThrow("unexpected getter failure"); + }); }); diff --git a/packages/core/tests/unit/runtime/manifest-build.test.ts b/packages/core/tests/unit/runtime/manifest-build.test.ts index f92a54f67b..08bf9ed350 100644 --- a/packages/core/tests/unit/runtime/manifest-build.test.ts +++ b/packages/core/tests/unit/runtime/manifest-build.test.ts @@ -57,8 +57,7 @@ const configCollections = { }, }; -function buildRuntime(db: Kysely): EmDashRuntime { - const config: EmDashConfig = {}; +function buildRuntime(db: Kysely, config: EmDashConfig = {}): EmDashRuntime { const pipelineFactoryOptions = { db } as const; const hooks = createHookPipeline([], pipelineFactoryOptions); const pipelineRef = { current: hooks }; @@ -338,6 +337,24 @@ describe("EmDashRuntime.getManifest()", () => { expect(manifest.contentLocale).toEqual({ defaultLocale: "en", implicit: true }); }); + it("keeps the admin manifest available with a safe registry configuration diagnostic", async () => { + const log = vi.spyOn(console, "error").mockImplementation(() => undefined); + const runtime = buildRuntime(db, { + experimental: { registry: { aggregatorUrl: "not a URL" } }, + }); + + const manifest = await runtime.getManifest(); + + expect(manifest.registry).toBeUndefined(); + expect(manifest.registryConfigurationError).toEqual({ + code: "REGISTRY_AGGREGATOR_URL_INVALID", + field: "experimental.registry.aggregatorUrl", + }); + expect(log).toHaveBeenCalledWith( + "EmDash registry configuration error in experimental.registry.aggregatorUrl (REGISTRY_AGGREGATOR_URL_INVALID)", + ); + }); + it("reports the configured content default independently of admin language", async () => { setI18nConfig({ defaultLocale: "ja", locales: ["ja", "en"] }); const runtime = buildRuntime(db); From 72970d331a6e5eb3b8ffb64df466c74554a146ba Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 12 Sep 2026 09:16:33 +0100 Subject: [PATCH 2/2] fix: validate registry exclusion shape --- .../RegistryConfigurationBanner.tsx | 5 ++++- .../RegistryConfigurationBanner.test.tsx | 20 +++++++++++++++++++ packages/core/src/registry/config.ts | 7 +++++++ .../core/tests/unit/registry/config.test.ts | 17 ++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/admin/src/components/RegistryConfigurationBanner.tsx b/packages/admin/src/components/RegistryConfigurationBanner.tsx index a34ead7c3c..45fd91ef54 100644 --- a/packages/admin/src/components/RegistryConfigurationBanner.tsx +++ b/packages/admin/src/components/RegistryConfigurationBanner.tsx @@ -10,6 +10,9 @@ export function RegistryConfigurationBanner({ error }: { error: RegistryConfigur let description: string; switch (error.field) { + case "experimental.registry.aggregatorUrl": + description = t`Check experimental.registry.aggregatorUrl in astro.config.mjs, then restart EmDash.`; + break; case "experimental.registry.policy.minimumReleaseAge": description = t`Check experimental.registry.policy.minimumReleaseAge in astro.config.mjs, then restart EmDash.`; break; @@ -17,7 +20,7 @@ export function RegistryConfigurationBanner({ error }: { error: RegistryConfigur description = t`Check experimental.registry.policy.minimumReleaseAgeExclude in astro.config.mjs, then restart EmDash.`; break; default: - description = t`Check experimental.registry.aggregatorUrl in astro.config.mjs, then restart EmDash.`; + description = t`Check experimental.registry in astro.config.mjs, then restart EmDash.`; } return ( diff --git a/packages/admin/tests/components/RegistryConfigurationBanner.test.tsx b/packages/admin/tests/components/RegistryConfigurationBanner.test.tsx index ecb70b2e1f..d60d48731f 100644 --- a/packages/admin/tests/components/RegistryConfigurationBanner.test.tsx +++ b/packages/admin/tests/components/RegistryConfigurationBanner.test.tsx @@ -44,4 +44,24 @@ describe("RegistryConfigurationBanner", () => { ) .toBeInTheDocument(); }); + + it("does not name the wrong setting for a diagnostic from a newer server", async () => { + const screen = await render( + , + ); + + await expect + .element( + screen.getByText("Check experimental.registry in astro.config.mjs, then restart EmDash."), + ) + .toBeInTheDocument(); + expect(screen.container.textContent).not.toContain("aggregatorUrl"); + }); }); diff --git a/packages/core/src/registry/config.ts b/packages/core/src/registry/config.ts index c1a0f37353..518722c104 100644 --- a/packages/core/src/registry/config.ts +++ b/packages/core/src/registry/config.ts @@ -362,6 +362,13 @@ export function normalizeRegistryConfig( } if (config.policy?.minimumReleaseAgeExclude !== undefined) { + if (!Array.isArray(config.policy.minimumReleaseAgeExclude)) { + throw new RegistryConfigurationError( + "REGISTRY_MINIMUM_RELEASE_AGE_EXCLUDE_INVALID", + "experimental.registry.policy.minimumReleaseAgeExclude", + "must be an array of DIDs or / entries", + ); + } // Normalize at load time so callers (browser and server) can do // plain string compares without each one re-implementing the // case-folding rule. diff --git a/packages/core/tests/unit/registry/config.test.ts b/packages/core/tests/unit/registry/config.test.ts index f6805c53ef..0138d7ccac 100644 --- a/packages/core/tests/unit/registry/config.test.ts +++ b/packages/core/tests/unit/registry/config.test.ts @@ -56,6 +56,23 @@ describe("normalizeRegistryConfig", () => { }); }); + it("returns a safe manifest diagnostic when release age exclusions are not an array", () => { + expect( + resolveManifestRegistryConfig({ + aggregatorUrl: "https://registry.example.com", + policy: { + // @ts-expect-error - runtime validation covers untyped JavaScript configuration + minimumReleaseAgeExclude: "did:plc:publisher", + }, + }), + ).toEqual({ + error: { + code: "REGISTRY_MINIMUM_RELEASE_AGE_EXCLUDE_INVALID", + field: "experimental.registry.policy.minimumReleaseAgeExclude", + }, + }); + }); + it("does not turn unexpected failures into configuration diagnostics", () => { const input = { aggregatorUrl: "https://registry.example.com",