All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m21s
CI / Build container images (pull_request) Successful in 3m59s
CI / Auth e2e pack (pull_request) Successful in 8m35s
CI / Import/export fidelity gate (pull_request) Successful in 1m1s
CD / Build and push images (push) Successful in 17s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 12s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m27s
CI / Import/export fidelity gate (push) Successful in 58s
CI / Lint, typecheck, test (push) Successful in 6m30s
The install path records the SHA-256 of the delivered bundle ZIP (plugins.bundle_hash; pre-#232 installs show it as unknown until reinstalled). plugins.allowlist in instance_settings names permitted ids with their pinned hashes: empty (default) = not enforced, existing instances unchanged; non-empty = installs of unlisted or deviating bundles are rejected (plugin_not_pinned / plugin_hash_mismatch, 403), and an installed plugin outside the list or with a deviating hash does not load — absent from pond mount lists, frame/assets 404. Every rejection is audited (plugin.rejected, catalogue v1.5). A version bump changes the hash and therefore requires an explicit re-pin — the intended friction (ADR 0025). Admin UI shows observed vs pinned hash per plugin with pin/re-pin/unpin. Scope stated honestly in plugin-architecture.md: the pin answers "is this the reviewed bundle"; post-install disk tampering is platform integrity (ADR 0019), sandbox containment stays the sandbox's job. Hardening guide row + catalog advisory triage; residual risk R-03 resolved. e2e: empty-allowlist compatibility, pinned load, unpinned and tampered installs rejected and audited, pin drift blocks loading while the admin still sees the mismatch, version bump needs re-pin. Full api suite 101 files / 561 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
117 lines
3.8 KiB
TypeScript
117 lines
3.8 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Body,
|
|
ConflictException,
|
|
ForbiddenException,
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
HttpCode,
|
|
HttpException,
|
|
NotFoundException,
|
|
Param,
|
|
Patch,
|
|
PayloadTooLargeException,
|
|
Post,
|
|
Req,
|
|
UploadedFile,
|
|
UseGuards,
|
|
UseInterceptors,
|
|
} from '@nestjs/common';
|
|
import { FileInterceptor } from '@nestjs/platform-express';
|
|
import { pluginModeInputSchema, type PluginModeInput, type PluginView } from '@dorfteich/shared';
|
|
|
|
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';
|
|
|
|
/** Maps a package/registry error to the HTTP status that fits its class. */
|
|
function toHttpException(error: PluginPackageError): HttpException {
|
|
const body = error.details
|
|
? { code: error.code, message: error.message, details: error.details }
|
|
: { code: error.code, message: error.message };
|
|
switch (error.code) {
|
|
case 'plugin_not_found':
|
|
return new NotFoundException(body);
|
|
case 'plugin_required_cannot_uninstall':
|
|
case 'plugin_version_not_higher':
|
|
case 'plugin_not_optional':
|
|
return new ConflictException(body);
|
|
// Hash pinning (#232): the upload is well-formed, the policy says no.
|
|
case 'plugin_not_pinned':
|
|
case 'plugin_hash_mismatch':
|
|
return new ForbiddenException(body);
|
|
case 'plugin_too_large':
|
|
return new PayloadTooLargeException(body);
|
|
default:
|
|
return new BadRequestException(body);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Site Admin plugin administration (ADR 0008, issue #71). Installing is
|
|
* deliberately restricted to Site Admins (kickoff decision); the instance-mode
|
|
* and per-pond-activation writes arrive with the admin UI (#72).
|
|
*/
|
|
@Controller('admin/plugins')
|
|
// 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) {}
|
|
|
|
/** Upload and install (or update) a plugin ZIP. */
|
|
@Post()
|
|
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_PLUGIN_ZIP_BYTES } }))
|
|
async install(
|
|
@UploadedFile() file: Express.Multer.File | undefined,
|
|
@Req() request: AuthedRequest,
|
|
): Promise<PluginView> {
|
|
if (!file) throw new BadRequestException({ code: 'bad_request', message: 'No file uploaded' });
|
|
try {
|
|
return await this.plugins.install(file.buffer, request.user!);
|
|
} catch (error) {
|
|
if (error instanceof PluginPackageError) throw toHttpException(error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** All installed plugins. */
|
|
@Get()
|
|
list(): Promise<PluginView[]> {
|
|
return this.plugins.list();
|
|
}
|
|
|
|
/** Set a plugin's instance mode: disabled | optional | required (#72). */
|
|
@Patch(':id/mode')
|
|
async setMode(
|
|
@Param('id') id: string,
|
|
@Body(new ZodValidationPipe(pluginModeInputSchema)) body: PluginModeInput,
|
|
@Req() request: AuthedRequest,
|
|
): Promise<PluginView> {
|
|
try {
|
|
return await this.plugins.setMode(id, body.mode, request.user!);
|
|
} catch (error) {
|
|
if (error instanceof PluginPackageError) throw toHttpException(error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** Uninstall a plugin (refused while `required`). */
|
|
@Delete(':id')
|
|
@HttpCode(204)
|
|
async uninstall(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
|
|
try {
|
|
await this.plugins.uninstall(id, request.user!);
|
|
} catch (error) {
|
|
if (error instanceof PluginPackageError) throw toHttpException(error);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|