diff --git a/app/(main)/admin/_tabs/plugin-config-panel.tsx b/app/(main)/admin/_tabs/plugin-config-panel.tsx index 9f5370614..723d18839 100644 --- a/app/(main)/admin/_tabs/plugin-config-panel.tsx +++ b/app/(main)/admin/_tabs/plugin-config-panel.tsx @@ -115,6 +115,13 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) { try { let hasError = false; + let firstError: string | null = null; + const noteError = async (res: Response) => { + hasError = true; + if (firstError) return; + const data = await res.json().catch(() => null); + if (typeof data?.error === 'string') firstError = data.error; + }; for (const [key, field] of Object.entries(plugin.configSchema)) { const newVal = formValues[key] ?? ''; const oldVal = config[key] !== undefined ? String(config[key]) : ''; @@ -135,7 +142,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) { if (res.ok) { setConfig(prev => { const next = { ...prev }; delete next[key]; return next; }); } else { - hasError = true; + await noteError(res); } continue; } @@ -148,12 +155,12 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) { if (res.ok) { setConfig(prev => ({ ...prev, [key]: value })); } else { - hasError = true; + await noteError(res); } } setMessage(hasError - ? { type: 'error', text: 'Some settings failed to save' } + ? { type: 'error', text: firstError ?? 'Some settings failed to save' } : { type: 'success', text: 'Configuration saved' } ); } catch { diff --git a/app/(main)/admin/layout.tsx b/app/(main)/admin/layout.tsx index 201cc68d7..ab54a0e44 100644 --- a/app/(main)/admin/layout.tsx +++ b/app/(main)/admin/layout.tsx @@ -90,6 +90,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) const [authError, setAuthError] = useState(null); const [isStalwartAutoLogin, setIsStalwartAutoLogin] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false); + const [configReadOnly, setConfigReadOnly] = useState(false); const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig(); const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); @@ -178,6 +179,18 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) return () => { cancelled = true; }; }, [pathname, router]); + // With ADMIN_CONFIG_READONLY the panel still shows every setting, but + // nothing can be saved; say so up front instead of after a failed save (#1249). + useEffect(() => { + if (!authenticated) return; + let cancelled = false; + apiFetch('/api/setup/status') + .then(res => (res.ok ? res.json() : null)) + .then(data => { if (!cancelled) setConfigReadOnly(data?.readOnly === true); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [authenticated]); + function handleLogout() { requestLeave(async () => { await apiFetch('/api/admin/auth', { method: 'DELETE' }); @@ -425,7 +438,18 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) Loading admin panel… ) : authenticated ? ( - children + <> + {configReadOnly && ( +
+

The configuration is read-only

+

+ ADMIN_CONFIG_READONLY is set, so you can look at the settings here but not save changes. + Change them in the mounted config files or environment instead. +

+
+ )} + {children} + ) : null} diff --git a/app/api/admin/__tests__/read-only-config.test.ts b/app/api/admin/__tests__/read-only-config.test.ts new file mode 100644 index 000000000..f7e7a429c --- /dev/null +++ b/app/api/admin/__tests__/read-only-config.test.ts @@ -0,0 +1,125 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import { NextRequest } from 'next/server'; +import { mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs'; +import path from 'node:path'; + +// Writes from the admin panel must answer 409 with the read-only message +// instead of a bare 500, and must not change anything on the way (#1249). + +// Hoisted so the dirs are set before any admin module loads. +const configDir = await vi.hoisted(async () => { + const { mkdtempSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const dir = mkdtempSync(join(tmpdir(), 'bulwark-readonly-')); + process.env.ADMIN_CONFIG_DIR = dir; + process.env.ADMIN_STATE_DIR = join(dir, 'state'); + return dir; +}); + +let readOnly = false; + +vi.mock('@/lib/admin/session', () => ({ + requireAdminAuth: async () => ({ payload: { role: 'admin' } }), + getClientIP: () => '127.0.0.1', +})); +vi.mock('@/lib/admin/audit', () => ({ auditLog: async () => {} })); +vi.mock('@/lib/admin/paths', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isConfigReadOnly: () => readOnly, + assertWritable: (operation: string) => { + if (readOnly) throw new actual.ConfigReadOnlyError(operation); + }, + }; +}); + +import { PATCH as patchConfig, DELETE as deleteConfig } from '../config/route'; +import { PUT as putPolicy } from '../policy/route'; +import { DELETE as deleteBranding } from '../branding/route'; +import { POST as changePassword } from '../change-password/route'; +import { POST as decidePluginApproval } from '../plugin-approvals/route'; +import { setInitialAdminPassword, verifyAdminPassword } from '@/lib/admin/password'; +import { requestApproval, listApprovals } from '@/lib/admin/plugin-approvals'; +import { configManager } from '@/lib/admin/config-manager'; + +function req(url: string, method: string, body: unknown) { + return new NextRequest(`http://localhost${url}`, { + method, + body: JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + }); +} + +async function expectReadOnly(res: Response) { + expect(res.status).toBe(409); + expect((await res.json()).error).toMatch(/configuration is read-only/); +} + +beforeAll(async () => { + await setInitialAdminPassword('old-password-123'); + await requestApproval('demo-plugin', 'abcdef0123456789', { name: 'Demo', version: '1.0.0' } as never, 'user@example.org'); + await configManager.ensureLoaded(); +}); + +beforeEach(() => { + readOnly = true; +}); + +afterAll(() => { + rmSync(configDir, { recursive: true, force: true }); +}); + +describe('admin writes on a read-only config (#1249)', () => { + it('PATCH /api/admin/config answers 409 with the reason', async () => { + await expectReadOnly(await patchConfig(req('/api/admin/config', 'PATCH', { appName: 'Changed' }))); + expect(configManager.get('appName')).not.toBe('Changed'); + }); + + it('DELETE /api/admin/config answers 409 with the reason', async () => { + await expectReadOnly(await deleteConfig(req('/api/admin/config', 'DELETE', { key: 'appName' }))); + }); + + it('PUT /api/admin/policy answers 409 with the reason', async () => { + await expectReadOnly(await putPolicy(req('/api/admin/policy', 'PUT', { restrictions: {} }))); + }); + + it('DELETE /api/admin/branding refuses before deleting the file', async () => { + const dir = path.join(configDir, 'branding'); + mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'faviconUrl.png'); + writeFileSync(file, 'png'); + + await expectReadOnly(await deleteBranding(req('/api/admin/branding', 'DELETE', { slot: 'faviconUrl' }))); + expect(existsSync(file)).toBe(true); + }); + + it('POST /api/admin/change-password refuses and keeps the old password', async () => { + await expectReadOnly(await changePassword(req('/api/admin/change-password', 'POST', { + currentPassword: 'old-password-123', + newPassword: 'new-password-456', + }))); + expect(await verifyAdminPassword('old-password-123')).toBe(true); + expect(await verifyAdminPassword('new-password-456')).toBe(false); + }); + + it('POST /api/admin/plugin-approvals refuses and leaves the request pending', async () => { + await expectReadOnly(await decidePluginApproval(req('/api/admin/plugin-approvals', 'POST', { + pluginId: 'demo-plugin', + bundleHash: 'abcdef0123456789', + decision: 'approved', + }))); + expect((await listApprovals()).map(e => e.status)).toEqual(['pending']); + }); + + it('still saves when the config is writable', async () => { + readOnly = false; + const res = await patchConfig(req('/api/admin/config', 'PATCH', { appName: 'Writable' })); + expect(res.status).toBe(200); + expect(configManager.get('appName')).toBe('Writable'); + }); +}); diff --git a/app/api/admin/branding/route.ts b/app/api/admin/branding/route.ts index 11f68b1e6..b7d5e4ff5 100644 --- a/app/api/admin/branding/route.ts +++ b/app/api/admin/branding/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { configManager } from '@/lib/admin/config-manager'; -import { getConfigDir } from '@/lib/admin/paths'; +import { getConfigDir, assertWritable } from '@/lib/admin/paths'; import { parseDomainBranding, type DomainBrandingEntry, @@ -12,6 +12,7 @@ import { logger } from '@/lib/logger'; import { writeFile, unlink, mkdir, readdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import path from 'node:path'; +import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; function getBrandingDir(): string { return path.join(getConfigDir(), 'branding'); @@ -124,6 +125,9 @@ export async function POST(request: NextRequest) { try { const result = await requireAdminAuth(request); if ('error' in result) return result.error; + // Refuse before touching the branding dir: the config write that + // follows would fail only after the files had already changed. + assertWritable('change branding'); const ip = getClientIP(request); const formData = await request.formData(); @@ -206,6 +210,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ url: servedUrl, filename: safeName }); } catch (error) { + const readOnly = configReadOnlyResponse(error); + if (readOnly) return readOnly; logger.error('Branding upload error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } @@ -224,6 +230,9 @@ export async function DELETE(request: NextRequest) { try { const result = await requireAdminAuth(request); if ('error' in result) return result.error; + // Refuse before touching the branding dir: the config write that + // follows would fail only after the files had already changed. + assertWritable('change branding'); const ip = getClientIP(request); const body = await request.json().catch(() => ({})) as { slot?: string; host?: string }; @@ -271,6 +280,8 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ success: true }); } catch (error) { + const readOnly = configReadOnlyResponse(error); + if (readOnly) return readOnly; logger.error('Branding delete error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } diff --git a/app/api/admin/change-password/route.ts b/app/api/admin/change-password/route.ts index e29cc542c..2cc06b3db 100644 --- a/app/api/admin/change-password/route.ts +++ b/app/api/admin/change-password/route.ts @@ -3,6 +3,7 @@ import { changeAdminPassword } from '@/lib/admin/password'; import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; +import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; /** * POST /api/admin/change-password - Change admin password @@ -31,6 +32,8 @@ export async function POST(request: NextRequest) { await auditLog('admin.change-password', {}, ip); return NextResponse.json({ ok: true }); } catch (error) { + const readOnly = configReadOnlyResponse(error); + if (readOnly) return readOnly; logger.error('Admin change password error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts index c65dcb10a..450b88674 100644 --- a/app/api/admin/config/route.ts +++ b/app/api/admin/config/route.ts @@ -8,6 +8,7 @@ import { parseDomainBranding } from '@/lib/admin/domain-branding'; import { initAdminPassword, isAdminEnabled } from '@/lib/admin/password'; import { logger } from '@/lib/logger'; import { hasSessionSecret } from '@/lib/auth/session-secret'; +import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; // Strings that count as "no real secret configured" - used so the dashboard // can warn about a placeholder session secret without us ever returning the @@ -145,6 +146,8 @@ export async function PATCH(request: NextRequest) { return NextResponse.json({ ok: true }); } catch (error) { + const readOnly = configReadOnlyResponse(error); + if (readOnly) return readOnly; logger.error('Admin config update error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } @@ -175,6 +178,8 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ ok: true }); } catch (error) { + const readOnly = configReadOnlyResponse(error); + if (readOnly) return readOnly; logger.error('Admin config revert error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index 9cf833457..233b5974c 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -23,6 +23,7 @@ import { } from '@/lib/extension-package'; import { configManager } from '@/lib/admin/config-manager'; import { checkBundleSignature } from '@/lib/admin/directory-keys'; +import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; async function getDirectoryUrl(): Promise { await configManager.ensureLoaded(); @@ -303,6 +304,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true, plugin, warnings: pkg.warnings, updated: isUpdate, signature: signature.status }); } catch (error) { + const readOnly = configReadOnlyResponse(error); + if (readOnly) return readOnly; logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Installation failed' }, { status: 500 }); } diff --git a/app/api/admin/plugin-approvals/route.ts b/app/api/admin/plugin-approvals/route.ts index 200f3ad78..684f3e706 100644 --- a/app/api/admin/plugin-approvals/route.ts +++ b/app/api/admin/plugin-approvals/route.ts @@ -3,6 +3,7 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; import { listApprovals, decideApproval, revokeApproval } from '@/lib/admin/plugin-approvals'; +import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; /** * Admin-protected CRUD for the per-(pluginId, bundleHash) approval table. @@ -55,6 +56,8 @@ export async function POST(request: NextRequest) { await auditLog('plugin.approval', { pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision: entry.status }, ip); return NextResponse.json({ entry }); } catch (err) { + const readOnly = configReadOnlyResponse(err); + if (readOnly) return readOnly; logger.error('plugin-approvals POST', { error: err instanceof Error ? err.message : String(err) }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } @@ -79,6 +82,8 @@ export async function DELETE(request: NextRequest) { await auditLog('plugin.approval.revoke', { pluginId, bundleHash, by: adminUser }, ip); return NextResponse.json({ ok: true }); } catch (err) { + const readOnly = configReadOnlyResponse(err); + if (readOnly) return readOnly; logger.error('plugin-approvals DELETE', { error: err instanceof Error ? err.message : String(err) }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } diff --git a/app/api/admin/plugins/[id]/config/route.ts b/app/api/admin/plugins/[id]/config/route.ts index 7e75f1e61..b6b8a61a4 100644 --- a/app/api/admin/plugins/[id]/config/route.ts +++ b/app/api/admin/plugins/[id]/config/route.ts @@ -4,6 +4,7 @@ import { getDevPlugin } from '@/lib/admin/plugin-dev'; import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config'; import { requireAdminAuth } from '@/lib/admin/session'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; /** Resolve a plugin from the persisted registry first, then PLUGIN_DEV_DIR. */ async function resolvePlugin(id: string) { @@ -128,7 +129,9 @@ export async function PUT( await setPluginConfig(id, body.key, body.value); return NextResponse.json({ ok: true }); - } catch { + } catch (error) { + const readOnly = configReadOnlyResponse(error); + if (readOnly) return readOnly; return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } @@ -165,7 +168,9 @@ export async function DELETE( await deletePluginConfigKey(id, body.key); return NextResponse.json({ ok: true }); - } catch { + } catch (error) { + const readOnly = configReadOnlyResponse(error); + if (readOnly) return readOnly; return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index 7e0306515..afed1c56a 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -14,6 +14,7 @@ import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins'; import { cleanUpDeletedPlugin } from '@/lib/admin/extension-cleanup'; import { ExtensionPackageError, readPluginPackage, suspiciousCode } from '@/lib/extension-package'; import { MAX_PLUGIN_SIZE } from '@/lib/plugin-types'; +import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; /** * GET /api/admin/plugins - List all admin-managed plugins @@ -124,6 +125,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ plugin, warnings: pkg.warnings }); } catch (error) { + const readOnly = configReadOnlyResponse(error); + if (readOnly) return readOnly; logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } @@ -175,6 +178,8 @@ export async function PATCH(request: NextRequest) { await auditLog('plugin.update', { id, ...updates }, ip); return NextResponse.json({ plugin: updated }); } catch (error) { + const readOnly = configReadOnlyResponse(error); + if (readOnly) return readOnly; logger.error('Plugin update error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } @@ -206,6 +211,8 @@ export async function DELETE(request: NextRequest) { await auditLog('plugin.delete', { id }, ip); return NextResponse.json({ success: true }); } catch (error) { + const readOnly = configReadOnlyResponse(error); + if (readOnly) return readOnly; logger.error('Plugin delete error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } diff --git a/app/api/admin/policy/route.ts b/app/api/admin/policy/route.ts index 681aaae28..6e707577e 100644 --- a/app/api/admin/policy/route.ts +++ b/app/api/admin/policy/route.ts @@ -5,6 +5,7 @@ import { auditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; import { POLICY_SCOPE_HEADER, type SettingsPolicy } from '@/lib/admin/types'; import { canSeeInstanceDetails } from '@/lib/auth/instance-details'; +import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; /** * GET /api/admin/policy - Get settings policy (NOT admin-protected - users read this; @@ -81,6 +82,8 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ ok: true }); } catch (error) { + const readOnly = configReadOnlyResponse(error); + if (readOnly) return readOnly; logger.error('Policy update error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } diff --git a/app/api/admin/themes/route.ts b/app/api/admin/themes/route.ts index 3950021aa..70d88d06f 100644 --- a/app/api/admin/themes/route.ts +++ b/app/api/admin/themes/route.ts @@ -13,6 +13,7 @@ import { import { forgetExtensionInPolicy } from '@/lib/admin/extension-cleanup'; import { readThemePackage } from '@/lib/extension-package'; +import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; /** * GET /api/admin/themes - List all admin-managed themes @@ -76,6 +77,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ theme, warnings }); } catch (error) { + const readOnly = configReadOnlyResponse(error); + if (readOnly) return readOnly; logger.error('Theme install error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } @@ -113,6 +116,8 @@ export async function PATCH(request: NextRequest) { await auditLog('theme.update', { id, ...updates }, ip); return NextResponse.json({ theme: updated }); } catch (error) { + const readOnly = configReadOnlyResponse(error); + if (readOnly) return readOnly; logger.error('Theme update error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } @@ -143,6 +148,8 @@ export async function DELETE(request: NextRequest) { await auditLog('theme.delete', { id }, ip); return NextResponse.json({ success: true }); } catch (error) { + const readOnly = configReadOnlyResponse(error); + if (readOnly) return readOnly; logger.error('Theme delete error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } diff --git a/lib/admin/config-read-only-response.ts b/lib/admin/config-read-only-response.ts new file mode 100644 index 000000000..c334d3799 --- /dev/null +++ b/lib/admin/config-read-only-response.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server'; +import { ConfigReadOnlyError } from './paths'; + +/** + * The 409 an admin route answers when a write hit ADMIN_CONFIG_READONLY, + * carrying the ConfigReadOnlyError message so the panel can show why + * nothing was saved (#1249). Returns null for any other error, which the + * route keeps reporting as a 500. + */ +export function configReadOnlyResponse(error: unknown): NextResponse | null { + if (!(error instanceof ConfigReadOnlyError)) return null; + return NextResponse.json({ error: error.message }, { status: 409 }); +} diff --git a/lib/admin/password.ts b/lib/admin/password.ts index d9dff58a5..c9772bd4a 100644 --- a/lib/admin/password.ts +++ b/lib/admin/password.ts @@ -185,9 +185,12 @@ export async function changeAdminPassword(currentPassword: string, newPassword: const valid = await verifyAdminPassword(currentPassword); if (!valid) return false; + // Write first, then swap the cache: a refused write (read-only config) + // must not leave the new password active until the next restart. const hash = await hashPassword(newPassword); - cachedConfig = { passwordHash: hash, passwordHashFile: undefined }; - await writeConfigData(cachedConfig); + const next: AdminConfigData = { passwordHash: hash, passwordHashFile: undefined }; + await writeConfigData(next); + cachedConfig = next; cachedState = { ...(cachedState ?? freshState()), diff --git a/lib/admin/plugin-approvals.ts b/lib/admin/plugin-approvals.ts index 2654b9298..ef0ac516b 100644 --- a/lib/admin/plugin-approvals.ts +++ b/lib/admin/plugin-approvals.ts @@ -148,6 +148,7 @@ export async function requestApproval( const file = cached!; const existing = findEntry(file, pluginId, bundleHash); if (existing) return existing; + assertWritable('request plugin approval'); const pendingByUser = file.entries.filter(e => e.status === 'pending' && e.requestedBy === requestedBy).length; if (pendingByUser >= MAX_PENDING_PER_USER) { throw new ApprovalLimitError(`You already have ${MAX_PENDING_PER_USER} plugins waiting for approval`); @@ -182,6 +183,7 @@ export async function decideApproval( decision: 'approved' | 'denied', decidedBy: string, ): Promise { + assertWritable('decide plugin approval'); await ensureLoaded(); const file = cached!; const entry = findEntry(file, pluginId, bundleHash); @@ -194,6 +196,7 @@ export async function decideApproval( } export async function revokeApproval(pluginId: string, bundleHash: string): Promise { + assertWritable('revoke plugin approval'); await ensureLoaded(); const file = cached!; const idx = file.entries.findIndex(e => e.pluginId === pluginId && e.bundleHash === bundleHash);