Files
webmail/lib/extension-package.ts
T
Linus Rath 022b83c005 feat: let plugins use accounts and API keys through the server without seeing them
Plugins that talk to Nextcloud, Paperless or an AI provider used to need
the password or key in their own settings, readable by the plugin and
stored in the browser. A plugin can now declare credentials in its
manifest and send requests "with credential X" through the server:

- `credentials` in the manifest: admin scope (secret and server from the
  admin config) or user scope (the user connects an account; the server
  address is fixed or chosen by the user)
- `/api/plugin-secrets`: the user's connected accounts, encrypted per
  mail account under `PLUGIN_SECRETS_DIR`; listing never returns secrets
- `/api/plugin-proxy`: adds the secret and forwards only to the
  credential's own origin, through the SSRF guard unless the admin set
  the server up or listed it in `PLUGIN_PROXY_PRIVATE_ORIGINS`; no
  redirects or WebDAV moves to other servers, streamed bodies capped at
  512 MB up and 2 GB down, 300 requests a minute per plugin, 8 at once
  per user, and one audit line per request without paths or bodies
- a capture mode that stores a secret from a sign-in response (Nextcloud
  Login Flow v2) and removes it from what the plugin gets back
2026-10-14 04:00:36 +02:00

700 lines
29 KiB
TypeScript

// Reading and validating plugin and theme packages (ZIP + manifest.json).
//
// One implementation for every install path: the browser upload in Settings,
// the admin upload, the marketplace install and the marketplace preview. The
// copies these replace had drifted apart: some checked file types, some the
// permission list, some the origin lists, and none bounded how much a ZIP may
// inflate to.
import { checkCredentials } from './plugin-secrets/credentials';
import JSZip from 'jszip';
import {
ALL_PERMISSIONS,
ALLOWED_PLUGIN_FILES,
DEPRECATED_PERMISSIONS,
MAX_PLUGIN_SIZE,
MAX_THEME_SIZE,
type PluginManifest,
type PluginTier,
type PluginType,
type SettingFieldSchema,
type ThemeManifest,
MAX_THEME_SKIN_BYTES,
} from './plugin-types';
import { sanitizeSkinCSS, sanitizeThemeCSS } from './theme-loader';
import { compileAdvancedTheme, isAdvancedManifest } from './theme-compiler';
import { sanitizeApiPostPaths, sanitizeFrameOrigins, sanitizeHttpOrigins } from './extension-origins';
export const EXTENSION_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
/** No real plugin or theme needs more files than this. */
export const MAX_ZIP_ENTRIES = 200;
/** Largest single file we inflate. Bundles are capped at 5 MB zipped. */
export const MAX_ENTRY_BYTES = 20 * 1024 * 1024;
const MAX_MANIFEST_BYTES = 512 * 1024;
export type ExtensionKind = 'plugin' | 'theme';
export class ExtensionPackageError extends Error {}
export interface OpenedPackage {
zip: JSZip;
/** Folder prefix when every entry sits inside one top-level directory. */
root: string;
}
function toUint8(data: ArrayBuffer | Uint8Array): Uint8Array {
return data instanceof Uint8Array ? data : new Uint8Array(data);
}
/**
* Open a package ZIP. Checks the compressed size and entry count; files are
* only inflated when read (see `readEntry`), each under a hard cap.
*/
export async function openPackage(data: ArrayBuffer | Uint8Array, kind: ExtensionKind): Promise<OpenedPackage> {
const bytes = toUint8(data);
const max = kind === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE;
if (bytes.byteLength > max) {
throw new ExtensionPackageError(`${kind === 'theme' ? 'Theme' : 'Plugin'} ZIP exceeds ${Math.round(max / (1024 * 1024))} MB size limit`);
}
let zip: JSZip;
try {
zip = await JSZip.loadAsync(bytes);
} catch {
throw new ExtensionPackageError('Invalid ZIP file');
}
const names = Object.keys(zip.files);
if (names.length > MAX_ZIP_ENTRIES) {
throw new ExtensionPackageError(`ZIP has ${names.length} entries; at most ${MAX_ZIP_ENTRIES} are allowed`);
}
return { zip, root: findRoot(zip) };
}
function findRoot(zip: JSZip): string {
const entries = Object.keys(zip.files);
const topDirs = new Set(entries.map((e) => e.split('/')[0]));
if (topDirs.size === 1) {
const dir = [...topDirs][0];
if (zip.files[dir + '/'] || entries.some((e) => e.startsWith(dir + '/'))) return dir + '/';
}
return '';
}
/**
* Inflate one entry, stopping as soon as it passes `maxBytes`. The size in
* the ZIP header is whatever the author wrote, so it can't be trusted to
* stop a ZIP bomb; counting the inflated bytes can.
*/
export async function readEntry(pkg: OpenedPackage, name: string, maxBytes = MAX_ENTRY_BYTES): Promise<Uint8Array | null> {
const entry = pkg.zip.file(pkg.root + name);
if (!entry || entry.dir) return null;
return new Promise<Uint8Array>((resolve, reject) => {
const chunks: Uint8Array[] = [];
let total = 0;
let done = false;
// internalStream is part of JSZip's API but missing from its typings.
const stream = (entry as unknown as { internalStream(type: 'uint8array'): JSZipStream }).internalStream('uint8array');
stream
.on('data', (chunk: Uint8Array) => {
if (done) return;
total += chunk.byteLength;
if (total > maxBytes) {
done = true;
stream.pause();
reject(new ExtensionPackageError(`${name} is larger than ${Math.round(maxBytes / 1024)} KB once unpacked`));
return;
}
chunks.push(chunk);
})
.on('error', (err: Error) => {
if (done) return;
done = true;
reject(new ExtensionPackageError(`Could not read ${name}: ${err.message}`));
})
.on('end', () => {
if (done) return;
done = true;
const out = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
out.set(c, offset);
offset += c.byteLength;
}
resolve(out);
})
.resume();
});
}
interface JSZipStream {
on(event: 'data', cb: (chunk: Uint8Array) => void): JSZipStream;
on(event: 'error', cb: (err: Error) => void): JSZipStream;
on(event: 'end', cb: () => void): JSZipStream;
pause(): JSZipStream;
resume(): JSZipStream;
}
export async function readText(pkg: OpenedPackage, name: string, maxBytes = MAX_ENTRY_BYTES): Promise<string | null> {
const bytes = await readEntry(pkg, name, maxBytes);
return bytes === null ? null : new TextDecoder('utf-8').decode(bytes);
}
export async function readManifest(pkg: OpenedPackage): Promise<Record<string, unknown>> {
const raw = await readText(pkg, 'manifest.json', MAX_MANIFEST_BYTES);
if (raw === null) throw new ExtensionPackageError('Missing manifest.json');
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new ExtensionPackageError('Invalid manifest.json (not valid JSON)');
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new ExtensionPackageError('manifest.json must be a JSON object');
}
return parsed as Record<string, unknown>;
}
/** Files in a plugin package whose type the host refuses. */
export function disallowedPluginFiles(pkg: OpenedPackage): string[] {
const out: string[] = [];
for (const [path, entry] of Object.entries(pkg.zip.files)) {
if (entry.dir) continue;
const dot = path.lastIndexOf('.');
const ext = dot >= 0 ? path.slice(dot).toLowerCase() : '';
if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) out.push(path);
}
return out;
}
// ─── Manifest validation ─────────────────────────────────────
export interface ManifestCheck<T> {
manifest: T | null;
errors: string[];
warnings: string[];
}
type InstallablePluginType = Exclude<PluginType, 'theme'>;
const PLUGIN_TYPES: InstallablePluginType[] = ['ui-extension', 'sidebar-app', 'hook'];
const KNOWN_PERMISSIONS = new Set<string>(ALL_PERMISSIONS);
function requireString(m: Record<string, unknown>, key: string, errors: string[], max = 200): string {
const v = m[key];
if (typeof v !== 'string' || v.trim() === '') {
errors.push(`Missing or invalid "${key}"`);
return '';
}
if (v.length > max) errors.push(`"${key}" is longer than ${max} characters`);
return v;
}
function optionalString(m: Record<string, unknown>, key: string, errors: string[], max = 2000): string | undefined {
const v = m[key];
if (v === undefined) return undefined;
if (typeof v !== 'string') {
errors.push(`"${key}" must be a string`);
return undefined;
}
if (v.length > max) errors.push(`"${key}" is longer than ${max} characters`);
return v;
}
/** `entrypoint` must name a .js file inside the package. */
function isSafeEntrypoint(value: string): boolean {
if (value.startsWith('/') || value.includes('\\')) return false;
if (value.split('/').some((seg) => seg === '..' || seg === '.' || seg === '')) return false;
return /\.(m?js)$/.test(value) && value.length <= 200;
}
const VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
/** Compare dotted versions numerically; pre-release tags are ignored. */
export function compareVersions(a: string, b: string): number {
const pa = a.split(/[-+]/)[0].split('.').map((n) => parseInt(n, 10) || 0);
const pb = b.split(/[-+]/)[0].split('.').map((n) => parseInt(n, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
if (d !== 0) return d < 0 ? -1 : 1;
}
return 0;
}
function validateSettingsSchema(raw: unknown, errors: string[]): Record<string, SettingFieldSchema> | undefined {
if (raw === undefined) return undefined;
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
errors.push('"settingsSchema" must be an object');
return undefined;
}
const out: Record<string, SettingFieldSchema> = {};
for (const [key, field] of Object.entries(raw as Record<string, unknown>)) {
const where = `settingsSchema.${key}`;
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key)) {
errors.push(`${where}: key must be letters, digits and _`);
continue;
}
if (!field || typeof field !== 'object' || Array.isArray(field)) {
errors.push(`${where} must be an object`);
continue;
}
const f = field as Record<string, unknown>;
const type = f.type;
if (type !== 'boolean' && type !== 'string' && type !== 'number' && type !== 'select') {
errors.push(`${where}.type must be boolean, string, number or select`);
continue;
}
if (typeof f.label !== 'string' || !f.label) {
errors.push(`${where}.label is required`);
continue;
}
if (f.description !== undefined && typeof f.description !== 'string') {
errors.push(`${where}.description must be a string`);
continue;
}
const entry: SettingFieldSchema = { type, label: f.label, default: f.default };
if (typeof f.description === 'string') entry.description = f.description;
if (type === 'select') {
if (!Array.isArray(f.options) || f.options.length === 0 || f.options.some((o) => typeof o !== 'string')) {
errors.push(`${where}.options must be a non-empty list of strings`);
continue;
}
entry.options = f.options as string[];
if (!entry.options.includes(f.default as string)) {
errors.push(`${where}.default must be one of its options`);
continue;
}
} else if (type === 'boolean') {
if (typeof f.default !== 'boolean') {
errors.push(`${where}.default must be true or false`);
continue;
}
} else if (type === 'string') {
if (typeof f.default !== 'string') {
errors.push(`${where}.default must be a string`);
continue;
}
} else if (type === 'number') {
for (const bound of ['min', 'max'] as const) {
if (f[bound] !== undefined && (typeof f[bound] !== 'number' || !Number.isFinite(f[bound]))) {
errors.push(`${where}.${bound} must be a number`);
} else if (typeof f[bound] === 'number') {
entry[bound] = f[bound] as number;
}
}
if (typeof f.default !== 'number' || !Number.isFinite(f.default)) {
errors.push(`${where}.default must be a number`);
continue;
}
if ((entry.min !== undefined && f.default < entry.min) || (entry.max !== undefined && f.default > entry.max)) {
errors.push(`${where}.default is outside min/max`);
continue;
}
}
out[key] = entry;
}
return out;
}
const CONFIG_FIELD_TYPES = new Set(['string', 'secret', 'boolean', 'number', 'select']);
/** Admin-level setting a plugin declares (`configSchema`); mirrors PluginConfigField. */
export interface ConfigFieldSchema {
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
label: string;
description?: string;
required?: boolean;
default?: unknown;
placeholder?: string;
options?: { label: string; value: string }[];
}
function validateConfigSchema(raw: unknown, errors: string[]): Record<string, ConfigFieldSchema> | undefined {
if (raw === undefined) return undefined;
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
errors.push('"configSchema" must be an object');
return undefined;
}
const out: Record<string, ConfigFieldSchema> = {};
for (const [key, field] of Object.entries(raw as Record<string, unknown>)) {
const where = `configSchema.${key}`;
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key)) {
errors.push(`${where}: key must be letters, digits and _`);
continue;
}
const f = field as Record<string, unknown> | null;
if (!f || typeof f !== 'object' || Array.isArray(f) || !CONFIG_FIELD_TYPES.has(f.type as string) || typeof f.label !== 'string') {
errors.push(`${where} needs a type (string, secret, boolean, number, select) and a label`);
continue;
}
if (f.type === 'select' && (!Array.isArray(f.options) || f.options.some((o) => !o || typeof (o as { value?: unknown }).value !== 'string'))) {
errors.push(`${where}.options must be a list of { label, value }`);
continue;
}
out[key] = f as unknown as ConfigFieldSchema;
}
return out;
}
const MAX_LOCALES = 64;
const MAX_LOCALE_KEYS = 2000;
function validateLocales(raw: unknown, errors: string[]): Record<string, Record<string, string>> | undefined {
if (raw === undefined) return undefined;
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
errors.push('"locales" must be an object of locale → { key: text }');
return undefined;
}
const entries = Object.entries(raw as Record<string, unknown>);
if (entries.length > MAX_LOCALES) {
errors.push(`"locales" has more than ${MAX_LOCALES} languages`);
return undefined;
}
const out: Record<string, Record<string, string>> = {};
for (const [locale, table] of entries) {
if (!/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(locale)) {
errors.push(`locales: "${locale}" is not a language tag`);
continue;
}
if (!table || typeof table !== 'object' || Array.isArray(table)) {
errors.push(`locales.${locale} must be an object`);
continue;
}
const strings = Object.entries(table as Record<string, unknown>);
if (strings.length > MAX_LOCALE_KEYS || strings.some(([, v]) => typeof v !== 'string')) {
errors.push(`locales.${locale} must map at most ${MAX_LOCALE_KEYS} keys to strings`);
continue;
}
out[locale] = table as Record<string, string>;
}
return out;
}
function originList(
m: Record<string, unknown>,
key: 'frameOrigins' | 'httpOrigins' | 'apiPostPaths',
sanitize: (v: unknown) => string[],
errors: string[],
warnings: string[],
): string[] | undefined {
const raw = m[key];
if (raw === undefined) return undefined;
if (!Array.isArray(raw)) {
errors.push(`"${key}" must be a list`);
return undefined;
}
const kept = sanitize(raw);
const dropped = raw.filter((v) => typeof v !== 'string' || !kept.includes(v));
if (dropped.length > 0) warnings.push(`Ignored invalid ${key}: ${dropped.map(String).join(', ')}`);
return kept.length > 0 ? kept : undefined;
}
/** What every install path stores for a plugin, after validation. */
export type ValidatedPluginManifest = PluginManifest & {
frameOrigins?: string[];
configSchema?: Record<string, ConfigFieldSchema>;
};
/**
* Validate a plugin manifest. Returns a copy holding only known, checked
* fields, so nothing the host didn't validate is ever stored.
*/
export function checkPluginManifest(m: Record<string, unknown>, opts: { appVersion?: string } = {}): ManifestCheck<ValidatedPluginManifest> {
const errors: string[] = [];
const warnings: string[] = [];
const id = requireString(m, 'id', errors, 64);
if (id && !EXTENSION_ID_RE.test(id)) errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars');
const name = requireString(m, 'name', errors, 100);
const version = requireString(m, 'version', errors, 64);
if (version && !VERSION_RE.test(version)) errors.push('"version" must look like 1.2.3');
const author = requireString(m, 'author', errors, 200);
const description = optionalString(m, 'description', errors) ?? '';
const type = m.type as InstallablePluginType;
if (!PLUGIN_TYPES.includes(type)) errors.push(`Invalid type "${String(m.type)}". Must be one of: ${PLUGIN_TYPES.join(', ')}`);
let tier: PluginTier | undefined;
if (m.tier !== undefined) {
if (m.tier !== 'untrusted' && m.tier !== 'privileged') errors.push('"tier" must be "untrusted" or "privileged"');
else tier = m.tier;
}
const entrypoint = typeof m.entrypoint === 'string' && m.entrypoint ? m.entrypoint : 'index.js';
if (!isSafeEntrypoint(entrypoint)) errors.push('"entrypoint" must be a .js file inside the package');
// A string here used to slip past every check and then match by substring
// ("crypto:full email:send" granted both).
let permissions: string[] = [];
if (m.permissions !== undefined) {
if (!Array.isArray(m.permissions) || m.permissions.some((p) => typeof p !== 'string')) {
errors.push('"permissions" must be a list of strings');
} else {
permissions = [...new Set(m.permissions as string[])];
const deprecated = permissions.filter((p) => DEPRECATED_PERMISSIONS.includes(p));
if (deprecated.length > 0) {
warnings.push(`Ignored permissions that no longer grant anything: ${deprecated.join(', ')}`);
permissions = permissions.filter((p) => !DEPRECATED_PERMISSIONS.includes(p));
}
const unknown = permissions.filter((p) => !KNOWN_PERMISSIONS.has(p));
if (unknown.length > 0) errors.push(`Unknown permissions: ${unknown.join(', ')} (this Bulwark version does not offer them)`);
}
}
if (tier === 'privileged' && !permissions.includes('crypto:full')) {
errors.push('A privileged plugin must request the "crypto:full" permission');
}
const minAppVersion = optionalString(m, 'minAppVersion', errors, 32);
if (minAppVersion !== undefined && !VERSION_RE.test(minAppVersion)) errors.push('"minAppVersion" must look like 1.2.3');
if (minAppVersion && opts.appVersion && VERSION_RE.test(opts.appVersion) && compareVersions(opts.appVersion, minAppVersion) < 0) {
errors.push(`Needs Bulwark ${minAppVersion} or later (this is ${opts.appVersion})`);
}
const settingsSchema = validateSettingsSchema(m.settingsSchema, errors);
const configSchema = validateConfigSchema(m.configSchema, errors);
const locales = validateLocales(m.locales, errors);
const frameOrigins = originList(m, 'frameOrigins', sanitizeFrameOrigins, errors, warnings);
const httpOrigins = originList(m, 'httpOrigins', sanitizeHttpOrigins, errors, warnings);
const apiPostPaths = originList(m, 'apiPostPaths', sanitizeApiPostPaths, errors, warnings);
const credentialCheck = checkCredentials(m.credentials, configSchema);
errors.push(...credentialCheck.errors);
if (errors.length > 0) return { manifest: null, errors, warnings };
const manifest: ValidatedPluginManifest = {
id, name, version, author, description, type, permissions, entrypoint,
...(tier ? { tier } : {}),
...(minAppVersion ? { minAppVersion } : {}),
...(settingsSchema && Object.keys(settingsSchema).length ? { settingsSchema } : {}),
...(configSchema && Object.keys(configSchema).length ? { configSchema } : {}),
...(locales && Object.keys(locales).length ? { locales } : {}),
...(frameOrigins ? { frameOrigins } : {}),
...(httpOrigins ? { httpOrigins } : {}),
...(apiPostPaths ? { apiPostPaths } : {}),
...(credentialCheck.credentials && Object.keys(credentialCheck.credentials).length ? { credentials: credentialCheck.credentials } : {}),
};
return { manifest, errors, warnings };
}
// ─── Plugin code checks ──────────────────────────────────────
const SUSPICIOUS_JS_PATTERNS = [
{ pattern: /\beval\s*\(/, label: 'eval()' },
{ pattern: /\bnew\s+Function\s*\(/, label: 'new Function()' },
{ pattern: /document\.cookie/, label: 'document.cookie' },
{ pattern: /document\.write/, label: 'document.write' },
{ pattern: /innerHTML\s*=/, label: 'innerHTML assignment' },
];
/** Patterns worth a reviewer's second look. Not a security boundary: the sandbox is. */
export function suspiciousCode(code: string): string[] {
return SUSPICIOUS_JS_PATTERNS.filter(({ pattern }) => pattern.test(code)).map(({ label }) => label);
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
if (value && typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(',')}}`;
}
return JSON.stringify(value);
}
/**
* SHA-256 over the validated manifest and the code. An admin approval is
* pinned to this, so re-zipping the same code with more permissions or
* other origins needs a new approval. Same result in the browser and on
* the server.
*/
export async function packageHash(manifest: ValidatedPluginManifest, code: string): Promise<string> {
const data = new TextEncoder().encode(`${canonicalJson(manifest)}\n${code}`);
const digest = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
}
export interface PluginPackage {
manifest: ValidatedPluginManifest;
code: string;
warnings: string[];
/** The opened ZIP, for callers that read more files (preview image). */
pkg: OpenedPackage;
}
/**
* Open, validate and read a plugin package. Throws ExtensionPackageError
* with every problem found when it is not installable.
*/
export async function readPluginPackage(
data: ArrayBuffer | Uint8Array,
opts: { appVersion?: string; expectedId?: string } = {},
): Promise<PluginPackage> {
const pkg = await openPackage(data, 'plugin');
const raw = await readManifest(pkg);
const check = checkPluginManifest(raw, opts);
const errors = [...check.errors];
const bad = disallowedPluginFiles(pkg);
if (bad.length > 0) errors.push(`Disallowed file types: ${bad.join(', ')}`);
if (check.manifest && opts.expectedId !== undefined && check.manifest.id !== opts.expectedId) {
errors.push(`Package id "${check.manifest.id}" does not match "${opts.expectedId}"`);
}
if (errors.length > 0 || !check.manifest) throw new ExtensionPackageError(errors.join('; '));
const code = await readText(pkg, check.manifest.entrypoint);
if (code === null) throw new ExtensionPackageError(`Missing entrypoint file: ${check.manifest.entrypoint}`);
const warnings = [...check.warnings, ...suspiciousCode(code).map((l) => `Contains ${l} - review for security`)];
return { manifest: check.manifest, code, warnings, pkg };
}
// ─── Themes ──────────────────────────────────────────────────
function validateBaseManifest(manifest: Record<string, unknown>): string[] {
const errors: string[] = [];
if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"');
if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"');
if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"');
if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"');
if (!manifest.type || typeof manifest.type !== 'string') errors.push('Missing or invalid "type"');
// Validate ID format (alphanumeric + hyphens)
if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) {
errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars');
}
return errors;
}
export function checkThemeManifest(manifest: Record<string, unknown>): { result: ThemeManifest | null; errors: string[] } {
const errors = validateBaseManifest(manifest);
if (manifest.type !== 'theme') {
errors.push(`Expected type "theme", got "${manifest.type}"`);
}
if (!manifest.variants || !Array.isArray(manifest.variants) || manifest.variants.length === 0) {
errors.push('Missing or empty "variants" array (must be ["light"], ["dark"], or ["light","dark"])');
} else {
const valid = manifest.variants.every((v: unknown) => v === 'light' || v === 'dark');
if (!valid) errors.push('Variants must be "light" or "dark"');
}
// ── Theme API v2 fields (all optional) ──
if (manifest.apiVersion !== undefined && manifest.apiVersion !== 1 && manifest.apiVersion !== 2) {
errors.push('"apiVersion" must be 1 or 2 if present');
}
if (manifest.extends !== undefined && typeof manifest.extends !== 'string') {
errors.push('"extends" must be a string (the parent theme id)');
}
if (manifest.tokens !== undefined && (typeof manifest.tokens !== 'object' || manifest.tokens === null)) {
errors.push('"tokens" must be an object with optional "common"/"light"/"dark" maps');
}
if (manifest.density !== undefined && !['compact', 'normal', 'touch'].includes(manifest.density as string)) {
errors.push('"density" must be "compact", "normal", or "touch"');
}
if (manifest.derive !== undefined && typeof manifest.derive !== 'boolean') {
errors.push('"derive" must be a boolean');
}
if (manifest.radii !== undefined && (typeof manifest.radii !== 'object' || manifest.radii === null)) {
errors.push('"radii" must be an object');
}
if (manifest.typography !== undefined && (typeof manifest.typography !== 'object' || manifest.typography === null)) {
errors.push('"typography" must be an object');
}
if (errors.length > 0) return { result: null, errors };
return {
result: manifest as unknown as ThemeManifest,
errors: [],
};
}
export interface ThemePackageResult {
valid: boolean;
errors: string[];
warnings: string[];
manifest: ThemeManifest | null;
/** Compiled (Theme API v2) and sanitized CSS. */
css: string;
/** Sanitized skin.css for v2 themes, else null. */
skin: string | null;
pkg: OpenedPackage | null;
}
/**
* Open, validate and compile a theme package. v1 themes need theme.css; v2
* themes may be tokens only and may ship a skin.css. `resolveExtends` returns
* the CSS of an installed theme for `extends`.
*/
export async function readThemePackage(
data: ArrayBuffer | Uint8Array,
opts: { resolveExtends?: (id: string) => string | null } = {},
): Promise<ThemePackageResult> {
const fail = (errors: string[], warnings: string[] = [], manifest: ThemeManifest | null = null, pkg: OpenedPackage | null = null): ThemePackageResult =>
({ valid: false, errors, warnings, manifest, css: '', skin: null, pkg });
const errors: string[] = [];
const warnings: string[] = [];
let pkg: OpenedPackage;
let manifestData: Record<string, unknown>;
try {
pkg = await openPackage(data, 'theme');
manifestData = await readManifest(pkg);
} catch (err) {
return fail([err instanceof ExtensionPackageError ? err.message : 'Invalid ZIP file']);
}
const { result: manifest, errors: manifestErrors } = checkThemeManifest(manifestData);
if (!manifest) return fail(manifestErrors, warnings, null, pkg);
// theme.css is required for v1 themes, optional for v2 ones, whose CSS the
// compiler can produce from the manifest alone.
const isAdvanced = isAdvancedManifest(manifest);
let cssText: string | null;
let skinText: string | null;
try {
cssText = await readText(pkg, 'theme.css');
skinText = await readText(pkg, 'skin.css', MAX_THEME_SKIN_BYTES * 4);
} catch (err) {
return fail([err instanceof ExtensionPackageError ? err.message : 'Could not read theme files'], warnings, manifest, pkg);
}
let userCSS = '';
if (cssText !== null) {
// Sanitize instead of rejecting, and always: the sanitizer is also what
// drops rules outside :root and .dark.
const sanitized = sanitizeThemeCSS(cssText);
userCSS = sanitized.css;
warnings.push(...sanitized.warnings);
} else if (!isAdvanced) {
return fail(['Missing theme.css'], warnings, manifest, pkg);
}
let css = userCSS;
if (isAdvanced) {
const compiled = compileAdvancedTheme(manifest, { userCSS, resolveExtends: opts.resolveExtends });
if (compiled.errors.length > 0) return fail([...errors, ...compiled.errors], warnings, manifest, pkg);
warnings.push(...compiled.warnings);
// Defence in depth: sanitize the compiled output too.
const sanitized = sanitizeThemeCSS(compiled.css);
css = sanitized.css;
warnings.push(...sanitized.warnings);
}
// Skins target real component selectors and skip the :root/.dark selector
// check; they still go through the dangerous-pattern sanitizer.
let skin: string | null = null;
if (skinText !== null) {
if (!isAdvanced) {
warnings.push('skin.css ignored - only Theme API v2 manifests can ship a skin');
} else if (skinText.length > MAX_THEME_SKIN_BYTES) {
warnings.push(`skin.css exceeds ${Math.round(MAX_THEME_SKIN_BYTES / 1024)} KB and was dropped`);
} else {
const sanitized = sanitizeSkinCSS(skinText);
skin = sanitized.css;
warnings.push(...sanitized.warnings);
}
}
return { valid: true, errors: [], warnings, manifest, css, skin, pkg };
}