Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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,15 +14,18 @@
*/

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 {
canonicalCapabilitiesForDriftCheck,
getRegistryPackage,
hostEnvFromManifest,
installRegistryPlugin,
listRegistryReleases,
releasePassesPolicy,
Expand Down Expand Up @@ -59,6 +62,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 @@ -211,6 +224,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 @@ -367,7 +394,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 @@ -434,6 +461,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 @@ -595,6 +648,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
2 changes: 2 additions & 0 deletions packages/core/src/api/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ export {

// Registry handlers (experimental)
export {
assertEnvCompatible,
buildHostEnv,
handleRegistryInstall,
handleRegistryUninstall,
handleRegistryUpdate,
Expand Down
84 changes: 83 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,12 @@

import { ClientResponseError, ClientValidationError } from "@atcute/client";
import type { Did } from "@atcute/lexicons";
import {
checkEnvCompatibility,
findSkippedEnvConstraints,
hostEnvFromVersions,
} 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 +526,62 @@ async function fetchArtifact(mirrors: string[], declaredUrl: string): Promise<Ui
);
}

/**
* Build the host-environment map the install/update gate compares a release's
* `requires` against. Delegates to the shared {@link hostEnvFromVersions} so the
* server gate and the admin's client-side warning apply the same dev-skip /
* astro-omit rule.
*/
export function buildHostEnv(emdashVersion: string, astroVersion: string | undefined): HostEnv {
return hostEnvFromVersions(emdashVersion, astroVersion);
}

/**
* 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 +590,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 +799,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 +1285,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 +1437,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