Files
ELY-Browser/cloudflare/src/plugin_registry.ts
T

447 lines
12 KiB
TypeScript

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);
}