mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
441 lines
19 KiB
TypeScript
441 lines
19 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { DatabaseService } from '../database/database.service';
|
|
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,
|
|
) {}
|
|
|
|
private get db() {
|
|
return this.dbs.connection;
|
|
}
|
|
|
|
/** Hosts an admin has added for a plugin (0 unless it declared operatorEgress). */
|
|
private egressHostCount(id: string): number {
|
|
try {
|
|
return (this.db.prepare('SELECT COUNT(*) AS n FROM plugin_egress_hosts WHERE plugin_id = ?').get(id) as { n: number }).n;
|
|
} catch {
|
|
return 0; // table absent (a slimmed test app)
|
|
}
|
|
}
|
|
|
|
private instanceSettingsCount(id: string): number {
|
|
try {
|
|
return (
|
|
this.db.prepare("SELECT COUNT(*) AS n FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance'").get(id) as { n: number }
|
|
).n;
|
|
} catch {
|
|
return 0; // table absent (a slimmed test app)
|
|
}
|
|
}
|
|
|
|
private instanceActionsCount(id: string): number {
|
|
try {
|
|
return (
|
|
this.db.prepare("SELECT COUNT(*) AS n FROM plugin_actions WHERE plugin_id = ? AND scope = 'instance'").get(id) as { n: number }
|
|
).n;
|
|
} catch {
|
|
return 0; // table absent (a slimmed test app)
|
|
}
|
|
}
|
|
|
|
async list(): Promise<{ enabled: boolean; devLink: boolean; ignoreTrekRange: boolean; plugins: PluginListItem[] }> {
|
|
const rows = this.db
|
|
.prepare(
|
|
`SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo,
|
|
permissions, capabilities, dependencies, operator_egress, trek_range,
|
|
author_pubkey, update_block_code, update_block_detail, update_block_version, update_hold
|
|
FROM plugins
|
|
ORDER BY sort_order, name`,
|
|
)
|
|
.all() 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: this.egressHostCount(r.id),
|
|
instanceSettingsCount: this.instanceSettingsCount(r.id),
|
|
instanceActionsCount: 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.
|
|
*/
|
|
resumeUpdates(id: string): boolean {
|
|
return this.db.prepare('UPDATE plugins SET update_hold = 0 WHERE id = ?').run(id).changes > 0;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
updateInstanceConfig(id: string, patch: Record<string, unknown>): Record<string, unknown> {
|
|
const row = this.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(
|
|
(
|
|
this.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 allowed = new Set(
|
|
(
|
|
this.db
|
|
.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance'")
|
|
.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 (!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;
|
|
}
|
|
}
|
|
this.assertRequiredFilled(id, 'instance', config);
|
|
this.db.prepare('UPDATE plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(config), id);
|
|
return maskSecrets(config, secretKeys);
|
|
}
|
|
|
|
/** The plugin's `scope:'user'` settings fields, in declared order (for the user form). */
|
|
userSettingsFields(id: string): PluginSettingsField[] {
|
|
return this.settingsFields(id, 'user');
|
|
}
|
|
|
|
/** The plugin's `scope:'instance'` settings fields, in declared order (for the ADMIN form). */
|
|
instanceSettingsFields(id: string): PluginSettingsField[] {
|
|
return this.settingsFields(id, 'instance');
|
|
}
|
|
|
|
private settingsFields(id: string, scope: 'user' | 'instance'): PluginSettingsField[] {
|
|
return this.db
|
|
.prepare(
|
|
`SELECT field_key AS key, label, input_type, placeholder, hint, required, secret, options, default_value
|
|
FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? ORDER BY sort_order, id`,
|
|
)
|
|
.all(id, scope)
|
|
.map((r) => {
|
|
const row = r as Record<string, unknown>;
|
|
return {
|
|
key: String(row.key),
|
|
label: (row.label ?? null) as string | null,
|
|
input_type: (row.input_type ?? 'text') as string,
|
|
placeholder: (row.placeholder ?? null) as string | null,
|
|
hint: (row.hint ?? null) as string | null,
|
|
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 string) as PluginSettingsField['options'])
|
|
: undefined,
|
|
};
|
|
});
|
|
}
|
|
|
|
private userSecretKeys(id: string): Set<string> {
|
|
return new Set(
|
|
(
|
|
this.db
|
|
.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user' AND secret = 1")
|
|
.all(id) as Array<{ field_key: string }>
|
|
).map((r) => r.field_key),
|
|
);
|
|
}
|
|
|
|
/** A user's own config for a plugin, secrets masked (safe to send to the client). */
|
|
getUserConfig(id: string, userId: number): Record<string, unknown> {
|
|
const row = this.db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as
|
|
| { config: string }
|
|
| undefined;
|
|
return maskSecrets(safeParse(row?.config ?? '{}'), 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. */
|
|
updateUserConfig(id: string, userId: number, patch: Record<string, unknown>): Record<string, unknown> {
|
|
const allowed = new Set(
|
|
(this.db.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user'").all(id) as Array<{ field_key: string }>).map(
|
|
(r) => r.field_key,
|
|
),
|
|
);
|
|
const secretKeys = this.userSecretKeys(id);
|
|
const existing = this.db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as
|
|
| { config: string }
|
|
| undefined;
|
|
const config = safeParse(existing?.config ?? '{}');
|
|
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;
|
|
}
|
|
}
|
|
this.assertRequiredFilled(id, 'user', config);
|
|
this.db.prepare(
|
|
`INSERT INTO plugin_user_config (plugin_id, user_id, config, updated_at) VALUES (?, ?, ?, datetime('now'))
|
|
ON CONFLICT(plugin_id, user_id) DO UPDATE SET config = excluded.config, updated_at = excluded.updated_at`,
|
|
).run(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. */
|
|
getUserConfigDecrypted(id: string, userId: number): Record<string, unknown> {
|
|
const row = this.db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as
|
|
| { config: string }
|
|
| undefined;
|
|
const config = safeParse(row?.config ?? '{}');
|
|
const secretKeys = 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. */
|
|
errors(id: string): Array<{ ts: string; level: string; message: string }> {
|
|
return this.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 {
|
|
this.db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id);
|
|
}
|
|
|
|
/** A plugin's hash-chained capability audit log, newest first. */
|
|
auditLog(id: string): unknown[] {
|
|
return readAudit(this.db, id);
|
|
}
|
|
|
|
/** A plugin's broker budget usage for today (AI + notification counts vs caps). */
|
|
budget(id: string): ReturnType<typeof pluginBudgetUsage> {
|
|
return pluginBudgetUsage(id, this.db);
|
|
}
|
|
|
|
/** Read the instance config with secret fields masked. */
|
|
getInstanceConfig(id: string): Record<string, unknown> {
|
|
const row = this.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(
|
|
(
|
|
this.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);
|
|
}
|
|
|
|
/**
|
|
* `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 assertRequiredFilled(id: string, scope: 'instance' | 'user', config: Record<string, unknown>): void {
|
|
const required = this.db
|
|
.prepare(
|
|
"SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? AND required = 1 AND input_type != 'checkbox'",
|
|
)
|
|
.all(id, scope) as Array<{ field_key: string }>;
|
|
const defaults = settingDefaults(this.db, id, scope);
|
|
for (const f of required) {
|
|
// The runtime resolves the default too, so it counts as filled here as well.
|
|
if (!isFilled(config[f.field_key] ?? defaults[f.field_key])) throw new MissingRequiredSettingError(f.field_key);
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|