Expose plugin registry API
This commit is contained in:
@@ -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<Respons
|
||||
if (url.pathname === "/api/plugins/signing-keys") {
|
||||
return handlePublicSigningKeys(request, env);
|
||||
}
|
||||
const pluginRoute = parsePluginRoute(url.pathname);
|
||||
if (pluginRoute !== null) {
|
||||
return handlePluginRoute(request, env, pluginRoute);
|
||||
}
|
||||
if (url.pathname === "/api/releases/manifest") {
|
||||
return handleReleaseManifest(request, env);
|
||||
}
|
||||
@@ -34,6 +47,11 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
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> {
|
||||
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<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> {
|
||||
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<string, string> {
|
||||
return { "Cache-Control": "public, max-age=300, stale-while-revalidate=60" };
|
||||
}
|
||||
|
||||
function jsonResponse(
|
||||
body: unknown,
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user