fix(plugins): boot enabled plugins after the rpc-registry scan, not before

PluginRuntimeService booted enabled plugins from onModuleInit, but Nest fires
same-module onModuleInit hooks in providers-array order, where the runtime
precedes PluginRpcRegistryService — so every boot-activated plugin's rpc host
was bound from a still-empty registry and its first RPC (typically db.migrate
in onLoad) came back PERMISSION_DENIED on every cold boot, until an admin
deactivated and reactivated it by hand. Move the boot reconcile to
onApplicationBootstrap, which is guaranteed to run after every module's
onModuleInit, registry scan included.

Also fix the denial message, which interpolated the plugin id where the
permission belonged ('db.migrate requires a permission "featured-guides" was
not granted'); it now names both the missing permission and the plugin.
This commit is contained in:
jubnl
2026-08-25 08:06:50 +02:00
parent 4548743874
commit decc0e1a12
5 changed files with 169 additions and 13 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
import {
KNOWN_METHODS,
METHOD_PERMISSION,
type KnownMethod,
type RpcError,
type RpcRequest,
@@ -119,7 +120,7 @@ export class PluginRpcHost {
req.id,
known ? 'PERMISSION_DENIED' : 'UNKNOWN_METHOD',
known
? `${req.method} requires a permission "${this.pluginId}" was not granted`
? `${req.method} requires the "${(METHOD_PERMISSION as Record<string, string>)[req.method]}" permission, which was not granted to plugin "${this.pluginId}"`
: `unknown method ${req.method}`,
);
}
@@ -1,4 +1,4 @@
import { Injectable, type OnModuleInit, type OnModuleDestroy } from '@nestjs/common';
import { Injectable, type OnApplicationBootstrap, type OnModuleDestroy } from '@nestjs/common';
import semver from 'semver';
import { DatabaseService } from '../database/database.service';
import { pluginsEnabled } from './kill-switch';
@@ -121,7 +121,7 @@ export class PluginDependencyError extends Error {
*/
@Injectable()
export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDestroy {
// The rpc-host factory is bound to `this` as the inter-plugin router, so a
// plugin's ctx.plugins.call / ctx.events.emit resolve through callPlugin/
// emitPluginEvent below (which own the dependency-edge authorization). The
@@ -182,7 +182,15 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
return this.dbs.connection;
}
onModuleInit(): void {
// onApplicationBootstrap, NOT onModuleInit: boot activation builds each plugin's
// rpc host synchronously, and the host snapshots PluginRpcRegistryService at
// construction (bindInto). Same-module onModuleInit hooks fire in providers-array
// declaration order, where this service precedes the registry — so booting from
// onModuleInit bound every host to a still-empty registry and every enabled
// plugin's first RPC after a restart came back PERMISSION_DENIED (pinned by
// tests/integration/plugins/boot-registry-order.test.ts). onApplicationBootstrap
// is guaranteed to run after EVERY module's onModuleInit, registry scan included.
onApplicationBootstrap(): void {
if (!pluginsEnabled()) return;
// If a restore staged plugin trees, swap them into place NOW — before we open any
// plugin DB below. This is where a restored backup's plugin data/code actually