#200: hard instance-wide plugins.enabled kill switch #260
@ -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) {}
|
||||
|
||||
|
||||
@ -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<PluginFallbackView> {
|
||||
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,
|
||||
|
||||
@ -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) {}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
23
apps/api/src/plugins/plugins-enabled.guard.ts
Normal file
23
apps/api/src/plugins/plugins-enabled.guard.ts
Normal file
@ -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<boolean> {
|
||||
if (!(await this.settings.get('plugins.enabled'))) throw new NotFoundException();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
],
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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')}
|
||||
</label>
|
||||
<p className="api-opt-in__hint">{t('admin.feedsHint')}</p>
|
||||
<label className="api-opt-in__label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings['plugins.enabled']}
|
||||
onChange={(event) => void save({ 'plugins.enabled': event.target.checked })}
|
||||
/>
|
||||
{t('admin.pluginsLabel')}
|
||||
</label>
|
||||
<p className="api-opt-in__hint">{t('admin.pluginsHint')}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user