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
21 changes: 21 additions & 0 deletions src/ui/components/workbench/InformationalRenderer.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="informational-renderer" aria-label="Plugin information">
{content.summary && <p>{content.summary}</p>}
{(content.sections || []).map(section => (
<section key={section.heading} aria-labelledby={`informational-${section.heading}`}>
<h2 id={`informational-${section.heading}`}>{section.heading}</h2>
{section.paragraphs.map(paragraph => <p key={paragraph}>{paragraph}</p>)}
</section>
))}
</section>
);
}
4 changes: 3 additions & 1 deletion src/ui/components/workbench/rendererRegistry.jsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import AsyncAnalysisRenderer from "./AsyncAnalysisRenderer";
import InformationalRenderer from "./InformationalRenderer";
import QueryRenderer from "./QueryRenderer";

// Only a browser lifecycle with evidence in the current generic family is
// registered. generic_runner is retained as a descriptor compatibility alias;
// 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;
Expand Down
31 changes: 29 additions & 2 deletions src/ui/lib/pluginApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -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\/([^/]+)\//);
Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/ui/pages/PluginPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ServiceViewer url={url} label={label} onBack={onBack} backLabel={backLabel} />;
}
if (error) {
Expand Down
16 changes: 16 additions & 0 deletions tests/ui/plugin-api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down Expand Up @@ -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: "<script>" } }, "biosamples"))
.toThrow("Invalid informational descriptor");
expect(() => validatePluginDescriptor({ ...informationalDescriptor, content: { summary: "x", sections: [{ heading: "x", paragraphs: ["ok"], html: "<b>" }] } }, "biosamples"))
.toThrow("Invalid informational descriptor");
});

it("accepts a query table without detail and rejects inconsistent detail metadata", () => {
expect(validatePluginDescriptor(noDetailQueryDescriptor, "clinvitae").capabilities).toEqual({ query: true });
expect(() => validatePluginDescriptor({ ...noDetailQueryDescriptor, capabilities: { query: true, detail: true } }, "clinvitae"))
Expand Down
7 changes: 7 additions & 0 deletions tests/ui/plugin-page.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,11 @@ describe("PluginPage capability routing", () => {
expect(await screen.findByRole("alert")).toHaveTextContent("server unavailable");
expect(screen.queryByTestId("service-viewer")).not.toBeInTheDocument();
});

it("falls back safely when descriptor validation rejects an informational payload", async () => {
const invalid = Object.assign(new Error("invalid descriptor"), { name: "PluginDescriptorError", status: 0 });
loadPluginDescriptor.mockRejectedValueOnce(invalid);
render(<PluginPage slug="biosamples" url="/_svc/workbench/plugins/biosamples/" label="BioSamples" onBack={vi.fn()} />);
expect(await screen.findByTestId("service-viewer")).toHaveTextContent("BioSamples");
});
});
7 changes: 4 additions & 3 deletions tests/ui/renderer-registry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ import { describe, expect, it } from "vitest";
import { RENDERER_REGISTRY, normalizeRendererName, resolveWorkbenchRenderer } from "../../src/ui/components/workbench/rendererRegistry";

describe("allowlisted Workbench renderer registry", () => {
it("contains only the proven async and query lifecycle renderers", () => {
expect(Object.keys(RENDERER_REGISTRY)).toEqual(["async_analysis", "query"]);
it("contains the proven lifecycle renderers and the finite informational renderer", () => {
expect(Object.keys(RENDERER_REGISTRY)).toEqual(["async_analysis", "informational", "query"]);
expect(resolveWorkbenchRenderer("async_analysis")).toBeTruthy();
expect(normalizeRendererName("generic_runner")).toBe("async_analysis");
expect(resolveWorkbenchRenderer("generic_runner")).toBeTruthy();
expect(resolveWorkbenchRenderer("query")).toBeTruthy();
expect(resolveWorkbenchRenderer("informational")).toBeTruthy();
});

it.each(["search", "informational", "multi_step", "../../module", "https://evil.example", "javascript:alert(1)", "constructor", "prototype", "__proto__"])('rejects unsafe or unimplemented renderer "%s"', renderer => {
it.each(["search", "multi_step", "../../module", "https://evil.example", "javascript:alert(1)", "constructor", "prototype", "__proto__"])('rejects unsafe or unimplemented renderer "%s"', renderer => {
expect(resolveWorkbenchRenderer(renderer)).toBeNull();
expect(normalizeRendererName(renderer)).toBeNull();
});
Expand Down
Loading