From e6eda3bf1f0ca647c3d860637dbaf772a0e4d654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 8 May 2026 16:37:20 -0400 Subject: [PATCH] Expose plugin registry API --- cloudflare/src/index.ts | 106 ++++++ cloudflare/src/plugin_registry.ts | 446 +++++++++++++++++++++++ cloudflare/tests/index.test.ts | 161 +++++++- cloudflare/tests/plugin_registry.test.ts | 179 +++++++++ 4 files changed, 891 insertions(+), 1 deletion(-) create mode 100644 cloudflare/src/plugin_registry.ts create mode 100644 cloudflare/tests/plugin_registry.test.ts diff --git a/cloudflare/src/index.ts b/cloudflare/src/index.ts index d48bff5..52292e5 100644 --- a/cloudflare/src/index.ts +++ b/cloudflare/src/index.ts @@ -1,4 +1,13 @@ import type { Env } from "./bindings.js"; +import { + PluginRegistrySchemaError, + parsePluginRegistryDocument, + pluginCatalogDocument, + pluginDetailsDocument, + pluginPackageDocument, + pluginRegistryId, + pluginRegistryKvKey, +} from "./plugin_registry.js"; import { ReleaseManifestSchemaError, ReleaseSignatureQueryError, @@ -24,6 +33,10 @@ export async function handleRequest(request: Request, env: Env): Promise { if (request.method !== "GET") { return jsonResponse({ error: "method_not_allowed" }, 405, { Allow: "GET" }); @@ -58,6 +76,48 @@ async function handlePublicSigningKeys(request: Request, env: Env): Promise { + if (request.method !== "GET") { + return jsonResponse({ error: "method_not_allowed" }, 405, { Allow: "GET" }); + } + + const kvKey = pluginRegistryKvKey(env.ELY_ENVIRONMENT); + const value = await env.ELY_KV.get(kvKey); + if (value === null) { + return jsonResponse({ error: "plugin_registry_unavailable" }, 503); + } + + try { + const registry = parsePluginRegistryDocument(value); + if (route.kind === "catalog") { + return jsonResponse(pluginCatalogDocument(registry), 200, publicPluginCacheHeaders()); + } + + if (route.kind === "details") { + const document = pluginDetailsDocument(registry, route.pluginId); + if (document === null) { + return jsonResponse({ error: "plugin_not_found" }, 404); + } + return jsonResponse(document, 200, publicPluginCacheHeaders()); + } + + const document = pluginPackageDocument(registry, route.pluginId); + if (document === null) { + return jsonResponse({ error: "plugin_not_found" }, 404); + } + return jsonResponse(document, 200, publicPluginCacheHeaders()); + } catch (error) { + if (error instanceof PluginRegistrySchemaError) { + return jsonResponse({ error: "plugin_registry_invalid" }, 500); + } + throw error; + } +} + async function handleReleaseManifest(request: Request, env: Env): Promise { if (request.method !== "GET") { return jsonResponse({ error: "method_not_allowed" }, 405, { Allow: "GET" }); @@ -124,6 +184,52 @@ async function handleReleaseSignature( } } +function parsePluginRoute(pathname: string): PluginRoute | null { + if (pathname === "/api/plugins") { + return { kind: "catalog" }; + } + + if (!pathname.startsWith("/api/plugins/")) { + return null; + } + + const segments = pathname.split("/"); + if (segments.length === 4) { + const pluginId = pluginRouteId(segments[3]); + if (pluginId === null) { + return null; + } + return { kind: "details", pluginId }; + } + if (segments.length === 5 && segments[4] === "package") { + const pluginId = pluginRouteId(segments[3]); + if (pluginId === null) { + return null; + } + return { kind: "package", pluginId }; + } + + return null; +} + +function pluginRouteId(segment: string | undefined): string | null { + if (segment === undefined) { + return null; + } + try { + return pluginRegistryId(segment); + } catch (error) { + if (error instanceof PluginRegistrySchemaError) { + return null; + } + throw error; + } +} + +function publicPluginCacheHeaders(): Record { + return { "Cache-Control": "public, max-age=300, stale-while-revalidate=60" }; +} + function jsonResponse( body: unknown, status: number, diff --git a/cloudflare/src/plugin_registry.ts b/cloudflare/src/plugin_registry.ts new file mode 100644 index 0000000..6c46a11 --- /dev/null +++ b/cloudflare/src/plugin_registry.ts @@ -0,0 +1,446 @@ +import { prefixedKvKey } from "./kv_keys.js"; + +const PLUGIN_REGISTRY_NAMESPACE = "plugin_registry_cache"; +const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*(?:\.[a-z0-9][a-z0-9_-]*)*$/; +const SIGNATURE_KEY_ID_PATTERN = /^[a-z0-9._-]{3,128}$/; +const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; +const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/; +const ED25519_SIGNATURE_HEX_PATTERN = /^[a-f0-9]{128}$/; +const PUBLIC_KEY_HEX_PATTERN = /^[a-f0-9]{64}$/; +const PLUGIN_PERMISSIONS = new Set([ + "tabs:read", + "tabs:write", + "spaces:read", + "spaces:write", + "bookmarks:read", + "bookmarks:write", + "history:read", + "downloads:read", + "downloads:write", + "page:metadata", + "page:screenshot", + "page:script", + "clipboard:read", + "clipboard:write", + "filesystem:read", + "filesystem:write", + "network:fetch", + "settings:read", + "settings:write", + "sync:plugin", + "ui:panel", + "ui:command", + "ui:context_menu", +]); +const PLUGIN_CONTRIBUTIONS = new Set([ + "command-bar-command", + "tab-context-menu", + "page-context-menu", + "sidebar-panel", + "settings-page", + "status-bar-indicator", + "download-action", + "bookmark-action", + "reading-mode-exporter", +]); + +export interface PluginSignatureDocument { + algorithm: "ed25519"; + key_id: string; + public_key: string; + value: string; +} + +export interface PluginPackageLocation { + url: string; + sha256: string; + size_bytes: number; +} + +export interface PluginRegistryEntry { + id: string; + name: string; + description: string; + author: string; + homepage: string; + permissions: string[]; + contributes: string[]; + min_ely_build: string; + checksum: string; + signature: PluginSignatureDocument; + package: PluginPackageLocation; +} + +export interface PluginRegistryDocument { + version: 1; + generated_at: string; + plugins: PluginRegistryEntry[]; +} + +export interface PluginCatalogEntry { + id: string; + name: string; + description: string; + author: string; + homepage: string; + permissions: string[]; + contributes: string[]; + min_ely_build: string; +} + +export interface PluginCatalogDocument { + version: 1; + generated_at: string; + plugins: PluginCatalogEntry[]; +} + +export interface PluginDetailsDocument { + version: 1; + generated_at: string; + plugin: Omit; +} + +export interface PluginPackageDocument { + version: 1; + plugin_id: string; + url: string; + sha256: string; + signature: string; + size_bytes: number; +} + +export class PluginRegistrySchemaError extends Error { + constructor(message: string) { + super(message); + this.name = "PluginRegistrySchemaError"; + } +} + +export function pluginRegistryKvKey(environment: string): string { + return prefixedKvKey(environment, PLUGIN_REGISTRY_NAMESPACE); +} + +export function parsePluginRegistryDocument(value: string): PluginRegistryDocument { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new PluginRegistrySchemaError("plugin registry must be valid JSON"); + } + + if (!isRecord(parsed)) { + throw new PluginRegistrySchemaError("plugin registry must be an object"); + } + assertOnlyFields(parsed, ["version", "generated_at", "plugins"], "plugin registry"); + + if (parsed.version !== 1) { + throw new PluginRegistrySchemaError("plugin registry version must be 1"); + } + + return { + version: 1, + generated_at: isoTimestamp(stringField(parsed, "generated_at"), "generated_at"), + plugins: parsePlugins(parsed.plugins), + }; +} + +export function pluginCatalogDocument( + registry: PluginRegistryDocument, +): PluginCatalogDocument { + return { + version: 1, + generated_at: registry.generated_at, + plugins: registry.plugins.map(pluginCatalogEntry), + }; +} + +export function pluginDetailsDocument( + registry: PluginRegistryDocument, + pluginId: string, +): PluginDetailsDocument | null { + const plugin = registry.plugins.find((entry) => entry.id === pluginId); + if (plugin === undefined) { + return null; + } + + return { + version: 1, + generated_at: registry.generated_at, + plugin: pluginDetailsEntry(plugin), + }; +} + +export function pluginPackageDocument( + registry: PluginRegistryDocument, + pluginId: string, +): PluginPackageDocument | null { + const plugin = registry.plugins.find((entry) => entry.id === pluginId); + if (plugin === undefined) { + return null; + } + + return { + version: 1, + plugin_id: plugin.id, + url: plugin.package.url, + sha256: plugin.package.sha256, + signature: plugin.signature.value, + size_bytes: plugin.package.size_bytes, + }; +} + +export function pluginRegistryId(value: string): string { + if (!isPluginId(value)) { + throw new PluginRegistrySchemaError(`invalid plugin id: ${value}`); + } + return value; +} + +function parsePlugins(value: unknown): PluginRegistryEntry[] { + if (!Array.isArray(value) || value.length === 0) { + throw new PluginRegistrySchemaError("plugin registry must contain plugins"); + } + + const plugins: PluginRegistryEntry[] = []; + const seenPluginIds = new Set(); + for (const pluginValue of value) { + const plugin = parsePlugin(pluginValue); + if (seenPluginIds.has(plugin.id)) { + throw new PluginRegistrySchemaError(`duplicate plugin id: ${plugin.id}`); + } + seenPluginIds.add(plugin.id); + plugins.push(plugin); + } + return plugins; +} + +function parsePlugin(value: unknown): PluginRegistryEntry { + if (!isRecord(value)) { + throw new PluginRegistrySchemaError("plugin entry must be an object"); + } + assertOnlyFields( + value, + [ + "id", + "name", + "description", + "author", + "homepage", + "permissions", + "contributes", + "min_ely_build", + "checksum", + "signature", + "package", + ], + "plugin entry", + ); + + return { + id: pluginRegistryId(stringField(value, "id")), + name: stringField(value, "name"), + description: stringField(value, "description"), + author: stringField(value, "author"), + homepage: httpUrl(stringField(value, "homepage"), "homepage"), + permissions: uniqueKnownStrings(value.permissions, PLUGIN_PERMISSIONS, "permission"), + contributes: uniqueKnownStrings(value.contributes, PLUGIN_CONTRIBUTIONS, "contribution"), + min_ely_build: semver(stringField(value, "min_ely_build"), "min_ely_build"), + checksum: sha256Hex(stringField(value, "checksum"), "checksum"), + signature: parseSignature(value.signature), + package: parsePackage(value.package), + }; +} + +function parseSignature(value: unknown): PluginSignatureDocument { + if (!isRecord(value)) { + throw new PluginRegistrySchemaError("plugin signature must be an object"); + } + assertOnlyFields(value, ["algorithm", "key_id", "public_key", "value"], "plugin signature"); + + const algorithm = stringField(value, "algorithm"); + if (algorithm !== "ed25519") { + throw new PluginRegistrySchemaError(`invalid plugin signature algorithm: ${algorithm}`); + } + + return { + algorithm: "ed25519", + key_id: signatureKeyId(stringField(value, "key_id")), + public_key: publicKeyHex(stringField(value, "public_key")), + value: ed25519SignatureHex(stringField(value, "value")), + }; +} + +function parsePackage(value: unknown): PluginPackageLocation { + if (!isRecord(value)) { + throw new PluginRegistrySchemaError("plugin package must be an object"); + } + assertOnlyFields(value, ["url", "sha256", "size_bytes"], "plugin package"); + + return { + url: httpsUrl(stringField(value, "url"), "package.url"), + sha256: sha256Hex(stringField(value, "sha256"), "package.sha256"), + size_bytes: positiveIntegerField(value, "size_bytes"), + }; +} + +function pluginCatalogEntry(plugin: PluginRegistryEntry): PluginCatalogEntry { + return { + id: plugin.id, + name: plugin.name, + description: plugin.description, + author: plugin.author, + homepage: plugin.homepage, + permissions: plugin.permissions, + contributes: plugin.contributes, + min_ely_build: plugin.min_ely_build, + }; +} + +function pluginDetailsEntry(plugin: PluginRegistryEntry): Omit { + return { + ...pluginCatalogEntry(plugin), + checksum: plugin.checksum, + signature: plugin.signature, + }; +} + +function uniqueKnownStrings(value: unknown, allowed: Set, label: string): string[] { + if (!Array.isArray(value)) { + throw new PluginRegistrySchemaError(`plugin ${label}s must be an array`); + } + + const values: string[] = []; + const seen = new Set(); + for (const item of value) { + if (typeof item !== "string" || item.trim() === "") { + throw new PluginRegistrySchemaError(`plugin ${label} must be a non-empty string`); + } + + const normalized = item.trim(); + if (!allowed.has(normalized)) { + throw new PluginRegistrySchemaError(`invalid plugin ${label}: ${normalized}`); + } + if (seen.has(normalized)) { + throw new PluginRegistrySchemaError(`duplicate plugin ${label}: ${normalized}`); + } + seen.add(normalized); + values.push(normalized); + } + return values; +} + +function signatureKeyId(value: string): string { + if (!SIGNATURE_KEY_ID_PATTERN.test(value)) { + throw new PluginRegistrySchemaError(`invalid plugin signature key id: ${value}`); + } + return value; +} + +function semver(value: string, field: string): string { + if (!SEMVER_PATTERN.test(value)) { + throw new PluginRegistrySchemaError(`${field} must be a semantic version`); + } + return value; +} + +function httpUrl(value: string, field: string): string { + const url = parsedUrl(value, field); + if (!matchesProtocol(url, ["http:", "https:"])) { + throw new PluginRegistrySchemaError(`${field} must use http or https`); + } + return url.toString(); +} + +function httpsUrl(value: string, field: string): string { + const url = parsedUrl(value, field); + if (url.protocol !== "https:") { + throw new PluginRegistrySchemaError(`${field} must use https`); + } + return url.toString(); +} + +function parsedUrl(value: string, field: string): URL { + let url: URL; + try { + url = new URL(value); + } catch { + throw new PluginRegistrySchemaError(`${field} must be a valid URL`); + } + if (url.host === "") { + throw new PluginRegistrySchemaError(`${field} must include a host`); + } + return url; +} + +function matchesProtocol(url: URL, protocols: string[]): boolean { + return protocols.includes(url.protocol); +} + +function sha256Hex(value: string, field: string): string { + const normalized = value.toLowerCase(); + if (!SHA256_HEX_PATTERN.test(normalized)) { + throw new PluginRegistrySchemaError(`${field} must be a SHA-256 hex digest`); + } + return normalized; +} + +function publicKeyHex(value: string): string { + const normalized = value.toLowerCase(); + if (!PUBLIC_KEY_HEX_PATTERN.test(normalized)) { + throw new PluginRegistrySchemaError("plugin signature public key must be Ed25519 hex"); + } + return normalized; +} + +function ed25519SignatureHex(value: string): string { + const normalized = value.toLowerCase(); + if (!ED25519_SIGNATURE_HEX_PATTERN.test(normalized)) { + throw new PluginRegistrySchemaError("plugin signature value must be Ed25519 hex"); + } + return normalized; +} + +function isoTimestamp(value: string, field: string): string { + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) { + throw new PluginRegistrySchemaError(`${field} must be an ISO timestamp`); + } + return new Date(timestamp).toISOString(); +} + +function positiveIntegerField(value: Record, field: string): number { + const fieldValue = value[field]; + if (typeof fieldValue !== "number" || !Number.isSafeInteger(fieldValue) || fieldValue <= 0) { + throw new PluginRegistrySchemaError(`${field} must be a positive integer`); + } + return fieldValue; +} + +function stringField(value: Record, field: string): string { + const fieldValue = value[field]; + if (typeof fieldValue !== "string" || fieldValue.trim() === "") { + throw new PluginRegistrySchemaError(`${field} must be a non-empty string`); + } + return fieldValue.trim(); +} + +function assertOnlyFields( + value: Record, + allowedFields: string[], + label: string, +): void { + const allowed = new Set(allowedFields); + for (const field of Object.keys(value)) { + if (!allowed.has(field)) { + throw new PluginRegistrySchemaError(`${label} has unknown field: ${field}`); + } + } +} + +function isPluginId(value: string): boolean { + return value.length >= 3 && value.length <= 128 && PLUGIN_ID_PATTERN.test(value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/cloudflare/tests/index.test.ts b/cloudflare/tests/index.test.ts index dadb686..8545286 100644 --- a/cloudflare/tests/index.test.ts +++ b/cloudflare/tests/index.test.ts @@ -3,12 +3,16 @@ import { describe, it } from "node:test"; import type { Env } from "../src/bindings.js"; import { handleRequest } from "../src/index.js"; +import { pluginRegistryKvKey } from "../src/plugin_registry.js"; import { releaseManifestKvKey } from "../src/release_manifests.js"; import { publicSigningKeysKvKey } from "../src/signing_keys.js"; const PUBLIC_KEY = "a".repeat(64); const RELEASE_SIGNATURE = "b".repeat(128); const RELEASE_SHA256 = "c".repeat(64); +const PLUGIN_CHECKSUM = "d".repeat(64); +const PLUGIN_PACKAGE_SHA256 = "e".repeat(64); +const PLUGIN_SIGNATURE = "f".repeat(128); describe("worker routes", () => { it("returns public plugin signing keys from KV", async () => { @@ -78,6 +82,123 @@ describe("worker routes", () => { assert.deepEqual(await response.json(), { error: "not_found" }); }); + it("returns public plugin catalog from KV", async () => { + const response = await handleRequest( + new Request("https://elydora.test/api/plugins"), + testEnv(null, null, pluginRegistryDocument()), + ); + + assert.equal(response.status, 200); + assert.equal( + response.headers.get("cache-control"), + "public, max-age=300, stale-while-revalidate=60", + ); + assert.deepEqual(await response.json(), { + version: 1, + generated_at: "2026-05-08T00:00:00.000Z", + plugins: [ + { + id: "elydora.reader", + name: "Reader", + description: "Reading workflow tools.", + author: "Elydora", + homepage: "https://elydora.com/plugins/reader", + permissions: ["page:metadata", "ui:command"], + contributes: ["command-bar-command"], + min_ely_build: "0.1.0", + }, + ], + }); + }); + + it("returns public plugin details from KV", async () => { + const response = await handleRequest( + new Request("https://elydora.test/api/plugins/elydora.reader"), + testEnv(null, null, pluginRegistryDocument()), + ); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + version: 1, + generated_at: "2026-05-08T00:00:00.000Z", + plugin: { + id: "elydora.reader", + name: "Reader", + description: "Reading workflow tools.", + author: "Elydora", + homepage: "https://elydora.com/plugins/reader", + permissions: ["page:metadata", "ui:command"], + contributes: ["command-bar-command"], + min_ely_build: "0.1.0", + checksum: PLUGIN_CHECKSUM, + signature: { + algorithm: "ed25519", + key_id: "elydora-alpha-plugins", + public_key: PUBLIC_KEY, + value: PLUGIN_SIGNATURE, + }, + }, + }); + }); + + it("returns public plugin package download information", async () => { + const response = await handleRequest( + new Request("https://elydora.test/api/plugins/elydora.reader/package"), + testEnv(null, null, pluginRegistryDocument()), + ); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + version: 1, + plugin_id: "elydora.reader", + url: "https://downloads.elydora.com/plugins/reader/0.1.0/reader.rplug", + sha256: PLUGIN_PACKAGE_SHA256, + signature: PLUGIN_SIGNATURE, + size_bytes: 65536, + }); + }); + + it("rejects unsupported methods on public plugin catalog", async () => { + const response = await handleRequest( + new Request("https://elydora.test/api/plugins", { method: "POST" }), + testEnv(null, null, pluginRegistryDocument()), + ); + + assert.equal(response.status, 405); + assert.equal(response.headers.get("allow"), "GET"); + assert.deepEqual(await response.json(), { error: "method_not_allowed" }); + }); + + it("returns service unavailable when plugin registry is missing", async () => { + const response = await handleRequest( + new Request("https://elydora.test/api/plugins"), + testEnv(null), + ); + + assert.equal(response.status, 503); + assert.deepEqual(await response.json(), { error: "plugin_registry_unavailable" }); + }); + + it("returns a generic server error for malformed plugin registry", async () => { + const response = await handleRequest( + new Request("https://elydora.test/api/plugins"), + testEnv(null, null, "{"), + ); + + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { error: "plugin_registry_invalid" }); + }); + + it("returns not found for unknown public plugins", async () => { + const response = await handleRequest( + new Request("https://elydora.test/api/plugins/elydora.unknown"), + testEnv(null, null, pluginRegistryDocument()), + ); + + assert.equal(response.status, 404); + assert.deepEqual(await response.json(), { error: "plugin_not_found" }); + }); + it("returns release manifest from KV", async () => { const env = testEnv(null, releaseManifestDocument()); @@ -202,7 +323,11 @@ describe("worker routes", () => { }); }); -function testEnv(publicSigningKeys: string | null, releaseManifest?: string | null): Env { +function testEnv( + publicSigningKeys: string | null, + releaseManifest?: string | null, + pluginRegistry?: string | null, +): Env { const values = new Map(); if (publicSigningKeys !== null) { values.set(publicSigningKeysKvKey("local"), publicSigningKeys); @@ -210,6 +335,9 @@ function testEnv(publicSigningKeys: string | null, releaseManifest?: string | nu if (releaseManifest !== undefined && releaseManifest !== null) { values.set(releaseManifestKvKey("local"), releaseManifest); } + if (pluginRegistry !== undefined && pluginRegistry !== null) { + values.set(pluginRegistryKvKey("local"), pluginRegistry); + } return { ELY_ENVIRONMENT: "local", @@ -239,3 +367,34 @@ function releaseManifestDocument(): string { ], }); } + +function pluginRegistryDocument(): string { + return JSON.stringify({ + version: 1, + generated_at: "2026-05-08T00:00:00.000Z", + plugins: [ + { + id: "elydora.reader", + name: "Reader", + description: "Reading workflow tools.", + author: "Elydora", + homepage: "https://elydora.com/plugins/reader", + permissions: ["page:metadata", "ui:command"], + contributes: ["command-bar-command"], + min_ely_build: "0.1.0", + checksum: PLUGIN_CHECKSUM, + signature: { + algorithm: "ed25519", + key_id: "elydora-alpha-plugins", + public_key: PUBLIC_KEY, + value: PLUGIN_SIGNATURE, + }, + package: { + url: "https://downloads.elydora.com/plugins/reader/0.1.0/reader.rplug", + sha256: PLUGIN_PACKAGE_SHA256, + size_bytes: 65536, + }, + }, + ], + }); +} diff --git a/cloudflare/tests/plugin_registry.test.ts b/cloudflare/tests/plugin_registry.test.ts new file mode 100644 index 0000000..2076313 --- /dev/null +++ b/cloudflare/tests/plugin_registry.test.ts @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + PluginRegistrySchemaError, + parsePluginRegistryDocument, + pluginCatalogDocument, + pluginDetailsDocument, + pluginPackageDocument, + pluginRegistryKvKey, +} from "../src/plugin_registry.js"; + +const CHECKSUM = "a".repeat(64); +const PACKAGE_SHA256 = "b".repeat(64); +const PUBLIC_KEY = "c".repeat(64); +const SIGNATURE = "d".repeat(128); + +describe("plugin registry", () => { + it("builds an environment-prefixed KV key", () => { + assert.equal(pluginRegistryKvKey("production"), "ely:production:plugin_registry_cache"); + }); + + it("parses a valid plugin registry", () => { + const document = parsePluginRegistryDocument(validRegistry()); + + assert.deepEqual(document, { + version: 1, + generated_at: "2026-05-08T00:00:00.000Z", + plugins: [parsedPlugin()], + }); + }); + + it("builds a public catalog document", () => { + const registry = parsePluginRegistryDocument(validRegistry()); + + assert.deepEqual(pluginCatalogDocument(registry), { + version: 1, + generated_at: "2026-05-08T00:00:00.000Z", + plugins: [ + { + id: "elydora.reader", + name: "Reader", + description: "Reading workflow tools.", + author: "Elydora", + homepage: "https://elydora.com/plugins/reader", + permissions: ["page:metadata", "ui:command"], + contributes: ["command-bar-command"], + min_ely_build: "0.1.0", + }, + ], + }); + }); + + it("extracts plugin details and package download documents", () => { + const registry = parsePluginRegistryDocument(validRegistry()); + + assert.deepEqual(pluginDetailsDocument(registry, "elydora.reader"), { + version: 1, + generated_at: "2026-05-08T00:00:00.000Z", + plugin: { + id: "elydora.reader", + name: "Reader", + description: "Reading workflow tools.", + author: "Elydora", + homepage: "https://elydora.com/plugins/reader", + permissions: ["page:metadata", "ui:command"], + contributes: ["command-bar-command"], + min_ely_build: "0.1.0", + checksum: CHECKSUM, + signature: { + algorithm: "ed25519", + key_id: "elydora-alpha-plugins", + public_key: PUBLIC_KEY, + value: SIGNATURE, + }, + }, + }); + assert.deepEqual(pluginPackageDocument(registry, "elydora.reader"), { + version: 1, + plugin_id: "elydora.reader", + url: "https://downloads.elydora.com/plugins/reader/0.1.0/reader.rplug", + sha256: PACKAGE_SHA256, + signature: SIGNATURE, + size_bytes: 65536, + }); + }); + + it("rejects duplicate plugin ids", () => { + assert.throws( + () => + parsePluginRegistryDocument( + validRegistry({ + plugins: [validPlugin(), { ...validPlugin(), name: "Reader Copy" }], + }), + ), + PluginRegistrySchemaError, + ); + }); + + it("rejects malformed permission and package URLs", () => { + assert.throws( + () => + parsePluginRegistryDocument( + validRegistry({ + plugins: [{ ...validPlugin(), permissions: ["tabs:admin"] }], + }), + ), + PluginRegistrySchemaError, + ); + assert.throws( + () => + parsePluginRegistryDocument( + validRegistry({ + plugins: [ + { + ...validPlugin(), + package: { ...validPluginPackage(), url: "http://downloads.elydora.com/a.rplug" }, + }, + ], + }), + ), + PluginRegistrySchemaError, + ); + }); +}); + +function validRegistry(overrides: Record = {}): string { + return JSON.stringify({ + version: 1, + generated_at: "2026-05-08T00:00:00.000Z", + plugins: [validPlugin()], + ...overrides, + }); +} + +function validPlugin(): Record { + return { + id: "elydora.reader", + name: "Reader", + description: "Reading workflow tools.", + author: "Elydora", + homepage: "https://elydora.com/plugins/reader", + permissions: ["page:metadata", "ui:command"], + contributes: ["command-bar-command"], + min_ely_build: "0.1.0", + checksum: CHECKSUM, + signature: { + algorithm: "ed25519", + key_id: "elydora-alpha-plugins", + public_key: PUBLIC_KEY.toUpperCase(), + value: SIGNATURE.toUpperCase(), + }, + package: validPluginPackage(), + }; +} + +function parsedPlugin(): Record { + return { + ...validPlugin(), + signature: { + algorithm: "ed25519", + key_id: "elydora-alpha-plugins", + public_key: PUBLIC_KEY, + value: SIGNATURE, + }, + package: { + ...validPluginPackage(), + sha256: PACKAGE_SHA256, + }, + }; +} + +function validPluginPackage(): Record { + return { + url: "https://downloads.elydora.com/plugins/reader/0.1.0/reader.rplug", + sha256: PACKAGE_SHA256.toUpperCase(), + size_bytes: 65536, + }; +}