feat: add plugin/theme harness and admin dashboard

Plugin & Theme System:
- Add plugin type definitions, permissions (30+), and validation constants
- Add IndexedDB storage layer for plugin code, theme CSS, and previews
- Add theme CSS sanitization, injection, and safety validation
- Add HookBus event system with 130+ hooks across 20 domains
- Add plugin ZIP extraction and manifest validation with JS security checks
- Add sandboxed PluginAPI factory with scoped storage, logging, and permission gating
- Add plugin loader with blob URL dynamic import and auto-disable circuit breaker
- Add 3 built-in themes (Nord, Catppuccin, Solarized)
- Add Zustand plugin store with install/uninstall/enable/disable lifecycle
- Add PluginSlot, PluginSlotRenderer, and PluginErrorBoundary components
- Add plugins and themes settings UI panels
- Integrate plugin slots into email viewer, composer, navigation rail, sidebar, and context menu
- Extend theme store with custom theme installation and activation

Admin Dashboard:
- Add admin authentication with scrypt password hashing and AES-256-GCM sessions
- Add rate-limited login (5 attempts/15min per IP)
- Add config manager with admin override > env var > default priority
- Add settings policy system with feature gates and per-setting restrictions
- Add audit logging with rotation
- Add admin API routes (login, logout, config, policy, audit, password change)
- Add admin UI pages (login, dashboard, config, policy, audit)
- Add policy store for client-side feature gate enforcement
- Wire admin password initialization into server instrumentation

Tests:
- Add 139 tests across 10 test files covering all plugin/theme modules
This commit is contained in:
Linus Rath
2026-03-25 00:44:03 +01:00
parent 78bcf8db1b
commit 76b21147e4
63 changed files with 7894 additions and 67 deletions
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { BUILTIN_THEMES } from '../builtin-themes';
describe('BUILTIN_THEMES', () => {
it('contains exactly 3 themes', () => {
expect(BUILTIN_THEMES).toHaveLength(3);
});
it('all themes have required fields', () => {
for (const theme of BUILTIN_THEMES) {
expect(theme.id).toBeTruthy();
expect(theme.name).toBeTruthy();
expect(theme.version).toBeTruthy();
expect(theme.author).toBe('Built-in');
expect(theme.css).toBeTruthy();
expect(theme.variants).toEqual(['light', 'dark']);
expect(theme.enabled).toBe(true);
expect(theme.builtIn).toBe(true);
}
});
it('all IDs are prefixed with builtin-', () => {
for (const theme of BUILTIN_THEMES) {
expect(theme.id).toMatch(/^builtin-/);
}
});
it('all themes have both :root and .dark selectors', () => {
for (const theme of BUILTIN_THEMES) {
expect(theme.css).toContain(':root');
expect(theme.css).toContain('.dark');
}
});
it('all themes set --color-primary', () => {
for (const theme of BUILTIN_THEMES) {
expect(theme.css).toContain('--color-primary:');
}
});
it('themes have correct names', () => {
const names = BUILTIN_THEMES.map(t => t.name);
expect(names).toContain('Nord');
expect(names).toContain('Catppuccin');
expect(names).toContain('Solarized');
});
it('theme IDs are unique', () => {
const ids = BUILTIN_THEMES.map(t => t.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
+150
View File
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createPluginAPI, setSlotRegistrationBridge } from '../plugin-api';
import type { InstalledPlugin } from '../plugin-types';
import { clearAllHooks } from '../plugin-hooks';
function makePlugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
return {
id: 'test-plugin',
name: 'Test Plugin',
version: '1.0.0',
author: 'Test',
description: '',
type: 'ui-extension',
entrypoint: 'index.js',
permissions: [],
enabled: true,
status: 'running',
settings: {},
...overrides,
};
}
beforeEach(() => {
clearAllHooks();
localStorage.clear();
setSlotRegistrationBridge(null);
});
describe('createPluginAPI', () => {
it('exposes plugin info', () => {
const plugin = makePlugin();
const api = createPluginAPI(plugin);
expect(api.plugin.id).toBe('test-plugin');
expect(api.plugin.version).toBe('1.0.0');
});
it('returns a frozen copy of settings', () => {
const plugin = makePlugin({ settings: { key: 'val' } });
const api = createPluginAPI(plugin);
expect(api.plugin.settings).toEqual({ key: 'val' });
});
});
describe('plugin storage (scoped localStorage)', () => {
it('set and get a value', () => {
const api = createPluginAPI(makePlugin());
api.storage.set('foo', 42);
expect(api.storage.get('foo')).toBe(42);
});
it('scopes to plugin id', () => {
const api1 = createPluginAPI(makePlugin({ id: 'p1' }));
const api2 = createPluginAPI(makePlugin({ id: 'p2' }));
api1.storage.set('key', 'a');
api2.storage.set('key', 'b');
expect(api1.storage.get('key')).toBe('a');
expect(api2.storage.get('key')).toBe('b');
});
it('remove deletes a value', () => {
const api = createPluginAPI(makePlugin());
api.storage.set('x', 10);
api.storage.remove('x');
expect(api.storage.get('x')).toBeNull();
});
it('keys lists only plugin-scoped keys', () => {
const api = createPluginAPI(makePlugin({ id: 'kp' }));
api.storage.set('a', 1);
api.storage.set('b', 2);
localStorage.setItem('unrelated', 'val');
expect(api.storage.keys()).toContain('a');
expect(api.storage.keys()).toContain('b');
expect(api.storage.keys()).not.toContain('unrelated');
});
});
describe('plugin logger', () => {
it('prefixes log messages with plugin id', () => {
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
const api = createPluginAPI(makePlugin({ id: 'log-test' }));
api.log.info('hello');
expect(infoSpy).toHaveBeenCalledWith('[plugin:log-test]', 'hello');
infoSpy.mockRestore();
});
});
describe('hooks permission gating', () => {
it('returns no-op disposable without permission', () => {
const plugin = makePlugin({ permissions: [] }); // no email:read
const api = createPluginAPI(plugin);
const d = api.hooks.onEmailOpen(vi.fn());
expect(d).toBeDefined();
expect(d.dispose).toBeInstanceOf(Function);
});
it('registers handler when permission is granted', () => {
const plugin = makePlugin({ permissions: ['email:read'] });
const api = createPluginAPI(plugin);
const fn = vi.fn();
const d = api.hooks.onEmailOpen(fn);
expect(d).toBeDefined();
d.dispose(); // should not throw
});
});
describe('ui permission requirement', () => {
it('throws without ui:toolbar permission', () => {
const plugin = makePlugin({ permissions: [] });
const api = createPluginAPI(plugin);
expect(() => api.ui.registerToolbarAction({
id: 'test',
label: 'Test',
onClick: () => {},
})).toThrow('lacks permission');
});
it('does not throw with correct permission (slot bridge not set, returns no-op)', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const plugin = makePlugin({ permissions: ['ui:toolbar'] });
const api = createPluginAPI(plugin);
const d = api.ui.registerToolbarAction({ id: 'test', label: 'Test', onClick: () => {} });
expect(d.dispose).toBeInstanceOf(Function);
consoleSpy.mockRestore();
});
});
describe('slot registration bridge', () => {
it('calls bridge when set', () => {
const bridge = vi.fn((_name, _reg) => ({ dispose: () => {} }));
setSlotRegistrationBridge(bridge);
const plugin = makePlugin({ permissions: ['ui:email-footer'] });
const api = createPluginAPI(plugin);
const DummyComponent = () => null;
api.ui.registerEmailFooter(DummyComponent);
expect(bridge).toHaveBeenCalled();
});
});
describe('toast bridge', () => {
it('exposes success/error/info/warning methods', () => {
const plugin = makePlugin();
const api = createPluginAPI(plugin);
expect(api.toast.success).toBeInstanceOf(Function);
expect(api.toast.error).toBeInstanceOf(Function);
expect(api.toast.info).toBeInstanceOf(Function);
expect(api.toast.warning).toBeInstanceOf(Function);
});
});
+249
View File
@@ -0,0 +1,249 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HookBus, pluginErrorTracker, removeAllPluginHooks, clearAllHooks, emailHooks, calendarHooks } from '../plugin-hooks';
beforeEach(() => {
pluginErrorTracker.resetAll();
clearAllHooks();
});
describe('HookBus', () => {
describe('register / size / dispose', () => {
it('registers a handler', () => {
const bus = new HookBus();
bus.register('p1', vi.fn());
expect(bus.size).toBe(1);
});
it('dispose removes the handler', () => {
const bus = new HookBus();
const d = bus.register('p1', vi.fn());
d.dispose();
expect(bus.size).toBe(0);
});
it('registering multiple handlers', () => {
const bus = new HookBus();
bus.register('p1', vi.fn());
bus.register('p2', vi.fn());
expect(bus.size).toBe(2);
});
it('removePlugin removes all handlers for that plugin', () => {
const bus = new HookBus();
bus.register('p1', vi.fn());
bus.register('p1', vi.fn());
bus.register('p2', vi.fn());
bus.removePlugin('p1');
expect(bus.size).toBe(1);
});
it('clear removes all handlers', () => {
const bus = new HookBus();
bus.register('p1', vi.fn());
bus.register('p2', vi.fn());
bus.clear();
expect(bus.size).toBe(0);
});
});
describe('emit (observer)', () => {
it('calls all handlers with args', async () => {
const bus = new HookBus<(x: number) => void>();
const fn1 = vi.fn();
const fn2 = vi.fn();
bus.register('p1', fn1);
bus.register('p2', fn2);
await bus.emit(42);
expect(fn1).toHaveBeenCalledWith(42);
expect(fn2).toHaveBeenCalledWith(42);
});
it('calls handlers in order', async () => {
const bus = new HookBus<() => void>();
const order: number[] = [];
bus.register('p1', () => order.push(200), 200);
bus.register('p2', () => order.push(50), 50);
bus.register('p3', () => order.push(100), 100);
await bus.emit();
expect(order).toEqual([50, 100, 200]);
});
it('catches handler errors and records them', async () => {
const bus = new HookBus<() => void>();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
bus.register('p1', () => { throw new Error('fail'); });
await bus.emit();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('skips disabled plugins', async () => {
const bus = new HookBus<() => void>();
const fn = vi.fn();
bus.register('p1', fn);
// Manually trigger circuit breaker
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
for (let i = 0; i < 3; i++) {
pluginErrorTracker.record('p1', new Error('test'));
}
consoleSpy.mockRestore();
expect(pluginErrorTracker.isDisabled('p1')).toBe(true);
await bus.emit();
expect(fn).not.toHaveBeenCalled();
});
});
describe('emitSync', () => {
it('calls handlers synchronously', () => {
const bus = new HookBus<(x: string) => void>();
const fn = vi.fn();
bus.register('p1', fn);
bus.emitSync('hello');
expect(fn).toHaveBeenCalledWith('hello');
});
it('catches errors without throwing', () => {
const bus = new HookBus<() => void>();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
bus.register('p1', () => { throw new Error('boom'); });
expect(() => bus.emitSync()).not.toThrow();
consoleSpy.mockRestore();
});
});
describe('intercept', () => {
it('returns true when all handlers pass', async () => {
const bus = new HookBus<() => boolean>();
bus.register('p1', () => true);
bus.register('p2', () => true);
const result = await bus.intercept();
expect(result).toBe(true);
});
it('returns false when any handler returns false', async () => {
const bus = new HookBus<() => boolean>();
bus.register('p1', () => true);
bus.register('p2', () => false);
const result = await bus.intercept();
expect(result).toBe(false);
});
it('stops early on false (short-circuits)', async () => {
const bus = new HookBus<() => boolean>();
const fn3 = vi.fn(() => true);
bus.register('p1', () => true, 10);
bus.register('p2', () => false, 20);
bus.register('p3', fn3, 30);
await bus.intercept();
expect(fn3).not.toHaveBeenCalled();
});
it('returns true when no handlers registered', async () => {
const bus = new HookBus<() => boolean>();
expect(await bus.intercept()).toBe(true);
});
});
describe('transform', () => {
it('chains values through handlers', async () => {
const bus = new HookBus<(val: number) => number>();
bus.register('p1', (val: number) => val * 2);
bus.register('p2', (val: number) => val + 1);
const result = await bus.transform(5);
expect(result).toBe(11); // (5 * 2) + 1
});
it('returns initial value when no handlers', async () => {
const bus = new HookBus<(val: string) => string>();
const result = await bus.transform('hello');
expect(result).toBe('hello');
});
it('skips handler that returns undefined', async () => {
const bus = new HookBus<(val: number) => number | undefined>();
bus.register('p1', () => undefined);
bus.register('p2', (val: number) => val + 10);
const result = await bus.transform(5);
expect(result).toBe(15);
});
});
});
describe('PluginErrorTracker', () => {
it('is not disabled initially', () => {
expect(pluginErrorTracker.isDisabled('some-plugin')).toBe(false);
});
it('disables after threshold errors', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
pluginErrorTracker.record('p1', new Error('1'));
pluginErrorTracker.record('p1', new Error('2'));
expect(pluginErrorTracker.isDisabled('p1')).toBe(false);
pluginErrorTracker.record('p1', new Error('3'));
expect(pluginErrorTracker.isDisabled('p1')).toBe(true);
consoleSpy.mockRestore();
});
it('calls auto-disable callback', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const cb = vi.fn();
pluginErrorTracker.setAutoDisableCallback(cb);
for (let i = 0; i < 3; i++) {
pluginErrorTracker.record('p2', new Error(`err-${i}`));
}
expect(cb).toHaveBeenCalledWith('p2', expect.any(Error));
consoleSpy.mockRestore();
pluginErrorTracker.setAutoDisableCallback(() => {});
});
it('reset re-enables a plugin', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
for (let i = 0; i < 3; i++) {
pluginErrorTracker.record('p3', new Error(`err-${i}`));
}
expect(pluginErrorTracker.isDisabled('p3')).toBe(true);
pluginErrorTracker.reset('p3');
expect(pluginErrorTracker.isDisabled('p3')).toBe(false);
consoleSpy.mockRestore();
});
});
describe('Hook domain instances', () => {
it('emailHooks has expected buses', () => {
expect(emailHooks.onEmailOpen).toBeInstanceOf(HookBus);
expect(emailHooks.onBeforeEmailSend).toBeInstanceOf(HookBus);
expect(emailHooks.onAfterEmailDelete).toBeInstanceOf(HookBus);
expect(emailHooks.onNewEmailReceived).toBeInstanceOf(HookBus);
});
it('calendarHooks has expected buses', () => {
expect(calendarHooks.onCalendarEventOpen).toBeInstanceOf(HookBus);
expect(calendarHooks.onBeforeEventCreate).toBeInstanceOf(HookBus);
expect(calendarHooks.onEventRsvp).toBeInstanceOf(HookBus);
});
});
describe('removeAllPluginHooks', () => {
it('removes handlers from all buses for a plugin', () => {
emailHooks.onEmailOpen.register('test-p', vi.fn());
calendarHooks.onCalendarEventOpen.register('test-p', vi.fn());
emailHooks.onEmailOpen.register('other-p', vi.fn());
removeAllPluginHooks('test-p');
expect(emailHooks.onEmailOpen.size).toBe(1); // other-p remains
expect(calendarHooks.onCalendarEventOpen.size).toBe(0);
});
});
describe('clearAllHooks', () => {
it('removes all handlers from all buses', () => {
emailHooks.onEmailOpen.register('p1', vi.fn());
calendarHooks.onCalendarEventOpen.register('p2', vi.fn());
clearAllHooks();
expect(emailHooks.onEmailOpen.size).toBe(0);
expect(calendarHooks.onCalendarEventOpen.size).toBe(0);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
exposePluginExternals,
deactivatePlugin,
isPluginActive,
deactivateAllPlugins,
} from '../plugin-loader';
import { clearAllHooks, pluginErrorTracker } from '../plugin-hooks';
beforeEach(() => {
clearAllHooks();
pluginErrorTracker.resetAll();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (globalThis as any).__PLUGIN_EXTERNALS__;
});
describe('exposePluginExternals', () => {
it('sets window.__PLUGIN_EXTERNALS__ with React, ReactDOM, ReactJSX', () => {
exposePluginExternals();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const externals = (globalThis as any).__PLUGIN_EXTERNALS__;
expect(externals).toBeDefined();
expect(externals.React).toBeDefined();
expect(externals.ReactDOM).toBeDefined();
expect(externals.ReactJSX).toBeDefined();
});
});
describe('isPluginActive', () => {
it('returns false for unknown plugin', () => {
expect(isPluginActive('nonexistent')).toBe(false);
});
});
describe('deactivatePlugin', () => {
it('does nothing for unknown plugin (no error)', () => {
expect(() => deactivatePlugin('nonexistent')).not.toThrow();
});
});
describe('deactivateAllPlugins', () => {
it('does not throw when no plugins active', () => {
expect(() => deactivateAllPlugins()).not.toThrow();
});
});
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { render } from '@testing-library/react';
import type { SlotRegistration } from '@/lib/plugin-types';
// Mock the plugin store
const mockSlots: Record<string, SlotRegistration[]> = {};
vi.mock('@/stores/plugin-store', () => ({
usePluginStore: (selector: (s: { slots: typeof mockSlots }) => unknown) =>
selector({ slots: mockSlots }),
}));
// Import after mocks
import { PluginSlot } from '@/components/plugins/plugin-slot';
import { PluginErrorBoundary } from '@/components/plugins/plugin-error-boundary';
beforeEach(() => {
Object.keys(mockSlots).forEach(k => delete mockSlots[k]);
});
describe('PluginSlot', () => {
it('renders null when no registrations', () => {
mockSlots['toolbar-actions'] = [];
const { container } = render(
React.createElement(PluginSlot, { name: 'toolbar-actions' })
);
expect(container.innerHTML).toBe('');
});
it('renders null when slot has undefined registrations', () => {
// slot entry doesn't exist at all
const { container } = render(
React.createElement(PluginSlot, { name: 'toolbar-actions' })
);
expect(container.innerHTML).toBe('');
});
it('renders registered components', () => {
const TestComponent = () => React.createElement('span', null, 'Hello Plugin');
mockSlots['email-footer'] = [
{ pluginId: 'test', component: TestComponent, order: 100 },
];
const { getByText } = render(
React.createElement(PluginSlot, { name: 'email-footer' })
);
expect(getByText('Hello Plugin')).toBeTruthy();
});
it('sets data-plugin-slot attribute', () => {
const TestComponent = () => React.createElement('span', null, 'x');
mockSlots['sidebar-widget'] = [
{ pluginId: 'sw', component: TestComponent, order: 100 },
];
const { container } = render(
React.createElement(PluginSlot, { name: 'sidebar-widget' })
);
expect(container.querySelector('[data-plugin-slot="sidebar-widget"]')).toBeTruthy();
});
});
describe('PluginErrorBoundary', () => {
it('renders children when no error', () => {
const { getByText } = render(
React.createElement(
PluginErrorBoundary,
{ pluginId: 'test' },
React.createElement('span', null, 'Child')
)
);
expect(getByText('Child')).toBeTruthy();
});
it('renders fallback on error', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const ThrowingComponent = () => { throw new Error('boom'); };
const { getByText } = render(
React.createElement(
PluginErrorBoundary,
{ pluginId: 'err', fallback: React.createElement('span', null, 'Error caught') },
React.createElement(ThrowingComponent)
)
);
expect(getByText('Error caught')).toBeTruthy();
consoleSpy.mockRestore();
});
it('renders null on error when no fallback provided', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const ThrowingComponent = () => { throw new Error('boom'); };
const { container } = render(
React.createElement(
PluginErrorBoundary,
{ pluginId: 'err2' },
React.createElement(ThrowingComponent)
)
);
// ErrorBoundary renders null fallback
expect(container.innerHTML).toBe('');
consoleSpy.mockRestore();
});
});
+81
View File
@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import 'fake-indexeddb/auto';
import { pluginStorage } from '../plugin-storage';
// Use unique keys per test to avoid shared state (avoiding deleteDatabase which
// blocks on open connections that the module never closes).
describe('pluginStorage', () => {
describe('plugin code', () => {
it('saves and retrieves code', async () => {
await pluginStorage.saveCode('code-save-1', 'console.log("hello")');
const code = await pluginStorage.getCode('code-save-1');
expect(code).toBe('console.log("hello")');
});
it('returns null for missing plugin', async () => {
const code = await pluginStorage.getCode('code-missing-xyz');
expect(code).toBeNull();
});
it('overwrites existing code', async () => {
await pluginStorage.saveCode('code-overwrite-1', 'v1');
await pluginStorage.saveCode('code-overwrite-1', 'v2');
const code = await pluginStorage.getCode('code-overwrite-1');
expect(code).toBe('v2');
});
it('deletes code', async () => {
await pluginStorage.saveCode('code-del-1', 'code');
await pluginStorage.deleteCode('code-del-1');
const code = await pluginStorage.getCode('code-del-1');
expect(code).toBeNull();
});
it('stores multiple plugins independently', async () => {
await pluginStorage.saveCode('code-multi-a', 'code-a');
await pluginStorage.saveCode('code-multi-b', 'code-b');
expect(await pluginStorage.getCode('code-multi-a')).toBe('code-a');
expect(await pluginStorage.getCode('code-multi-b')).toBe('code-b');
});
});
describe('theme CSS', () => {
it('saves and retrieves CSS', async () => {
const css = ':root { --color-primary: blue; }';
await pluginStorage.saveThemeCSS('css-save-1', css);
const result = await pluginStorage.getThemeCSS('css-save-1');
expect(result).toBe(css);
});
it('returns null for missing theme', async () => {
const result = await pluginStorage.getThemeCSS('css-missing-xyz');
expect(result).toBeNull();
});
it('deletes CSS', async () => {
await pluginStorage.saveThemeCSS('css-del-1', 'css');
await pluginStorage.deleteThemeCSS('css-del-1');
expect(await pluginStorage.getThemeCSS('css-del-1')).toBeNull();
});
});
describe('previews', () => {
it('saves and retrieves preview data URI', async () => {
const dataUri = 'data:image/png;base64,iVBORw0KGgo=';
await pluginStorage.savePreview('prev-save-1', dataUri);
const result = await pluginStorage.getPreview('prev-save-1');
expect(result).toBe(dataUri);
});
it('returns null for missing preview', async () => {
expect(await pluginStorage.getPreview('prev-missing-xyz')).toBeNull();
});
it('deletes preview', async () => {
await pluginStorage.savePreview('prev-del-1', 'data:...');
await pluginStorage.deletePreview('prev-del-1');
expect(await pluginStorage.getPreview('prev-del-1')).toBeNull();
});
});
});
+156
View File
@@ -0,0 +1,156 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { SlotRegistration, InstalledPlugin } from '@/lib/plugin-types';
// We test the raw store by directly invoking Zustand
// Mock the external dependencies the store imports
vi.mock('@/lib/plugin-storage', () => ({
pluginStorage: {
saveCode: vi.fn().mockResolvedValue(undefined),
getCode: vi.fn().mockResolvedValue(null),
deleteCode: vi.fn().mockResolvedValue(undefined),
saveThemeCSS: vi.fn().mockResolvedValue(undefined),
getThemeCSS: vi.fn().mockResolvedValue(null),
deleteThemeCSS: vi.fn().mockResolvedValue(undefined),
savePreview: vi.fn().mockResolvedValue(undefined),
getPreview: vi.fn().mockResolvedValue(null),
deletePreview: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock('@/lib/plugin-validator', () => ({
extractPlugin: vi.fn(),
}));
vi.mock('@/lib/plugin-loader', () => ({
loadPlugin: vi.fn().mockResolvedValue(undefined),
deactivatePlugin: vi.fn(),
setPluginStoreAccessor: vi.fn(),
setupAutoDisable: vi.fn(),
}));
vi.mock('@/lib/plugin-api', () => ({
setSlotRegistrationBridge: vi.fn(),
}));
vi.mock('@/lib/plugin-hooks', () => ({
removeAllPluginHooks: vi.fn(),
}));
// Import after mocks
import { usePluginStore } from '@/stores/plugin-store';
function resetStore() {
usePluginStore.setState({
plugins: [],
slots: {
'toolbar-actions': [],
'email-banner': [],
'email-footer': [],
'composer-toolbar': [],
'sidebar-widget': [],
'settings-section': [],
'context-menu-email': [],
'navigation-rail-bottom': [],
},
initialized: false,
});
}
function mockPlugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
return {
id: 'test-plugin',
name: 'Test',
version: '1.0.0',
author: 'Test',
description: '',
type: 'hook',
entrypoint: 'index.js',
permissions: [],
enabled: false,
status: 'installed',
settings: {},
...overrides,
};
}
beforeEach(() => {
resetStore();
vi.clearAllMocks();
});
describe('usePluginStore', () => {
describe('registerSlot / dispose', () => {
it('adds registration to slot and removes on dispose', () => {
const { registerSlot } = usePluginStore.getState();
const reg: SlotRegistration = {
pluginId: 'p1',
component: () => null,
order: 100,
};
const disposable = registerSlot('toolbar-actions', reg);
expect(usePluginStore.getState().slots['toolbar-actions']).toHaveLength(1);
disposable.dispose();
expect(usePluginStore.getState().slots['toolbar-actions']).toHaveLength(0);
});
it('sorts registrations by order', () => {
const { registerSlot } = usePluginStore.getState();
registerSlot('email-banner', { pluginId: 'p1', component: () => null, order: 200 });
registerSlot('email-banner', { pluginId: 'p2', component: () => null, order: 50 });
registerSlot('email-banner', { pluginId: 'p3', component: () => null, order: 100 });
const regs = usePluginStore.getState().slots['email-banner'];
expect(regs.map(r => r.pluginId)).toEqual(['p2', 'p3', 'p1']);
});
});
describe('setPluginStatus', () => {
it('updates status for existing plugin', () => {
usePluginStore.setState({ plugins: [mockPlugin()] });
usePluginStore.getState().setPluginStatus('test-plugin', 'running');
expect(usePluginStore.getState().plugins[0].status).toBe('running');
});
it('sets error message', () => {
usePluginStore.setState({ plugins: [mockPlugin()] });
usePluginStore.getState().setPluginStatus('test-plugin', 'error', 'something broke');
const p = usePluginStore.getState().plugins[0];
expect(p.status).toBe('error');
expect(p.error).toBe('something broke');
});
});
describe('updatePluginSettings', () => {
it('merges settings', () => {
usePluginStore.setState({ plugins: [mockPlugin({ settings: { a: 1 } })] });
usePluginStore.getState().updatePluginSettings('test-plugin', { b: 2 });
expect(usePluginStore.getState().plugins[0].settings).toEqual({ a: 1, b: 2 });
});
});
describe('disablePlugin', () => {
it('sets enabled false and status disabled', () => {
usePluginStore.setState({
plugins: [mockPlugin({ enabled: true, status: 'running' })],
});
usePluginStore.getState().disablePlugin('test-plugin');
const p = usePluginStore.getState().plugins[0];
expect(p.enabled).toBe(false);
expect(p.status).toBe('disabled');
});
});
describe('uninstallPlugin', () => {
it('removes plugin from list', () => {
usePluginStore.setState({ plugins: [mockPlugin()] });
usePluginStore.getState().uninstallPlugin('test-plugin');
expect(usePluginStore.getState().plugins).toHaveLength(0);
});
it('no-op for unknown plugin', () => {
usePluginStore.setState({ plugins: [mockPlugin()] });
usePluginStore.getState().uninstallPlugin('unknown');
expect(usePluginStore.getState().plugins).toHaveLength(1);
});
});
});
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import {
ALL_PERMISSIONS,
IMPLICIT_PERMISSIONS,
MAX_PLUGIN_SIZE,
MAX_THEME_SIZE,
ALLOWED_PLUGIN_FILES,
DISALLOWED_CSS_PATTERNS,
} from '../plugin-types';
describe('plugin-types constants', () => {
describe('ALL_PERMISSIONS', () => {
it('contains at least 30 permissions', () => {
expect(ALL_PERMISSIONS.length).toBeGreaterThanOrEqual(30);
});
it('has no duplicates', () => {
const unique = new Set(ALL_PERMISSIONS);
expect(unique.size).toBe(ALL_PERMISSIONS.length);
});
it('all permissions follow domain:action format', () => {
for (const perm of ALL_PERMISSIONS) {
expect(perm).toMatch(/^[a-z]+:[a-z-]+$/);
}
});
it('includes core email/calendar/contacts permissions', () => {
expect(ALL_PERMISSIONS).toContain('email:read');
expect(ALL_PERMISSIONS).toContain('email:write');
expect(ALL_PERMISSIONS).toContain('email:send');
expect(ALL_PERMISSIONS).toContain('calendar:read');
expect(ALL_PERMISSIONS).toContain('calendar:write');
expect(ALL_PERMISSIONS).toContain('contacts:read');
expect(ALL_PERMISSIONS).toContain('contacts:write');
});
it('includes UI permissions for all slot types', () => {
expect(ALL_PERMISSIONS).toContain('ui:toolbar');
expect(ALL_PERMISSIONS).toContain('ui:email-banner');
expect(ALL_PERMISSIONS).toContain('ui:email-footer');
expect(ALL_PERMISSIONS).toContain('ui:composer-toolbar');
expect(ALL_PERMISSIONS).toContain('ui:sidebar-widget');
expect(ALL_PERMISSIONS).toContain('ui:settings-section');
expect(ALL_PERMISSIONS).toContain('ui:context-menu');
expect(ALL_PERMISSIONS).toContain('ui:navigation-rail');
});
});
describe('IMPLICIT_PERMISSIONS', () => {
it('contains ui:observe and app:lifecycle', () => {
expect(IMPLICIT_PERMISSIONS).toContain('ui:observe');
expect(IMPLICIT_PERMISSIONS).toContain('app:lifecycle');
});
it('has exactly 2 implicit permissions', () => {
expect(IMPLICIT_PERMISSIONS).toHaveLength(2);
});
it('implicit permissions are in ALL_PERMISSIONS', () => {
for (const perm of IMPLICIT_PERMISSIONS) {
expect(ALL_PERMISSIONS).toContain(perm);
}
});
});
describe('size limits', () => {
it('MAX_PLUGIN_SIZE is 5 MB', () => {
expect(MAX_PLUGIN_SIZE).toBe(5 * 1024 * 1024);
});
it('MAX_THEME_SIZE is 1 MB', () => {
expect(MAX_THEME_SIZE).toBe(1 * 1024 * 1024);
});
});
describe('ALLOWED_PLUGIN_FILES', () => {
it('allows JavaScript files', () => {
expect(ALLOWED_PLUGIN_FILES.has('.js')).toBe(true);
expect(ALLOWED_PLUGIN_FILES.has('.mjs')).toBe(true);
});
it('allows assets', () => {
expect(ALLOWED_PLUGIN_FILES.has('.css')).toBe(true);
expect(ALLOWED_PLUGIN_FILES.has('.json')).toBe(true);
expect(ALLOWED_PLUGIN_FILES.has('.png')).toBe(true);
expect(ALLOWED_PLUGIN_FILES.has('.svg')).toBe(true);
});
it('does not allow executable types', () => {
expect(ALLOWED_PLUGIN_FILES.has('.exe')).toBe(false);
expect(ALLOWED_PLUGIN_FILES.has('.sh')).toBe(false);
expect(ALLOWED_PLUGIN_FILES.has('.bat')).toBe(false);
expect(ALLOWED_PLUGIN_FILES.has('.html')).toBe(false);
});
});
describe('DISALLOWED_CSS_PATTERNS', () => {
it('blocks @import', () => {
const match = DISALLOWED_CSS_PATTERNS.some(p => p.test('@import url("evil.css")'));
expect(match).toBe(true);
});
it('blocks external URLs', () => {
const match = DISALLOWED_CSS_PATTERNS.some(p => p.test('background: url("https://evil.com/track.png")'));
expect(match).toBe(true);
});
it('blocks javascript: in CSS', () => {
const match = DISALLOWED_CSS_PATTERNS.some(p => p.test('background: javascript:alert(1)'));
expect(match).toBe(true);
});
it('blocks expression()', () => {
const match = DISALLOWED_CSS_PATTERNS.some(p => p.test('width: expression(document.body.clientWidth)'));
expect(match).toBe(true);
});
it('blocks -moz-binding', () => {
const match = DISALLOWED_CSS_PATTERNS.some(p => p.test('-moz-binding: url("evil.xml#xbl")'));
expect(match).toBe(true);
});
it('blocks behavior:', () => {
const match = DISALLOWED_CSS_PATTERNS.some(p => p.test('behavior: url(evil.htc)'));
expect(match).toBe(true);
});
it('allows safe CSS', () => {
const safeCSS = ':root { --color-primary: #3b82f6; }';
const match = DISALLOWED_CSS_PATTERNS.some(p => p.test(safeCSS));
expect(match).toBe(false);
});
});
});
+322
View File
@@ -0,0 +1,322 @@
import { describe, it, expect } from 'vitest';
import JSZip from 'jszip';
import { extractTheme, extractPlugin } from '../plugin-validator';
function createZipFile(zip: JSZip, name = 'test.zip'): Promise<File> {
return zip.generateAsync({ type: 'blob' }).then(blob => new File([blob], name));
}
describe('extractTheme', () => {
it('extracts a valid theme ZIP', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'my-theme',
name: 'My Theme',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light', 'dark'],
}));
zip.file('theme.css', ':root { --color-primary: #ff0000; }\n.dark { --color-primary: #00ff00; }');
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(true);
expect(result.manifest).not.toBeNull();
expect(result.manifest!.id).toBe('my-theme');
expect(result.css).toContain('--color-primary');
});
it('rejects oversized theme', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'big-theme',
name: 'Big',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light'],
}));
// Make a large file > 1MB
zip.file('theme.css', 'x'.repeat(1024 * 1024 + 1));
// Manually create oversized File
const oversizedFile = new File([new ArrayBuffer(1024 * 1024 + 1)], 'big.zip');
const result = await extractTheme(oversizedFile);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Theme ZIP exceeds 1 MB size limit');
});
it('rejects non-ZIP file', async () => {
const file = new File(['not a zip'], 'bad.zip');
const result = await extractTheme(file);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Invalid ZIP file');
});
it('rejects missing manifest.json', async () => {
const zip = new JSZip();
zip.file('theme.css', ':root { --color-primary: blue; }');
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Missing manifest.json');
});
it('rejects invalid JSON manifest', async () => {
const zip = new JSZip();
zip.file('manifest.json', 'not json {{{');
zip.file('theme.css', ':root { --color-primary: blue; }');
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Invalid manifest.json (not valid JSON)');
});
it('rejects missing theme.css', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'no-css',
name: 'No CSS',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light'],
}));
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Missing theme.css');
});
it('rejects wrong type in manifest', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'wrong-type',
name: 'Wrong',
version: '1.0.0',
author: 'Test',
type: 'plugin', // wrong
variants: ['light'],
}));
zip.file('theme.css', ':root { --color-primary: blue; }');
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('Expected type "theme"'))).toBe(true);
});
it('rejects missing variants', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'no-variants',
name: 'No Variants',
version: '1.0.0',
author: 'Test',
type: 'theme',
}));
zip.file('theme.css', ':root { --color-primary: blue; }');
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('variants'))).toBe(true);
});
it('handles ZIP with folder root', async () => {
const zip = new JSZip();
const folder = zip.folder('my-theme')!;
folder.file('manifest.json', JSON.stringify({
id: 'nested-theme',
name: 'Nested',
version: '1.0.0',
author: 'Test',
type: 'theme',
variants: ['light', 'dark'],
}));
folder.file('theme.css', ':root { --color-primary: #aaa; }\n.dark { --color-primary: #bbb; }');
const file = await createZipFile(zip);
const result = await extractTheme(file);
expect(result.valid).toBe(true);
expect(result.manifest!.id).toBe('nested-theme');
});
});
describe('extractPlugin', () => {
it('extracts a valid plugin ZIP', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'my-plugin',
name: 'My Plugin',
version: '1.0.0',
author: 'Test',
type: 'ui-extension',
entrypoint: 'index.js',
permissions: ['email:read'],
}));
zip.file('index.js', 'export function activate(api) { console.log("hi"); }');
const file = await createZipFile(zip);
const result = await extractPlugin(file);
expect(result.valid).toBe(true);
expect(result.manifest!.id).toBe('my-plugin');
expect(result.code).toContain('activate');
});
it('rejects oversized plugin', async () => {
const oversizedFile = new File([new ArrayBuffer(5 * 1024 * 1024 + 1)], 'big.zip');
const result = await extractPlugin(oversizedFile);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Plugin ZIP exceeds 5 MB size limit');
});
it('rejects non-ZIP file', async () => {
const file = new File(['not a zip'], 'bad.zip');
const result = await extractPlugin(file);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Invalid ZIP file');
});
it('rejects missing manifest', async () => {
const zip = new JSZip();
zip.file('index.js', 'export function activate() {}');
const file = await createZipFile(zip);
const result = await extractPlugin(file);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Missing manifest.json');
});
it('rejects disallowed file types', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'bad-files',
name: 'Bad',
version: '1.0.0',
author: 'Test',
type: 'hook',
entrypoint: 'index.js',
permissions: [],
}));
zip.file('index.js', 'export function activate() {}');
zip.file('hack.exe', 'binary');
const file = await createZipFile(zip);
const result = await extractPlugin(file);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('.exe'))).toBe(true);
});
it('rejects unknown permissions', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'bad-perms',
name: 'Bad perms',
version: '1.0.0',
author: 'Test',
type: 'hook',
entrypoint: 'index.js',
permissions: ['email:read', 'nuclear:launch'],
}));
zip.file('index.js', 'export function activate() {}');
const file = await createZipFile(zip);
const result = await extractPlugin(file);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('nuclear:launch'))).toBe(true);
});
it('warns about eval() in code', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'eval-plugin',
name: 'Eval',
version: '1.0.0',
author: 'Test',
type: 'hook',
entrypoint: 'index.js',
permissions: [],
}));
zip.file('index.js', 'export function activate() { eval("alert(1)"); }');
const file = await createZipFile(zip);
const result = await extractPlugin(file);
expect(result.valid).toBe(true);
expect(result.warnings.some(w => w.includes('eval()'))).toBe(true);
});
it('warns about document.cookie in code', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'cookie-plugin',
name: 'Cookie',
version: '1.0.0',
author: 'Test',
type: 'hook',
entrypoint: 'index.js',
permissions: [],
}));
zip.file('index.js', 'export function activate() { const c = document.cookie; }');
const file = await createZipFile(zip);
const result = await extractPlugin(file);
expect(result.valid).toBe(true);
expect(result.warnings.some(w => w.includes('document.cookie'))).toBe(true);
});
it('rejects invalid plugin type', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'bad-type',
name: 'Bad Type',
version: '1.0.0',
author: 'Test',
type: 'theme', // wrong type for plugin
entrypoint: 'index.js',
permissions: [],
}));
zip.file('index.js', 'export function activate() {}');
const file = await createZipFile(zip);
const result = await extractPlugin(file);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('Invalid type'))).toBe(true);
});
it('rejects missing entrypoint', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'no-entry',
name: 'No Entry',
version: '1.0.0',
author: 'Test',
type: 'hook',
entrypoint: 'main.js',
permissions: [],
}));
zip.file('index.js', 'export function activate() {}');
// entrypoint 'main.js' doesn't exist
const file = await createZipFile(zip);
const result = await extractPlugin(file);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('Missing entrypoint'))).toBe(true);
});
it('rejects invalid manifest ID format', async () => {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({
id: 'Bad_ID!',
name: 'Bad ID',
version: '1.0.0',
author: 'Test',
type: 'hook',
entrypoint: 'index.js',
permissions: [],
}));
zip.file('index.js', 'export function activate() {}');
const file = await createZipFile(zip);
const result = await extractPlugin(file);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes('ID must be lowercase'))).toBe(true);
});
});
+175
View File
@@ -0,0 +1,175 @@
import { describe, it, expect, afterEach } from 'vitest';
import {
sanitizeThemeCSS,
validateThemeSelectors,
injectThemeCSS,
removeThemeCSS,
validateThemeCSSSafety,
} from '../theme-loader';
describe('theme-loader', () => {
describe('sanitizeThemeCSS', () => {
it('passes through safe CSS unchanged', () => {
const css = ':root { --color-primary: #3b82f6; }';
const { css: cleaned, warnings } = sanitizeThemeCSS(css);
expect(cleaned).toBe(css);
expect(warnings).toHaveLength(0);
});
it('strips @import directives', () => {
const css = '@import url("evil.css");\n:root { --color-primary: red; }';
const { css: cleaned, warnings } = sanitizeThemeCSS(css);
expect(cleaned).not.toContain('@import');
expect(warnings.length).toBeGreaterThan(0);
});
it('strips external url() references', () => {
const css = ':root { background: url("https://evil.com/track.png"); }';
const { css: cleaned, warnings } = sanitizeThemeCSS(css);
expect(cleaned).not.toContain('https://evil.com');
expect(warnings.length).toBeGreaterThan(0);
});
it('strips javascript: in CSS', () => {
const css = ':root { background: javascript:alert(1); }';
const { css: cleaned, warnings } = sanitizeThemeCSS(css);
expect(cleaned).not.toContain('javascript:');
expect(warnings.length).toBeGreaterThan(0);
});
it('strips expression()', () => {
const css = ':root { width: expression(document.body.clientWidth); }';
const { css: cleaned, warnings } = sanitizeThemeCSS(css);
expect(cleaned).not.toContain('expression(');
expect(warnings.length).toBeGreaterThan(0);
});
it('strips -moz-binding', () => {
const css = ':root { -moz-binding: url("evil.xml#xbl"); }';
const { css: cleaned, warnings } = sanitizeThemeCSS(css);
expect(cleaned).not.toContain('-moz-binding');
expect(warnings.length).toBeGreaterThan(0);
});
it('strips behavior:', () => {
const css = ':root { behavior: url(evil.htc); }';
const { css: cleaned, warnings } = sanitizeThemeCSS(css);
expect(cleaned).not.toContain('behavior');
expect(warnings.length).toBeGreaterThan(0);
});
it('strips multiple dangerous patterns at once', () => {
const css = '@import url("a.css"); :root { -moz-binding: url("b.xml"); background: expression(1); }';
const { css: cleaned, warnings } = sanitizeThemeCSS(css);
expect(cleaned).not.toContain('@import');
expect(cleaned).not.toContain('-moz-binding');
expect(cleaned).not.toContain('expression(');
expect(warnings.length).toBeGreaterThanOrEqual(3);
});
});
describe('validateThemeSelectors', () => {
it('accepts :root selector', () => {
const warnings = validateThemeSelectors(':root { --color-primary: red; }');
expect(warnings).toHaveLength(0);
});
it('accepts .dark selector', () => {
const warnings = validateThemeSelectors('.dark { --color-primary: blue; }');
expect(warnings).toHaveLength(0);
});
it('accepts @media queries', () => {
const css = '@media (prefers-color-scheme: dark) { :root { --color-bg: #000; } }';
const warnings = validateThemeSelectors(css);
expect(warnings).toHaveLength(0);
});
it('accepts @font-face', () => {
const css = '@font-face { font-family: "Test"; src: local("Test"); }';
const warnings = validateThemeSelectors(css);
expect(warnings).toHaveLength(0);
});
it('warns about body selector', () => {
const css = 'body { background: red; }';
const warnings = validateThemeSelectors(css);
expect(warnings.length).toBeGreaterThan(0);
expect(warnings[0]).toContain('body');
});
it('warns about element selectors', () => {
const css = 'button { color: red; }';
const warnings = validateThemeSelectors(css);
expect(warnings.length).toBeGreaterThan(0);
});
it('warns about class selectors other than .dark', () => {
const css = '.my-class { color: red; }';
const warnings = validateThemeSelectors(css);
expect(warnings.length).toBeGreaterThan(0);
});
});
describe('injectThemeCSS / removeThemeCSS', () => {
afterEach(() => {
removeThemeCSS();
});
it('injects a style element into head', () => {
injectThemeCSS(':root { --color-primary: red; }');
const styleEl = document.getElementById('active-theme');
expect(styleEl).not.toBeNull();
expect(styleEl?.tagName).toBe('STYLE');
expect(styleEl?.textContent).toBe(':root { --color-primary: red; }');
});
it('updates existing style element on subsequent call', () => {
injectThemeCSS(':root { --color-primary: red; }');
injectThemeCSS(':root { --color-primary: blue; }');
const styleEls = document.querySelectorAll('#active-theme');
expect(styleEls).toHaveLength(1);
expect(styleEls[0].textContent).toBe(':root { --color-primary: blue; }');
});
it('removeThemeCSS removes the style element', () => {
injectThemeCSS(':root { --color-primary: red; }');
removeThemeCSS();
const styleEl = document.getElementById('active-theme');
expect(styleEl).toBeNull();
});
it('removeThemeCSS is safe when no theme is injected', () => {
expect(() => removeThemeCSS()).not.toThrow();
});
});
describe('validateThemeCSSSafety', () => {
it('accepts valid theme CSS', () => {
const css = ':root { --color-primary: #3b82f6; --color-background: #fff; }';
const { valid, errors } = validateThemeCSSSafety(css);
expect(valid).toBe(true);
expect(errors).toHaveLength(0);
});
it('rejects empty CSS', () => {
const { valid, errors } = validateThemeCSSSafety(' ');
expect(valid).toBe(false);
expect(errors).toContain('Theme CSS is empty');
});
it('rejects CSS without color variables', () => {
const css = ':root { font-size: 16px; }';
const { valid, errors } = validateThemeCSSSafety(css);
expect(valid).toBe(false);
expect(errors.some(e => e.includes('--color-'))).toBe(true);
});
it('flags dangerous patterns', () => {
const css = ':root { --color-primary: red; } @import url("evil.css");';
const { valid, errors } = validateThemeCSSSafety(css);
expect(valid).toBe(false);
expect(errors.some(e => e.includes('disallowed'))).toBe(true);
});
});
});
+91
View File
@@ -0,0 +1,91 @@
import { appendFile, stat, rename, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
import type { AuditEntry } from './types';
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB
const MAX_ROTATIONS = 3;
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
function getAuditLogPath(): string {
return path.join(getAdminDir(), 'audit.log');
}
/**
* Append an audit entry to the admin audit log.
*/
export async function auditLog(action: string, detail: Record<string, unknown>, ip: string): Promise<void> {
const dir = getAdminDir();
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
const entry: AuditEntry = {
ts: new Date().toISOString(),
action,
detail,
ip,
};
const logPath = getAuditLogPath();
try {
await appendFile(logPath, JSON.stringify(entry) + '\n', 'utf-8');
await rotateIfNeeded(logPath);
} catch (error) {
logger.error('Failed to write audit log', { error: error instanceof Error ? error.message : 'Unknown error' });
}
}
async function rotateIfNeeded(logPath: string): Promise<void> {
try {
const stats = await stat(logPath);
if (stats.size < MAX_LOG_SIZE) return;
// Rotate: audit.log.3 → deleted, audit.log.2 → .3, audit.log.1 → .2, audit.log → .1
for (let i = MAX_ROTATIONS; i >= 1; i--) {
const from = i === 1 ? logPath : `${logPath}.${i - 1}`;
const to = `${logPath}.${i}`;
if (existsSync(from)) {
try { await rename(from, to); } catch { /* target may exist on overwrite */ }
}
}
} catch {
// stat failed, probably file doesn't exist yet
}
}
/**
* Read audit log entries, newest first. Supports pagination.
*/
export async function readAuditLog(page: number = 1, limit: number = 50, actionFilter?: string): Promise<{ entries: AuditEntry[]; total: number }> {
const logPath = getAuditLogPath();
try {
const { readFile } = await import('node:fs/promises');
const content = await readFile(logPath, 'utf-8');
const lines = content.trim().split('\n').filter(Boolean);
let entries: AuditEntry[] = lines.map(line => {
try { return JSON.parse(line); } catch { return null; }
}).filter((e): e is AuditEntry => e !== null);
if (actionFilter) {
entries = entries.filter(e => e.action === actionFilter);
}
const total = entries.length;
// Return newest first
entries.reverse();
const start = (page - 1) * limit;
return { entries: entries.slice(start, start + limit), total };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { entries: [], total: 0 };
}
logger.warn('Failed to read audit log', { error: error instanceof Error ? error.message : 'Unknown error' });
return { entries: [], total: 0 };
}
}
+159
View File
@@ -0,0 +1,159 @@
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
import { CONFIG_ENV_MAP, DEFAULT_POLICY, type SettingsPolicy } from './types';
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
function parseEnvValue(value: string, type: string): unknown {
switch (type) {
case 'boolean':
return value === 'true';
case 'string':
case 'url':
case 'enum':
return value;
default:
return value;
}
}
class ConfigManager {
private adminConfig: Record<string, unknown> = {};
private policyCache: SettingsPolicy = { ...DEFAULT_POLICY };
private loaded = false;
/** Load admin config and policy from disk. Called once at startup and on reload. */
async load(): Promise<void> {
this.adminConfig = await this.readJsonFile('config.json') || {};
const policy = await this.readJsonFile('policy.json');
this.policyCache = policy ? { ...DEFAULT_POLICY, ...policy } : { ...DEFAULT_POLICY };
this.loaded = true;
logger.debug('ConfigManager loaded', { configKeys: Object.keys(this.adminConfig).length });
}
/** Ensure config is loaded (no-op if already loaded). */
async ensureLoaded(): Promise<void> {
if (!this.loaded) await this.load();
}
/**
* Get a config value. Priority: admin override > env var > default.
*/
get<T>(key: string, defaultValue?: T): T {
// Admin override (highest priority)
if (key in this.adminConfig) {
return this.adminConfig[key] as T;
}
// Environment variable
const mapping = CONFIG_ENV_MAP[key];
if (mapping) {
const envVal = process.env[mapping.envVar];
if (envVal !== undefined) {
return parseEnvValue(envVal, mapping.type) as T;
}
if (defaultValue !== undefined) return defaultValue;
return mapping.defaultValue as T;
}
return defaultValue as T;
}
/**
* Get all config values as a flat object (merged from all layers).
*/
getAll(): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, mapping] of Object.entries(CONFIG_ENV_MAP)) {
result[key] = this.get(key, mapping.defaultValue);
}
return result;
}
/**
* Get all config values with source information (for admin UI).
*/
getAllWithSources(): Record<string, { value: unknown; source: 'admin' | 'env' | 'default' }> {
const result: Record<string, { value: unknown; source: 'admin' | 'env' | 'default' }> = {};
for (const [key, mapping] of Object.entries(CONFIG_ENV_MAP)) {
if (key in this.adminConfig) {
result[key] = { value: this.adminConfig[key], source: 'admin' };
} else {
const envVal = process.env[mapping.envVar];
if (envVal !== undefined) {
result[key] = { value: parseEnvValue(envVal, mapping.type), source: 'env' };
} else {
result[key] = { value: mapping.defaultValue, source: 'default' };
}
}
}
return result;
}
/**
* Update admin config overrides. Writes to disk.
*/
async setAdminConfig(updates: Record<string, unknown>): Promise<void> {
Object.assign(this.adminConfig, updates);
await this.writeJsonFile('config.json', this.adminConfig);
}
/**
* Remove an admin override, reverting to env/default.
*/
async removeAdminOverride(key: string): Promise<void> {
delete this.adminConfig[key];
await this.writeJsonFile('config.json', this.adminConfig);
}
/**
* Get the current settings policy.
*/
getPolicy(): SettingsPolicy {
return this.policyCache;
}
/**
* Update the settings policy. Writes to disk.
*/
async setPolicy(policy: SettingsPolicy): Promise<void> {
this.policyCache = { ...DEFAULT_POLICY, ...policy };
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
}
/**
* Reload config from disk (for manual file edits or multi-instance).
*/
async reload(): Promise<void> {
await this.load();
}
private async readJsonFile(filename: string): Promise<Record<string, unknown> | null> {
const filePath = path.join(getAdminDir(), filename);
try {
const raw = await readFile(filePath, 'utf-8');
return JSON.parse(raw);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
logger.warn(`Failed to read ${filename}`, { error: error instanceof Error ? error.message : 'Unknown error' });
return null;
}
}
private async writeJsonFile(filename: string, data: Record<string, unknown>): Promise<void> {
const dir = getAdminDir();
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
const targetPath = path.join(dir, filename);
const tmpPath = targetPath + '.tmp';
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
await rename(tmpPath, targetPath);
}
}
export const configManager = new ConfigManager();
+206
View File
@@ -0,0 +1,206 @@
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
import type { AdminData } from './types';
const SCRYPT_KEYLEN = 64;
const SCRYPT_COST = 16384; // 2^14
const SCRYPT_BLOCK_SIZE = 8;
const SCRYPT_PARALLELIZATION = 1;
const SALT_LENGTH = 32;
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
function getAdminJsonPath(): string {
return path.join(getAdminDir(), 'admin.json');
}
function hashPassword(password: string): Promise<string> {
return new Promise((resolve, reject) => {
const salt = randomBytes(SALT_LENGTH);
scrypt(password, salt, SCRYPT_KEYLEN, { N: SCRYPT_COST, r: SCRYPT_BLOCK_SIZE, p: SCRYPT_PARALLELIZATION }, (err, derivedKey) => {
if (err) return reject(err);
// Format: $scrypt$N=16384,r=8,p=1$<salt_base64>$<hash_base64>
const params = `N=${SCRYPT_COST},r=${SCRYPT_BLOCK_SIZE},p=${SCRYPT_PARALLELIZATION}`;
resolve(`$scrypt$${params}$${salt.toString('base64')}$${derivedKey.toString('base64')}`);
});
});
}
function verifyPassword(password: string, stored: string): Promise<boolean> {
return new Promise((resolve, reject) => {
// Support both scrypt format and bcrypt-prefixed values
if (stored.startsWith('$scrypt$')) {
const parts = stored.split('$');
// $scrypt$N=...,r=...,p=...$salt$hash
if (parts.length !== 5) return resolve(false);
const paramStr = parts[2];
const salt = Buffer.from(parts[3], 'base64');
const storedHash = Buffer.from(parts[4], 'base64');
const params: Record<string, number> = {};
for (const p of paramStr.split(',')) {
const [k, v] = p.split('=');
params[k] = parseInt(v, 10);
}
scrypt(password, salt, storedHash.length, { N: params.N, r: params.r, p: params.p }, (err, derivedKey) => {
if (err) return reject(err);
resolve(timingSafeEqual(derivedKey, storedHash));
});
} else {
// Unknown format
resolve(false);
}
});
}
function isHashed(value: string): boolean {
return value.startsWith('$scrypt$') || value.startsWith('$2a$') || value.startsWith('$2b$');
}
async function readAdminData(): Promise<AdminData | null> {
const filePath = getAdminJsonPath();
try {
const raw = await readFile(filePath, 'utf-8');
return JSON.parse(raw) as AdminData;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
logger.warn('Failed to read admin.json', { error: error instanceof Error ? error.message : 'Unknown error' });
return null;
}
}
async function writeAdminData(data: AdminData): Promise<void> {
const dir = getAdminDir();
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
const targetPath = getAdminJsonPath();
const tmpPath = targetPath + '.tmp';
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
await rename(tmpPath, targetPath);
}
let cachedAdminData: AdminData | null = null;
let initialized = false;
/**
* Initialize admin password on startup.
* If ADMIN_PASSWORD is cleartext, hash it and write to admin.json.
* Returns true if admin is enabled.
*/
export async function initAdminPassword(): Promise<boolean> {
if (initialized) return cachedAdminData !== null;
// Check persistent file first
const existing = await readAdminData();
if (existing) {
cachedAdminData = existing;
initialized = true;
logger.info('Admin dashboard enabled (password loaded from admin.json)');
return true;
}
// Check env var
const envPassword = process.env.ADMIN_PASSWORD;
if (!envPassword) {
initialized = true;
logger.info('Admin dashboard disabled (no ADMIN_PASSWORD set)');
return false;
}
if (isHashed(envPassword)) {
// Already hashed in env — save to file
const data: AdminData = {
passwordHash: envPassword,
createdAt: new Date().toISOString(),
lastLogin: null,
passwordChangedAt: new Date().toISOString(),
};
await writeAdminData(data);
cachedAdminData = data;
initialized = true;
logger.info('Admin password hash saved to admin.json from environment variable');
return true;
}
// Cleartext — hash it
const hash = await hashPassword(envPassword);
const data: AdminData = {
passwordHash: hash,
createdAt: new Date().toISOString(),
lastLogin: null,
passwordChangedAt: new Date().toISOString(),
};
await writeAdminData(data);
cachedAdminData = data;
initialized = true;
logger.warn('Admin password hashed and saved to admin.json. You may now remove ADMIN_PASSWORD from .env');
return true;
}
/**
* Verify a password against the stored admin hash.
*/
export async function verifyAdminPassword(password: string): Promise<boolean> {
if (!cachedAdminData) {
cachedAdminData = await readAdminData();
}
if (!cachedAdminData) return false;
return verifyPassword(password, cachedAdminData.passwordHash);
}
/**
* Change the admin password. Returns true on success.
*/
export async function changeAdminPassword(currentPassword: string, newPassword: string): Promise<boolean> {
const valid = await verifyAdminPassword(currentPassword);
if (!valid) return false;
const hash = await hashPassword(newPassword);
if (!cachedAdminData) return false;
cachedAdminData = {
...cachedAdminData,
passwordHash: hash,
passwordChangedAt: new Date().toISOString(),
};
await writeAdminData(cachedAdminData);
return true;
}
/**
* Update the last login timestamp.
*/
export async function updateLastLogin(): Promise<void> {
if (!cachedAdminData) return;
cachedAdminData = {
...cachedAdminData,
lastLogin: new Date().toISOString(),
};
await writeAdminData(cachedAdminData);
}
/**
* Check if admin dashboard is enabled (has a password configured).
*/
export function isAdminEnabled(): boolean {
return cachedAdminData !== null;
}
/**
* Get admin metadata (without the hash).
*/
export function getAdminMeta(): { createdAt: string; lastLogin: string | null; passwordChangedAt: string } | null {
if (!cachedAdminData) return null;
return {
createdAt: cachedAdminData.createdAt,
lastLogin: cachedAdminData.lastLogin,
passwordChangedAt: cachedAdminData.passwordChangedAt,
};
}
+45
View File
@@ -0,0 +1,45 @@
/**
* In-memory rate limiter for admin login.
* Max 5 attempts per IP per 15 minutes.
*/
const MAX_ATTEMPTS = 5;
const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
interface RateLimitEntry {
count: number;
resetAt: number;
}
const attempts = new Map<string, RateLimitEntry>();
// Clean up expired entries periodically
setInterval(() => {
const now = Date.now();
for (const [key, entry] of attempts) {
if (entry.resetAt <= now) {
attempts.delete(key);
}
}
}, 60_000).unref();
/**
* Check if the IP is rate limited. Returns remaining attempts, or 0 if blocked.
*/
export function checkRateLimit(ip: string): { allowed: boolean; remaining: number; retryAfterMs: number } {
const now = Date.now();
const entry = attempts.get(ip);
if (!entry || entry.resetAt <= now) {
// New window
attempts.set(ip, { count: 1, resetAt: now + WINDOW_MS });
return { allowed: true, remaining: MAX_ATTEMPTS - 1, retryAfterMs: 0 };
}
if (entry.count >= MAX_ATTEMPTS) {
return { allowed: false, remaining: 0, retryAfterMs: entry.resetAt - now };
}
entry.count++;
return { allowed: true, remaining: MAX_ATTEMPTS - entry.count, retryAfterMs: 0 };
}
+126
View File
@@ -0,0 +1,126 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types';
import type { AdminSessionPayload } from './types';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
function getKey(): Buffer {
const secret = process.env.SESSION_SECRET;
if (!secret) throw new Error('SESSION_SECRET not configured');
return createHash('sha256').update(secret).digest();
}
function getSessionTTL(): number {
const ttl = parseInt(process.env.ADMIN_SESSION_TTL || '', 10);
return isNaN(ttl) || ttl <= 0 ? DEFAULT_ADMIN_SESSION_TTL : ttl;
}
/**
* Create an encrypted admin session token.
*/
export function createAdminSession(): string {
const key = getKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const now = Math.floor(Date.now() / 1000);
const payload: AdminSessionPayload = {
role: 'admin',
iat: now,
exp: now + getSessionTTL(),
};
const json = JSON.stringify(payload);
const encrypted = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString('base64');
}
/**
* Verify and decode an admin session token. Returns null if invalid or expired.
*/
export function verifyAdminSession(token: string): AdminSessionPayload | null {
try {
const key = getKey();
const data = Buffer.from(token, 'base64');
if (data.length < IV_LENGTH + TAG_LENGTH) return null;
const iv = data.subarray(0, IV_LENGTH);
const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const encrypted = data.subarray(IV_LENGTH + TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
const payload = JSON.parse(decrypted.toString('utf8')) as AdminSessionPayload;
if (payload.role !== 'admin') return null;
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
return payload;
} catch {
return null;
}
}
/**
* Validate the admin session from cookies. Returns the payload or a 401 response.
*/
export async function requireAdminAuth(): Promise<{ payload: AdminSessionPayload } | { error: NextResponse }> {
const cookieStore = await cookies();
const token = cookieStore.get(ADMIN_SESSION_COOKIE)?.value;
if (!token) {
return { error: NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) };
}
const payload = verifyAdminSession(token);
if (!payload) {
cookieStore.delete(ADMIN_SESSION_COOKIE);
return { error: NextResponse.json({ error: 'Session expired' }, { status: 401 }) };
}
return { payload };
}
/**
* Set the admin session cookie.
*/
export async function setAdminSessionCookie(): Promise<void> {
const token = createAdminSession();
const cookieStore = await cookies();
cookieStore.set(ADMIN_SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: getSessionTTL(),
});
}
/**
* Clear the admin session cookie.
*/
export async function clearAdminSessionCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(ADMIN_SESSION_COOKIE);
}
/**
* Get the client IP from the request headers.
*/
export function getClientIP(request: Request): string {
const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) {
return forwarded.split(',')[0].trim();
}
return request.headers.get('x-real-ip') || '0.0.0.0';
}
+111
View File
@@ -0,0 +1,111 @@
// Admin dashboard types
export interface AdminData {
passwordHash: string;
createdAt: string;
lastLogin: string | null;
passwordChangedAt: string;
}
export interface AdminSessionPayload {
role: 'admin';
iat: number;
exp: number;
}
export interface SettingRestriction {
locked?: boolean;
value?: unknown;
hidden?: boolean;
allowedValues?: unknown[];
min?: number;
max?: number;
}
export interface FeatureGates {
sidebarAppsEnabled: boolean;
userThemesEnabled: boolean;
settingsExportEnabled: boolean;
customKeywordsEnabled: boolean;
templatesEnabled: boolean;
calendarTasksEnabled: boolean;
smimeEnabled: boolean;
externalContentEnabled: boolean;
debugModeEnabled: boolean;
folderIconsEnabled: boolean;
hoverActionsConfigEnabled: boolean;
}
export const DEFAULT_FEATURE_GATES: FeatureGates = {
sidebarAppsEnabled: true,
userThemesEnabled: true,
settingsExportEnabled: true,
customKeywordsEnabled: true,
templatesEnabled: true,
calendarTasksEnabled: true,
smimeEnabled: true,
externalContentEnabled: true,
debugModeEnabled: true,
folderIconsEnabled: true,
hoverActionsConfigEnabled: true,
};
export interface SettingsPolicy {
restrictions: Record<string, SettingRestriction>;
features: FeatureGates;
defaults: Record<string, unknown>;
}
export const DEFAULT_POLICY: SettingsPolicy = {
restrictions: {},
features: { ...DEFAULT_FEATURE_GATES },
defaults: {},
};
export interface AuditEntry {
ts: string;
action: string;
detail: Record<string, unknown>;
ip: string;
}
/** Config keys that map to environment variables */
export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | 'boolean' | 'url' | 'enum'; defaultValue: unknown; enumValues?: string[] }> = {
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
stalwartApiUrl: { envVar: 'STALWART_API_URL', type: 'url', defaultValue: '' },
demoMode: { envVar: 'DEMO_MODE', type: 'boolean', defaultValue: false },
devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false },
faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' },
appLogoLightUrl: { envVar: 'APP_LOGO_LIGHT_URL', type: 'url', defaultValue: '' },
appLogoDarkUrl: { envVar: 'APP_LOGO_DARK_URL', type: 'url', defaultValue: '' },
loginLogoLightUrl: { envVar: 'LOGIN_LOGO_LIGHT_URL', type: 'url', defaultValue: '/branding/Bulwark_Logo_Color.svg' },
loginLogoDarkUrl: { envVar: 'LOGIN_LOGO_DARK_URL', type: 'url', defaultValue: '/branding/Bulwark_Logo_White.svg' },
loginCompanyName: { envVar: 'LOGIN_COMPANY_NAME', type: 'string', defaultValue: '' },
loginImprintUrl: { envVar: 'LOGIN_IMPRINT_URL', type: 'url', defaultValue: '' },
loginPrivacyPolicyUrl: { envVar: 'LOGIN_PRIVACY_POLICY_URL', type: 'url', defaultValue: '' },
loginWebsiteUrl: { envVar: 'LOGIN_WEBSITE_URL', type: 'url', defaultValue: '' },
oauthEnabled: { envVar: 'OAUTH_ENABLED', type: 'boolean', defaultValue: false },
oauthOnly: { envVar: 'OAUTH_ONLY', type: 'boolean', defaultValue: false },
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', type: 'string', defaultValue: '' },
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
parentOrigin: { envVar: 'NEXT_PUBLIC_PARENT_ORIGIN', type: 'string', defaultValue: '' },
settingsSyncEnabled: { envVar: 'SETTINGS_SYNC_ENABLED', type: 'boolean', defaultValue: false },
logFormat: { envVar: 'LOG_FORMAT', type: 'enum', defaultValue: 'text', enumValues: ['text', 'json'] },
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
sessionSecret: { envVar: 'SESSION_SECRET', type: 'string', defaultValue: '' },
};
/** Keys that should never be exposed to the client config endpoint */
export const SENSITIVE_CONFIG_KEYS = new Set(['oauthClientSecret', 'sessionSecret']);
/** Admin session cookie name */
export const ADMIN_SESSION_COOKIE = 'admin_session';
/** Default admin session TTL in seconds */
export const DEFAULT_ADMIN_SESSION_TTL = 3600;
+157
View File
@@ -0,0 +1,157 @@
import type { InstalledTheme } from './plugin-types';
const nordCSS = `
:root {
--color-border: #d8dee9;
--color-input: #d8dee9;
--color-ring: #81a1c1;
--color-background: #eceff4;
--color-foreground: #2e3440;
--color-primary: #5e81ac;
--color-primary-foreground: #eceff4;
--color-secondary: #e5e9f0;
--color-secondary-foreground: #2e3440;
--color-muted: #d8dee9;
--color-muted-foreground: #4c566a;
--color-accent: #81a1c1;
--color-accent-foreground: #2e3440;
--color-destructive: #bf616a;
--color-destructive-foreground: #eceff4;
--color-popover: #eceff4;
--color-popover-foreground: #2e3440;
}
.dark {
--color-border: #3b4252;
--color-input: #3b4252;
--color-ring: #88c0d0;
--color-background: #2e3440;
--color-foreground: #eceff4;
--color-primary: #88c0d0;
--color-primary-foreground: #2e3440;
--color-secondary: #3b4252;
--color-secondary-foreground: #eceff4;
--color-muted: #3b4252;
--color-muted-foreground: #d8dee9;
--color-accent: #434c5e;
--color-accent-foreground: #88c0d0;
--color-destructive: #bf616a;
--color-destructive-foreground: #eceff4;
--color-popover: #3b4252;
--color-popover-foreground: #eceff4;
}`;
const catppuccinCSS = `
:root {
--color-border: #ccd0da;
--color-input: #ccd0da;
--color-ring: #8839ef;
--color-background: #eff1f5;
--color-foreground: #4c4f69;
--color-primary: #8839ef;
--color-primary-foreground: #eff1f5;
--color-secondary: #e6e9ef;
--color-secondary-foreground: #4c4f69;
--color-muted: #dce0e8;
--color-muted-foreground: #6c6f85;
--color-accent: #8839ef;
--color-accent-foreground: #eff1f5;
--color-destructive: #d20f39;
--color-destructive-foreground: #eff1f5;
--color-popover: #eff1f5;
--color-popover-foreground: #4c4f69;
}
.dark {
--color-border: #45475a;
--color-input: #45475a;
--color-ring: #cba6f7;
--color-background: #1e1e2e;
--color-foreground: #cdd6f4;
--color-primary: #cba6f7;
--color-primary-foreground: #1e1e2e;
--color-secondary: #313244;
--color-secondary-foreground: #cdd6f4;
--color-muted: #313244;
--color-muted-foreground: #a6adc8;
--color-accent: #45475a;
--color-accent-foreground: #cba6f7;
--color-destructive: #f38ba8;
--color-destructive-foreground: #1e1e2e;
--color-popover: #313244;
--color-popover-foreground: #cdd6f4;
}`;
const solarizedCSS = `
:root {
--color-border: #eee8d5;
--color-input: #eee8d5;
--color-ring: #268bd2;
--color-background: #fdf6e3;
--color-foreground: #657b83;
--color-primary: #268bd2;
--color-primary-foreground: #fdf6e3;
--color-secondary: #eee8d5;
--color-secondary-foreground: #586e75;
--color-muted: #eee8d5;
--color-muted-foreground: #93a1a1;
--color-accent: #268bd2;
--color-accent-foreground: #fdf6e3;
--color-destructive: #dc322f;
--color-destructive-foreground: #fdf6e3;
--color-popover: #fdf6e3;
--color-popover-foreground: #657b83;
}
.dark {
--color-border: #073642;
--color-input: #073642;
--color-ring: #268bd2;
--color-background: #002b36;
--color-foreground: #839496;
--color-primary: #268bd2;
--color-primary-foreground: #002b36;
--color-secondary: #073642;
--color-secondary-foreground: #93a1a1;
--color-muted: #073642;
--color-muted-foreground: #586e75;
--color-accent: #073642;
--color-accent-foreground: #268bd2;
--color-destructive: #dc322f;
--color-destructive-foreground: #fdf6e3;
--color-popover: #073642;
--color-popover-foreground: #93a1a1;
}`;
export const BUILTIN_THEMES: InstalledTheme[] = [
{
id: 'builtin-nord',
name: 'Nord',
version: '1.0.0',
author: 'Built-in',
description: 'Arctic, north-bluish color palette inspired by nordtheme.com',
css: nordCSS,
variants: ['light', 'dark'],
enabled: true,
builtIn: true,
},
{
id: 'builtin-catppuccin',
name: 'Catppuccin',
version: '1.0.0',
author: 'Built-in',
description: 'Soothing pastel theme with Latte (light) and Mocha (dark) variants',
css: catppuccinCSS,
variants: ['light', 'dark'],
enabled: true,
builtIn: true,
},
{
id: 'builtin-solarized',
name: 'Solarized',
version: '1.0.0',
author: 'Built-in',
description: 'Precision colors for machines and people by Ethan Schoonover',
css: solarizedCSS,
variants: ['light', 'dark'],
enabled: true,
builtIn: true,
},
];
+592
View File
@@ -0,0 +1,592 @@
// PluginAPI factory — builds the sandboxed API facade for each plugin
import type {
Disposable,
InstalledPlugin,
Permission,
ToolbarAction,
BannerFactory,
SettingsSection,
ComposerAction,
SidebarWidget,
ContextMenuItem,
KeyboardShortcut,
SlotName,
} from './plugin-types';
import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
import {
emailHooks, calendarHooks, contactHooks, fileHooks,
authHooks, settingsHooks, identityHooks, filterHooks,
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
sidebarAppHooks,
} from './plugin-hooks';
import { toast as appToast } from '@/stores/toast-store';
// ─── Permission helpers ──────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getPluginExternals(): any {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (globalThis as any).__PLUGIN_EXTERNALS__;
}
function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean {
if ((IMPLICIT as readonly string[]).includes(perm)) return true;
return plugin.permissions.includes(perm);
}
function requirePermission(plugin: InstalledPlugin, perm: Permission): void {
if (!hasPermission(plugin, perm)) {
throw new Error(`Plugin "${plugin.id}" lacks permission "${perm}"`);
}
}
/** Returns a no-op disposable when permission is missing (silent failure) */
function guardedHook<T extends (...args: never[]) => unknown>(
plugin: InstalledPlugin,
perm: Permission,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
bus: { register: (pluginId: string, handler: any, order?: number) => Disposable },
handler: T,
order: number = 100,
): Disposable {
if (!hasPermission(plugin, perm)) {
return { dispose: () => {} };
}
return bus.register(plugin.id, handler, order);
}
// ─── Plugin-scoped storage ───────────────────────────────────
function createPluginStorage(pluginId: string) {
const prefix = `plugin:${pluginId}:`;
return {
get: <T>(key: string): T | null => {
if (typeof window === 'undefined') return null;
const raw = localStorage.getItem(prefix + key);
if (raw === null) return null;
try { return JSON.parse(raw) as T; } catch { return null; }
},
set: <T>(key: string, value: T): void => {
if (typeof window === 'undefined') return;
localStorage.setItem(prefix + key, JSON.stringify(value));
},
remove: (key: string): void => {
if (typeof window === 'undefined') return;
localStorage.removeItem(prefix + key);
},
keys: (): string[] => {
if (typeof window === 'undefined') return [];
const keys: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k?.startsWith(prefix)) keys.push(k.slice(prefix.length));
}
return keys;
},
};
}
// ─── Plugin-scoped logger ────────────────────────────────────
function createPluginLogger(pluginId: string) {
const tag = `[plugin:${pluginId}]`;
return {
debug: (...args: unknown[]) => console.debug(tag, ...args),
info: (...args: unknown[]) => console.info(tag, ...args),
warn: (...args: unknown[]) => console.warn(tag, ...args),
error: (...args: unknown[]) => console.error(tag, ...args),
};
}
// ─── PluginAPI interface ─────────────────────────────────────
export interface PluginAPI {
plugin: { id: string; version: string; settings: Record<string, unknown> };
ui: {
registerToolbarAction: (action: ToolbarAction) => Disposable;
registerEmailBanner: (factory: BannerFactory) => Disposable;
registerEmailFooter: (component: React.ComponentType) => Disposable;
registerSettingsSection: (section: SettingsSection) => Disposable;
registerComposerAction: (action: ComposerAction) => Disposable;
registerSidebarWidget: (widget: SidebarWidget) => Disposable;
registerContextMenuItem: (item: ContextMenuItem) => Disposable;
registerNavigationRailItem: (component: React.ComponentType) => Disposable;
};
hooks: PluginHooksAPI;
toast: {
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
warning: (message: string) => void;
};
storage: ReturnType<typeof createPluginStorage>;
log: ReturnType<typeof createPluginLogger>;
}
// Simplified hooks API type (all hooks return Disposable)
export interface PluginHooksAPI {
// Email
onEmailOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailClose: (handler: () => void) => Disposable;
onEmailContentRender: (handler: (...args: unknown[]) => unknown) => Disposable;
onThreadExpand: (handler: (...args: unknown[]) => unknown) => Disposable;
onComposerOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
onDraftAutoSave: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailReadStateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailStarToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailSpamToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailKeywordChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onMailboxChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onMailboxesRefresh: (handler: (...args: unknown[]) => unknown) => Disposable;
onMailboxCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onMailboxRename: (handler: (...args: unknown[]) => unknown) => Disposable;
onMailboxDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onMailboxEmpty: (handler: (...args: unknown[]) => unknown) => Disposable;
onSearch: (handler: (...args: unknown[]) => unknown) => Disposable;
onSearchResults: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailSelectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onNewEmailReceived: (handler: (...args: unknown[]) => unknown) => Disposable;
onPushConnectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Calendar
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEventUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEventUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEventDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEventDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onEventRsvp: (handler: (...args: unknown[]) => unknown) => Disposable;
onEventsImport: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarDateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarViewChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarVisibilityToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarAlertAcknowledge: (handler: (...args: unknown[]) => unknown) => Disposable;
// Contacts
onContactOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeContactCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterContactCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeContactUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterContactUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeContactDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterContactDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onContactsImport: (handler: (...args: unknown[]) => unknown) => Disposable;
onContactSelectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onContactGroupChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onContactGroupMemberChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onContactMove: (handler: (...args: unknown[]) => unknown) => Disposable;
// Files
onFileNavigate: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeFileUpload: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterFileUpload: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileDownload: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileUploadCancel: (handler: (...args: unknown[]) => unknown) => Disposable;
onDirectoryCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileRename: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileMove: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileCopy: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileDuplicate: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileFavoriteToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileSelectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileUndo: (handler: (...args: unknown[]) => unknown) => Disposable;
// Auth
onLogin: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeLogout: (handler: () => void) => Disposable;
onAfterLogout: (handler: () => void) => Disposable;
onAccountSwitch: (handler: (...args: unknown[]) => unknown) => Disposable;
onAccountAdd: (handler: (...args: unknown[]) => unknown) => Disposable;
onAccountRemove: (handler: (...args: unknown[]) => unknown) => Disposable;
onTokenRefresh: (handler: () => void) => Disposable;
onAuthReady: (handler: (...args: unknown[]) => unknown) => Disposable;
// Settings
onSettingChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onSettingsExport: (handler: () => void) => Disposable;
onSettingsImport: (handler: (...args: unknown[]) => unknown) => Disposable;
onSettingsReset: (handler: () => void) => Disposable;
onSettingsSync: (handler: (...args: unknown[]) => unknown) => Disposable;
onKeywordChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onTrustedSenderChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Identity
onIdentitiesLoaded: (handler: (...args: unknown[]) => unknown) => Disposable;
onIdentityCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onIdentityUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
onIdentityDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onIdentitySelect: (handler: (...args: unknown[]) => unknown) => Disposable;
onSignatureRender: (handler: (...args: unknown[]) => unknown) => Disposable;
// Filters
onFiltersLoaded: (handler: (...args: unknown[]) => unknown) => Disposable;
onFilterRuleChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onFiltersSave: (handler: (...args: unknown[]) => unknown) => Disposable;
onSieveScriptChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Tasks
onTasksLoaded: (handler: (...args: unknown[]) => unknown) => Disposable;
onTaskCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onTaskUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
onTaskDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onTaskToggleComplete: (handler: (...args: unknown[]) => unknown) => Disposable;
onTaskFilterChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Templates
onTemplateCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onTemplateUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
onTemplateDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onTemplateApply: (handler: (...args: unknown[]) => unknown) => Disposable;
onTemplatesImport: (handler: (...args: unknown[]) => unknown) => Disposable;
onTemplateRender: (handler: (...args: unknown[]) => unknown) => Disposable;
// S/MIME
onSmimeKeyImport: (handler: (...args: unknown[]) => unknown) => Disposable;
onSmimeCertImport: (handler: (...args: unknown[]) => unknown) => Disposable;
onSmimeKeyStateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onSmimeDefaultsChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Vacation
onVacationLoaded: (handler: (...args: unknown[]) => unknown) => Disposable;
onVacationUpdate: (handler: (...args: unknown[]) => unknown) => Disposable;
// UI
onViewChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onSidebarToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
onSidebarCollapse: (handler: (...args: unknown[]) => unknown) => Disposable;
onDeviceTypeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onColumnResize: (handler: (...args: unknown[]) => unknown) => Disposable;
onMobileBack: (handler: () => void) => Disposable;
onMobileViewSwitch: (handler: (...args: unknown[]) => unknown) => Disposable;
// Theme
onThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onCustomThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onLocaleChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Toast
onToastShow: (handler: (...args: unknown[]) => unknown) => Disposable;
onToastDismiss: (handler: (...args: unknown[]) => unknown) => Disposable;
onBrowserNotification: (handler: (...args: unknown[]) => unknown) => Disposable;
// Drag & Drop
onDragStart: (handler: (...args: unknown[]) => unknown) => Disposable;
onDragEnd: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailDrop: (handler: (...args: unknown[]) => unknown) => Disposable;
onTagDrop: (handler: (...args: unknown[]) => unknown) => Disposable;
// Keyboard
registerShortcut: (shortcut: KeyboardShortcut) => Disposable;
onBeforeShortcut: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterShortcut: (handler: (...args: unknown[]) => unknown) => Disposable;
// App Lifecycle
onAppReady: (handler: () => void) => Disposable;
onVisibilityChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeUnload: (handler: () => void) => Disposable;
onAppError: (handler: (...args: unknown[]) => unknown) => Disposable;
onInterval: (handler: () => void, intervalMs: number) => Disposable;
// Account Security
onPasswordChange: (handler: () => void) => Disposable;
onTotpChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onAppPasswordChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onEncryptionChange: (handler: () => void) => Disposable;
onDisplayNameChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Sidebar Apps
onSidebarAppOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onSidebarAppClose: (handler: (...args: unknown[]) => unknown) => Disposable;
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
}
// ─── Permission mapping for hooks ────────────────────────────
const HOOK_PERMISSIONS: Record<string, Permission> = {
// Email
onEmailOpen: 'email:read', onEmailClose: 'email:read',
onEmailContentRender: 'email:read', onThreadExpand: 'email:read',
onComposerOpen: 'email:read', onDraftAutoSave: 'email:read',
onMailboxChange: 'email:read', onMailboxesRefresh: 'email:read',
onSearch: 'email:read', onSearchResults: 'email:read',
onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send',
onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
onEmailReadStateChange: 'email:write', onEmailStarToggle: 'email:write',
onEmailSpamToggle: 'email:write', onEmailKeywordChange: 'email:write',
onMailboxCreate: 'email:write', onMailboxRename: 'email:write',
onMailboxDelete: 'email:write', onMailboxEmpty: 'email:write',
// Calendar
onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read',
onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read',
onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read',
onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write',
onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write',
onBeforeEventDelete: 'calendar:write', onAfterEventDelete: 'calendar:write',
onEventRsvp: 'calendar:write', onEventsImport: 'calendar:write',
onCalendarChange: 'calendar:write', onICalSubscriptionChange: 'calendar:write',
// Contacts
onContactOpen: 'contacts:read', onContactSelectionChange: 'contacts:read',
onBeforeContactCreate: 'contacts:write', onAfterContactCreate: 'contacts:write',
onBeforeContactUpdate: 'contacts:write', onAfterContactUpdate: 'contacts:write',
onBeforeContactDelete: 'contacts:write', onAfterContactDelete: 'contacts:write',
onContactsImport: 'contacts:write', onContactGroupChange: 'contacts:write',
onContactGroupMemberChange: 'contacts:write', onContactMove: 'contacts:write',
// Files
onFileNavigate: 'files:read', onFileDownload: 'files:read', onFileSelectionChange: 'files:read',
onBeforeFileUpload: 'files:write', onAfterFileUpload: 'files:write',
onFileUploadCancel: 'files:write', onDirectoryCreate: 'files:write',
onBeforeFileDelete: 'files:write', onAfterFileDelete: 'files:write',
onFileRename: 'files:write', onFileMove: 'files:write', onFileCopy: 'files:write',
onFileDuplicate: 'files:write', onFileFavoriteToggle: 'files:write', onFileUndo: 'files:write',
// Auth
onLogin: 'auth:observe', onBeforeLogout: 'auth:observe', onAfterLogout: 'auth:observe',
onAccountSwitch: 'auth:observe', onAccountAdd: 'auth:observe', onAccountRemove: 'auth:observe',
onTokenRefresh: 'auth:observe', onAuthReady: 'auth:observe',
// Settings
onSettingChange: 'settings:read', onSettingsExport: 'settings:read',
onSettingsImport: 'settings:read', onSettingsReset: 'settings:read',
onSettingsSync: 'settings:read', onKeywordChange: 'settings:read',
onTrustedSenderChange: 'settings:read',
// Identity
onIdentitiesLoaded: 'identity:read', onIdentitySelect: 'identity:read',
onSignatureRender: 'identity:read',
onIdentityCreate: 'identity:write', onIdentityUpdate: 'identity:write',
onIdentityDelete: 'identity:write',
// Filters
onFiltersLoaded: 'filters:read',
onFilterRuleChange: 'filters:write', onFiltersSave: 'filters:write',
onSieveScriptChange: 'filters:write',
// Tasks
onTasksLoaded: 'tasks:read', onTaskFilterChange: 'tasks:read',
onTaskCreate: 'tasks:write', onTaskUpdate: 'tasks:write',
onTaskDelete: 'tasks:write', onTaskToggleComplete: 'tasks:write',
// Templates
onTemplateApply: 'templates:read', onTemplateRender: 'templates:read',
onTemplateCreate: 'templates:write', onTemplateUpdate: 'templates:write',
onTemplateDelete: 'templates:write', onTemplatesImport: 'templates:write',
// S/MIME
onSmimeKeyImport: 'smime:read', onSmimeCertImport: 'smime:read',
onSmimeKeyStateChange: 'smime:read', onSmimeDefaultsChange: 'smime:read',
// Vacation
onVacationLoaded: 'vacation:read', onVacationUpdate: 'vacation:write',
// UI
onViewChange: 'ui:observe', onSidebarToggle: 'ui:observe',
onSidebarCollapse: 'ui:observe', onDeviceTypeChange: 'ui:observe',
onColumnResize: 'ui:observe', onMobileBack: 'ui:observe',
onMobileViewSwitch: 'ui:observe',
// Theme
onThemeChange: 'ui:observe', onCustomThemeChange: 'ui:observe',
onLocaleChange: 'ui:observe',
// Toast
onToastShow: 'ui:observe', onToastDismiss: 'ui:observe',
onBrowserNotification: 'ui:observe',
// Drag & Drop
onDragStart: 'ui:observe', onDragEnd: 'ui:observe',
onEmailDrop: 'ui:observe', onTagDrop: 'ui:observe',
// Keyboard
registerShortcut: 'ui:keyboard', onBeforeShortcut: 'ui:keyboard',
onAfterShortcut: 'ui:keyboard',
// App Lifecycle
onAppReady: 'app:lifecycle', onVisibilityChange: 'app:lifecycle',
onBeforeUnload: 'app:lifecycle', onAppError: 'app:lifecycle',
onInterval: 'app:lifecycle',
// Account Security
onPasswordChange: 'security:read', onTotpChange: 'security:read',
onAppPasswordChange: 'security:read', onEncryptionChange: 'security:read',
onDisplayNameChange: 'security:read',
// Sidebar Apps
onSidebarAppOpen: 'ui:observe', onSidebarAppClose: 'ui:observe',
onSidebarAppChange: 'ui:observe',
};
// Map hook names → actual HookBus instances
const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...args: unknown[]) => unknown, order?: number) => Disposable }> = {
// Email
...Object.fromEntries(Object.entries(emailHooks)),
// Calendar
...Object.fromEntries(Object.entries(calendarHooks)),
// Contacts
...Object.fromEntries(Object.entries(contactHooks)),
// Files
...Object.fromEntries(Object.entries(fileHooks)),
// Auth
...Object.fromEntries(Object.entries(authHooks)),
// Settings
...Object.fromEntries(Object.entries(settingsHooks)),
// Identity
...Object.fromEntries(Object.entries(identityHooks)),
// Filters
...Object.fromEntries(Object.entries(filterHooks)),
// Tasks
...Object.fromEntries(Object.entries(taskHooks)),
// Templates
...Object.fromEntries(Object.entries(templateHooks)),
// S/MIME
...Object.fromEntries(Object.entries(smimeHooks)),
// Vacation
...Object.fromEntries(Object.entries(vacationHooks)),
// UI
...Object.fromEntries(Object.entries(uiHooks)),
// Theme
...Object.fromEntries(Object.entries(themeHooks)),
// Toast
...Object.fromEntries(Object.entries(toastHooks)),
// Drag & Drop
...Object.fromEntries(Object.entries(dragDropHooks)),
// Keyboard
...Object.fromEntries(Object.entries(keyboardHooks)),
// App Lifecycle
...Object.fromEntries(Object.entries(appLifecycleHooks)),
// Account Security
...Object.fromEntries(Object.entries(accountSecurityHooks)),
// Sidebar Apps
...Object.fromEntries(Object.entries(sidebarAppHooks)),
};
// ─── Slot registration bridge ────────────────────────────────
// Lazy import to avoid circular dependency — plugin-store imports plugin-api indirectly
let registerSlotFn: ((name: SlotName, reg: { pluginId: string; component: React.ComponentType<Record<string, unknown>>; order: number }) => Disposable) | null = null;
export function setSlotRegistrationBridge(fn: typeof registerSlotFn): void {
registerSlotFn = fn;
}
function registerSlot(
pluginId: string,
slotName: SlotName,
component: React.ComponentType<Record<string, unknown>>,
order: number = 100,
): Disposable {
if (!registerSlotFn) {
console.warn(`[plugin:${pluginId}] Slot registration not available yet`);
return { dispose: () => {} };
}
return registerSlotFn(slotName, { pluginId, component, order });
}
// ─── Factory ─────────────────────────────────────────────────
export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
// Build hooks proxy — each hook method checks permission and registers on the right bus
const hooks: PluginHooksAPI = {} as PluginHooksAPI;
for (const [hookName, bus] of Object.entries(HOOK_BUSES)) {
const perm = HOOK_PERMISSIONS[hookName];
if (!perm) continue;
if (hookName === 'onInterval') {
// Special: onInterval takes (handler, intervalMs)
(hooks as unknown as Record<string, unknown>)[hookName] = (handler: () => void, intervalMs: number) => {
if (!hasPermission(plugin, perm)) return { dispose: () => {} };
const safeMs = Math.max(intervalMs, 60_000); // min 60s
const id = setInterval(handler, safeMs);
return { dispose: () => clearInterval(id) };
};
} else if (hookName === 'registerShortcut') {
// Special: registerShortcut takes a KeyboardShortcut object
(hooks as unknown as Record<string, unknown>)[hookName] = (shortcut: KeyboardShortcut) => {
return guardedHook(plugin, perm, bus, shortcut.handler);
};
} else {
(hooks as unknown as Record<string, unknown>)[hookName] = (handler: (...args: unknown[]) => unknown) => {
return guardedHook(plugin, perm, bus, handler);
};
}
}
return {
plugin: {
id: plugin.id,
version: plugin.version,
settings: { ...plugin.settings },
},
ui: {
registerToolbarAction: (action: ToolbarAction) => {
requirePermission(plugin, 'ui:toolbar');
const Component = () => {
const externals = getPluginExternals();
const React = externals?.React;
if (!React) return null;
const createElement = (React as { createElement: typeof import('react').createElement }).createElement;
return createElement('button', {
onClick: action.onClick,
className: 'plugin-toolbar-action',
title: action.label,
}, action.label);
};
return registerSlot(plugin.id, 'toolbar-actions', Component as React.ComponentType<Record<string, unknown>>, action.order ?? 100);
},
registerEmailBanner: (factory: BannerFactory) => {
requirePermission(plugin, 'ui:email-banner');
return registerSlot(plugin.id, 'email-banner', factory.render as unknown as React.ComponentType<Record<string, unknown>>, 100);
},
registerEmailFooter: (component: React.ComponentType) => {
requirePermission(plugin, 'ui:email-footer');
return registerSlot(plugin.id, 'email-footer', component as React.ComponentType<Record<string, unknown>>, 100);
},
registerSettingsSection: (section: SettingsSection) => {
requirePermission(plugin, 'ui:settings-section');
return registerSlot(plugin.id, 'settings-section', section.render as React.ComponentType<Record<string, unknown>>, 100);
},
registerComposerAction: (action: ComposerAction) => {
requirePermission(plugin, 'ui:composer-toolbar');
const Component = () => {
const externals = getPluginExternals();
const React = externals?.React;
if (!React) return null;
const createElement = (React as { createElement: typeof import('react').createElement }).createElement;
return createElement('button', {
onClick: action.onClick,
className: 'plugin-composer-action',
title: action.label,
}, action.label);
};
return registerSlot(plugin.id, 'composer-toolbar', Component as React.ComponentType<Record<string, unknown>>, action.order ?? 100);
},
registerSidebarWidget: (widget: SidebarWidget) => {
requirePermission(plugin, 'ui:sidebar-widget');
return registerSlot(plugin.id, 'sidebar-widget', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
},
registerContextMenuItem: (item: ContextMenuItem) => {
requirePermission(plugin, 'ui:context-menu');
const Component = () => {
const externals = getPluginExternals();
const React = externals?.React;
if (!React) return null;
const createElement = (React as { createElement: typeof import('react').createElement }).createElement;
return createElement('button', {
onClick: () => item.onClick([]),
className: 'plugin-context-menu-item',
}, item.label);
};
return registerSlot(plugin.id, 'context-menu-email', Component as React.ComponentType<Record<string, unknown>>, item.order ?? 100);
},
registerNavigationRailItem: (component: React.ComponentType) => {
requirePermission(plugin, 'ui:navigation-rail');
return registerSlot(plugin.id, 'navigation-rail-bottom', component as React.ComponentType<Record<string, unknown>>, 100);
},
},
hooks,
toast: {
success: (message: string) => appToast.success(message),
error: (message: string) => appToast.error(message),
info: (message: string) => appToast.info(message),
warning: (message: string) => appToast.warning(message),
},
storage: createPluginStorage(plugin.id),
log: createPluginLogger(plugin.id),
};
}
+420
View File
@@ -0,0 +1,420 @@
// Plugin Hook Bus — event bus system for plugin lifecycle hooks
import type { Disposable } from './plugin-types';
// ─── Error Tracker (Circuit Breaker) ─────────────────────────
interface ErrorRecord {
timestamps: number[];
disabled: boolean;
}
const ERROR_THRESHOLD = 3;
const ERROR_WINDOW_MS = 60_000;
class PluginErrorTracker {
private records = new Map<string, ErrorRecord>();
private onAutoDisable?: (pluginId: string, error: unknown) => void;
setAutoDisableCallback(cb: (pluginId: string, error: unknown) => void): void {
this.onAutoDisable = cb;
}
record(pluginId: string, error: unknown): void {
const now = Date.now();
let rec = this.records.get(pluginId);
if (!rec) {
rec = { timestamps: [], disabled: false };
this.records.set(pluginId, rec);
}
// Prune old timestamps
rec.timestamps = rec.timestamps.filter(t => now - t < ERROR_WINDOW_MS);
rec.timestamps.push(now);
console.error(`[plugin:${pluginId}] Hook error:`, error);
if (rec.timestamps.length >= ERROR_THRESHOLD && !rec.disabled) {
rec.disabled = true;
console.error(`[plugin:${pluginId}] Auto-disabled after ${ERROR_THRESHOLD} errors in ${ERROR_WINDOW_MS / 1000}s`);
this.onAutoDisable?.(pluginId, error);
}
}
isDisabled(pluginId: string): boolean {
return this.records.get(pluginId)?.disabled ?? false;
}
reset(pluginId: string): void {
this.records.delete(pluginId);
}
resetAll(): void {
this.records.clear();
}
}
export const pluginErrorTracker = new PluginErrorTracker();
// ─── Timeout Helper ──────────────────────────────────────────
const DEFAULT_TIMEOUT_MS = 5000;
function withTimeout<T>(promise: T | Promise<T>, ms: number = DEFAULT_TIMEOUT_MS): Promise<T> {
if (!(promise instanceof Promise)) return Promise.resolve(promise);
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Hook timed out after ${ms}ms`)), ms);
promise.then(
(val) => { clearTimeout(timer); resolve(val); },
(err) => { clearTimeout(timer); reject(err); },
);
});
}
// ─── HookBus ─────────────────────────────────────────────────
interface HookEntry<T extends (...args: never[]) => unknown> {
pluginId: string;
handler: T;
order: number;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class HookBus<T extends (...args: any[]) => any> {
private handlers: HookEntry<T>[] = [];
register(pluginId: string, handler: T, order: number = 100): Disposable {
const entry: HookEntry<T> = { pluginId, handler, order };
this.handlers.push(entry);
this.handlers.sort((a, b) => a.order - b.order);
return {
dispose: () => {
this.handlers = this.handlers.filter(h => h !== entry);
},
};
}
/** Remove all handlers for a given plugin */
removePlugin(pluginId: string): void {
this.handlers = this.handlers.filter(h => h.pluginId !== pluginId);
}
/** Remove all handlers */
clear(): void {
this.handlers = [];
}
get size(): number {
return this.handlers.length;
}
/** Fire all handlers (observer pattern — no return values used) */
async emit(...args: Parameters<T>): Promise<void> {
for (const { pluginId, handler } of this.handlers) {
if (pluginErrorTracker.isDisabled(pluginId)) continue;
try {
await withTimeout(handler(...args));
} catch (err) {
pluginErrorTracker.record(pluginId, err);
}
}
}
/** Synchronous emit for performance-critical paths */
emitSync(...args: Parameters<T>): void {
for (const { pluginId, handler } of this.handlers) {
if (pluginErrorTracker.isDisabled(pluginId)) continue;
try {
handler(...args);
} catch (err) {
pluginErrorTracker.record(pluginId, err);
}
}
}
/** Fire handlers as interceptors — any returning false cancels the operation */
async intercept(...args: Parameters<T>): Promise<boolean> {
for (const { pluginId, handler } of this.handlers) {
if (pluginErrorTracker.isDisabled(pluginId)) continue;
try {
const result = await withTimeout(handler(...args));
if (result === false) return false;
} catch (err) {
pluginErrorTracker.record(pluginId, err);
}
}
return true;
}
/** Fire handlers as transforms — each receives the output of the previous */
async transform<V>(initial: V, ...rest: unknown[]): Promise<V> {
let value = initial;
for (const { pluginId, handler } of this.handlers) {
if (pluginErrorTracker.isDisabled(pluginId)) continue;
try {
const result = await withTimeout(handler(value, ...rest));
if (result !== undefined && result !== false) {
value = result as V;
}
} catch (err) {
pluginErrorTracker.record(pluginId, err);
}
}
return value;
}
}
// ─── All Hook Buses (one per hook across all 20 domains) ─────
// §7.1 Email Hooks
export const emailHooks = {
onEmailOpen: new HookBus(),
onEmailClose: new HookBus(),
onEmailContentRender: new HookBus(),
onThreadExpand: new HookBus(),
onComposerOpen: new HookBus(),
onBeforeEmailSend: new HookBus(),
onAfterEmailSend: new HookBus(),
onDraftAutoSave: new HookBus(),
onBeforeEmailDelete: new HookBus(),
onAfterEmailDelete: new HookBus(),
onBeforeEmailMove: new HookBus(),
onAfterEmailMove: new HookBus(),
onEmailReadStateChange: new HookBus(),
onEmailStarToggle: new HookBus(),
onEmailSpamToggle: new HookBus(),
onEmailKeywordChange: new HookBus(),
onMailboxChange: new HookBus(),
onMailboxesRefresh: new HookBus(),
onMailboxCreate: new HookBus(),
onMailboxRename: new HookBus(),
onMailboxDelete: new HookBus(),
onMailboxEmpty: new HookBus(),
onSearch: new HookBus(),
onSearchResults: new HookBus(),
onEmailSelectionChange: new HookBus(),
onNewEmailReceived: new HookBus(),
onPushConnectionChange: new HookBus(),
onQuotaChange: new HookBus(),
};
// §7.2 Calendar Hooks
export const calendarHooks = {
onCalendarEventOpen: new HookBus(),
onBeforeEventCreate: new HookBus(),
onAfterEventCreate: new HookBus(),
onBeforeEventUpdate: new HookBus(),
onAfterEventUpdate: new HookBus(),
onBeforeEventDelete: new HookBus(),
onAfterEventDelete: new HookBus(),
onEventRsvp: new HookBus(),
onEventsImport: new HookBus(),
onCalendarDateChange: new HookBus(),
onCalendarViewChange: new HookBus(),
onCalendarChange: new HookBus(),
onCalendarVisibilityToggle: new HookBus(),
onICalSubscriptionChange: new HookBus(),
onCalendarAlert: new HookBus(),
onCalendarAlertAcknowledge: new HookBus(),
};
// §7.3 Contact Hooks
export const contactHooks = {
onContactOpen: new HookBus(),
onBeforeContactCreate: new HookBus(),
onAfterContactCreate: new HookBus(),
onBeforeContactUpdate: new HookBus(),
onAfterContactUpdate: new HookBus(),
onBeforeContactDelete: new HookBus(),
onAfterContactDelete: new HookBus(),
onContactsImport: new HookBus(),
onContactSelectionChange: new HookBus(),
onContactGroupChange: new HookBus(),
onContactGroupMemberChange: new HookBus(),
onContactMove: new HookBus(),
};
// §7.4 File Hooks
export const fileHooks = {
onFileNavigate: new HookBus(),
onBeforeFileUpload: new HookBus(),
onAfterFileUpload: new HookBus(),
onFileDownload: new HookBus(),
onFileUploadCancel: new HookBus(),
onDirectoryCreate: new HookBus(),
onBeforeFileDelete: new HookBus(),
onAfterFileDelete: new HookBus(),
onFileRename: new HookBus(),
onFileMove: new HookBus(),
onFileCopy: new HookBus(),
onFileDuplicate: new HookBus(),
onFileFavoriteToggle: new HookBus(),
onFileSelectionChange: new HookBus(),
onFileUndo: new HookBus(),
};
// §7.5 Auth Hooks
export const authHooks = {
onLogin: new HookBus(),
onBeforeLogout: new HookBus(),
onAfterLogout: new HookBus(),
onAccountSwitch: new HookBus(),
onAccountAdd: new HookBus(),
onAccountRemove: new HookBus(),
onTokenRefresh: new HookBus(),
onAuthReady: new HookBus(),
};
// §7.6 Settings Hooks
export const settingsHooks = {
onSettingChange: new HookBus(),
onSettingsExport: new HookBus(),
onSettingsImport: new HookBus(),
onSettingsReset: new HookBus(),
onSettingsSync: new HookBus(),
onKeywordChange: new HookBus(),
onTrustedSenderChange: new HookBus(),
};
// §7.7 Identity Hooks
export const identityHooks = {
onIdentitiesLoaded: new HookBus(),
onIdentityCreate: new HookBus(),
onIdentityUpdate: new HookBus(),
onIdentityDelete: new HookBus(),
onIdentitySelect: new HookBus(),
onSignatureRender: new HookBus(),
};
// §7.8 Filter Hooks
export const filterHooks = {
onFiltersLoaded: new HookBus(),
onFilterRuleChange: new HookBus(),
onFiltersSave: new HookBus(),
onSieveScriptChange: new HookBus(),
};
// §7.9 Task Hooks
export const taskHooks = {
onTasksLoaded: new HookBus(),
onTaskCreate: new HookBus(),
onTaskUpdate: new HookBus(),
onTaskDelete: new HookBus(),
onTaskToggleComplete: new HookBus(),
onTaskFilterChange: new HookBus(),
};
// §7.10 Template Hooks
export const templateHooks = {
onTemplateCreate: new HookBus(),
onTemplateUpdate: new HookBus(),
onTemplateDelete: new HookBus(),
onTemplateApply: new HookBus(),
onTemplatesImport: new HookBus(),
onTemplateRender: new HookBus(),
};
// §7.11 S/MIME Hooks
export const smimeHooks = {
onSmimeKeyImport: new HookBus(),
onSmimeCertImport: new HookBus(),
onSmimeKeyStateChange: new HookBus(),
onSmimeDefaultsChange: new HookBus(),
};
// §7.12 Vacation Hooks
export const vacationHooks = {
onVacationLoaded: new HookBus(),
onVacationUpdate: new HookBus(),
};
// §7.13 UI Hooks
export const uiHooks = {
onViewChange: new HookBus(),
onSidebarToggle: new HookBus(),
onSidebarCollapse: new HookBus(),
onDeviceTypeChange: new HookBus(),
onColumnResize: new HookBus(),
onMobileBack: new HookBus(),
onMobileViewSwitch: new HookBus(),
};
// §7.14 Theme Hooks
export const themeHooks = {
onThemeChange: new HookBus(),
onCustomThemeChange: new HookBus(),
onLocaleChange: new HookBus(),
};
// §7.15 Toast Hooks
export const toastHooks = {
onToastShow: new HookBus(),
onToastDismiss: new HookBus(),
onBrowserNotification: new HookBus(),
};
// §7.16 Drag & Drop Hooks
export const dragDropHooks = {
onDragStart: new HookBus(),
onDragEnd: new HookBus(),
onEmailDrop: new HookBus(),
onTagDrop: new HookBus(),
};
// §7.17 Keyboard Hooks
export const keyboardHooks = {
registerShortcut: new HookBus(),
onBeforeShortcut: new HookBus(),
onAfterShortcut: new HookBus(),
};
// §7.18 App Lifecycle Hooks
export const appLifecycleHooks = {
onAppReady: new HookBus(),
onVisibilityChange: new HookBus(),
onBeforeUnload: new HookBus(),
onAppError: new HookBus(),
onInterval: new HookBus(),
};
// §7.19 Account Security Hooks
export const accountSecurityHooks = {
onPasswordChange: new HookBus(),
onTotpChange: new HookBus(),
onAppPasswordChange: new HookBus(),
onEncryptionChange: new HookBus(),
onDisplayNameChange: new HookBus(),
};
// §7.20 Sidebar App Hooks
export const sidebarAppHooks = {
onSidebarAppOpen: new HookBus(),
onSidebarAppClose: new HookBus(),
onSidebarAppChange: new HookBus(),
};
// ─── Aggregate: remove all handlers for a plugin across all buses ───
const allHookGroups = [
emailHooks, calendarHooks, contactHooks, fileHooks,
authHooks, settingsHooks, identityHooks, filterHooks,
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
];
export function removeAllPluginHooks(pluginId: string): void {
for (const group of allHookGroups) {
for (const bus of Object.values(group)) {
(bus as HookBus<never>).removePlugin(pluginId);
}
}
}
export function clearAllHooks(): void {
for (const group of allHookGroups) {
for (const bus of Object.values(group)) {
(bus as HookBus<never>).clear();
}
}
}
+160
View File
@@ -0,0 +1,160 @@
// Plugin Loader — loads and activates plugins via blob URL dynamic import
import type { InstalledPlugin, Disposable } from './plugin-types';
import { pluginStorage } from './plugin-storage';
import { createPluginAPI, type PluginAPI } from './plugin-api';
import { removeAllPluginHooks, pluginErrorTracker } from './plugin-hooks';
import React from 'react';
import ReactDOM from 'react-dom';
import * as ReactJSX from 'react/jsx-runtime';
// ─── Shared React (window.__PLUGIN_EXTERNALS__) ─────────────
export function exposePluginExternals(): void {
if (typeof window === 'undefined') return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).__PLUGIN_EXTERNALS__ = {
React,
ReactDOM,
ReactJSX,
};
}
// ─── Active plugin tracking ──────────────────────────────────
interface ActivePlugin {
id: string;
api: PluginAPI;
disposable?: Disposable;
deactivate?: () => void;
}
const activePlugins = new Map<string, ActivePlugin>();
// ─── Load a single plugin ────────────────────────────────────
type PluginStoreAccessor = {
setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void;
};
let storeAccessor: PluginStoreAccessor | null = null;
export function setPluginStoreAccessor(accessor: PluginStoreAccessor): void {
storeAccessor = accessor;
}
export async function loadPlugin(plugin: InstalledPlugin): Promise<void> {
if (activePlugins.has(plugin.id)) {
console.warn(`[plugin-loader] Plugin "${plugin.id}" is already loaded`);
return;
}
try {
// 1. Read bundle from IndexedDB
const code = await pluginStorage.getCode(plugin.id);
if (!code) {
throw new Error(`No code found in storage for plugin "${plugin.id}"`);
}
// 2. Create scoped module via blob URL
const blob = new Blob([code], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
// 3. Dynamic import (webpackIgnore prevents bundler processing)
let mod: { activate?: (api: PluginAPI) => void | Disposable; deactivate?: () => void };
try {
mod = await import(/* webpackIgnore: true */ url);
} finally {
URL.revokeObjectURL(url);
}
if (typeof mod.activate !== 'function') {
throw new Error(`Plugin "${plugin.id}" has no activate() export`);
}
// 4. Build sandboxed API
const api = createPluginAPI(plugin);
// 5. Call activate
const disposable = await mod.activate(api);
// 6. Track active plugin
activePlugins.set(plugin.id, {
id: plugin.id,
api,
disposable: disposable && typeof disposable === 'object' && 'dispose' in disposable
? disposable as Disposable
: undefined,
deactivate: mod.deactivate,
});
// 7. Mark running
storeAccessor?.setPluginStatus(plugin.id, 'running');
console.info(`[plugin-loader] Plugin "${plugin.id}" activated`);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
storeAccessor?.setPluginStatus(plugin.id, 'error', errorMsg);
console.error(`[plugin-loader] Plugin "${plugin.id}" failed to load:`, err);
}
}
// ─── Deactivate a single plugin ──────────────────────────────
export function deactivatePlugin(pluginId: string): void {
const active = activePlugins.get(pluginId);
if (!active) return;
try {
// Call deactivate() if provided
active.deactivate?.();
// Dispose the disposable returned from activate()
active.disposable?.dispose();
} catch (err) {
console.error(`[plugin-loader] Error deactivating plugin "${pluginId}":`, err);
}
// Remove all hook subscriptions for this plugin
removeAllPluginHooks(pluginId);
// Reset error tracker
pluginErrorTracker.reset(pluginId);
activePlugins.delete(pluginId);
storeAccessor?.setPluginStatus(pluginId, 'disabled');
console.info(`[plugin-loader] Plugin "${pluginId}" deactivated`);
}
// ─── Activate all enabled plugins ────────────────────────────
export async function activateAllPlugins(plugins: InstalledPlugin[]): Promise<void> {
// Ensure externals are exposed
exposePluginExternals();
const enabledPlugins = plugins.filter(p => p.enabled && p.status !== 'error');
for (const plugin of enabledPlugins) {
await loadPlugin(plugin);
}
}
// ─── Deactivate all plugins ─────────────────────────────────
export function deactivateAllPlugins(): void {
for (const pluginId of [...activePlugins.keys()]) {
deactivatePlugin(pluginId);
}
}
// ─── Check if a plugin is active ─────────────────────────────
export function isPluginActive(pluginId: string): boolean {
return activePlugins.has(pluginId);
}
// ─── Setup auto-disable callback ─────────────────────────────
export function setupAutoDisable(): void {
pluginErrorTracker.setAutoDisableCallback((pluginId) => {
deactivatePlugin(pluginId);
storeAccessor?.setPluginStatus(pluginId, 'error', 'Auto-disabled due to repeated errors');
});
}
+96
View File
@@ -0,0 +1,96 @@
// IndexedDB storage for plugin/theme binary blobs (JS bundles, CSS, previews)
const DB_NAME = 'bulwark-plugins';
const DB_VERSION = 1;
const STORE_PLUGINS = 'plugin-code';
const STORE_THEMES = 'theme-css';
const STORE_PREVIEWS = 'previews';
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_PLUGINS)) {
db.createObjectStore(STORE_PLUGINS);
}
if (!db.objectStoreNames.contains(STORE_THEMES)) {
db.createObjectStore(STORE_THEMES);
}
if (!db.objectStoreNames.contains(STORE_PREVIEWS)) {
db.createObjectStore(STORE_PREVIEWS);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function putItem(storeName: string, key: string, value: string | Blob): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
tx.objectStore(storeName).put(value, key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function getItem<T = string>(storeName: string, key: string): Promise<T | null> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const request = tx.objectStore(storeName).get(key);
request.onsuccess = () => resolve(request.result ?? null);
request.onerror = () => reject(request.error);
});
}
async function deleteItem(storeName: string, key: string): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
tx.objectStore(storeName).delete(key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
// ─── Public API ──────────────────────────────────────────────
export const pluginStorage = {
// Plugin JS bundles
async saveCode(pluginId: string, code: string): Promise<void> {
await putItem(STORE_PLUGINS, pluginId, code);
},
async getCode(pluginId: string): Promise<string | null> {
return getItem<string>(STORE_PLUGINS, pluginId);
},
async deleteCode(pluginId: string): Promise<void> {
await deleteItem(STORE_PLUGINS, pluginId);
},
// Theme CSS blobs
async saveThemeCSS(themeId: string, css: string): Promise<void> {
await putItem(STORE_THEMES, themeId, css);
},
async getThemeCSS(themeId: string): Promise<string | null> {
return getItem<string>(STORE_THEMES, themeId);
},
async deleteThemeCSS(themeId: string): Promise<void> {
await deleteItem(STORE_THEMES, themeId);
},
// Preview images (stored as data URIs)
async savePreview(id: string, dataUri: string): Promise<void> {
await putItem(STORE_PREVIEWS, id, dataUri);
},
async getPreview(id: string): Promise<string | null> {
return getItem<string>(STORE_PREVIEWS, id);
},
async deletePreview(id: string): Promise<void> {
await deleteItem(STORE_PREVIEWS, id);
},
};
+397
View File
@@ -0,0 +1,397 @@
// Plugin & Theme system types
// ─── Common ──────────────────────────────────────────────────
export type Disposable = { dispose: () => void };
export type MaybePromise<T> = T | Promise<T>;
export type PluginType = 'ui-extension' | 'sidebar-app' | 'hook' | 'theme';
export type PluginStatus = 'installed' | 'enabled' | 'running' | 'disabled' | 'error';
export type ThemeVariant = 'light' | 'dark';
// ─── Manifests ───────────────────────────────────────────────
export interface ThemeManifest {
id: string;
name: string;
version: string;
author: string;
description: string;
type: 'theme';
preview?: string;
variants: ThemeVariant[];
minAppVersion?: string;
}
export interface PluginManifest {
id: string;
name: string;
version: string;
author: string;
description: string;
type: Exclude<PluginType, 'theme'>;
permissions: string[];
entrypoint: string;
minAppVersion?: string;
settingsSchema?: Record<string, SettingFieldSchema>;
}
export interface SettingFieldSchema {
type: 'boolean' | 'string' | 'number' | 'select';
label: string;
description?: string;
default: unknown;
options?: string[];
min?: number;
max?: number;
}
// ─── Installed Items ─────────────────────────────────────────
export interface InstalledTheme {
id: string;
name: string;
version: string;
author: string;
description: string;
preview?: string; // data: URI or blob URL
css: string; // raw CSS text
variants: ThemeVariant[];
enabled: boolean;
builtIn: boolean;
}
export interface InstalledPlugin {
id: string;
name: string;
version: string;
author: string;
description: string;
type: Exclude<PluginType, 'theme'>;
permissions: string[];
entrypoint: string;
enabled: boolean;
status: PluginStatus;
error?: string;
settingsSchema?: Record<string, SettingFieldSchema>;
settings: Record<string, unknown>;
}
// ─── UI Slots ────────────────────────────────────────────────
export type SlotName =
| 'toolbar-actions'
| 'email-banner'
| 'email-footer'
| 'composer-toolbar'
| 'sidebar-widget'
| 'settings-section'
| 'context-menu-email'
| 'navigation-rail-bottom';
export interface SlotRegistration {
pluginId: string;
component: React.ComponentType<Record<string, unknown>>;
order: number;
}
// ─── Plugin API Types ────────────────────────────────────────
export interface ToolbarAction {
id: string;
label: string;
icon?: string;
onClick: () => void;
order?: number;
}
export interface BannerFactory {
shouldShow: (email: EmailReadView) => boolean;
render: React.ComponentType<{ email: EmailReadView }>;
}
export interface SettingsSection {
id: string;
label: string;
icon?: string;
render: React.ComponentType;
}
export interface ComposerAction {
id: string;
label: string;
icon?: string;
onClick: () => void;
order?: number;
}
export interface SidebarWidget {
id: string;
label: string;
render: React.ComponentType;
order?: number;
}
export interface ContextMenuItem {
id: string;
label: string;
icon?: string;
onClick: (emailIds: string[]) => void;
order?: number;
}
export interface KeyboardShortcut {
id: string;
keys: string;
label: string;
category: string;
handler: () => void;
}
// ─── Read-Only View Types ────────────────────────────────────
// Projected views exposed to plugins — no direct store references
export interface EmailReadView {
id: string;
threadId: string;
mailboxIds: string[];
from: { name: string; email: string }[];
to: { name: string; email: string }[];
cc: { name: string; email: string }[];
subject: string;
receivedAt: string;
isRead: boolean;
isFlagged: boolean;
hasAttachment: boolean;
preview: string;
keywords: string[];
}
export interface DraftView {
to: string[];
cc: string[];
bcc: string[];
subject: string;
htmlBody: string;
textBody: string;
identityId: string;
inReplyTo?: string;
attachments: { name: string; type: string; size: number }[];
}
export interface MailboxView {
id: string;
name: string;
role: string | null;
totalEmails: number;
unreadEmails: number;
parentId: string | null;
}
export interface CalendarEventView {
id: string;
calendarId: string;
title: string;
description: string;
start: string;
end: string;
isAllDay: boolean;
location: string;
status: string;
recurrenceRule?: string;
}
export interface CalendarView {
id: string;
name: string;
color: string;
isVisible: boolean;
isDefault: boolean;
}
export interface ContactView {
id: string;
addressBookId: string;
firstName: string;
lastName: string;
emails: string[];
phones: string[];
company: string;
notes: string;
}
export interface AddressBookView {
id: string;
name: string;
isDefault: boolean;
}
export interface ContactGroupView {
id: string;
name: string;
memberCount: number;
}
export interface FileResourceView {
id: string;
name: string;
type: 'file' | 'directory';
size: number;
mimeType: string;
path: string;
modified: string;
}
export interface IdentityView {
id: string;
name: string;
email: string;
replyTo: string | null;
bcc: string | null;
htmlSignature: string;
textSignature: string;
}
export interface TaskView {
id: string;
title: string;
description: string;
isComplete: boolean;
dueDate: string | null;
priority: string;
calendarId: string;
}
export interface TemplateView {
id: string;
name: string;
subject: string;
htmlBody: string;
textBody: string;
}
export interface FilterRuleView {
id: string;
name: string;
isActive: boolean;
conditions: unknown[];
actions: unknown[];
}
export interface KeywordView {
id: string;
name: string;
color: string;
}
export interface QuotaView {
used: number;
total: number;
percentUsed: number;
}
export interface CalendarAlertView {
id: string;
eventId: string;
eventTitle: string;
triggerTime: string;
}
export interface SearchFilters {
from?: string;
to?: string;
subject?: string;
hasAttachment?: boolean;
after?: string;
before?: string;
inMailbox?: string;
}
export interface NewEmailNotification {
emailId: string;
from: { name: string; email: string };
subject: string;
preview: string;
}
export interface VacationView {
isEnabled: boolean;
subject: string;
htmlBody: string;
textBody: string;
fromDate: string | null;
toDate: string | null;
}
export interface KeyboardEventView {
key: string;
code: string;
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
metaKey: boolean;
}
export interface AppConfigView {
appName: string;
demoMode: boolean;
stalwartFeaturesEnabled: boolean;
oauthEnabled: boolean;
}
export interface FileInfo {
name: string;
size: number;
type: string;
}
export interface ComposerContext {
mode: 'new' | 'reply' | 'reply-all' | 'forward';
inReplyToId?: string;
originalSubject?: string;
}
// ─── Permission Reference ────────────────────────────────────
export const ALL_PERMISSIONS = [
'email:read', 'email:write', 'email:send',
'calendar:read', 'calendar:write',
'contacts:read', 'contacts:write',
'files:read', 'files:write',
'identity:read', 'identity:write',
'filters:read', 'filters:write',
'tasks:read', 'tasks:write',
'templates:read', 'templates:write',
'smime:read',
'vacation:read', 'vacation:write',
'settings:read', 'settings:write',
'security:read',
'auth:observe',
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
'app:lifecycle',
] as const;
export type Permission = (typeof ALL_PERMISSIONS)[number];
/** Permissions always granted regardless of manifest */
export const IMPLICIT_PERMISSIONS: Permission[] = ['ui:observe', 'app:lifecycle'];
// ─── Validation ──────────────────────────────────────────────
export const MAX_PLUGIN_SIZE = 5 * 1024 * 1024; // 5 MB
export const MAX_THEME_SIZE = 1 * 1024 * 1024; // 1 MB
export const ALLOWED_PLUGIN_FILES = new Set([
'.js', '.mjs', '.css', '.json', '.png', '.svg', '.woff2', '.jpg', '.jpeg', '.webp',
]);
export const DISALLOWED_CSS_PATTERNS = [
/@import\b/i,
/url\s*\(\s*['"]?https?:/i,
/expression\s*\(/i,
/javascript\s*:/i,
/-moz-binding/i,
/behavior\s*:/i,
];
+326
View File
@@ -0,0 +1,326 @@
// Plugin/Theme ZIP upload validation, extraction, and manifest parsing
import JSZip from 'jszip';
import {
type ThemeManifest,
type PluginManifest,
type PluginType,
ALL_PERMISSIONS,
MAX_PLUGIN_SIZE,
MAX_THEME_SIZE,
ALLOWED_PLUGIN_FILES,
} from './plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from './theme-loader';
export interface ValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
export interface ThemeExtractionResult extends ValidationResult {
manifest: ThemeManifest | null;
css: string;
preview: string | null; // data URI
}
export interface PluginExtractionResult extends ValidationResult {
manifest: PluginManifest | null;
code: string;
preview: string | null;
}
// ─── Manifest Validation ─────────────────────────────────────
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;
}
function validateThemeManifest(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"');
}
if (errors.length > 0) return { result: null, errors };
return {
result: manifest as unknown as ThemeManifest,
errors: [],
};
}
function validatePluginManifest(manifest: Record<string, unknown>): { result: PluginManifest | null; errors: string[] } {
const errors = validateBaseManifest(manifest);
const validTypes: PluginType[] = ['ui-extension', 'sidebar-app', 'hook'];
if (!validTypes.includes(manifest.type as PluginType)) {
errors.push(`Invalid type "${manifest.type}". Must be one of: ${validTypes.join(', ')}`);
}
if (!manifest.entrypoint || typeof manifest.entrypoint !== 'string') {
errors.push('Missing or invalid "entrypoint"');
}
if (manifest.permissions && Array.isArray(manifest.permissions)) {
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
const unknown = (manifest.permissions as string[]).filter(p => !validPerms.has(p));
if (unknown.length > 0) {
errors.push(`Unknown permissions: ${unknown.join(', ')}`);
}
}
if (errors.length > 0) return { result: null, errors };
return {
result: {
...(manifest as unknown as PluginManifest),
permissions: (manifest.permissions as string[]) || [],
},
errors: [],
};
}
// ─── JS Security Checks ─────────────────────────────────────
const SUSPICIOUS_JS_PATTERNS = [
{ pattern: /\beval\s*\(/g, label: 'eval()' },
{ pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
{ pattern: /document\.cookie/g, label: 'document.cookie' },
{ pattern: /document\.write/g, label: 'document.write' },
{ pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
];
function checkJSSecurity(code: string): string[] {
const warnings: string[] = [];
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
if (pattern.test(code)) {
warnings.push(`Contains ${label} — review for security`);
}
pattern.lastIndex = 0; // reset regex
}
return warnings;
}
// ─── ZIP Extraction ──────────────────────────────────────────
function getExtension(filename: string): string {
const dot = filename.lastIndexOf('.');
return dot >= 0 ? filename.slice(dot).toLowerCase() : '';
}
/**
* Find the root of the ZIP contents.
* Some ZIPs have all files inside a single top-level folder.
*/
function findZipRoot(zip: JSZip): string {
const entries = Object.keys(zip.files);
// Check if all entries share a common top-level directory
const topDirs = new Set(entries.map(e => e.split('/')[0]));
if (topDirs.size === 1) {
const dir = [...topDirs][0];
// Verify it's actually a directory (has entries inside it)
if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
return dir + '/';
}
}
return '';
}
/**
* Extract and validate a theme ZIP file.
*/
export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
const errors: string[] = [];
const warnings: string[] = [];
// Size check
if (file.size > MAX_THEME_SIZE) {
return { valid: false, errors: ['Theme ZIP exceeds 1 MB size limit'], warnings: [], manifest: null, css: '', preview: null };
}
let zip: JSZip;
try {
const buffer = await file.arrayBuffer();
zip = await JSZip.loadAsync(buffer);
} catch {
return { valid: false, errors: ['Invalid ZIP file'], warnings: [], manifest: null, css: '', preview: null };
}
const root = findZipRoot(zip);
// Read manifest
const manifestFile = zip.file(root + 'manifest.json');
if (!manifestFile) {
return { valid: false, errors: ['Missing manifest.json'], warnings: [], manifest: null, css: '', preview: null };
}
let manifestData: Record<string, unknown>;
try {
const raw = await manifestFile.async('string');
manifestData = JSON.parse(raw);
} catch {
return { valid: false, errors: ['Invalid manifest.json (not valid JSON)'], warnings: [], manifest: null, css: '', preview: null };
}
const { result: manifest, errors: manifestErrors } = validateThemeManifest(manifestData);
errors.push(...manifestErrors);
if (!manifest) {
return { valid: false, errors, warnings, manifest: null, css: '', preview: null };
}
// Read theme.css
const cssFile = zip.file(root + 'theme.css');
if (!cssFile) {
errors.push('Missing theme.css');
return { valid: false, errors, warnings, manifest, css: '', preview: null };
}
let rawCSS = await cssFile.async('string');
// Validate CSS safety
const safety = validateThemeCSSSafety(rawCSS);
if (!safety.valid) {
// Sanitize instead of rejecting
const sanitized = sanitizeThemeCSS(rawCSS);
rawCSS = sanitized.css;
warnings.push(...sanitized.warnings);
}
// Read preview image if present
let preview: string | null = null;
if (manifest.preview) {
const previewFile = zip.file(root + manifest.preview);
if (previewFile) {
try {
const blob = await previewFile.async('blob');
preview = await blobToDataUri(blob);
} catch {
warnings.push('Could not read preview image');
}
}
}
return {
valid: errors.length === 0,
errors,
warnings,
manifest,
css: rawCSS,
preview,
};
}
/**
* Extract and validate a plugin ZIP file.
*/
export async function extractPlugin(file: File): Promise<PluginExtractionResult> {
const errors: string[] = [];
const warnings: string[] = [];
if (file.size > MAX_PLUGIN_SIZE) {
return { valid: false, errors: ['Plugin ZIP exceeds 5 MB size limit'], warnings: [], manifest: null, code: '', preview: null };
}
let zip: JSZip;
try {
const buffer = await file.arrayBuffer();
zip = await JSZip.loadAsync(buffer);
} catch {
return { valid: false, errors: ['Invalid ZIP file'], warnings: [], manifest: null, code: '', preview: null };
}
const root = findZipRoot(zip);
// Check for disallowed file extensions
for (const [path, entry] of Object.entries(zip.files)) {
if (entry.dir) continue;
const ext = getExtension(path);
if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) {
errors.push(`Disallowed file type: ${path} (${ext})`);
}
}
// Read manifest
const manifestFile = zip.file(root + 'manifest.json');
if (!manifestFile) {
return { valid: false, errors: ['Missing manifest.json', ...errors], warnings, manifest: null, code: '', preview: null };
}
let manifestData: Record<string, unknown>;
try {
const raw = await manifestFile.async('string');
manifestData = JSON.parse(raw);
} catch {
return { valid: false, errors: ['Invalid manifest.json (not valid JSON)', ...errors], warnings, manifest: null, code: '', preview: null };
}
const { result: manifest, errors: manifestErrors } = validatePluginManifest(manifestData);
errors.push(...manifestErrors);
if (!manifest) {
return { valid: false, errors, warnings, manifest: null, code: '', preview: null };
}
// Read entrypoint
const entryFile = zip.file(root + manifest.entrypoint);
if (!entryFile) {
errors.push(`Missing entrypoint file: ${manifest.entrypoint}`);
return { valid: false, errors, warnings, manifest, code: '', preview: null };
}
const code = await entryFile.async('string');
// JS security checks
warnings.push(...checkJSSecurity(code));
// Read preview if present
let preview: string | null = null;
const previewFile = zip.file(root + 'preview.png') || zip.file(root + 'preview.svg');
if (previewFile) {
try {
const blob = await previewFile.async('blob');
preview = await blobToDataUri(blob);
} catch {
warnings.push('Could not read preview image');
}
}
return {
valid: errors.length === 0,
errors,
warnings,
manifest,
code,
preview,
};
}
function blobToDataUri(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
+115
View File
@@ -0,0 +1,115 @@
// Theme CSS injection and sanitization
import { DISALLOWED_CSS_PATTERNS } from './plugin-types';
const THEME_STYLE_ID = 'active-theme';
/**
* Sanitize theme CSS: strip dangerous patterns like @import, external url(),
* JavaScript expressions, and -moz-binding. Returns cleaned CSS.
*/
export function sanitizeThemeCSS(css: string): { css: string; warnings: string[] } {
const warnings: string[] = [];
let cleaned = css;
for (const pattern of DISALLOWED_CSS_PATTERNS) {
if (pattern.test(cleaned)) {
warnings.push(`Removed disallowed pattern: ${pattern.source}`);
cleaned = cleaned.replace(new RegExp(pattern.source, 'gi'), '/* [removed] */');
}
}
return { css: cleaned, warnings };
}
/**
* Validate that theme CSS only targets :root and .dark selectors.
* Returns warnings for any other selectors found.
*/
export function validateThemeSelectors(css: string): string[] {
const warnings: string[] = [];
// Remove comments
const noComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
// Find selector blocks (text before { that isn't inside a value)
const selectorRegex = /([^{}]+)\{/g;
let match;
while ((match = selectorRegex.exec(noComments)) !== null) {
const selector = match[1].trim();
// Allow :root, .dark, @font-face, @keyframes, @media
if (
selector === ':root' ||
selector === '.dark' ||
selector.startsWith('@font-face') ||
selector.startsWith('@keyframes') ||
selector.startsWith('@media') ||
selector === ''
) {
continue;
}
// Inside @media blocks, also allow :root and .dark
if (selector === ':root' || selector === '.dark') continue;
warnings.push(`Non-standard selector "${selector}" — themes should only use :root and .dark`);
}
return warnings;
}
/**
* Inject theme CSS into the document head.
* Inserted after globals.css so theme variables win specificity.
*/
export function injectThemeCSS(css: string): void {
if (typeof document === 'undefined') return;
let styleEl = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = THEME_STYLE_ID;
document.head.appendChild(styleEl);
}
styleEl.textContent = css;
}
/**
* Remove injected theme CSS, reverting to default.
*/
export function removeThemeCSS(): void {
if (typeof document === 'undefined') return;
const styleEl = document.getElementById(THEME_STYLE_ID);
if (styleEl) {
styleEl.remove();
}
}
/**
* Check if a theme CSS string is valid and safe.
*/
export function validateThemeCSSSafety(css: string): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (!css.trim()) {
errors.push('Theme CSS is empty');
return { valid: false, errors };
}
// Check for dangerous patterns
for (const pattern of DISALLOWED_CSS_PATTERNS) {
if (pattern.test(css)) {
errors.push(`Contains disallowed pattern: ${pattern.source}`);
}
}
// Check the CSS actually sets some variables
if (!css.includes('--color-')) {
errors.push('Theme CSS should set at least one --color-* variable');
}
return { valid: errors.length === 0, errors };
}