Expose plugin registry API
This commit is contained in:
@@ -1,4 +1,13 @@
|
|||||||
import type { Env } from "./bindings.js";
|
import type { Env } from "./bindings.js";
|
||||||
|
import {
|
||||||
|
PluginRegistrySchemaError,
|
||||||
|
parsePluginRegistryDocument,
|
||||||
|
pluginCatalogDocument,
|
||||||
|
pluginDetailsDocument,
|
||||||
|
pluginPackageDocument,
|
||||||
|
pluginRegistryId,
|
||||||
|
pluginRegistryKvKey,
|
||||||
|
} from "./plugin_registry.js";
|
||||||
import {
|
import {
|
||||||
ReleaseManifestSchemaError,
|
ReleaseManifestSchemaError,
|
||||||
ReleaseSignatureQueryError,
|
ReleaseSignatureQueryError,
|
||||||
@@ -24,6 +33,10 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
if (url.pathname === "/api/plugins/signing-keys") {
|
if (url.pathname === "/api/plugins/signing-keys") {
|
||||||
return handlePublicSigningKeys(request, env);
|
return handlePublicSigningKeys(request, env);
|
||||||
}
|
}
|
||||||
|
const pluginRoute = parsePluginRoute(url.pathname);
|
||||||
|
if (pluginRoute !== null) {
|
||||||
|
return handlePluginRoute(request, env, pluginRoute);
|
||||||
|
}
|
||||||
if (url.pathname === "/api/releases/manifest") {
|
if (url.pathname === "/api/releases/manifest") {
|
||||||
return handleReleaseManifest(request, env);
|
return handleReleaseManifest(request, env);
|
||||||
}
|
}
|
||||||
@@ -34,6 +47,11 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
return jsonResponse({ error: "not_found" }, 404);
|
return jsonResponse({ error: "not_found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PluginRoute =
|
||||||
|
| { kind: "catalog" }
|
||||||
|
| { kind: "details"; pluginId: string }
|
||||||
|
| { kind: "package"; pluginId: string };
|
||||||
|
|
||||||
async function handlePublicSigningKeys(request: Request, env: Env): Promise<Response> {
|
async function handlePublicSigningKeys(request: Request, env: Env): Promise<Response> {
|
||||||
if (request.method !== "GET") {
|
if (request.method !== "GET") {
|
||||||
return jsonResponse({ error: "method_not_allowed" }, 405, { Allow: "GET" });
|
return jsonResponse({ error: "method_not_allowed" }, 405, { Allow: "GET" });
|
||||||
@@ -58,6 +76,48 @@ async function handlePublicSigningKeys(request: Request, env: Env): Promise<Resp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handlePluginRoute(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
route: PluginRoute,
|
||||||
|
): Promise<Response> {
|
||||||
|
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<Response> {
|
async function handleReleaseManifest(request: Request, env: Env): Promise<Response> {
|
||||||
if (request.method !== "GET") {
|
if (request.method !== "GET") {
|
||||||
return jsonResponse({ error: "method_not_allowed" }, 405, { Allow: "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<string, string> {
|
||||||
|
return { "Cache-Control": "public, max-age=300, stale-while-revalidate=60" };
|
||||||
|
}
|
||||||
|
|
||||||
function jsonResponse(
|
function jsonResponse(
|
||||||
body: unknown,
|
body: unknown,
|
||||||
status: number,
|
status: number,
|
||||||
|
|||||||
@@ -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<PluginRegistryEntry, "package">;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
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<PluginRegistryEntry, "package"> {
|
||||||
|
return {
|
||||||
|
...pluginCatalogEntry(plugin),
|
||||||
|
checksum: plugin.checksum,
|
||||||
|
signature: plugin.signature,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueKnownStrings(value: unknown, allowed: Set<string>, label: string): string[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new PluginRegistrySchemaError(`plugin ${label}s must be an array`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const values: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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<string, unknown>, 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<string, unknown>, 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<string, unknown>,
|
||||||
|
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<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -3,12 +3,16 @@ import { describe, it } from "node:test";
|
|||||||
|
|
||||||
import type { Env } from "../src/bindings.js";
|
import type { Env } from "../src/bindings.js";
|
||||||
import { handleRequest } from "../src/index.js";
|
import { handleRequest } from "../src/index.js";
|
||||||
|
import { pluginRegistryKvKey } from "../src/plugin_registry.js";
|
||||||
import { releaseManifestKvKey } from "../src/release_manifests.js";
|
import { releaseManifestKvKey } from "../src/release_manifests.js";
|
||||||
import { publicSigningKeysKvKey } from "../src/signing_keys.js";
|
import { publicSigningKeysKvKey } from "../src/signing_keys.js";
|
||||||
|
|
||||||
const PUBLIC_KEY = "a".repeat(64);
|
const PUBLIC_KEY = "a".repeat(64);
|
||||||
const RELEASE_SIGNATURE = "b".repeat(128);
|
const RELEASE_SIGNATURE = "b".repeat(128);
|
||||||
const RELEASE_SHA256 = "c".repeat(64);
|
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", () => {
|
describe("worker routes", () => {
|
||||||
it("returns public plugin signing keys from KV", async () => {
|
it("returns public plugin signing keys from KV", async () => {
|
||||||
@@ -78,6 +82,123 @@ describe("worker routes", () => {
|
|||||||
assert.deepEqual(await response.json(), { error: "not_found" });
|
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 () => {
|
it("returns release manifest from KV", async () => {
|
||||||
const env = testEnv(null, releaseManifestDocument());
|
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<string, string>();
|
const values = new Map<string, string>();
|
||||||
if (publicSigningKeys !== null) {
|
if (publicSigningKeys !== null) {
|
||||||
values.set(publicSigningKeysKvKey("local"), publicSigningKeys);
|
values.set(publicSigningKeysKvKey("local"), publicSigningKeys);
|
||||||
@@ -210,6 +335,9 @@ function testEnv(publicSigningKeys: string | null, releaseManifest?: string | nu
|
|||||||
if (releaseManifest !== undefined && releaseManifest !== null) {
|
if (releaseManifest !== undefined && releaseManifest !== null) {
|
||||||
values.set(releaseManifestKvKey("local"), releaseManifest);
|
values.set(releaseManifestKvKey("local"), releaseManifest);
|
||||||
}
|
}
|
||||||
|
if (pluginRegistry !== undefined && pluginRegistry !== null) {
|
||||||
|
values.set(pluginRegistryKvKey("local"), pluginRegistry);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ELY_ENVIRONMENT: "local",
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, unknown> = {}): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
generated_at: "2026-05-08T00:00:00.000Z",
|
||||||
|
plugins: [validPlugin()],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validPlugin(): Record<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
return {
|
||||||
|
...validPlugin(),
|
||||||
|
signature: {
|
||||||
|
algorithm: "ed25519",
|
||||||
|
key_id: "elydora-alpha-plugins",
|
||||||
|
public_key: PUBLIC_KEY,
|
||||||
|
value: SIGNATURE,
|
||||||
|
},
|
||||||
|
package: {
|
||||||
|
...validPluginPackage(),
|
||||||
|
sha256: PACKAGE_SHA256,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validPluginPackage(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
url: "https://downloads.elydora.com/plugins/reader/0.1.0/reader.rplug",
|
||||||
|
sha256: PACKAGE_SHA256.toUpperCase(),
|
||||||
|
size_bytes: 65536,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user