feat(plugins): plugin system scaffold — registry tables + admin panel

First slice of the plugin system. Lays down the data model and a read-only
admin surface; nothing executes yet.

- Migration 155: plugins, plugin_meta_migrations, plugin_error_log and
  plugin_settings_fields tables. Plugin data will live in a per-plugin sqlite
  file under /plugins-data, never in these tables.
- New Nest module server/src/nest/plugins with GET /api/admin/plugins
  (admin-gated, returns the installed list + the runtime-enabled flag).
- TREK_PLUGINS_ENABLED kill switch (config.pluginsEnabled), off by default.
- Admin → Plugins tab with a read-only panel: installed list, status badges,
  and a clear banner when the runtime is disabled by server config.
- i18n keys for the tab and panel across all locales.

Install, activation, the isolated runtime and the registry browser follow in
later slices.
This commit is contained in:
mauriceboe
2026-07-03 15:27:11 +02:00
parent 452360683c
commit 252eae368d
31 changed files with 555 additions and 2 deletions
+11
View File
@@ -174,3 +174,14 @@ export const SESSION_DURATION_REMEMBER =
export const SESSION_DURATION_REMEMBER_MS = parsedRememberMs ?? parseDurationMs(DEFAULT_SESSION_DURATION_REMEMBER)!;
/** "Remember me" session length in seconds — passed to `jwt.sign({ expiresIn })`. */
export const SESSION_DURATION_REMEMBER_SECONDS = Math.floor(SESSION_DURATION_REMEMBER_MS / 1000);
/**
* Plugin-system kill switch (#plugins). Off by default — a brand-new, high-risk
* surface an instance owner must deliberately opt into. When false, the plugin
* module serves its admin listing (so the tab isn't a dead end) but never spawns
* a plugin process and refuses install/activate. Read at call time so tests and
* runtime env changes take effect without re-import.
*/
export function pluginsEnabled(): boolean {
return (process.env.TREK_PLUGINS_ENABLED || '').trim().toLowerCase() === 'true';
}
+63
View File
@@ -3289,6 +3289,69 @@ function runMigrations(db: Database.Database): void {
() => {
try { db.exec('ALTER TABLE invite_tokens ADD COLUMN trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); }
},
// Migration 155: plugin system scaffold (#plugins). A plugin is a row here;
// its code lives on the /plugins volume and (once the runtime lands) runs in
// an isolated child process. This migration only lays down the registry
// tables — nothing executes yet. Own data lives in a per-plugin sqlite file
// under /plugins-data, never in these tables.
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL DEFAULT 'integration',
icon TEXT DEFAULT 'Blocks',
version TEXT,
api_version INTEGER DEFAULT 1,
min_trek_version TEXT,
permissions TEXT DEFAULT '[]',
granted_permissions TEXT DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'inactive',
config TEXT DEFAULT '{}',
source_repo TEXT,
source_commit TEXT,
sha256 TEXT,
crash_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
reviewed_at TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
installed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS plugin_meta_migrations (
plugin_id TEXT NOT NULL,
migration_id TEXT NOT NULL,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (plugin_id, migration_id)
);
CREATE TABLE IF NOT EXISTS plugin_error_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_id TEXT NOT NULL,
ts DATETIME DEFAULT CURRENT_TIMESTAMP,
level TEXT NOT NULL DEFAULT 'error',
message TEXT,
stack TEXT
);
CREATE INDEX IF NOT EXISTS idx_plugin_error_log_plugin ON plugin_error_log(plugin_id, ts);
CREATE TABLE IF NOT EXISTS plugin_settings_fields (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_id TEXT NOT NULL,
field_key TEXT NOT NULL,
label TEXT,
input_type TEXT NOT NULL DEFAULT 'text',
placeholder TEXT,
hint TEXT,
required INTEGER NOT NULL DEFAULT 0,
secret INTEGER NOT NULL DEFAULT 0,
scope TEXT NOT NULL DEFAULT 'instance',
options TEXT,
oauth_config TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
UNIQUE (plugin_id, field_key)
);
`);
},
];
if (currentVersion < migrations.length) {
+2 -1
View File
@@ -41,6 +41,7 @@ import { OidcModule } from './oidc/oidc.module';
import { OauthModule } from './oauth/oauth.module';
import { AdminModule } from './admin/admin.module';
import { AddonsModule } from './addons/addons.module';
import { PluginsModule } from './plugins/plugins.module';
import { TrekExceptionFilter } from './common/trek-exception.filter';
import { SpaFallbackFilter } from './platform/spa-fallback.filter';
import { IdempotencyInterceptor } from './common/idempotency.interceptor';
@@ -51,7 +52,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor';
* migrated.
*/
@Module({
imports: [DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule],
imports: [DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, PluginsModule, BookingImportModule],
controllers: [HealthController],
providers: [
HealthService,
@@ -0,0 +1,24 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { PluginsService } from './plugins.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard';
/**
* /api/admin/plugins — admin-only plugin control surface (#plugins).
*
* M0 scaffold: read-only listing + the runtime-enabled flag. Install, activate,
* disable, uninstall and the registry browser are added in later milestones.
* Admin-gated like the rest of /api/admin. The proxy namespace /api/plugins/:id
* is reserved for the (future) runtime and is intentionally NOT part of this
* admin controller.
*/
@Controller('api/admin/plugins')
@UseGuards(JwtAuthGuard, AdminGuard)
export class PluginsController {
constructor(private readonly plugins: PluginsService) {}
@Get()
list() {
return this.plugins.list();
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { PluginsController } from './plugins.controller';
import { PluginsService } from './plugins.service';
/**
* Plugin system (#plugins). M0 registers only the read side; the isolated
* runtime supervisor, install pipeline and registry service are added in later
* milestones under this same module.
*/
@Module({
controllers: [PluginsController],
providers: [PluginsService],
})
export class PluginsModule {}
@@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import { db } from '../../db/database';
import { pluginsEnabled } from '../../config';
/**
* Read side of the plugin system (#plugins), M0 scaffold. Lists installed
* plugins from the `plugins` registry table and reports whether the runtime is
* enabled (TREK_PLUGINS_ENABLED). No execution here — the isolated runtime,
* install pipeline and registry fetch land in later milestones.
*/
export interface PluginListItem {
id: string;
name: string;
description: string | null;
type: string;
icon: string | null;
version: string | null;
status: string;
reviewed_at: string | null;
source_repo: string | null;
}
@Injectable()
export class PluginsService {
list(): { enabled: boolean; plugins: PluginListItem[] } {
const plugins = db
.prepare(
`SELECT id, name, description, type, icon, version, status, reviewed_at, source_repo
FROM plugins
ORDER BY sort_order, name`,
)
.all() as PluginListItem[];
return { enabled: pluginsEnabled(), plugins };
}
}