From c4c84b33f9d02f96eefc81fc9b7b6611791eb2e2 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 04:42:42 +0200 Subject: [PATCH] #200: hard instance-wide plugins.enabled kill switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plugins.enabled (instance setting, default on — plugins predate the switch; the VS-NfD reference configuration turns it off) makes every plugin surface answer 404 via a shared guard: Site-Admin install/list/mode, pond activation and plugin list, the sandbox frame and asset routes. The dropzone watcher quarantines drops instead of installing. Deliberately NOT guarded: the authenticated fallback-metadata route — it serves no plugin code and existing plugin_block nodes need it to render their declared fallback (an image fallback degrades to the neutral placeholder while off, because its bytes live on the disabled asset surface). The editor offers no plugin blocks because the pond plugin list is one of the 404ing surfaces. Admin settings panel gets the toggle (i18n de+en) with the documented api-restart note (in-process settings cache). Answers "code execution inside the zone?" with one verifiable off-switch instead of per-plugin trust machinery (#232, ADR 0025). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ --- .../src/plugins/plugin-admin.controller.ts | 6 +- .../src/plugins/plugin-assets.controller.ts | 13 ++ .../api/src/plugins/plugin-pond.controller.ts | 6 + .../api/src/plugins/plugin-watcher.service.ts | 9 ++ apps/api/src/plugins/plugins-enabled.guard.ts | 23 +++ apps/api/src/plugins/plugins.e2e.db.test.ts | 135 ++++++++++++++++++ apps/api/src/plugins/plugins.module.ts | 5 +- .../src/settings/instance-settings.service.ts | 9 ++ apps/web/src/pages/AdminSettingsPage.tsx | 10 ++ docs/architecture/plugin-architecture.md | 17 +++ docs/vs-nfd/20-massnahmenplan.md | 2 +- packages/shared/i18n/de/apiTokens.json | 4 +- packages/shared/i18n/en/apiTokens.json | 4 +- 13 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/plugins/plugins-enabled.guard.ts diff --git a/apps/api/src/plugins/plugin-admin.controller.ts b/apps/api/src/plugins/plugin-admin.controller.ts index 671b684..7d50236 100644 --- a/apps/api/src/plugins/plugin-admin.controller.ts +++ b/apps/api/src/plugins/plugin-admin.controller.ts @@ -24,6 +24,7 @@ import { SiteAdminGuard } from '../admin/site-admin.guard'; import { AuthedRequest } from '../auth/auth.guard'; import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { PluginsEnabledGuard } from './plugins-enabled.guard'; import { PluginsService } from './plugins.service'; import { MAX_PLUGIN_ZIP_BYTES, PluginPackageError } from './plugin.constants'; @@ -52,7 +53,10 @@ function toHttpException(error: PluginPackageError): HttpException { * and per-pond-activation writes arrive with the admin UI (#72). */ @Controller('admin/plugins') -@UseGuards(SiteAdminGuard) +// Kill switch first (issue #200): while plugins are disabled instance-wide, +// even a Site Admin sees 404 here — the switch is flipped in the settings +// panel, not by probing dead routes. +@UseGuards(PluginsEnabledGuard, SiteAdminGuard) export class PluginAdminController { constructor(private readonly plugins: PluginsService) {} diff --git a/apps/api/src/plugins/plugin-assets.controller.ts b/apps/api/src/plugins/plugin-assets.controller.ts index 698c5c8..a20a20b 100644 --- a/apps/api/src/plugins/plugin-assets.controller.ts +++ b/apps/api/src/plugins/plugin-assets.controller.ts @@ -6,6 +6,7 @@ import { Req, Res, StreamableFile, + UseGuards, } from '@nestjs/common'; import type { Request, Response } from 'express'; import type { PluginFallbackView } from '@dorfteich/shared'; @@ -13,9 +14,11 @@ import type { PluginFallbackView } from '@dorfteich/shared'; import { Public } from '../auth/auth.guard'; import { AppConfig } from '../config/app-config.service'; import { AuthenticatedOnly } from '../permissions/permission.decorators'; +import { InstanceSettingsService } from '../settings/instance-settings.service'; import { buildPluginAssetBase, buildPluginFrameCsp, buildPluginFrameHtml } from './plugin-frame'; import { PluginStorageService } from './plugin-storage.service'; +import { PluginsEnabledGuard } from './plugins-enabled.guard'; import { PluginsService } from './plugins.service'; /** Content types for the file kinds a plugin bundle ships. Unknown extensions @@ -56,6 +59,7 @@ export class PluginAssetsController { private readonly plugins: PluginsService, private readonly storage: PluginStorageService, private readonly config: AppConfig, + private readonly settings: InstanceSettingsService, ) {} /** @@ -63,12 +67,19 @@ export class PluginAssetsController { * (issue #76). Deliberately NOT `@Public`: it names installed plugins, which * is instance metadata for signed-in users, not sandbox-servable content. * 404 covers "never installed" — the client shows a neutral placeholder. + * Deliberately NOT behind the kill switch either (issue #200): it serves no + * plugin code, and with plugins disabled the existing blocks still need it + * to render their declared fallback. An image fallback degrades to the + * neutral placeholder then — its bytes live on the disabled asset surface. */ @Get(':id/fallback') @AuthenticatedOnly() async fallback(@Param('id') id: string): Promise { const view = await this.plugins.fallbackFor(id); if (!view) throw new NotFoundException(); + if (view.fallback?.type === 'image' && !(await this.settings.get('plugins.enabled'))) { + return { ...view, fallback: null }; + } return view; } @@ -79,6 +90,7 @@ export class PluginAssetsController { */ @Get(':id/:version/frame') @Public() + @UseGuards(PluginsEnabledGuard) async frame( @Param('id') id: string, @Param('version') version: string, @@ -102,6 +114,7 @@ export class PluginAssetsController { @Get(':id/:version/*rest') @Public() + @UseGuards(PluginsEnabledGuard) async serve( @Param('id') id: string, @Param('version') version: string, diff --git a/apps/api/src/plugins/plugin-pond.controller.ts b/apps/api/src/plugins/plugin-pond.controller.ts index d2de6b1..24eb9c6 100644 --- a/apps/api/src/plugins/plugin-pond.controller.ts +++ b/apps/api/src/plugins/plugin-pond.controller.ts @@ -9,6 +9,7 @@ import { Put, Body, Req, + UseGuards, } from '@nestjs/common'; import { pondPluginToggleInputSchema, @@ -21,6 +22,7 @@ import { AuthedRequest } from '../auth/auth.guard'; import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { RequiresPondRole } from '../permissions/permission.decorators'; +import { PluginsEnabledGuard } from './plugins-enabled.guard'; import { PluginsService } from './plugins.service'; import { PluginPackageError } from './plugin.constants'; @@ -31,6 +33,10 @@ import { PluginPackageError } from './plugin.constants'; * plugins is a Pond Admin action. */ @Controller('ponds/:pondId/plugins') +// Kill switch (issue #200): with plugins disabled instance-wide the SPA's +// plugin-list query 404s, which its consumers treat as "no plugins" — the +// editor then offers no plugin blocks. +@UseGuards(PluginsEnabledGuard) export class PluginPondController { constructor(private readonly plugins: PluginsService) {} diff --git a/apps/api/src/plugins/plugin-watcher.service.ts b/apps/api/src/plugins/plugin-watcher.service.ts index 56bdae3..219a71e 100644 --- a/apps/api/src/plugins/plugin-watcher.service.ts +++ b/apps/api/src/plugins/plugin-watcher.service.ts @@ -7,6 +7,7 @@ import { PinoLogger } from 'nestjs-pino'; import { ClockService } from '../common/clock.service'; import { AppConfig } from '../config/app-config.service'; +import { InstanceSettingsService } from '../settings/instance-settings.service'; import { PluginStorageService } from './plugin-storage.service'; import { PluginPackageError } from './plugin.constants'; @@ -31,6 +32,7 @@ export class PluginWatcherService implements OnModuleInit, OnModuleDestroy { private readonly plugins: PluginsService, private readonly storage: PluginStorageService, private readonly config: AppConfig, + private readonly settings: InstanceSettingsService, private readonly clock: ClockService, private readonly logger: PinoLogger, ) { @@ -83,6 +85,13 @@ export class PluginWatcherService implements OnModuleInit, OnModuleDestroy { async processDropped( filePath: string, ): Promise<{ installed: true; id: string } | { installed: false; code: string }> { + // The kill switch (issue #200) covers this install surface like the GUI + // routes: a drop is quarantined, not installed, while plugins are off. + if (!(await this.settings.get('plugins.enabled'))) { + await this.quarantine(filePath); + this.logger.warn({ file: filePath }, 'plugins disabled; quarantined dropped plugin ZIP'); + return { installed: false, code: 'plugins_disabled' }; + } let zip: Buffer; try { zip = await readFile(filePath); diff --git a/apps/api/src/plugins/plugins-enabled.guard.ts b/apps/api/src/plugins/plugins-enabled.guard.ts new file mode 100644 index 0000000..85db8be --- /dev/null +++ b/apps/api/src/plugins/plugins-enabled.guard.ts @@ -0,0 +1,23 @@ +import { CanActivate, Injectable, NotFoundException } from '@nestjs/common'; + +import { InstanceSettingsService } from '../settings/instance-settings.service'; + +/** + * Instance-wide plugin kill switch (issue #200, ADR 0025): while + * `plugins.enabled` is false, every guarded plugin surface answers 404 — + * existence stays hidden, the same semantics as `api.enabled` and + * `mcp.enabled`. The fallback-metadata route is deliberately NOT guarded + * (it serves no plugin code and existing blocks need it for their declared + * fallback). The settings cache is in-process, so flipping the switch is + * followed by an api restart like every other instance setting + * (operations.md); the admin UI toggle documents that. + */ +@Injectable() +export class PluginsEnabledGuard implements CanActivate { + constructor(private readonly settings: InstanceSettingsService) {} + + async canActivate(): Promise { + if (!(await this.settings.get('plugins.enabled'))) throw new NotFoundException(); + return true; + } +} diff --git a/apps/api/src/plugins/plugins.e2e.db.test.ts b/apps/api/src/plugins/plugins.e2e.db.test.ts index 316ad44..fc013fd 100644 --- a/apps/api/src/plugins/plugins.e2e.db.test.ts +++ b/apps/api/src/plugins/plugins.e2e.db.test.ts @@ -379,3 +379,138 @@ describe.skipIf(!hasTestDb)('plugins install (e2e, issue #71)', () => { expect(list.body.map((p: { id: string }) => p.id)).not.toContain('removable'); }); }); + +/** + * Instance-wide plugin kill switch (issue #200, ADR 0025): with + * `plugins.enabled = false` every plugin surface answers 404 — even for a + * Site Admin — while existing blocks keep their declared fallback readable; + * the dropzone quarantines instead of installing; and the switch itself is + * flipped through the admin settings surface. Lives in this file because the + * install suite above wipes the plugin registry in its setup — a separate + * parallel file would race that wipe. + */ +describe.skipIf(!hasTestDb)('plugins kill switch (e2e, issue #200)', () => { + let app: INestApplication; + let prisma: PrismaClient; + let watcher: PluginWatcherService; + let storage: PluginStorageService; + const suffix = uniqueSuffix(); + const password = 'schalter aus heisst wirklich aus 1'; + const admin = `kira-killswitch-${suffix}`; + const pluginId = `switched-${suffix}`; + let adminCookie: string; + let pondId: string; + + const api = () => request(app.getHttpServer()); + + beforeAll(async () => { + prisma = createTestPrisma(); + // Written BEFORE the app boots — the settings cache is in-process and + // fills on first read. Cleared afterAll. + await prisma.instanceSetting.upsert({ + where: { key: 'plugins.enabled' }, + create: { key: 'plugins.enabled', value: false }, + update: { value: false }, + }); + // Registry row for the fallback assertion, created directly — the + // install route is exactly what the switch turns off. + await prisma.plugin.create({ + data: { + id: pluginId, + name: 'Switched Off', + version: '1.0.0', + apiVersion: '1', + kind: 'code', + mode: 'OPTIONAL', + manifest: { + ...codeManifest({ id: pluginId, name: 'Switched Off' }), + fallback: { type: 'text', value: 'Der Block schlummert.' }, + }, + }, + }); + app = await createTestApp(); + watcher = app.get(PluginWatcherService); + storage = app.get(PluginStorageService); + const users = app.get(UsersService); + const tokens = app.get(AuthTokensService); + const adminUser = await users.createUser({ + username: admin, + email: `${admin}@example.org`, + displayName: `Kira Killswitch ${suffix}`, + password, + locale: 'en', + }); + const verify = await tokens.issue(adminUser.id, 'EMAIL_VERIFICATION', 600); + await api().post('/api/v1/auth/verify-email').send({ token: verify }).expect(204); + await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } }); + const login = await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: admin, password }) + .expect(200); + adminCookie = sessionCookieOf(login); + pondId = (await prisma.pond.findFirstOrThrow({ where: { ownerId: adminUser.id } })).id; + }); + + afterAll(async () => { + await prisma.instanceSetting.deleteMany({ where: { key: 'plugins.enabled' } }); + await prisma.plugin.deleteMany({ where: { id: pluginId } }); + const where = { pond: { owner: { username: { contains: suffix } } } }; + await prisma.roleGrant.deleteMany({ where }); + await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } }); + await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); + await app.close(); + }); + + it('404s every plugin surface, even for a Site Admin', async () => { + await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(404); + await api() + .post('/api/v1/admin/plugins') + .set('Cookie', adminCookie) + .attach('file', pluginZip(codeManifest({ id: 'nope', name: 'Nope' })), 'nope.zip') + .expect(404); + await api().get(`/api/v1/ponds/${pondId}/plugins`).set('Cookie', adminCookie).expect(404); + await api() + .put(`/api/v1/ponds/${pondId}/plugins/${pluginId}`) + .set('Cookie', adminCookie) + .send({ enabled: true }) + .expect(404); + await api().get(`/api/v1/plugins/${pluginId}/1.0.0/frame`).expect(404); + await api().get(`/api/v1/plugins/${pluginId}/1.0.0/plugin.js`).expect(404); + }); + + it('keeps the declared fallback readable so existing blocks render it', async () => { + const res = await api() + .get(`/api/v1/plugins/${pluginId}/fallback`) + .set('Cookie', adminCookie) + .expect(200); + expect(res.body).toMatchObject({ + name: 'Switched Off', + fallback: { type: 'text', value: 'Der Block schlummert.' }, + }); + }); + + it('quarantines a dropzone drop instead of installing it', async () => { + await storage.ensureServiceDirs(); + const dropPath = join(storage.dropzoneDir, `switched-drop-${suffix}.zip`); + await writeFile(dropPath, pluginZip(codeManifest({ id: 'dropped', name: 'Dropped' }))); + const outcome = await watcher.processDropped(dropPath); + expect(outcome).toEqual({ installed: false, code: 'plugins_disabled' }); + const quarantined = await readdir(storage.quarantineDir); + expect(quarantined.some((name) => name.endsWith(`switched-drop-${suffix}.zip`))).toBe(true); + }); + + it('is flipped through the admin settings surface', async () => { + await api() + .patch('/api/v1/admin/settings') + .set('Cookie', adminCookie) + .send({ 'plugins.enabled': true }) + .expect(200); + await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(200); + await api() + .patch('/api/v1/admin/settings') + .set('Cookie', adminCookie) + .send({ 'plugins.enabled': false }) + .expect(200); + await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(404); + }); +}); diff --git a/apps/api/src/plugins/plugins.module.ts b/apps/api/src/plugins/plugins.module.ts index 150fdea..5739e76 100644 --- a/apps/api/src/plugins/plugins.module.ts +++ b/apps/api/src/plugins/plugins.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { CommonModule } from '../common/common.module'; +import { SettingsModule } from '../settings/settings.module'; import { PluginAdminController } from './plugin-admin.controller'; import { PluginAssetsController } from './plugin-assets.controller'; @@ -9,6 +10,7 @@ import { PluginPondController } from './plugin-pond.controller'; import { PluginPackageService } from './plugin-package.service'; import { PluginStorageService } from './plugin-storage.service'; import { PluginWatcherService } from './plugin-watcher.service'; +import { PluginsEnabledGuard } from './plugins-enabled.guard'; import { PluginsService } from './plugins.service'; /** @@ -18,12 +20,13 @@ import { PluginsService } from './plugins.service'; * dropzone directory watcher. */ @Module({ - imports: [CommonModule], + imports: [CommonModule, SettingsModule], controllers: [PluginAdminController, PluginAssetsController, PluginPondController], providers: [ PluginFallbackRenderer, PluginPackageService, PluginStorageService, + PluginsEnabledGuard, PluginsService, PluginWatcherService, ], diff --git a/apps/api/src/settings/instance-settings.service.ts b/apps/api/src/settings/instance-settings.service.ts index f86fa69..4b42156 100644 --- a/apps/api/src/settings/instance-settings.service.ts +++ b/apps/api/src/settings/instance-settings.service.ts @@ -72,6 +72,15 @@ export const INSTANCE_SETTINGS = { // Built-in MCP endpoint master switch (issue #105, default off) — // independent of the REST switch; ponds opt in via `mcpEnabled`. 'mcp.enabled': z.boolean().default(false), + // Plugin-architecture master switch (issue #200, ADR 0025). Default ON: + // plugins predate the switch, so existing instances keep working; the + // VS-NfD reference configuration (#227) turns it off. While off, every + // plugin surface answers 404 (admin install/list, pond toggles, frame + // and asset routes) — only the authenticated fallback-metadata route + // stays, so existing blocks still render their declared text fallback + // (an image fallback degrades to neutral text: its bytes live on the + // disabled asset surface). + 'plugins.enabled': z.boolean().default(true), // Atom feed master switch (issue #191). Default ON: feeds predate the // switch, so existing instances and their subscribed readers keep // working; the VS-NfD reference configuration (#227) turns it off. diff --git a/apps/web/src/pages/AdminSettingsPage.tsx b/apps/web/src/pages/AdminSettingsPage.tsx index 13ff59d..c0e26c6 100644 --- a/apps/web/src/pages/AdminSettingsPage.tsx +++ b/apps/web/src/pages/AdminSettingsPage.tsx @@ -25,6 +25,7 @@ interface InstanceSettings { 'api.enabled': boolean; 'mcp.enabled': boolean; 'feeds.enabled': boolean; + 'plugins.enabled': boolean; 'upload.allowedExtensions': string[]; 'upload.svgPolicy': 'reject' | 'sanitize'; 'legal.imprint': string; @@ -249,6 +250,15 @@ function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): Re {t('admin.feedsLabel')}

{t('admin.feedsHint')}

+ +

{t('admin.pluginsHint')}

); } diff --git a/docs/architecture/plugin-architecture.md b/docs/architecture/plugin-architecture.md index d502886..8f95f31 100644 --- a/docs/architecture/plugin-architecture.md +++ b/docs/architecture/plugin-architecture.md @@ -115,6 +115,23 @@ person looking at it could. removed, existing `plugin_block` nodes render the manifest `fallback` (documents are never mutated by plugin removal). +**Instance kill switch (issue #200, ADR 0025)**: `plugins.enabled` +(instance setting, default on; the VS-NfD reference configuration turns +it off) sits above the whole lifecycle. While off, every plugin surface +answers 404 — admin install/list/mode, pond activation, the sandbox frame +and asset routes — and the dropzone watcher quarantines instead of +installing. Only the authenticated fallback-metadata route stays alive: +it serves no plugin code, and existing `plugin_block` nodes use it to +render their declared fallback (an image fallback degrades to the neutral +placeholder, because its bytes live on the disabled asset surface — in +the reference configuration no plugin is installed, so nothing degrades). +The editor offers no plugin blocks because the pond plugin list is one of +the 404ing surfaces. Like every instance setting it is cached in-process: +flipping it is followed by an api restart to take full effect. This +single, verifiable off-switch is what answers "code execution inside the +zone?" at the offer stage — cheaper than per-plugin trust machinery +(#232) and sufficient because it removes the surface entirely. + ## Reference plugins (shipped with the product, also serving as examples) - `section-styles-basic` (`section_style`): a set of colored callout/box diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index 61047d5..94f55fb 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -110,7 +110,7 @@ chain`_ - [x] **Attachment-Integritätshashes** · +2–3 AT · #199 ⟵ neu aus Roadmap SHA-256-Spalte, Berechnung beim Upload, Prüfung beim Download, Backfill-Migration. Nebennutzen: Orphan-Sweep, Dedup, Backup-Verifikation. -- [ ] **Plugins hart abschaltbar** (`plugins.enabled = false`) · +2 AT · #200 ⟵ neu +- [x] **Plugins hart abschaltbar** (`plugins.enabled = false`) · +2 AT · #200 ⟵ neu Deckt das Risiko „Codeausführung in der VS-Zone" für den Angebotsstand vollständig ab. Hash-Pinning siehe Phase 4. - [ ] **Syslog/SIEM: Ereigniskatalog** · +3–4 AT · #201 ⟵ neu aus Roadmap diff --git a/packages/shared/i18n/de/apiTokens.json b/packages/shared/i18n/de/apiTokens.json index fd58e4f..ddd86f3 100644 --- a/packages/shared/i18n/de/apiTokens.json +++ b/packages/shared/i18n/de/apiTokens.json @@ -52,7 +52,9 @@ "mcpLabel": "Eingebauten MCP-Endpoint aktivieren", "mcpHint": "Hauptschalter (standardmäßig aus), unabhängig von der REST-API. MCP-Clients verbinden sich mit einem API-Token auf /api/mcp; jeder Teich gibt sich zusätzlich über seine Teich-Einstellungen frei. Siehe docs/self-hosting/public-api.md.", "feedsLabel": "Atom-Feeds aktivieren", - "feedsHint": "Hauptschalter (standardmäßig an). Ausgeschaltet antworten alle Feed-Adressen und die Feed-Token-Verwaltung mit 404 — für gehärtete Umgebungen, in denen Feed-Tokens als Lese-Zugangsdaten nicht in URLs auftauchen dürfen." + "feedsHint": "Hauptschalter (standardmäßig an). Ausgeschaltet antworten alle Feed-Adressen und die Feed-Token-Verwaltung mit 404 — für gehärtete Umgebungen, in denen Feed-Tokens als Lese-Zugangsdaten nicht in URLs auftauchen dürfen.", + "pluginsLabel": "Plugin-Architektur aktivieren", + "pluginsHint": "Hauptschalter (standardmäßig an). Ausgeschaltet antworten alle Plugin-Oberflächen mit 404 — Installation, Teich-Freigaben, Sandbox-Frames und -Assets — und bestehende Plugin-Blöcke zeigen ihren hinterlegten Fallback. Für gehärtete Umgebungen, die „keine Fremdcode-Ausführung“ nachweisbar beantworten müssen. Greift vollständig nach einem api-Neustart (Einstellungen sind im Prozess gecacht)." }, "feed": { "title": "Feed-Tokens", diff --git a/packages/shared/i18n/en/apiTokens.json b/packages/shared/i18n/en/apiTokens.json index 7af1b71..07ce95c 100644 --- a/packages/shared/i18n/en/apiTokens.json +++ b/packages/shared/i18n/en/apiTokens.json @@ -52,7 +52,9 @@ "mcpLabel": "Enable the built-in MCP endpoint", "mcpHint": "Master switch (default off), independent of the REST API. MCP clients connect to /api/mcp with an API token; each pond additionally opts in via its pond settings. See docs/self-hosting/public-api.md.", "feedsLabel": "Enable Atom feeds", - "feedsHint": "Master switch (default on). While off, every feed URL and the feed-token management answer 404 — for hardened environments where feed tokens must not appear in URLs as read credentials." + "feedsHint": "Master switch (default on). While off, every feed URL and the feed-token management answer 404 — for hardened environments where feed tokens must not appear in URLs as read credentials.", + "pluginsLabel": "Enable the plugin architecture", + "pluginsHint": "Master switch (default on). While off, every plugin surface answers 404 — install, pond activation, sandbox frames and assets — and existing plugin blocks show their declared fallback. For hardened environments that must answer \"no third-party code execution\" verifiably. Takes full effect after an api restart (settings are cached in-process)." }, "feed": { "title": "Feed tokens",