Files
TREK/server/src/nest/plugins/plugins.service.ts
T

410 lines
19 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { DatabaseService } from '../database/database.service';
import { Plugins } from '../../db/entities/Plugins.entity';
import type { PluginsRepository } from '../../db/repositories/Plugins.repository';
import { PluginErrorLog } from '../../db/entities/PluginErrorLog.entity';
import type { PluginErrorLogRepository } from '../../db/repositories/PluginErrorLog.repository';
import { PluginEgressHosts } from '../../db/entities/PluginEgressHosts.entity';
import type { PluginEgressHostsRepository } from '../../db/repositories/PluginEgressHosts.repository';
import { PluginSettingsFields } from '../../db/entities/PluginSettingsFields.entity';
import type { PluginSettingsFieldsRepository } from '../../db/repositories/PluginSettingsFields.repository';
import { PluginActions } from '../../db/entities/PluginActions.entity';
import type { PluginActionsRepository } from '../../db/repositories/PluginActions.repository';
import { PluginUserConfig } from '../../db/entities/PluginUserConfig.entity';
import type { PluginUserConfigRepository } from '../../db/repositories/PluginUserConfig.repository';
import { PluginCapabilityAudit } from '../../db/entities/PluginCapabilityAudit.entity';
import type { PluginCapabilityAuditRepository } from '../../db/repositories/PluginCapabilityAudit.repository';
import { pluginsEnabled } from './kill-switch';
import { devLinkEnabled } from './dev-link';
import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { readAudit } from './host/plugin-audit';
import { keyFingerprint } from './signature-status';
import { pluginBudgetUsage } from './host/plugin-host-state';
import { safeParseConfig as safeParse } from './plugin-config-parse';
import { isFilled, parseDefaultValue, settingDefaults } from './settings-defaults';
import { AddonsService } from '../addons/addons.service';
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, type PluginDepRow, type PluginDependencies, type VersionMismatch } from './dependencies';
import { bypassedRange, hostSatisfies, hostVersion, trekRangeBypassed } from './install/host-compat';
import type { TrekRangeBypass } from './install/host-compat';
import type { PluginDependency } from './install/manifest';
import type { PluginSettingsField } from '@trek/shared';
const SECRET_MASK = '••••••••';
export type PluginDependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin' | 'hostIncompatible';
/** A save that would leave a `required` settings field empty — mapped to 400 by both controllers. */
export class MissingRequiredSettingError extends Error {
constructor(public readonly field: string) {
super(`Missing required setting "${field}"`);
this.name = 'MissingRequiredSettingError';
}
}
/**
* 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.
*/
interface PluginRawRow {
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;
permissions: string;
capabilities: string;
dependencies: string | null;
trek_range: string | null;
author_pubkey: string | null;
update_block_code: string | null;
update_block_detail: string | null;
update_block_version: string | null;
update_hold: number;
}
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;
/** Declared permissions (JSON string) — drives the "what this can access" chips. */
permissions: string;
/** Declared capabilities (JSON string) — e.g. widget slot. */
capabilities: string;
/** The plugin declared it needs OPERATOR-supplied egress hosts (a self-hosted target). */
operatorEgress: boolean;
/** How many hosts an admin has actually added — so the card can nudge when it's 0. */
egressHostCount: number;
/** How many `scope:'instance'` settings fields the plugin declares — gates the admin
* settings menu item without a per-plugin fetch. */
instanceSettingsCount: number;
/** How many `scope:'instance'` actions the plugin declares — gates the admin settings
* menu item together with instanceSettingsCount (a plugin can have actions and no fields). */
instanceActionsCount: number;
/** Declared dependencies (parsed) — required addons + plugin deps. */
dependencies: PluginDependencies;
/** Whether this plugin can currently activate, and why not if it can't. */
dependencyStatus: PluginDependencyStatus;
/** The TREK range the plugin declares it supports; null if it declared none. */
trekRange: string | null;
/** The running TREK, so the UI can say "needs X, you have Y" without doing semver. */
hostVersion: string;
/**
* Non-null when the plugin is outside its declared range (or declared none) and only
* TREK_PLUGINS_IGNORE_TREK_RANGE lets it activate. The card shows it as a warning
* rather than a blocker: the admin chose this, but must keep seeing what they chose.
*/
trekRangeBypassed: TrekRangeBypass | null;
/** The concrete blockers, so the UI can render chips + the resolve dialog. */
dependencyIssues: { disabledAddons: string[]; missing: PluginDependency[]; versionMismatch: VersionMismatch[] };
/**
* The author's signature was verified and their key TOFU-pinned at install.
* False means the bytes matched the registry's sha256 pin and nothing more — one
* fewer guarantee, not "insecure". Meaningless for a sideloaded/dev-linked plugin,
* which sits outside the registry trust model entirely; the UI badges those instead.
*/
signed: boolean;
/** Short, human-comparable form of the pinned key — for reading out to the author. */
keyFingerprint: string | null;
/** Why an update was refused, if one was. `version` is the registry version that was
* refused, so a caller can treat the block as stale once a newer one is on offer. */
updateBlock: { code: string; detail: string | null; version: string | null } | null;
/** A deliberate non-latest install paused updates: excluded from the banner/Update all
* until the admin resumes, or an install lands back on the newest compatible version. */
updateHold: boolean;
}
@Injectable()
export class PluginsService {
constructor(
private readonly dbs: DatabaseService,
private readonly addons: AddonsService,
// Plan 3j Task 2 — PS1-PS13's own tables, injected as repositories. All 6
// REQUIRED (not `@Optional()`), matching `PluginRuntimeService`'s own ruling
// (Task 1's `PluginGuards`/`UsersRepository` precedent): virtually every method
// on this class reaches one of these tables, so a hand-built test instance
// needs real ones regardless (`sharedTestOrm(...).repo(Entity)`).
@InjectRepository(Plugins) private readonly plugins: PluginsRepository,
@InjectRepository(PluginEgressHosts) private readonly pluginEgressHosts: PluginEgressHostsRepository,
@InjectRepository(PluginSettingsFields) private readonly pluginSettingsFields: PluginSettingsFieldsRepository,
@InjectRepository(PluginActions) private readonly pluginActions: PluginActionsRepository,
@InjectRepository(PluginUserConfig) private readonly pluginUserConfig: PluginUserConfigRepository,
@InjectRepository(PluginErrorLog) private readonly pluginErrorLog: PluginErrorLogRepository,
// Plan 3j Task 3 landed concurrently and converted `readAudit`/`pluginBudgetUsage`
// (host/plugin-audit.ts, host/plugin-host-state.ts) to take this repository instead
// of a raw connection — this service's own `auditLog`/`budget` delegate calls need
// it too, not a new site of its own.
@InjectRepository(PluginCapabilityAudit) private readonly pluginCapabilityAudit: PluginCapabilityAuditRepository,
) {}
private get db() {
return this.dbs.connection;
}
/** PS1 — Hosts an admin has added for a plugin (0 unless it declared operatorEgress). */
private async egressHostCount(id: string): Promise<number> {
try {
return await this.pluginEgressHosts.countForPlugin(id);
} catch {
return 0; // table absent (a slimmed test app)
}
}
/** PS2 */
private async instanceSettingsCount(id: string): Promise<number> {
try {
return await this.pluginSettingsFields.countForPluginScope(id, 'instance');
} catch {
return 0; // table absent (a slimmed test app)
}
}
/** PS3 */
private async instanceActionsCount(id: string): Promise<number> {
try {
return await this.pluginActions.countForPluginScope(id, 'instance');
} catch {
return 0; // table absent (a slimmed test app)
}
}
async list(): Promise<{ enabled: boolean; devLink: boolean; ignoreTrekRange: boolean; plugins: PluginListItem[] }> {
const rows = (await this.plugins.listForAdmin()) as PluginRawRow[];
const installed = new Map<string, PluginDepRow>(
rows.map((r) => [r.id, { id: r.id, version: r.version, enabled: r.enabled, dependencies: r.dependencies }]),
);
// A `map` callback cannot await the addon gate, so the projection runs as an
// explicit loop — same order, same rows.
const plugins: PluginListItem[] = [];
for (const r of rows) {
const deps = parseDependencies(r.dependencies);
const disabledAddons = await disabledRequiredAddons(deps, (id) => this.addons.isAddonEnabled(id));
const state = resolveDependencyState(deps, installed);
// Mirrors the order of assertActivatable's gate, so the card explains the same
// blocker the activate call would hit rather than a second, lesser one.
const trekBypass = bypassedRange(r.trek_range);
const dependencyStatus: PluginDependencyStatus = !hostSatisfies(r.trek_range) && !trekBypass
? 'hostIncompatible'
: disabledAddons.length
? 'addonDisabled'
: state.missing.length || state.versionMismatch.length
? 'missingPlugin'
: 'ok';
const {
dependencies: _raw,
operator_egress: _oe,
trek_range,
author_pubkey,
update_block_code,
update_block_detail,
update_block_version,
update_hold,
...rest
} = r as PluginRawRow & { operator_egress?: number };
plugins.push({
...rest,
operatorEgress: _oe === 1,
egressHostCount: await this.egressHostCount(r.id),
instanceSettingsCount: await this.instanceSettingsCount(r.id),
instanceActionsCount: await this.instanceActionsCount(r.id),
dependencies: deps,
dependencyStatus,
trekRange: trek_range,
hostVersion: hostVersion(),
trekRangeBypassed: trekBypass,
dependencyIssues: { disabledAddons, missing: state.missing, versionMismatch: state.versionMismatch },
signed: !!author_pubkey,
keyFingerprint: keyFingerprint(author_pubkey),
updateBlock: update_block_code
? { code: update_block_code, detail: update_block_detail, version: update_block_version }
: null,
updateHold: update_hold === 1,
});
}
return { enabled: pluginsEnabled(), devLink: devLinkEnabled(), ignoreTrekRange: trekRangeBypassed(), plugins };
}
/**
* Release a per-plugin update hold (set by a deliberate non-latest install).
* Returns whether the plugin row existed — the controller answers 404 otherwise.
*/
async resumeUpdates(id: string): Promise<boolean> {
return await this.plugins.clearUpdateHold(id);
}
/**
* Merge instance-scope settings into the plugin's config, encrypting fields
* declared secret (unless the value is the unchanged mask sentinel). Only keys
* declared as `scope:'instance'` fields are accepted, same as updateUserConfig.
* Returns the config with secrets masked for the client.
*/
async updateInstanceConfig(id: string, patch: Record<string, unknown>): Promise<Record<string, unknown>> {
const rowConfig = await this.plugins.findConfig(id);
if (rowConfig === null) throw new Error(`plugin ${id} not found`);
const secretKeys = new Set(await this.pluginSettingsFields.listSecretFieldKeys(id, 'instance'));
const allowed = new Set(await this.pluginSettingsFields.listFieldKeys(id, 'instance'));
const config = safeParse(rowConfig);
for (const [k, v] of Object.entries(patch)) {
if (!allowed.has(k)) continue; // never store an undeclared key
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;
}
}
await this.assertRequiredFilled(id, 'instance', config);
await this.plugins.setConfig(id, JSON.stringify(config));
return maskSecrets(config, secretKeys);
}
/** The plugin's `scope:'user'` settings fields, in declared order (for the user form). */
async userSettingsFields(id: string): Promise<PluginSettingsField[]> {
return await this.settingsFields(id, 'user');
}
/** The plugin's `scope:'instance'` settings fields, in declared order (for the ADMIN form). */
async instanceSettingsFields(id: string): Promise<PluginSettingsField[]> {
return await this.settingsFields(id, 'instance');
}
private async settingsFields(id: string, scope: 'user' | 'instance'): Promise<PluginSettingsField[]> {
const rows = await this.pluginSettingsFields.listFields(id, scope);
return rows.map((row) => ({
key: row.field_key,
label: row.label,
input_type: row.input_type,
placeholder: row.placeholder,
hint: row.hint,
required: row.required === 1,
secret: row.secret === 1,
default: parseDefaultValue(row.default_value),
// Stored as manifest-validated JSON ({value,label} pairs) — parse, don't re-check.
options: typeof row.options === 'string' && row.options ? (safeArray(row.options) as PluginSettingsField['options']) : undefined,
}));
}
private async userSecretKeys(id: string): Promise<Set<string>> {
return new Set(await this.pluginSettingsFields.listSecretFieldKeys(id, 'user'));
}
/** A user's own config for a plugin, secrets masked (safe to send to the client). PS7. */
async getUserConfig(id: string, userId: number): Promise<Record<string, unknown>> {
const config = await this.pluginUserConfig.findConfig(id, userId);
return maskSecrets(safeParse(config ?? '{}'), await this.userSecretKeys(id));
}
/** Merge a user's own settings, encrypting secret fields (SECRET_MASK = keep stored
* ciphertext). Only keys declared as `scope:'user'` fields are accepted. Returns masked. */
async updateUserConfig(id: string, userId: number, patch: Record<string, unknown>): Promise<Record<string, unknown>> {
const allowed = new Set(await this.pluginSettingsFields.listFieldKeys(id, 'user'));
const secretKeys = await this.userSecretKeys(id);
// PS9 — the pre-write existence check shares `PluginUserConfigRepository.findConfig`
// with PS7/PS11 (the triple-duplicate text the brief names explicitly).
const existingConfig = await this.pluginUserConfig.findConfig(id, userId);
const config = safeParse(existingConfig ?? '{}');
for (const [k, v] of Object.entries(patch)) {
if (!allowed.has(k)) continue; // never store an undeclared key
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;
}
}
await this.assertRequiredFilled(id, 'user', config);
// PS10 — the composite-key upsert on (plugin_id, user_id).
await this.pluginUserConfig.upsertConfig(id, userId, JSON.stringify(config));
return maskSecrets(config, secretKeys);
}
/** A user's own config with secrets DECRYPTED — host-only, for runtime `ctx.settings`.
* Never sent to a client; the acting user is resolved host-side. PS11. */
async getUserConfigDecrypted(id: string, userId: number): Promise<Record<string, unknown>> {
const rowConfig = await this.pluginUserConfig.findConfig(id, userId);
const config = safeParse(rowConfig ?? '{}');
const secretKeys = await this.userSecretKeys(id);
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(config)) out[k] = secretKeys.has(k) && v ? decrypt_api_key(v) : v;
return out;
}
/** A plugin's error log, newest first. */
async errors(id: string): Promise<Array<{ ts: string; level: string; message: string }>> {
return await this.pluginErrorLog.listRecent(id);
}
async clearErrors(id: string): Promise<void> {
await this.pluginErrorLog.deleteAllForPlugin(id);
}
/** A plugin's hash-chained capability audit log, newest first. */
async auditLog(id: string): Promise<unknown[]> {
return await readAudit(this.pluginCapabilityAudit, id);
}
/** A plugin's broker budget usage for today (AI + notification counts vs caps). */
async budget(id: string): ReturnType<typeof pluginBudgetUsage> {
return await pluginBudgetUsage(id, this.pluginCapabilityAudit);
}
/** Read the instance config with secret fields masked. */
async getInstanceConfig(id: string): Promise<Record<string, unknown>> {
const rowConfig = await this.plugins.findConfig(id);
if (rowConfig === null) throw new Error(`plugin ${id} not found`);
const secretKeys = new Set(await this.pluginSettingsFields.listSecretFieldKeys(id, 'instance'));
return maskSecrets(safeParse(rowConfig), secretKeys);
}
/**
* `required` used to be a decorative asterisk (PR-87 feedback): the form rendered it, but
* nothing refused a save. Enforced on the MERGED result so partial patches stay legal and a
* stored secret (non-empty ciphertext) counts as filled. A `checkbox` is exempt — required
* would demand `true`, which is a consent flow, not a settings field.
*/
private async assertRequiredFilled(id: string, scope: 'instance' | 'user', config: Record<string, unknown>): Promise<void> {
const required = await this.pluginSettingsFields.listRequiredFieldKeys(id, scope);
const defaults = await settingDefaults(this.pluginSettingsFields, id, scope);
for (const fieldKey of required) {
// The runtime resolves the default too, so it counts as filled here as well.
if (!isFilled(config[fieldKey] ?? defaults[fieldKey])) throw new MissingRequiredSettingError(fieldKey);
}
}
}
function safeArray(json: string): unknown[] | undefined {
try {
const v = JSON.parse(json);
return Array.isArray(v) ? v : undefined;
} catch {
return undefined;
}
}
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;
}