diff --git a/src/ui/components/workbench/InformationalRenderer.jsx b/src/ui/components/workbench/InformationalRenderer.jsx
new file mode 100644
index 0000000..5663701
--- /dev/null
+++ b/src/ui/components/workbench/InformationalRenderer.jsx
@@ -0,0 +1,21 @@
+import React from "react";
+
+/**
+ * Render the finite, read-only informational descriptor vocabulary.
+ * Content is text data only; this component intentionally has no HTML or URL
+ * execution path.
+ */
+export default function InformationalRenderer({ descriptor }) {
+ const content = descriptor?.content || {};
+ return (
+
+ {content.summary && {content.summary}
}
+ {(content.sections || []).map(section => (
+
+
+ {section.paragraphs.map(paragraph => {paragraph}
)}
+
+ ))}
+
+ );
+}
diff --git a/src/ui/components/workbench/rendererRegistry.jsx b/src/ui/components/workbench/rendererRegistry.jsx
index 1733555..eb9da1b 100644
--- a/src/ui/components/workbench/rendererRegistry.jsx
+++ b/src/ui/components/workbench/rendererRegistry.jsx
@@ -1,4 +1,5 @@
import AsyncAnalysisRenderer from "./AsyncAnalysisRenderer";
+import InformationalRenderer from "./InformationalRenderer";
import QueryRenderer from "./QueryRenderer";
// Only a browser lifecycle with evidence in the current generic family is
@@ -6,13 +7,14 @@ import QueryRenderer from "./QueryRenderer";
// it is not a second implementation or renderer family.
export const RENDERER_REGISTRY = Object.freeze({
async_analysis: AsyncAnalysisRenderer,
+ informational: InformationalRenderer,
query: QueryRenderer,
});
const LEGACY_RENDERER_ALIASES = Object.freeze({ generic_runner: "async_analysis" });
export function normalizeRendererName(renderer) {
- if (renderer === "async_analysis" || renderer === "query") return renderer;
+ if (renderer === "async_analysis" || renderer === "informational" || renderer === "query") return renderer;
return typeof renderer === "string" && Object.prototype.hasOwnProperty.call(LEGACY_RENDERER_ALIASES, renderer)
? LEGACY_RENDERER_ALIASES[renderer]
: null;
diff --git a/src/ui/lib/pluginApi.js b/src/ui/lib/pluginApi.js
index d852999..2b073f5 100644
--- a/src/ui/lib/pluginApi.js
+++ b/src/ui/lib/pluginApi.js
@@ -3,7 +3,7 @@ import { isElectron } from "./session";
const BASE = "/_svc/workbench";
const SLUG = /^[a-z0-9][a-z0-9_-]*$/;
const ENDPOINT = /^\/plugins\/[a-z0-9][a-z0-9_-]*\/(?:api\/)?(?:run|status|log|artifacts|file|search|studies|experiments|variants|pathways|genes)\/(?:[A-Za-z0-9_.:-]+\/)?(?:\?[^#]*)?$/;
-const NATIVE_RENDERERS = new Set(["async_analysis", "generic_runner", "query"]);
+const NATIVE_RENDERERS = new Set(["async_analysis", "generic_runner", "informational", "query"]);
const ASYNC_CAPABILITIES = ["submit", "status", "logs", "artifacts", "downloads"];
const ASYNC_ENDPOINTS = ["submit", "status", "logs", "artifacts", "download"];
const QUERY_CAPABILITIES = ["query", "detail"];
@@ -117,6 +117,26 @@ function validateQueryDescriptor(data) {
if (detailEnabled) validatePluginEndpoint(data.endpoints.detail.replace("{detail_id}", "placeholder"), data.plugin.slug);
}
+function validateInformationalDescriptor(data) {
+ if (!Array.isArray(data.inputs) || data.inputs.length !== 0 ||
+ !Array.isArray(data.outputs) || data.outputs.length !== 0 ||
+ !data.capabilities || Object.keys(data.capabilities).join(",") !== "read_only" ||
+ data.capabilities.read_only !== true ||
+ !data.content || typeof data.content !== "object" || Array.isArray(data.content) ||
+ Object.keys(data.content).sort().join(",") !== "sections,summary" ||
+ typeof data.content.summary !== "string" || !Array.isArray(data.content.sections)) {
+ throw new PluginDescriptorError("Invalid informational descriptor.");
+ }
+ data.content.sections.forEach(section => {
+ if (!section || typeof section !== "object" || Array.isArray(section) ||
+ Object.keys(section).sort().join(",") !== "heading,paragraphs" ||
+ typeof section.heading !== "string" || !Array.isArray(section.paragraphs) ||
+ section.paragraphs.some(paragraph => typeof paragraph !== "string")) {
+ throw new PluginDescriptorError("Invalid informational descriptor.");
+ }
+ });
+}
+
function validatePluginEndpoint(path, slug) {
endpointUrl(path);
const match = path.match(/^\/plugins\/([^/]+)\//);
@@ -128,13 +148,20 @@ export function validatePluginDescriptor(data, slug) {
throw new PluginDescriptorError("Workbench returned an invalid plugin descriptor.");
}
if (!data.native_supported) return data;
- if (!NATIVE_RENDERERS.has(data.renderer) || !Array.isArray(data.inputs) || !Array.isArray(data.outputs) || !data.endpoints) {
+ if (!NATIVE_RENDERERS.has(data.renderer)) {
throw new PluginDescriptorError("Unsupported plugin renderer.");
}
const metadata = data.plugin;
if (["name", "version", "description", "category"].some(key => typeof metadata[key] !== "string")) {
throw new PluginDescriptorError("Invalid plugin metadata.");
}
+ if (data.renderer === "informational") {
+ validateInformationalDescriptor(data);
+ return data;
+ }
+ if (!Array.isArray(data.inputs) || !Array.isArray(data.outputs) || !data.endpoints) {
+ throw new PluginDescriptorError("Unsupported plugin renderer.");
+ }
if (data.renderer === "query") {
validateQueryDescriptor(data);
return data;
diff --git a/src/ui/pages/PluginPage.jsx b/src/ui/pages/PluginPage.jsx
index 7c34d1f..ba18a9f 100644
--- a/src/ui/pages/PluginPage.jsx
+++ b/src/ui/pages/PluginPage.jsx
@@ -21,7 +21,8 @@ export default function PluginPage({ slug, url, label, onBack, backLabel = "Back
}, [slug, attempt]);
const Renderer = descriptor?.native_supported ? resolveWorkbenchRenderer(descriptor.renderer) : null;
- if (error?.status === 404 || (descriptor && (descriptor.native_supported === false || !Renderer))) {
+ const invalidDescriptor = error?.name === "PluginDescriptorError" && error.status === 0;
+ if (error?.status === 404 || invalidDescriptor || (descriptor && (descriptor.native_supported === false || !Renderer))) {
return ;
}
if (error) {
diff --git a/tests/ui/plugin-api.test.js b/tests/ui/plugin-api.test.js
index f78edb6..1cfb009 100644
--- a/tests/ui/plugin-api.test.js
+++ b/tests/ui/plugin-api.test.js
@@ -43,6 +43,14 @@ const noDetailQueryDescriptor = {
result: { presentation: "table", rows_path: "results", columns: [{ key: "query", label: "Query" }] },
};
+const informationalDescriptor = {
+ schema_version: 1,
+ plugin: { slug: "biosamples", name: "BioSamples", version: "1.0.0", description: "desc", category: "reference_db" },
+ renderer: "informational", native_supported: true,
+ inputs: [], outputs: [], capabilities: { read_only: true },
+ content: { summary: "Read-only sample records.", sections: [] },
+};
+
beforeEach(() => vi.stubGlobal("fetch", vi.fn()));
afterEach(() => vi.unstubAllGlobals());
@@ -126,6 +134,14 @@ describe("plugin descriptor API boundary", () => {
.toThrow("Invalid query result schema");
});
+ it("accepts the finite informational contract and rejects executable or structured-content extensions", () => {
+ expect(validatePluginDescriptor(informationalDescriptor, "biosamples").renderer).toBe("informational");
+ expect(() => validatePluginDescriptor({ ...informationalDescriptor, content: { ...informationalDescriptor.content, html: "