From f829d4a2e8ed16fe05e05fbaa7d2845bf8c043f0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:14:44 +0100 Subject: [PATCH] fix: ask the next user again before running plugins installed by someone who signed out --- lib/__tests__/plugin-store.test.ts | 33 +++++++++++++++++++++++++++++- lib/sign-out-cleanup.ts | 15 ++++++++++++++ stores/plugin-store.ts | 19 +++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts index ea4ee26ac..3d208615d 100644 --- a/lib/__tests__/plugin-store.test.ts +++ b/lib/__tests__/plugin-store.test.ts @@ -16,6 +16,7 @@ vi.mock('@/lib/plugin-storage', () => ({ getPreview: vi.fn().mockResolvedValue(null), deletePreview: vi.fn().mockResolvedValue(undefined), }, + fileStorage: { clearFiles: vi.fn().mockResolvedValue(undefined) }, })); vi.mock('@/lib/plugin-validator', () => ({ @@ -61,7 +62,8 @@ import { register as registerActive, deregister as deregisterActive, get as getA // Import after mocks import { usePluginStore, PLUGIN_PERSIST_DELAY_MS } from '@/stores/plugin-store'; import { pluginStorage } from '@/lib/plugin-storage'; -import { forgetSlotOffers } from '@/lib/plugin-loader'; +import { deactivatePlugin, forgetSlotOffers } from '@/lib/plugin-loader'; +import { purgeSignedOutData } from '@/lib/sign-out-cleanup'; function resetStore() { usePluginStore.setState({ @@ -480,6 +482,35 @@ describe('plugins the admin removed', () => { }); }); +describe('a full sign-out', () => { + // On a shared browser the next person used to run the previous user's + // plugins with that user's grants and settings, without being asked. + it("drops the user's grants and settings from plugins they installed and leaves them off", () => { + usePluginStore.setState({ + plugins: [ + mockPlugin({ + id: 'mine', enabled: true, status: 'running', permissions: ['email:read', 'http:fetch'], + grantedPermissions: ['email:read', 'http:fetch'], settings: { token: 'a-secret' }, + }), + mockPlugin({ id: 'admins', managed: true, enabled: true, status: 'running', settings: { mode: 'dark' } }), + ], + }); + + purgeSignedOutData(); + window.dispatchEvent(new Event('pagehide')); + + const [mine, admins] = usePluginStore.getState().plugins; + expect(mine).toMatchObject({ id: 'mine', enabled: false, settings: {} }); + expect(mine.grantedPermissions).toBeUndefined(); + expect(deactivatePlugin).toHaveBeenCalledWith('mine'); + expect(admins).toMatchObject({ id: 'admins', enabled: true, status: 'running', settings: { mode: 'dark' } }); + const stored = JSON.parse(localStorage.getItem('plugin-storage')!).state.plugins; + expect(stored[0]).toMatchObject({ id: 'mine', enabled: false, settings: {} }); + expect(stored[0].grantedPermissions).toBeUndefined(); + expect(pluginStorage.deleteCode).not.toHaveBeenCalled(); + }); +}); + describe('saving the plugin list', () => { const writes = (setItem: { mock: { calls: unknown[][] } }) => setItem.mock.calls.filter(([key]) => key === 'plugin-storage') as [string, string][]; diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index da2212907..fb1b40404 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -28,6 +28,14 @@ const SIGNED_OUT_STORAGE_KEYS = [ const SIGNED_OUT_SIGNAL = 'bulwark:signed-out'; +// Stores the auth store can't import (they import it) clear themselves here. +const signedOutCleanups = new Set<() => void>(); + +/** Run `cleanup` when everyone signs out in this tab. */ +export function onFullSignOut(cleanup: () => void): void { + signedOutCleanups.add(cleanup); +} + /** Remove the accounts' leftovers from browser storage. */ export function purgeSignedOutData(): void { if (typeof window === 'undefined') return; @@ -40,6 +48,13 @@ export function purgeSignedOutData(): void { } // Attachment bytes a plugin staged for an upload that never started. void fileStorage.clearFiles().catch(() => {}); + for (const cleanup of signedOutCleanups) { + try { + cleanup(); + } catch { + /* one store failing must not keep the others' data */ + } + } } /** Tell the other tabs of this browser that everyone signed out. */ diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 9a4a527c3..7f894689b 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -26,6 +26,7 @@ import { fetchServerPluginList } from '@/lib/server-plugin-list'; import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types'; import type { Permission } from '@/lib/plugin-types'; import { IS_LITE } from '@/lib/lite'; +import { onFullSignOut } from '@/lib/sign-out-cleanup'; let pluginInitializationPromise: Promise | null = null; // One-time guard so we attach the locale and regional subscriptions only once. @@ -819,3 +820,21 @@ usePluginStore.subscribe((state, prev) => { forEachInstance(plugin.id, (instance) => instance.setSettings(next)); } }); + +// A plugin the user installed runs on that user's consent and settings. Once +// everyone has signed out, the next person at this browser must be asked +// again: the code stays, the grants and settings go, and it is left off. +// The admin's plugins are the admin's choice and stay as they are. +onFullSignOut(() => { + const { plugins } = usePluginStore.getState(); + for (const plugin of plugins) { + if (!plugin.managed && plugin.enabled) deactivatePlugin(plugin.id); + } + usePluginStore.setState({ + plugins: plugins.map((plugin) => { + if (plugin.managed) return plugin; + const { grantedPermissions: _granted, ...rest } = plugin; + return { ...rest, enabled: false, status: 'disabled' as PluginStatus, error: undefined, settings: {} }; + }), + }); +});