Files
TREK/server/src/nest/plugins/plugins.service.ts
T
mauriceboe 529c17baf4 feat(plugins): persist enable-intent across restarts + redesign admin page
The deactivation-on-deploy bug: `status` conflated the admin's ON/OFF intent with
runtime health, so a boot crash flipped status to 'error' and the plugin never
rebooted after the next deploy. Migration 156 adds an `enabled` flag (admin
intent) separate from `status` (runtime health); boot now retries every enabled
plugin regardless of last status, and a crash no longer erases the intent.

Admin → Plugins redesign:
- ON/OFF is a ToggleSwitch bound to `enabled`; runtime health shows separately as
  a coloured status dot, with the last error inline when it crashed.
- "Update → vX" badge when the registry has a newer version (one click updates
  and reactivates).
- Reviewed/unreviewed trust badges, cleaner cards, nicer empty state, registry
  browser marks already-installed plugins. i18n across all locales.
2026-07-03 19:19:30 +02:00

113 lines
4.0 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { db } from '../../db/database';
import { pluginsEnabled } from './kill-switch';
import { maybe_encrypt_api_key } from '../../services/apiKeyCrypto';
const SECRET_MASK = '••••••••';
/**
* 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;
enabled: number;
last_error: string | null;
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, enabled, last_error, reviewed_at, source_repo
FROM plugins
ORDER BY sort_order, name`,
)
.all() as PluginListItem[];
return { enabled: pluginsEnabled(), plugins };
}
/**
* Merge instance-scope settings into the plugin's config, encrypting fields
* declared secret (unless the value is the unchanged mask sentinel). Returns
* the config with secrets masked for the client.
*/
updateInstanceConfig(id: string, patch: Record<string, unknown>): Record<string, unknown> {
const row = db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined;
if (!row) throw new Error(`plugin ${id} not found`);
const secretKeys = new Set(
(
db
.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance' AND secret = 1")
.all(id) as Array<{ field_key: string }>
).map((r) => r.field_key),
);
const config = safeParse(row.config);
for (const [k, v] of Object.entries(patch)) {
if (secretKeys.has(k)) {
if (v === SECRET_MASK) continue; // unchanged secret — keep stored ciphertext
config[k] = maybe_encrypt_api_key(v);
} else {
config[k] = v;
}
}
db.prepare('UPDATE plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(config), id);
return maskSecrets(config, secretKeys);
}
/** A plugin's error log, newest first. */
errors(id: string): Array<{ ts: string; level: string; message: string }> {
return db
.prepare('SELECT ts, level, message FROM plugin_error_log WHERE plugin_id = ? ORDER BY ts DESC, id DESC LIMIT 200')
.all(id) as Array<{ ts: string; level: string; message: string }>;
}
clearErrors(id: string): void {
db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id);
}
/** Read the instance config with secret fields masked. */
getInstanceConfig(id: string): Record<string, unknown> {
const row = db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined;
if (!row) throw new Error(`plugin ${id} not found`);
const secretKeys = new Set(
(
db
.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance' AND secret = 1")
.all(id) as Array<{ field_key: string }>
).map((r) => r.field_key),
);
return maskSecrets(safeParse(row.config), secretKeys);
}
}
function safeParse(json: string): Record<string, unknown> {
try {
const v = JSON.parse(json || '{}');
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
} catch {
return {};
}
}
function maskSecrets(config: Record<string, unknown>, secretKeys: Set<string>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(config)) {
out[k] = secretKeys.has(k) && v ? SECRET_MASK : v;
}
return out;
}