feat: enforce forced/managed plugins and policy. Split user upload permission
This commit is contained in:
+250
-12
@@ -14,6 +14,7 @@ import { extractPlugin } from '@/lib/plugin-validator';
|
||||
import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable } from '@/lib/plugin-loader';
|
||||
import { setSlotRegistrationBridge } from '@/lib/plugin-api';
|
||||
import { removeAllPluginHooks } from '@/lib/plugin-hooks';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
|
||||
// ─── Slot State ──────────────────────────────────────────────
|
||||
|
||||
@@ -30,6 +31,8 @@ function emptySlots(): Record<SlotName, SlotRegistration[]> {
|
||||
return slots;
|
||||
}
|
||||
|
||||
let pluginInitializationPromise: Promise<void> | null = null;
|
||||
|
||||
// ─── Store Interface ─────────────────────────────────────────
|
||||
|
||||
interface PluginStoreState {
|
||||
@@ -88,6 +91,8 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
entrypoint: manifest.entrypoint,
|
||||
enabled: false, // Start disabled, user must enable
|
||||
status: 'installed',
|
||||
managed: false,
|
||||
forceEnabled: false,
|
||||
settings: existing?.settings ?? {},
|
||||
settingsSchema: manifest.settingsSchema,
|
||||
};
|
||||
@@ -108,6 +113,8 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
const { plugins } = get();
|
||||
const plugin = plugins.find(p => p.id === id);
|
||||
if (!plugin) return;
|
||||
const forceEnabledByPolicy = usePolicyStore.getState().isPluginForceEnabled(id);
|
||||
if (plugin.forceEnabled || forceEnabledByPolicy) return;
|
||||
|
||||
// Deactivate if running
|
||||
deactivatePlugin(id);
|
||||
@@ -155,6 +162,11 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
|
||||
disablePlugin: (id: string) => {
|
||||
const { plugins } = get();
|
||||
const plugin = plugins.find(p => p.id === id);
|
||||
if (!plugin) return;
|
||||
const forceEnabledByPolicy = usePolicyStore.getState().isPluginForceEnabled(id);
|
||||
if (plugin.forceEnabled || forceEnabledByPolicy) return;
|
||||
|
||||
deactivatePlugin(id);
|
||||
|
||||
set({
|
||||
@@ -207,20 +219,42 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
initializePlugins: async () => {
|
||||
if (get().initialized) return;
|
||||
|
||||
// Wire up bridges
|
||||
setPluginStoreAccessor({
|
||||
setPluginStatus: get().setPluginStatus,
|
||||
});
|
||||
setSlotRegistrationBridge(get().registerSlot);
|
||||
setupAutoDisable();
|
||||
|
||||
// Load all enabled plugins
|
||||
const enabledPlugins = get().plugins.filter(p => p.enabled && p.status !== 'error');
|
||||
for (const plugin of enabledPlugins) {
|
||||
await loadPlugin(plugin);
|
||||
if (pluginInitializationPromise) {
|
||||
await pluginInitializationPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
set({ initialized: true });
|
||||
pluginInitializationPromise = (async () => {
|
||||
// Clean up any previously persisted duplicates by plugin id.
|
||||
const deduped = dedupeInstalledPlugins(get().plugins);
|
||||
if (deduped.length !== get().plugins.length) {
|
||||
set({ plugins: deduped });
|
||||
}
|
||||
|
||||
// Wire up bridges
|
||||
setPluginStoreAccessor({
|
||||
setPluginStatus: get().setPluginStatus,
|
||||
});
|
||||
setSlotRegistrationBridge(get().registerSlot);
|
||||
setupAutoDisable();
|
||||
|
||||
// Sync server-managed plugins before loading
|
||||
await syncServerPlugins(get, set);
|
||||
|
||||
// Load all enabled plugins
|
||||
const enabledPlugins = get().plugins.filter(p => p.enabled && p.status !== 'error');
|
||||
for (const plugin of enabledPlugins) {
|
||||
await loadPlugin(plugin);
|
||||
}
|
||||
|
||||
set({ initialized: true });
|
||||
})();
|
||||
|
||||
try {
|
||||
await pluginInitializationPromise;
|
||||
} finally {
|
||||
pluginInitializationPromise = null;
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
@@ -237,6 +271,8 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
onRehydrateStorage: () => {
|
||||
return (state) => {
|
||||
if (state) {
|
||||
state.plugins = markServerManagedPlugins(state.plugins);
|
||||
state.plugins = dedupeInstalledPlugins(state.plugins);
|
||||
// Ensure slots are initialized after rehydration
|
||||
state.slots = emptySlots();
|
||||
state.initialized = false;
|
||||
@@ -246,3 +282,205 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// ─── Server Plugin Sync ──────────────────────────────────────
|
||||
|
||||
interface ServerPluginInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author: string;
|
||||
description: string;
|
||||
type: string;
|
||||
permissions: string[];
|
||||
entrypoint: string;
|
||||
forceEnabled: boolean;
|
||||
}
|
||||
|
||||
const SERVER_MANAGED_KEY = 'server-managed-plugin-ids';
|
||||
|
||||
function getServerManagedPluginIds(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(SERVER_MANAGED_KEY);
|
||||
return raw ? new Set(JSON.parse(raw)) : new Set();
|
||||
} catch { return new Set(); }
|
||||
}
|
||||
|
||||
function setServerManagedPluginIds(ids: Set<string>): void {
|
||||
try {
|
||||
localStorage.setItem(SERVER_MANAGED_KEY, JSON.stringify([...ids]));
|
||||
} catch { /* ok */ }
|
||||
}
|
||||
|
||||
function dedupeInstalledPlugins(plugins: InstalledPlugin[]): InstalledPlugin[] {
|
||||
const byId = new Map<string, InstalledPlugin>();
|
||||
|
||||
for (const plugin of plugins) {
|
||||
const existing = byId.get(plugin.id);
|
||||
if (!existing) {
|
||||
byId.set(plugin.id, plugin);
|
||||
continue;
|
||||
}
|
||||
|
||||
byId.set(plugin.id, {
|
||||
...existing,
|
||||
...plugin,
|
||||
enabled: existing.enabled || plugin.enabled,
|
||||
status: existing.enabled || plugin.enabled ? 'enabled' : plugin.status,
|
||||
settings: { ...existing.settings, ...plugin.settings },
|
||||
error: plugin.error ?? existing.error,
|
||||
managed: existing.managed || plugin.managed,
|
||||
forceEnabled: existing.forceEnabled || plugin.forceEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
function markServerManagedPlugins(plugins: InstalledPlugin[]): InstalledPlugin[] {
|
||||
const serverIds = getServerManagedPluginIds();
|
||||
if (serverIds.size === 0) return plugins;
|
||||
return plugins.map(plugin =>
|
||||
serverIds.has(plugin.id) ? { ...plugin, managed: true } : plugin
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync server-managed plugins to the client.
|
||||
* Downloads missing plugin bundles and installs them into IndexedDB + store.
|
||||
* Force-enabled plugins are auto-enabled.
|
||||
* Plugins removed from the server are cleaned up from the client.
|
||||
*/
|
||||
async function syncServerPlugins(
|
||||
get: () => PluginStoreState,
|
||||
set: (partial: Partial<PluginStoreState> | ((state: PluginStoreState) => Partial<PluginStoreState>)) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const res = await fetch('/api/plugins');
|
||||
if (!res.ok) return;
|
||||
|
||||
const data: { plugins: ServerPluginInfo[] } = await res.json();
|
||||
if (!data.plugins || !Array.isArray(data.plugins)) return;
|
||||
|
||||
const serverPlugins = data.plugins;
|
||||
const serverPluginIds = new Set(serverPlugins.map(p => p.id));
|
||||
|
||||
// Track which plugins came from the server (so we can clean up stale ones)
|
||||
const prevServerIds = getServerManagedPluginIds();
|
||||
|
||||
// Install or update server plugins that are missing/outdated locally
|
||||
for (const sp of serverPlugins) {
|
||||
const local = get().plugins.find(p => p.id === sp.id);
|
||||
|
||||
if (!local) {
|
||||
// New server plugin — download and install
|
||||
const code = await downloadPluginBundle(sp.id);
|
||||
if (!code) continue;
|
||||
|
||||
await pluginStorage.saveCode(sp.id, code);
|
||||
|
||||
const plugin: InstalledPlugin = {
|
||||
id: sp.id,
|
||||
name: sp.name,
|
||||
version: sp.version,
|
||||
author: sp.author,
|
||||
description: sp.description,
|
||||
type: sp.type as InstalledPlugin['type'],
|
||||
permissions: sp.permissions,
|
||||
entrypoint: sp.entrypoint,
|
||||
enabled: sp.forceEnabled,
|
||||
status: sp.forceEnabled ? 'enabled' : 'installed',
|
||||
managed: true,
|
||||
forceEnabled: sp.forceEnabled,
|
||||
settings: {},
|
||||
};
|
||||
|
||||
set(state => {
|
||||
if (state.plugins.some(p => p.id === sp.id)) {
|
||||
return {};
|
||||
}
|
||||
return { plugins: [...state.plugins, plugin] };
|
||||
});
|
||||
} else if (local.version !== sp.version) {
|
||||
// Version changed — re-download bundle
|
||||
const code = await downloadPluginBundle(sp.id);
|
||||
if (!code) continue;
|
||||
|
||||
await pluginStorage.saveCode(sp.id, code);
|
||||
|
||||
set(state => ({
|
||||
plugins: state.plugins.map(p =>
|
||||
p.id === sp.id
|
||||
? {
|
||||
...p,
|
||||
name: sp.name,
|
||||
version: sp.version,
|
||||
author: sp.author,
|
||||
description: sp.description,
|
||||
permissions: sp.permissions,
|
||||
entrypoint: sp.entrypoint,
|
||||
managed: true,
|
||||
forceEnabled: sp.forceEnabled,
|
||||
}
|
||||
: p
|
||||
),
|
||||
}));
|
||||
} else if (local.managed !== true || local.forceEnabled !== sp.forceEnabled) {
|
||||
set(state => ({
|
||||
plugins: state.plugins.map(p =>
|
||||
p.id === sp.id
|
||||
? {
|
||||
...p,
|
||||
managed: true,
|
||||
forceEnabled: sp.forceEnabled,
|
||||
}
|
||||
: p
|
||||
),
|
||||
}));
|
||||
} else if (sp.forceEnabled && !local.enabled) {
|
||||
// Force-enable if the server says so but client has it disabled
|
||||
set(state => ({
|
||||
plugins: state.plugins.map(p =>
|
||||
p.id === sp.id
|
||||
? { ...p, enabled: true, status: 'enabled' as const, managed: true, forceEnabled: true }
|
||||
: p
|
||||
),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure no duplicate IDs remain after sync.
|
||||
set(state => ({ plugins: dedupeInstalledPlugins(state.plugins) }));
|
||||
|
||||
// Remove plugins that were previously server-managed but no longer on the server
|
||||
const staleIds = [...prevServerIds].filter(id => !serverPluginIds.has(id));
|
||||
if (staleIds.length > 0) {
|
||||
for (const id of staleIds) {
|
||||
deactivatePlugin(id);
|
||||
removeAllPluginHooks(id);
|
||||
pluginStorage.deleteCode(id);
|
||||
}
|
||||
const staleSet = new Set(staleIds);
|
||||
set(state => ({
|
||||
plugins: state.plugins.filter(p => !staleSet.has(p.id)),
|
||||
}));
|
||||
}
|
||||
|
||||
// Persist current server plugin IDs for future cleanup
|
||||
setServerManagedPluginIds(serverPluginIds);
|
||||
} catch {
|
||||
// Sync failure is non-fatal — client continues with local plugins
|
||||
console.warn('[plugin-store] Server plugin sync failed, using local plugins only');
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadPluginBundle(pluginId: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/bundle`);
|
||||
if (!res.ok) return null;
|
||||
return await res.text();
|
||||
} catch {
|
||||
console.warn(`[plugin-store] Failed to download bundle for plugin "${pluginId}"`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user