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/registry-env-requires.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@emdash-cms/registry-client": minor
"@emdash-cms/plugin-cli": minor
"@emdash-cms/admin": minor
"emdash": minor
---

Registry plugins can now declare environment requirements. A plugin's manifest may set a release-level `requires` block (e.g. `{ "env:emdash": ">=1.0.0", "env:astro": ">=4.16" }`), which is published into the release record. When browsing a registry plugin, the admin compares those constraints against the running EmDash and Astro versions: if the host doesn't satisfy them, it shows a compatibility warning and disables the Install button. The server enforces the same check on install and update, refusing an incompatible release with `ENV_INCOMPATIBLE` so the gate can't be bypassed.
66 changes: 65 additions & 1 deletion packages/admin/src/components/RegistryPluginDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,20 @@
*/

import { Badge, Button, LinkButton, Select } from "@cloudflare/kumo";
import { checkEnvCompatibility } from "@emdash-cms/registry-client/env";
import { useLingui } from "@lingui/react/macro";
import { ShieldCheck, Warning } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as React from "react";

import { fetchManifest } from "../lib/api/client.js";
import {
artifactProxyUrl,
canonicalCapabilitiesForDriftCheck,
extractMediaArtifacts,
getRegistryPackage,
hostEnvFromManifest,
installRegistryPlugin,
listRegistryReleases,
releasePassesPolicy,
Expand Down Expand Up @@ -61,6 +64,16 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
},
});

// Host environment versions (`env:emdash`, `env:astro`) — used to evaluate
// the selected release's `requires` constraints before offering install.
// Derived from the admin manifest the shell already fetches under the same
// query key, so this view adds no extra round-trip.
const { data: manifest } = useQuery({
queryKey: ["manifest"],
queryFn: fetchManifest,
});
const hostEnv = React.useMemo(() => hostEnvFromManifest(manifest), [manifest]);

// Parse `<publisher>/<slug>` out of the route param. The publisher
// segment is either a handle (`example.dev`) or a DID
// (`did:plc:abc...`). Slugs are `[A-Za-z][A-Za-z0-9_-]*` (no `/`),
Expand Down Expand Up @@ -242,6 +255,20 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP

const policyOk =
release && pkg ? releasePassesPolicy(release, { did: pkg.did, slug }, config.policy) : true;

// Environment compatibility: compare the selected release's `requires`
// constraints against the running host. `requires` is the lexicon's open
// `unknown` value; `checkEnvCompatibility` guards its shape. Mirrors the
// server-side install gate so the admin can't offer an install the server
// would reject. While the manifest is still loading `hostEnv` is empty, so
// every constraint is skipped (fail-open until the data arrives; the server
// gate is the authority either way).
const envMismatches = React.useMemo(() => {
if (!release) return [];
return checkEnvCompatibility(release.release?.requires, hostEnv);
}, [release, hostEnv]);
const envOk = envMismatches.length === 0;

// Handle resolution affects display only -- installs are addressed
// by DID, so an unverified or missing handle doesn't block install.
// A handle that *claims* a value but doesn't verify (`status:
Expand Down Expand Up @@ -417,7 +444,7 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
) : (
<Button
variant="primary"
disabled={!release || !policyOk || handleResult.status === "invalid"}
disabled={!release || !policyOk || !envOk || handleResult.status === "invalid"}
onClick={() => setShowConsent(true)}
>
{t`Install`}
Expand Down Expand Up @@ -484,6 +511,32 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP
</div>
) : null}

{/* Environment compatibility notice. Mirrors the server install gate
(ENV_INCOMPATIBLE): the selected release declares `requires`
constraints the running host doesn't satisfy. Install is
disabled until the host is upgraded. */}
{release && !envOk ? (
<div
className="flex items-start gap-3 rounded-md border border-kumo-warning bg-kumo-warning/10 p-4 text-kumo-warning"
role="status"
>
<Warning className="mt-0.5 h-5 w-5 shrink-0" />
<div>
<p className="font-medium">{t`Not compatible with this environment`}</p>
<p className="mt-1 text-sm text-kumo-default">
{t`This release requires a newer environment than your site currently runs. Upgrade before installing.`}
</p>
<ul className="mt-2 space-y-1 text-sm text-kumo-default">
{envMismatches.map((m) => (
<li key={m.key}>
{t`${envLabel(m.key)} ${m.required} required — you have ${m.host}.`}
</li>
))}
</ul>
</div>
</div>
) : null}

{/* Description */}
{description ? <p className="text-base text-kumo-default">{description}</p> : null}

Expand Down Expand Up @@ -665,6 +718,17 @@ function isPreReleaseVersion(version: string): boolean {
return PRE_RELEASE_VERSION_RE.test(version);
}

/**
* Human-readable name for a `requires` env key. The known EmDash environments
* get their proper product names; anything else falls back to the key with the
* `env:` prefix stripped (product names, not localised strings).
*/
function envLabel(key: string): string {
if (key === "env:emdash") return "EmDash";
if (key === "env:astro") return "Astro";
return key.startsWith("env:") ? key.slice("env:".length) : key;
}

const YANKED_LABEL_VALUE = "security:yanked";

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/admin/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export interface FindManyResult<T> {
*/
export interface AdminManifest {
version: string;
/** Version of Astro the host is built with, when resolvable. */
astroVersion?: string;
hash: string;
collections: Record<
string,
Expand Down
23 changes: 22 additions & 1 deletion packages/admin/src/lib/api/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,21 @@ import type {
ValidatedReleaseView,
ValidatedSearchPackages,
} from "@emdash-cms/registry-client/discovery";
import { hostEnvFromVersions } from "@emdash-cms/registry-client/env";
import type { HostEnv } from "@emdash-cms/registry-client/env";
import { i18n } from "@lingui/core";
import { msg } from "@lingui/core/macro";

import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
import {
API_BASE,
apiFetch,
parseApiResponse,
throwResponseError,
type AdminManifest,
} from "./client.js";

export type { Did, Handle };
export type { HostEnv };

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -294,6 +303,18 @@ export async function listRegistryReleases(
return client.listReleases(did, slug, opts);
}

/**
* Derive the host environment versions (`env:emdash`, `env:astro`) the running
* EmDash install advertises, so a release's `requires` constraints can be
* evaluated client-side before offering install. Reads the already-fetched
* admin manifest (`version`, `astroVersion`) rather than issuing a second
* request. The dev-skip / astro-omit rule is shared with the server gate via
* `hostEnvFromVersions`.
*/
export function hostEnvFromManifest(manifest: AdminManifest | undefined): HostEnv {
return hostEnvFromVersions(manifest?.version, manifest?.astroVersion);
}

/**
* Resolve a publisher DID to its claimed handle using the same
* `LocalActorResolver` pattern as `@emdash-cms/plugin-cli` and
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ export const ErrorCode = {
AGGREGATOR_NOT_FOUND: "AGGREGATOR_NOT_FOUND",
CAPABILITY_ESCALATION: "CAPABILITY_ESCALATION",
ROUTE_VISIBILITY_ESCALATION: "ROUTE_VISIBILITY_ESCALATION",
ENV_INCOMPATIBLE: "ENV_INCOMPATIBLE",
INSTALL_FAILED: "INSTALL_FAILED",
UNINSTALL_FAILED: "UNINSTALL_FAILED",
SEARCH_FAILED: "SEARCH_FAILED",
Expand Down Expand Up @@ -414,6 +415,7 @@ export function mapErrorStatus(code: string | undefined): number {
case ErrorCode.ALREADY_INSTALLED:
case ErrorCode.ALREADY_CONFIGURED:
case ErrorCode.ALREADY_UP_TO_DATE:
case ErrorCode.ENV_INCOMPATIBLE:
return 409;

// 410 Gone
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/api/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ export {

// Registry handlers (experimental)
export {
assertEnvCompatible,
assertSafeArtifactUrl,
handleRegistryInstall,
handleRegistryUninstall,
Expand Down
70 changes: 69 additions & 1 deletion packages/core/src/api/handlers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@

import { ClientResponseError, ClientValidationError } from "@atcute/client";
import type { Did } from "@atcute/lexicons";
import { checkEnvCompatibility, findSkippedEnvConstraints } from "@emdash-cms/registry-client/env";
import type { HostEnv } from "@emdash-cms/registry-client/env";
import type { Kysely } from "kysely";

import type { Database } from "../../database/types.js";
Expand Down Expand Up @@ -520,6 +522,52 @@ async function fetchArtifact(mirrors: string[], declaredUrl: string): Promise<Ui
);
}

/**
* The shape of a single env-compatibility failure returned to the admin in
* the `ENV_INCOMPATIBLE` error's `details`.
*/
interface EnvIncompatibleError {
code: "ENV_INCOMPATIBLE";
message: string;
details: { requires: Record<string, string>; host: HostEnv };
}

/**
* Gate a release's `requires` constraints against the running host
* environment. `requires` is the lexicon-`unknown` value off the signed
* release record — never trust its shape; `checkEnvCompatibility` guards it.
*
* Returns `null` when every advertised constraint is satisfied (or there are
* none), or a structured `ENV_INCOMPATIBLE` error naming the unsatisfied
* constraints and the host versions. The error carries the guarded `requires`
* and `host` maps so the admin can render the same mismatch the UI gate shows.
*/
export function assertEnvCompatible(
requires: unknown,
hostEnv: HostEnv,
): EnvIncompatibleError | null {
// A constraint the host can't evaluate (unknown or unparseable host
// version) downgrades the gate to a no-op for that env. Log it so a
// silent bypass is observable rather than invisible.
for (const skipped of findSkippedEnvConstraints(requires, hostEnv)) {
console.warn(
`[registry] env compatibility constraint skipped: ${skipped.key} requires ${skipped.required} but host version is ${skipped.reason}`,
);
}
const mismatches = checkEnvCompatibility(requires, hostEnv);
if (mismatches.length === 0) return null;
const guarded: Record<string, string> = {};
for (const m of mismatches) guarded[m.key] = m.required;
const summary = mismatches
.map((m) => `${m.key} requires ${m.required} but host is ${m.host}`)
.join("; ");
return {
code: "ENV_INCOMPATIBLE",
message: `This release is not compatible with the current environment: ${summary}.`,
details: { requires: guarded, host: hostEnv },
};
}

// ── Install ────────────────────────────────────────────────────────

export async function handleRegistryInstall(
Expand All @@ -528,7 +576,7 @@ export async function handleRegistryInstall(
sandboxRunner: SandboxRunner | null,
registryConfigInput: RegistryConfigInput | undefined,
input: RegistryInstallInput,
opts?: { configuredPluginIds?: Set<string> },
opts?: { configuredPluginIds?: Set<string>; hostEnv?: HostEnv },
): Promise<ApiResult<RegistryInstallResult>> {
// Accept either the bare-string shorthand or the full
// `RegistryConfig` object (see `RegistryConfigInput`).
Expand Down Expand Up @@ -737,6 +785,17 @@ export async function handleRegistryInstall(
};
}

// Step 3b: environment compatibility. The signed release record may
// carry a `requires` block (`env:emdash`, `env:astro`, ...). Refuse
// the install if the running host doesn't satisfy a constraint, so a
// stale browser tab or non-UI caller can't bypass the admin's
// disabled Install button. `requires` is lexicon-`unknown`; the
// helper guards its shape.
if (opts?.hostEnv) {
const envError = assertEnvCompatible(releaseView.release?.requires, opts.hostEnv);
if (envError) return { success: false, error: envError };
}

// Step 3a: enforce the configured minimum release age. The browser
// applies the same check up front for UX, but the gate lives here
// -- a stale browser tab, a deep link, or a non-admin-UI caller
Expand Down Expand Up @@ -1212,6 +1271,7 @@ export async function handleRegistryUpdate(
version?: string;
confirmCapabilityChanges?: boolean;
confirmRouteVisibilityChanges?: boolean;
hostEnv?: HostEnv;
},
): Promise<ApiResult<RegistryUpdateResult>> {
const registryConfig = coerceRegistryConfig(registryConfigInput);
Expand Down Expand Up @@ -1363,6 +1423,14 @@ export async function handleRegistryUpdate(
};
}

// Environment compatibility gate. An ungated update could otherwise
// land a version whose `requires` the host doesn't satisfy. Same
// guard as install; `requires` is lexicon-`unknown`.
if (opts?.hostEnv) {
const envError = assertEnvCompatible(signedRelease.requires, opts.hostEnv);
if (envError) return { success: false, error: envError };
}

const declaredUrl = signedRelease.artifacts?.package?.url;
const declaredChecksum = signedRelease.artifacts?.package?.checksum;
if (!declaredUrl || !declaredChecksum) {
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/astro/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* to avoid bundling Node.js-only code into the production build.
*/

import { createRequire } from "node:module";

import type { AstroIntegration, AstroIntegrationLogger } from "astro";

import { validateAllowedOrigins, validateOriginShape } from "../../auth/allowed-origins.js";
Expand All @@ -34,6 +36,23 @@ export type {
} from "./runtime.js";
export { getStoredConfig } from "./runtime.js";

/**
* Resolve the version of Astro the host project is building with, by reading
* `astro/package.json` from the project's own dependency tree. Surfaced to the
* admin and the registry install gate so a plugin's `env:astro` constraint can
* be evaluated against the real host version. Returns `undefined` if Astro
* can't be resolved (shouldn't happen in a real build, but never throw here).
*/
function resolveAstroVersion(): string | undefined {
try {
const require = createRequire(import.meta.url);
const pkg = require("astro/package.json") as { version?: unknown };
return typeof pkg.version === "string" ? pkg.version : undefined;
} catch {
return undefined;
}
}

/** Default storage: Local filesystem in .emdash directory */
const DEFAULT_STORAGE = local({
directory: "./.emdash/uploads",
Expand Down Expand Up @@ -204,6 +223,13 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
command,
}) => {
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`
// constraint checks.
const astroVersion = resolveAstroVersion();
if (astroVersion !== undefined) {
serializableConfig.astroVersion = astroVersion;
}
// Extract i18n config from Astro config
// Astro locales can be strings OR { path, codes } objects — normalize to paths
if (astroConfig.i18n) {
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/astro/integration/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,14 @@ export interface EmDashConfig {
/** URL or path to a custom favicon for the admin panel. */
favicon?: string;
};

/**
* Version of Astro the host project is building with. Populated by the
* integration's `astro:config:setup` hook (not authored by the user) and
* surfaced to the admin and the registry install gate so a plugin's
* `env:astro` requirement can be evaluated against the real host version.
*/
astroVersion?: string;
}

const STORED_CONFIG_KEY = Symbol.for("emdash:stored-config");
Expand Down
Loading
Loading