#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 { AuthedRequest } from '../auth/auth.guard';
|
||||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
|
||||||
|
import { PluginsEnabledGuard } from './plugins-enabled.guard';
|
||||||
import { PluginsService } from './plugins.service';
|
import { PluginsService } from './plugins.service';
|
||||||
import { MAX_PLUGIN_ZIP_BYTES, PluginPackageError } from './plugin.constants';
|
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).
|
* and per-pond-activation writes arrive with the admin UI (#72).
|
||||||
*/
|
*/
|
||||||
@Controller('admin/plugins')
|
@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 {
|
export class PluginAdminController {
|
||||||
constructor(private readonly plugins: PluginsService) {}
|
constructor(private readonly plugins: PluginsService) {}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
StreamableFile,
|
StreamableFile,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { PluginFallbackView } from '@dorfteich/shared';
|
import type { PluginFallbackView } from '@dorfteich/shared';
|
||||||
@ -13,9 +14,11 @@ import type { PluginFallbackView } from '@dorfteich/shared';
|
|||||||
import { Public } from '../auth/auth.guard';
|
import { Public } from '../auth/auth.guard';
|
||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
import { AuthenticatedOnly } from '../permissions/permission.decorators';
|
import { AuthenticatedOnly } from '../permissions/permission.decorators';
|
||||||
|
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||||
|
|
||||||
import { buildPluginAssetBase, buildPluginFrameCsp, buildPluginFrameHtml } from './plugin-frame';
|
import { buildPluginAssetBase, buildPluginFrameCsp, buildPluginFrameHtml } from './plugin-frame';
|
||||||
import { PluginStorageService } from './plugin-storage.service';
|
import { PluginStorageService } from './plugin-storage.service';
|
||||||
|
import { PluginsEnabledGuard } from './plugins-enabled.guard';
|
||||||
import { PluginsService } from './plugins.service';
|
import { PluginsService } from './plugins.service';
|
||||||
|
|
||||||
/** Content types for the file kinds a plugin bundle ships. Unknown extensions
|
/** 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 plugins: PluginsService,
|
||||||
private readonly storage: PluginStorageService,
|
private readonly storage: PluginStorageService,
|
||||||
private readonly config: AppConfig,
|
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
|
* (issue #76). Deliberately NOT `@Public`: it names installed plugins, which
|
||||||
* is instance metadata for signed-in users, not sandbox-servable content.
|
* is instance metadata for signed-in users, not sandbox-servable content.
|
||||||
* 404 covers "never installed" — the client shows a neutral placeholder.
|
* 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')
|
@Get(':id/fallback')
|
||||||
@AuthenticatedOnly()
|
@AuthenticatedOnly()
|
||||||
async fallback(@Param('id') id: string): Promise<PluginFallbackView> {
|
async fallback(@Param('id') id: string): Promise<PluginFallbackView> {
|
||||||
const view = await this.plugins.fallbackFor(id);
|
const view = await this.plugins.fallbackFor(id);
|
||||||
if (!view) throw new NotFoundException();
|
if (!view) throw new NotFoundException();
|
||||||
|
if (view.fallback?.type === 'image' && !(await this.settings.get('plugins.enabled'))) {
|
||||||
|
return { ...view, fallback: null };
|
||||||
|
}
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +90,7 @@ export class PluginAssetsController {
|
|||||||
*/
|
*/
|
||||||
@Get(':id/:version/frame')
|
@Get(':id/:version/frame')
|
||||||
@Public()
|
@Public()
|
||||||
|
@UseGuards(PluginsEnabledGuard)
|
||||||
async frame(
|
async frame(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Param('version') version: string,
|
@Param('version') version: string,
|
||||||
@ -102,6 +114,7 @@ export class PluginAssetsController {
|
|||||||
|
|
||||||
@Get(':id/:version/*rest')
|
@Get(':id/:version/*rest')
|
||||||
@Public()
|
@Public()
|
||||||
|
@UseGuards(PluginsEnabledGuard)
|
||||||
async serve(
|
async serve(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Param('version') version: string,
|
@Param('version') version: string,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
Put,
|
Put,
|
||||||
Body,
|
Body,
|
||||||
Req,
|
Req,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
pondPluginToggleInputSchema,
|
pondPluginToggleInputSchema,
|
||||||
@ -21,6 +22,7 @@ import { AuthedRequest } from '../auth/auth.guard';
|
|||||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
import { RequiresPondRole } from '../permissions/permission.decorators';
|
import { RequiresPondRole } from '../permissions/permission.decorators';
|
||||||
|
|
||||||
|
import { PluginsEnabledGuard } from './plugins-enabled.guard';
|
||||||
import { PluginsService } from './plugins.service';
|
import { PluginsService } from './plugins.service';
|
||||||
import { PluginPackageError } from './plugin.constants';
|
import { PluginPackageError } from './plugin.constants';
|
||||||
|
|
||||||
@ -31,6 +33,10 @@ import { PluginPackageError } from './plugin.constants';
|
|||||||
* plugins is a Pond Admin action.
|
* plugins is a Pond Admin action.
|
||||||
*/
|
*/
|
||||||
@Controller('ponds/:pondId/plugins')
|
@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 {
|
export class PluginPondController {
|
||||||
constructor(private readonly plugins: PluginsService) {}
|
constructor(private readonly plugins: PluginsService) {}
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { PinoLogger } from 'nestjs-pino';
|
|||||||
|
|
||||||
import { ClockService } from '../common/clock.service';
|
import { ClockService } from '../common/clock.service';
|
||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
|
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||||
|
|
||||||
import { PluginStorageService } from './plugin-storage.service';
|
import { PluginStorageService } from './plugin-storage.service';
|
||||||
import { PluginPackageError } from './plugin.constants';
|
import { PluginPackageError } from './plugin.constants';
|
||||||
@ -31,6 +32,7 @@ export class PluginWatcherService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly plugins: PluginsService,
|
private readonly plugins: PluginsService,
|
||||||
private readonly storage: PluginStorageService,
|
private readonly storage: PluginStorageService,
|
||||||
private readonly config: AppConfig,
|
private readonly config: AppConfig,
|
||||||
|
private readonly settings: InstanceSettingsService,
|
||||||
private readonly clock: ClockService,
|
private readonly clock: ClockService,
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
) {
|
) {
|
||||||
@ -83,6 +85,13 @@ export class PluginWatcherService implements OnModuleInit, OnModuleDestroy {
|
|||||||
async processDropped(
|
async processDropped(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
): Promise<{ installed: true; id: string } | { installed: false; code: 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;
|
let zip: Buffer;
|
||||||
try {
|
try {
|
||||||
zip = await readFile(filePath);
|
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');
|
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 { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CommonModule } from '../common/common.module';
|
import { CommonModule } from '../common/common.module';
|
||||||
|
import { SettingsModule } from '../settings/settings.module';
|
||||||
|
|
||||||
import { PluginAdminController } from './plugin-admin.controller';
|
import { PluginAdminController } from './plugin-admin.controller';
|
||||||
import { PluginAssetsController } from './plugin-assets.controller';
|
import { PluginAssetsController } from './plugin-assets.controller';
|
||||||
@ -9,6 +10,7 @@ import { PluginPondController } from './plugin-pond.controller';
|
|||||||
import { PluginPackageService } from './plugin-package.service';
|
import { PluginPackageService } from './plugin-package.service';
|
||||||
import { PluginStorageService } from './plugin-storage.service';
|
import { PluginStorageService } from './plugin-storage.service';
|
||||||
import { PluginWatcherService } from './plugin-watcher.service';
|
import { PluginWatcherService } from './plugin-watcher.service';
|
||||||
|
import { PluginsEnabledGuard } from './plugins-enabled.guard';
|
||||||
import { PluginsService } from './plugins.service';
|
import { PluginsService } from './plugins.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -18,12 +20,13 @@ import { PluginsService } from './plugins.service';
|
|||||||
* dropzone directory watcher.
|
* dropzone directory watcher.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CommonModule],
|
imports: [CommonModule, SettingsModule],
|
||||||
controllers: [PluginAdminController, PluginAssetsController, PluginPondController],
|
controllers: [PluginAdminController, PluginAssetsController, PluginPondController],
|
||||||
providers: [
|
providers: [
|
||||||
PluginFallbackRenderer,
|
PluginFallbackRenderer,
|
||||||
PluginPackageService,
|
PluginPackageService,
|
||||||
PluginStorageService,
|
PluginStorageService,
|
||||||
|
PluginsEnabledGuard,
|
||||||
PluginsService,
|
PluginsService,
|
||||||
PluginWatcherService,
|
PluginWatcherService,
|
||||||
],
|
],
|
||||||
|
|||||||
@ -72,6 +72,15 @@ export const INSTANCE_SETTINGS = {
|
|||||||
// Built-in MCP endpoint master switch (issue #105, default off) —
|
// Built-in MCP endpoint master switch (issue #105, default off) —
|
||||||
// independent of the REST switch; ponds opt in via `mcpEnabled`.
|
// independent of the REST switch; ponds opt in via `mcpEnabled`.
|
||||||
'mcp.enabled': z.boolean().default(false),
|
'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
|
// Atom feed master switch (issue #191). Default ON: feeds predate the
|
||||||
// switch, so existing instances and their subscribed readers keep
|
// switch, so existing instances and their subscribed readers keep
|
||||||
// working; the VS-NfD reference configuration (#227) turns it off.
|
// working; the VS-NfD reference configuration (#227) turns it off.
|
||||||
|
|||||||
@ -25,6 +25,7 @@ interface InstanceSettings {
|
|||||||
'api.enabled': boolean;
|
'api.enabled': boolean;
|
||||||
'mcp.enabled': boolean;
|
'mcp.enabled': boolean;
|
||||||
'feeds.enabled': boolean;
|
'feeds.enabled': boolean;
|
||||||
|
'plugins.enabled': boolean;
|
||||||
'upload.allowedExtensions': string[];
|
'upload.allowedExtensions': string[];
|
||||||
'upload.svgPolicy': 'reject' | 'sanitize';
|
'upload.svgPolicy': 'reject' | 'sanitize';
|
||||||
'legal.imprint': string;
|
'legal.imprint': string;
|
||||||
@ -249,6 +250,15 @@ function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): Re
|
|||||||
{t('admin.feedsLabel')}
|
{t('admin.feedsLabel')}
|
||||||
</label>
|
</label>
|
||||||
<p className="api-opt-in__hint">{t('admin.feedsHint')}</p>
|
<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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -115,6 +115,23 @@ person looking at it could.
|
|||||||
removed, existing `plugin_block` nodes render the manifest `fallback`
|
removed, existing `plugin_block` nodes render the manifest `fallback`
|
||||||
(documents are never mutated by plugin removal).
|
(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)
|
## Reference plugins (shipped with the product, also serving as examples)
|
||||||
|
|
||||||
- `section-styles-basic` (`section_style`): a set of colored callout/box
|
- `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
|
- [x] **Attachment-Integritätshashes** · +2–3 AT · #199 ⟵ neu aus Roadmap
|
||||||
SHA-256-Spalte, Berechnung beim Upload, Prüfung beim Download,
|
SHA-256-Spalte, Berechnung beim Upload, Prüfung beim Download,
|
||||||
Backfill-Migration. Nebennutzen: Orphan-Sweep, Dedup, Backup-Verifikation.
|
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
|
Deckt das Risiko „Codeausführung in der VS-Zone" für den
|
||||||
Angebotsstand vollständig ab. Hash-Pinning siehe Phase 4.
|
Angebotsstand vollständig ab. Hash-Pinning siehe Phase 4.
|
||||||
- [ ] **Syslog/SIEM: Ereigniskatalog** · +3–4 AT · #201 ⟵ neu aus Roadmap
|
- [ ] **Syslog/SIEM: Ereigniskatalog** · +3–4 AT · #201 ⟵ neu aus Roadmap
|
||||||
|
|||||||
@ -52,7 +52,9 @@
|
|||||||
"mcpLabel": "Eingebauten MCP-Endpoint aktivieren",
|
"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.",
|
"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",
|
"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": {
|
"feed": {
|
||||||
"title": "Feed-Tokens",
|
"title": "Feed-Tokens",
|
||||||
|
|||||||
@ -52,7 +52,9 @@
|
|||||||
"mcpLabel": "Enable the built-in MCP endpoint",
|
"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.",
|
"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",
|
"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": {
|
"feed": {
|
||||||
"title": "Feed tokens",
|
"title": "Feed tokens",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user