Files
webmail/lib/admin/plugin-registry.ts
T

670 lines
25 KiB
TypeScript

import type { CredentialSpec } from '@/lib/plugin-secrets/credentials';
import { createHash, randomBytes } from 'node:crypto';
import { logger } from '@/lib/logger';
import { getStore, isSharedMode, replicaId } from '@/lib/shared-store';
import { announceChange } from '@/lib/shared-store/changes';
import { assertWritable, isConfigReadOnly } from './paths';
import { isStoreUnavailable } from './admin-store';
import { cacheBundle, getCachedBundle } from './bundle-cache';
import { pluginDocs, themeDocs } from './extension-store';
import { forgetVersion, purgeHistory, snapshotPlugin, snapshotTheme } from './extension-history';
// ─── Types ───────────────────────────────────────────────────
export interface PluginConfigField {
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
label: string;
description?: string;
required?: boolean;
default?: unknown;
placeholder?: string;
options?: { label: string; value: string }[];
/** `type: 'string'` only: the admin form shows a full-width text box. */
multiline?: boolean;
}
/**
* Per-user setting field, mirrors the manifest's `settingsSchema` shape
* (see lib/plugin-types.ts SettingFieldSchema). The server passes these
* through unchanged so the client can render the per-user settings UI.
*/
export interface PluginSettingsField {
type: 'boolean' | 'string' | 'number' | 'select';
label: string;
description?: string;
default: unknown;
options?: string[];
min?: number;
max?: number;
}
/**
* Where an installed extension came from. Set on install; entries written
* before this existed have none. The marketplace uses it to ask the directory
* about updates, yanked versions and advisories for exactly the extensions
* that came from it, and the admin UI uses it to link back to the listing.
*/
export type ExtensionSource =
| { kind: 'marketplace'; slug: string; directoryUrl: string }
| { kind: 'upload' };
export interface ServerPlugin {
id: string;
name: string;
version: string;
author: string;
description: string;
type: string;
/** Requested execution tier ('untrusted' | 'privileged'). Privileged plugins
* run in a same-origin sandbox and require admin approval + consent. */
tier?: string;
permissions: string[];
entrypoint: string;
enabled: boolean;
forceEnabled?: boolean;
configSchema?: Record<string, PluginConfigField>;
settingsSchema?: Record<string, PluginSettingsField>;
/**
* Optional per-locale translation tables (locale -> key -> string) declared
* in the plugin manifest. Surfaced to the sandbox so plugin code can call
* `api.i18n.t(key)`; without it a plugin's strings stay in its hardcoded
* default language.
*/
locales?: Record<string, Record<string, string>>;
installedAt: string;
updatedAt: string;
/**
* Full SHA-256 hex of the bundle code (64 chars). Refreshed every save so
* the same version re-uploaded with new code still appears as a change to
* the client. Also doubles as the HTTP ETag for the bundle endpoint and is
* verified by the sandbox loader on every load
* (`lib/plugin-sandbox/bundle-integrity.ts`), so it must match the served
* bytes exactly.
*/
bundleHash?: string;
/**
* Validated CSP origins (https-only, single-origin form) the plugin may
* embed. Merged into the host frame-src by the proxy.
*/
frameOrigins?: string[];
/**
* Validated HTTPS origins the plugin may target via `api.http.fetch()`.
* Same syntax as `frameOrigins`. Surfaced to clients via /api/plugins.
*/
httpOrigins?: string[];
/**
* Same-origin `/api/*` path allowlist for `api.http.post()`. See
* `InstalledPlugin.apiPostPaths` in `lib/plugin-types.ts`.
*/
apiPostPaths?: string[];
/** Credentials the plugin uses through the server proxy (no secrets here). */
credentials?: Record<string, CredentialSpec>;
source?: ExtensionSource;
/**
* The version's changelog (Markdown) from the directory, kept from a
* marketplace install so the admin UI and the rollback history can show it
* without the directory. Admin-only: /api/plugins doesn't send it.
*/
changelog?: string;
}
export interface ServerTheme {
id: string;
name: string;
version: string;
author: string;
description: string;
variants: string[];
enabled: boolean;
forceEnabled?: boolean;
installedAt: string;
updatedAt: string;
source?: ExtensionSource;
/** The version's changelog from the directory; see ServerPlugin.changelog. */
changelog?: string;
/** True when a Theme API v2 skin.css is stored next to the CSS. */
hasSkin?: boolean;
/**
* SHA-256 of the CSS and skin. Clients use it to notice a re-upload of
* the same version and as a cache key on the CSS and skin URLs.
*/
contentHash?: string;
}
interface PluginRegistry {
plugins: ServerPlugin[];
}
interface ThemeRegistry {
themes: ServerTheme[];
}
// ─── Storage ─────────────────────────────────────────────────
//
// Both registries, their bundles, signatures, CSS and history are
// documents of the `plugins` and `themes` collections (extension-store.ts):
// the files under C/plugins and C/themes in local mode, the shared store in
// shared mode (#1066). Every change writes the bundle (and its signature)
// before the registry entry that points at it, changes the registry with
// update() so a concurrent change of another entry stays, and then
// announces area `plugins` or `themes` so every replica drops what it
// derived from the old registry (the CSP frame origins, for one).
type RegistryKind = 'plugins' | 'themes';
interface RegistryGlobals {
/** One change at a time per registry in this process. */
queues: Map<string, Promise<unknown>>;
/** Shared mode: the last registry read, served while the store is down. */
lastGood: Map<RegistryKind, string>;
}
const GLOBAL_KEY = Symbol.for('bulwark.admin.pluginRegistry');
function registryGlobals(): RegistryGlobals {
const g = globalThis as typeof globalThis & { [GLOBAL_KEY]?: RegistryGlobals };
return (g[GLOBAL_KEY] ??= { queues: new Map(), lastGood: new Map() });
}
const docsFor = (kind: RegistryKind) => (kind === 'plugins' ? pluginDocs() : themeDocs());
const REGISTRY_ID = 'registry.json';
function serialize(registry: unknown): string {
return JSON.stringify(registry, null, 2);
}
/** A registry document, or `fallback` when it is missing or doesn't parse (as a broken file always did). */
function parseRegistry<T>(kind: RegistryKind, data: Buffer | null, fallback: () => T): T {
if (!data) return fallback();
try {
return JSON.parse(data.toString('utf-8')) as T;
} catch (error) {
logger.warn(`Failed to read the ${kind} registry`, { error: error instanceof Error ? error.message : 'Unknown error' });
return fallback();
}
}
/**
* Read a registry. A store outage serves the last copy this replica read
* (config reads keep the last good copy, plan §6); with none it throws.
*/
async function readRegistry<T>(kind: RegistryKind, fallback: () => T): Promise<T> {
const g = registryGlobals();
let data: Buffer | null;
try {
data = (await docsFor(kind).get(REGISTRY_ID))?.data ?? null;
} catch (error) {
if (isStoreUnavailable(error)) {
const last = g.lastGood.get(kind);
if (last !== undefined) return JSON.parse(last) as T;
throw error;
}
logger.warn(`Failed to read the ${kind} registry`, { error: error instanceof Error ? error.message : 'Unknown error' });
return fallback();
}
const registry = parseRegistry(kind, data, fallback);
if (isSharedMode()) g.lastGood.set(kind, serialize(registry));
return registry;
}
/** Change a registry with update(); `change` may run more than once. */
async function updateRegistry<T>(kind: RegistryKind, fallback: () => T, change: (registry: T) => boolean): Promise<void> {
const result = await docsFor(kind).update(REGISTRY_ID, (current) => {
const registry = parseRegistry(kind, current, fallback);
return change(registry) ? serialize(registry) : undefined;
});
if (result.data && isSharedMode()) registryGlobals().lastGood.set(kind, result.data.toString('utf-8'));
}
async function readText(kind: RegistryKind, id: string): Promise<string | null> {
const doc = await docsFor(kind).get(id);
return doc ? doc.data.toString('utf-8') : null;
}
/** Delete a document; only a store outage counts as a failure (a file that is gone already is fine). */
async function removeDoc(kind: RegistryKind, id: string): Promise<void> {
try {
await docsFor(kind).delete(id);
} catch (error) {
if (isStoreUnavailable(error)) throw error;
}
}
const LEASE_TTL_MS = 30_000;
const LEASE_WAIT_MS = 15_000;
/**
* Changes to one registry run one after another: in this process through a
* promise chain on globalThis (every module copy shares it), and across
* replicas under a lease, so two installs of the same extension at once
* can't leave one's bundle under the other's registry entry. The registry
* itself is changed with update(), so even a change that couldn't get the
* lease in time keeps every other entry. A store outage fails here, before
* anything is written.
*/
function withRegistryLock<T>(kind: RegistryKind, task: () => Promise<T>): Promise<T> {
const queues = registryGlobals().queues;
const locked = () => withLease(kind, task);
const run = (queues.get(kind) ?? Promise.resolve()).then(locked, locked);
queues.set(kind, run.catch(() => {}));
return run;
}
async function withLease<T>(kind: RegistryKind, task: () => Promise<T>): Promise<T> {
if (!isSharedMode() || isConfigReadOnly()) return task();
const store = getStore();
const name = `extension-registry-${kind}`;
const holder = `${replicaId()}-${randomBytes(4).toString('hex')}`;
const deadline = Date.now() + LEASE_WAIT_MS;
let held = await store.leases.acquire(name, holder, LEASE_TTL_MS);
while (!held && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 100 + Math.random() * 100));
held = await store.leases.acquire(name, holder, LEASE_TTL_MS);
}
if (!held) logger.warn(`Another instance is still changing the ${kind}; going ahead without waiting longer`);
try {
return await task();
} finally {
if (held) await store.leases.release(name, holder).catch(() => {});
}
}
const sha256Hex = (data: string) => createHash('sha256').update(data).digest('hex');
// ─── Plugin Operations ───────────────────────────────────────
const emptyPluginRegistry = (): PluginRegistry => ({ plugins: [] });
const FULL_HASH_RE = /^[0-9a-f]{64}$/;
/**
* Older builds wrote a 16-char SHA-256 prefix into `bundleHash`. The current
* client-side verifyBundle requires equal-length hex (and the full digest for
* real integrity), so any registry entry with a truncated or otherwise
* malformed hash needs to be re-hashed from the stored bundle. If the bundle
* is missing the hash is cleared so verifyBundle skips the check rather
* than refusing to load.
*
* Returns the fixed hash per plugin id and the hash it replaces (null =
* clear it), or null when nothing needs fixing.
*/
async function bundleHashFixes(registry: PluginRegistry): Promise<Map<string, { from: string; to: string | null }> | null> {
const fixes = new Map<string, { from: string; to: string | null }>();
for (const plugin of registry.plugins) {
if (!plugin.bundleHash || FULL_HASH_RE.test(plugin.bundleHash)) continue;
let code: string | null = null;
try {
code = await readText('plugins', `${plugin.id}.js`);
} catch {
/* unreadable: cleared, as a missing bundle */
}
fixes.set(plugin.id, { from: plugin.bundleHash, to: code === null ? null : sha256Hex(code) });
}
return fixes.size > 0 ? fixes : null;
}
function applyHashFixes(registry: PluginRegistry, fixes: Map<string, { from: string; to: string | null }>): boolean {
let changed = false;
for (const plugin of registry.plugins) {
const fix = fixes.get(plugin.id);
// Only an entry that still has the old hash: a save in between wins.
if (!fix || plugin.bundleHash !== fix.from) continue;
if (fix.to) plugin.bundleHash = fix.to;
else delete plugin.bundleHash;
changed = true;
}
return changed;
}
export async function getPluginRegistry(): Promise<PluginRegistry> {
const registry = await readRegistry('plugins', emptyPluginRegistry);
const fixes = await bundleHashFixes(registry);
if (fixes) {
applyHashFixes(registry, fixes);
// Stored with update(), so an install that lands meanwhile isn't undone.
try { await updateRegistry('plugins', emptyPluginRegistry, (stored) => applyHashFixes(stored, fixes)); } catch { /* read-only fs ok */ }
}
return registry;
}
export async function getPlugin(id: string): Promise<ServerPlugin | null> {
const registry = await getPluginRegistry();
return registry.plugins.find(p => p.id === id) || null;
}
const SAFE_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
export async function savePlugin(plugin: ServerPlugin, code: string): Promise<void> {
await withRegistryLock('plugins', () => savePluginNow(plugin, code));
await announceChange('plugins');
}
async function savePluginNow(
plugin: ServerPlugin,
code: string,
): Promise<void> {
assertWritable('install plugin');
if (!SAFE_ID_RE.test(plugin.id)) {
throw new Error('Invalid plugin id');
}
const bundleHash = sha256Hex(code);
const current = (await getPluginRegistry()).plugins.find(p => p.id === plugin.id);
// Sign now and keep the signature with the bundle. Signing at serve time
// would sign whatever is stored, so a bundle swapped after install would
// still verify. Signed before anything is written: a key that can't be
// loaded from the shared store fails the install, not half of it.
const signature = await signBundle(plugin.id, code);
// Keep what is about to be replaced, so the update can be undone
// (extension-history.ts). The same bytes under the same version are not
// a replacement worth keeping.
if (current) {
await forgetVersion('plugin', plugin.id, plugin.version);
if (current.version !== plugin.version || current.bundleHash !== bundleHash) {
const currentCode = await readText('plugins', `${plugin.id}.js`);
if (currentCode !== null) await snapshotPlugin(current, currentCode);
}
}
// The bundle and its signature first, then the registry entry pointing at them.
const docs = pluginDocs();
await docs.put(`${plugin.id}.js`, code);
await storeSignature(plugin.id, signature);
// Stamp content hash + updatedAt so clients can detect re-uploads even
// when the manifest version hasn't changed. Preserve the original
// installedAt across re-uploads. The full SHA-256 is required because the
// client-side verifyBundle compares the entire digest length-checked.
const now = new Date().toISOString();
await updateRegistry('plugins', emptyPluginRegistry, (registry) => {
const idx = registry.plugins.findIndex(p => p.id === plugin.id);
const next: ServerPlugin = {
...plugin,
bundleHash,
updatedAt: now,
installedAt: idx >= 0 ? registry.plugins[idx].installedAt : plugin.installedAt,
};
if (idx >= 0) {
registry.plugins[idx] = next;
} else {
registry.plugins.push(next);
}
return true;
});
if (isSharedMode()) cacheBundle(bundleHash, code, signature);
}
export async function updatePluginMeta(id: string, updates: Partial<Pick<ServerPlugin, 'enabled' | 'forceEnabled'>>): Promise<ServerPlugin | null> {
const updated = await withRegistryLock('plugins', () => updatePluginMetaNow(id, updates));
if (updated) await announceChange('plugins');
return updated;
}
async function updatePluginMetaNow(id: string, updates: Partial<Pick<ServerPlugin, 'enabled' | 'forceEnabled'>>): Promise<ServerPlugin | null> {
assertWritable('update plugin metadata');
const now = new Date().toISOString();
let updated: ServerPlugin | null = null;
await updateRegistry('plugins', emptyPluginRegistry, (registry) => {
updated = null;
const idx = registry.plugins.findIndex(p => p.id === id);
if (idx < 0) return false;
registry.plugins[idx] = { ...registry.plugins[idx], ...updates, updatedAt: now };
updated = registry.plugins[idx];
return true;
});
return updated;
}
export async function deletePlugin(id: string): Promise<boolean> {
const deleted = await withRegistryLock('plugins', () => deletePluginNow(id));
if (deleted) await announceChange('plugins');
return deleted;
}
async function deletePluginNow(id: string): Promise<boolean> {
assertWritable('delete plugin');
let found = false;
await updateRegistry('plugins', emptyPluginRegistry, (registry) => {
const idx = registry.plugins.findIndex(p => p.id === id);
found = idx >= 0;
if (!found) return false;
registry.plugins.splice(idx, 1);
return true;
});
if (!found) return false;
// Remove bundle and signature (the registry no longer points at them)
if (SAFE_ID_RE.test(id)) {
for (const file of [`${id}.js`, `${id}.sig`]) await removeDoc('plugins', file);
}
await purgeHistory('plugin', id);
return true;
}
/** Sign a bundle; null when no key can be loaded (the plugin then can't run privileged). */
async function signBundle(id: string, code: string): Promise<string | null> {
try {
const { signBytes } = await import('./plugin-signing');
return await signBytes(code);
} catch (err) {
if (isStoreUnavailable(err)) throw err;
// Without a key the bundle still installs; it just can't run privileged.
logger.warn('[plugin-registry] could not sign plugin bundle', { id, error: err instanceof Error ? err.message : String(err) });
return null;
}
}
async function storeSignature(id: string, signature: string | null): Promise<void> {
if (signature) await pluginDocs().put(`${id}.sig`, signature);
else await removeDoc('plugins', `${id}.sig`);
}
/**
* The signature made when the bundle was installed. Bundles installed before
* signatures were stored get one on first request (trust on first use), so
* a later change to the stored bundle stops verifying from then on.
*
* `bundleHash` (the registry entry's) lets shared mode answer from this
* replica's bundle cache.
*/
export async function getPluginSignature(id: string, code: string, bundleHash?: string): Promise<string | null> {
if (!SAFE_ID_RE.test(id)) return null;
const cached = bundleHash && isSharedMode() ? getCachedBundle(bundleHash) : undefined;
if (cached && cached.code === code && cached.signature !== undefined) return cached.signature;
let stored: string | null;
try {
stored = await readText('plugins', `${id}.sig`);
} catch (error) {
if (isStoreUnavailable(error)) throw error;
stored = null;
}
let signature: string | null;
if (stored !== null) {
signature = stored.trim() || null;
} else {
signature = await signBundle(id, code);
await storeSignature(id, signature);
}
if (cached && cached.code === code) cached.signature = signature;
return signature;
}
/**
* The stored bundle of a plugin. With `bundleHash` (the registry entry's),
* shared mode serves it from this replica's cache once it was fetched and
* found to match.
*/
export async function getPluginBundle(id: string, bundleHash?: string): Promise<string | null> {
if (!SAFE_ID_RE.test(id)) return null;
const useCache = !!bundleHash && isSharedMode();
if (useCache) {
const cached = getCachedBundle(bundleHash!);
if (cached) return cached.code;
}
let code: string | null;
try {
code = await readText('plugins', `${id}.js`);
} catch (error) {
if (isStoreUnavailable(error)) throw error;
return null;
}
if (code !== null && useCache && sha256Hex(code) === bundleHash) cacheBundle(bundleHash!, code);
return code;
}
// ─── Theme Operations ────────────────────────────────────────
const emptyThemeRegistry = (): ThemeRegistry => ({ themes: [] });
export async function getThemeRegistry(): Promise<ThemeRegistry> {
return readRegistry('themes', emptyThemeRegistry);
}
export async function getTheme(id: string): Promise<ServerTheme | null> {
const registry = await getThemeRegistry();
return registry.themes.find(t => t.id === id) || null;
}
export async function saveTheme(theme: ServerTheme, css: string, skin: string | null = null): Promise<void> {
await withRegistryLock('themes', () => saveThemeNow(theme, css, skin));
await announceChange('themes');
}
async function saveThemeNow(
theme: ServerTheme,
css: string,
skin: string | null = null,
): Promise<void> {
assertWritable('install theme');
if (!SAFE_ID_RE.test(theme.id)) {
throw new Error('Invalid theme id');
}
const contentHash = createHash('sha256').update(css).update('\0').update(skin ?? '').digest('hex');
const current = (await getThemeRegistry()).themes.find(t => t.id === theme.id);
const cssId = `${theme.id}.css`;
const skinId = `${theme.id}.skin.css`;
// Keep the theme being replaced for a rollback (extension-history.ts).
if (current) {
await forgetVersion('theme', theme.id, theme.version);
if (current.version !== theme.version || current.contentHash !== contentHash) {
const currentCss = await readText('themes', cssId);
const currentSkin = current.hasSkin ? await readText('themes', skinId) : null;
if (currentCss !== null) await snapshotTheme(current, currentCss, currentSkin);
}
}
// The CSS and skin first, then the registry entry.
const docs = themeDocs();
await docs.put(cssId, css);
if (skin) {
await docs.put(skinId, skin);
} else {
await removeDoc('themes', skinId);
}
const next: ServerTheme = { ...theme, hasSkin: !!skin, contentHash };
await updateRegistry('themes', emptyThemeRegistry, (registry) => {
const idx = registry.themes.findIndex(t => t.id === theme.id);
if (idx >= 0) {
registry.themes[idx] = next;
} else {
registry.themes.push(next);
}
return true;
});
}
export async function updateThemeMeta(id: string, updates: Partial<Pick<ServerTheme, 'enabled' | 'forceEnabled'>>): Promise<ServerTheme | null> {
const updated = await withRegistryLock('themes', () => updateThemeMetaNow(id, updates));
if (updated) await announceChange('themes');
return updated;
}
async function updateThemeMetaNow(id: string, updates: Partial<Pick<ServerTheme, 'enabled' | 'forceEnabled'>>): Promise<ServerTheme | null> {
assertWritable('update theme metadata');
const now = new Date().toISOString();
let updated: ServerTheme | null = null;
await updateRegistry('themes', emptyThemeRegistry, (registry) => {
updated = null;
const idx = registry.themes.findIndex(t => t.id === id);
if (idx < 0) return false;
registry.themes[idx] = { ...registry.themes[idx], ...updates, updatedAt: now };
updated = registry.themes[idx];
return true;
});
return updated;
}
export async function deleteTheme(id: string): Promise<boolean> {
const deleted = await withRegistryLock('themes', () => deleteThemeNow(id));
if (deleted) await announceChange('themes');
return deleted;
}
async function deleteThemeNow(id: string): Promise<boolean> {
assertWritable('delete theme');
let found = false;
await updateRegistry('themes', emptyThemeRegistry, (registry) => {
const idx = registry.themes.findIndex(t => t.id === id);
found = idx >= 0;
if (!found) return false;
registry.themes.splice(idx, 1);
return true;
});
if (!found) return false;
// Remove CSS and skin
if (SAFE_ID_RE.test(id)) {
for (const file of [`${id}.css`, `${id}.skin.css`]) await removeDoc('themes', file);
}
await purgeHistory('theme', id);
return true;
}
export async function getThemeSkin(id: string): Promise<string | null> {
if (!SAFE_ID_RE.test(id)) return null;
return readThemeAsset(`${id}.skin.css`);
}
async function readThemeAsset(file: string): Promise<string | null> {
try {
return await readText('themes', file);
} catch (error) {
if (isStoreUnavailable(error)) throw error;
return null;
}
}
/**
* CSS of every installed theme and every built-in theme, keyed by id, for
* resolving a new theme's `extends`.
*/
export async function themeCSSResolver(): Promise<(id: string) => string | null> {
const { BUILTIN_THEMES } = await import('@/lib/builtin-themes');
const map = new Map<string, string>();
for (const t of BUILTIN_THEMES) if (t.css) map.set(t.id, t.css);
for (const t of (await getThemeRegistry()).themes) {
const css = await getThemeCSS(t.id);
if (css) map.set(t.id, css);
}
return (id) => map.get(id) ?? null;
}
export async function getThemeCSS(id: string): Promise<string | null> {
if (!SAFE_ID_RE.test(id)) return null;
return readThemeAsset(`${id}.css`);
}
/** Forget the registries this replica keeps for an outage (tests). */
export function resetRegistryCacheForTests(): void {
const g = registryGlobals();
g.lastGood.clear();
g.queues.clear();
}