refactor(plugins): runtime lifecycle, config CRUD and the uninstall cascade through repositories

This commit is contained in:
jubnl
2026-09-24 02:46:01 +02:00
parent f337f116d9
commit ab63eaf9ad
29 changed files with 2114 additions and 293 deletions
@@ -56,4 +56,16 @@ export class NotificationChannelPreferencesRepository extends TrekRepository<Not
async deletePreference(userId: number, eventType: string, channel: string): Promise<number> {
return await this.nativeDelete({ user: userId, event_type: eventType, channel });
}
/**
* Plan 3j Task 2 — `plugin-runtime.service.ts#retireNotificationChannel` (PR50,
* cross-domain): `DELETE FROM notification_channel_preferences WHERE channel = ?`,
* fired when a plugin's notification channel is retired (uninstall, or the
* capability disappearing on update). Unlike every other write on this
* repository, there is no `user`/`event_type` filter — every user's opt-out for
* THIS channel goes, across every event.
*/
async deleteAllForChannel(channel: string): Promise<void> {
await this.nativeDelete({ channel });
}
}
@@ -1,4 +1,33 @@
import type { PluginActions } from '../entities/PluginActions.entity';
import type { PluginActionScope } from '@trek/shared';
import { TrekRepository } from './_shared/trek-repository';
export class PluginActionsRepository extends TrekRepository<PluginActions> {}
/** `actionsOf`'s own projection — mapped to `PluginActionDescriptor` in the service. */
export interface PluginActionRow {
action_key: string;
label: string;
hint: string | null;
danger: number;
scope: PluginActionScope;
}
export class PluginActionsRepository extends TrekRepository<PluginActions> {
/** PS3 (`plugins.service.ts#instanceActionsCount`) — `SELECT COUNT(*) AS n FROM plugin_actions WHERE plugin_id = ? AND scope = 'instance'`. Caller wraps this in its own try/catch → 0. */
async countForPluginScope(pluginId: string, scope: PluginActionScope): Promise<number> {
return await this.count({ plugin_id: pluginId, scope });
}
/** `actionsOf` — `SELECT action_key, label, hint, danger, scope FROM plugin_actions WHERE plugin_id = ? AND scope = ? ORDER BY sort_order`. Caller wraps this in its own try/catch → []. */
async listForPluginScope(pluginId: string, scope: PluginActionScope): Promise<PluginActionRow[]> {
const rows = await this.find(
{ plugin_id: pluginId, scope },
{ fields: ['action_key', 'label', 'hint', 'danger', 'scope'], orderBy: { sort_order: 'asc' } },
);
return rows.map((r) => ({ action_key: r.action_key, label: r.label, hint: r.hint ?? null, danger: r.danger, scope: r.scope as PluginActionScope }));
}
/** PR35 (uninstall cascade) — `DELETE FROM plugin_actions WHERE plugin_id = ?`. The caller wraps this in its own try/catch (table absent on a slimmed test schema is tolerated here). */
async deleteAllForPlugin(pluginId: string): Promise<void> {
await this.nativeDelete({ plugin_id: pluginId });
}
}
@@ -1,4 +1,181 @@
import type { PluginCapabilityAudit } from '../entities/PluginCapabilityAudit.entity';
import { TrekRepository } from './_shared/trek-repository';
export class PluginCapabilityAuditRepository extends TrekRepository<PluginCapabilityAudit> {}
/**
* Plan 3j Task 2 note: this repository's ONLY method today is the uninstall
* cascade's own delete (PR45) — R-hash-chain's own read/append/prune surface
* (`host/plugin-audit.ts`) belongs to Task 3, which adds its own additive
* methods to this same repository. Do NOT confuse this table with the global,
* admin-facing `audit_log` (3i's `AuditLogRepository`) — two unrelated "audit"
* systems (plan3j-inputs.md corrections §4).
*
* Plan 3j Task 3 note: the methods below are the promised additive surface —
* `pruneKeepingNewest`/`lastHash`/`insertRow`/`forUser`/`forPlugin`/`budgetSeed`,
* backing `host/plugin-audit.ts` (R-hash-chain) and `host/plugin-host-state.ts`'s
* `budgetFor` seed read (HS1). PR45's `deleteAllForPlugin` above is untouched.
*/
export class PluginCapabilityAuditRepository extends TrekRepository<PluginCapabilityAudit> {
/** PR45 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_capability_audit WHERE plugin_id = ?`. */
async deleteAllForPlugin(pluginId: string): Promise<void> {
await this.nativeDelete({ plugin_id: pluginId });
}
// -----------------------------------------------------------------------
// Plan 3j Task 3 — R-hash-chain (host/plugin-audit.ts) + HS1 (plugin-host-state.ts)
// -----------------------------------------------------------------------
/**
* PA1 (`pruneAudit`'s amortized retention) — `` DELETE FROM plugin_capability_audit
* WHERE plugin_id = ? AND id NOT IN (SELECT id FROM plugin_capability_audit WHERE
* plugin_id = ? ORDER BY id DESC LIMIT ?) ``. Same `NOT IN (subquery … ORDER BY …
* LIMIT)` shape as `PluginErrorLogRepository#pruneKeepingRecent` — no `nativeDelete`/
* `FilterQuery` equivalent (no correlated-subquery support), so this goes through
* `this.kysely()` (`_shared/trek-repository.ts`'s escape hatch). The `keep` count is
* a `?`-bound `limit()` call, never interpolated into SQL text.
*/
async pruneKeepingNewest(pluginId: string, keep: number): Promise<void> {
const db = this.kysely<PluginCapabilityAuditKyselyDB>();
await db
.deleteFrom('plugin_capability_audit')
.where('plugin_id', '=', pluginId)
.where(
'id',
'not in',
db.selectFrom('plugin_capability_audit').select('id').where('plugin_id', '=', pluginId).orderBy('id', 'desc').limit(keep),
)
.execute();
}
/**
* PA2 (`appendAudit`'s chain read) — `SELECT hash FROM plugin_capability_audit
* WHERE plugin_id = ? ORDER BY id DESC LIMIT 1`. `null` means "no prior row for
* this plugin" (a fresh chain) — `appendAudit` folds that into `''` the same way
* the legacy `?.hash ?? ''` did.
*/
async lastHash(pluginId: string): Promise<string | null> {
const row = await this.findOne({ plugin_id: pluginId }, { fields: ['hash'], orderBy: { id: 'desc' } });
return row?.hash ?? null;
}
/**
* PA3 (`appendAudit`'s own write) — `INSERT INTO plugin_capability_audit
* (plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash)
* VALUES (?,?,?,?,?,?,?,?)`. The hash itself (and `ts`, computed in JS as
* `new Date().toISOString()` BEFORE the hash) is computed by the caller —
* `appendAudit` in `host/plugin-audit.ts` — never here: this method persists
* the already-hashed row byte-for-byte, so R-hash-chain's construction stays
* visible and testable in one place.
*/
async insertRow(entry: NewAuditRow): Promise<void> {
await this.insert({
plugin_id: entry.plugin_id,
acting_user_id: entry.acting_user_id,
method: entry.method,
resource: entry.resource,
code: entry.code,
ts: entry.ts,
prev_hash: entry.prev_hash,
hash: entry.hash,
});
}
/**
* PA4 (`readAuditForUser`) — `SELECT a.ts, a.plugin_id, p.name AS plugin_name,
* a.method, a.resource, a.code FROM plugin_capability_audit a LEFT JOIN plugins
* p ON p.id = a.plugin_id WHERE a.acting_user_id = ? ORDER BY a.id DESC LIMIT ?`.
* Kysely against the literal `plugins` table, the same `AuditLogRepository
* #listPage` LEFT-JOIN precedent (a deleted/never-existing plugin leaves
* `plugin_name` null, the row itself still returned) — no `PluginsRepository`
* dependency: the join only needs the table, not that repository's own methods.
*/
async forUser(userId: number, limit: number): Promise<AuditForUserRow[]> {
const rows = await this.kysely<PluginCapabilityAuditKyselyDB>()
.selectFrom('plugin_capability_audit as a')
.leftJoin('plugins as p', 'p.id', 'a.plugin_id')
.select(['a.ts', 'a.plugin_id', 'p.name as plugin_name', 'a.method', 'a.resource', 'a.code'])
.where('a.acting_user_id', '=', userId)
.orderBy('a.id', 'desc')
.limit(limit)
.execute();
return rows.map((r) => ({ ts: r.ts, plugin_id: r.plugin_id, plugin_name: r.plugin_name ?? null, method: r.method, resource: r.resource, code: r.code }));
}
/**
* PA5 (`readAudit`, admin per-plugin view) — `SELECT ts, acting_user_id, method,
* resource, code FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id
* DESC LIMIT ?`.
*/
async forPlugin(pluginId: string, limit: number): Promise<AuditForPluginRow[]> {
const rows = await this.find(
{ plugin_id: pluginId },
{ fields: ['ts', 'acting_user_id', 'method', 'resource', 'code'], orderBy: { id: 'desc' }, limit },
);
return rows.map((r) => ({ ts: r.ts, acting_user_id: r.acting_user_id ?? null, method: r.method, resource: r.resource ?? null, code: r.code }));
}
/**
* HS1 (`plugin-host-state.ts#budgetFor`'s seed read) — `SELECT method, COUNT(*)
* AS n FROM plugin_capability_audit WHERE plugin_id = ? AND code = 'ok' AND
* ts >= ? AND method IN ('ai.complete','ai.extract','notify.send') GROUP BY
* method`. A GROUP BY aggregate — Kysely, same escape-hatch rule as PA1/PA4.
*/
async budgetSeed(pluginId: string, since: string): Promise<Array<{ method: string; n: number }>> {
const rows = await this.kysely<PluginCapabilityAuditKyselyDB>()
.selectFrom('plugin_capability_audit')
.select(({ fn }) => ['method', fn.count<number>('id').as('n')])
.where('plugin_id', '=', pluginId)
.where('code', '=', 'ok')
.where('ts', '>=', since)
.where('method', 'in', ['ai.complete', 'ai.extract', 'notify.send'])
.groupBy('method')
.execute();
return rows.map((r) => ({ method: r.method, n: Number(r.n) }));
}
}
/** The column set `insertRow` writes — everything `appendAudit` has already computed. */
export interface NewAuditRow {
plugin_id: string;
acting_user_id: number | null;
method: string;
resource: string | null;
code: string;
ts: string;
prev_hash: string | null;
hash: string;
}
/** `forUser`'s own projection (PA4). */
export interface AuditForUserRow {
ts: string;
plugin_id: string;
plugin_name: string | null;
method: string;
resource: string | null;
code: string;
}
/** `forPlugin`'s own projection (PA5). */
export interface AuditForPluginRow {
ts: string;
acting_user_id: number | null;
method: string;
resource: string | null;
code: string;
}
/** Kysely shape for `pruneKeepingNewest`/`forUser`/`budgetSeed`'s escape-hatch queries — only the columns/tables they actually touch. */
interface PluginCapabilityAuditKyselyDB {
plugin_capability_audit: {
id: number;
plugin_id: string;
acting_user_id: number | null;
method: string;
resource: string | null;
code: string;
ts: string;
prev_hash: string | null;
hash: string;
};
plugins: { id: string; name: string };
}
@@ -1,4 +1,39 @@
import type { PluginEgressHosts } from '../entities/PluginEgressHosts.entity';
import { TrekRepository } from './_shared/trek-repository';
export class PluginEgressHostsRepository extends TrekRepository<PluginEgressHosts> {}
export class PluginEgressHostsRepository extends TrekRepository<PluginEgressHosts> {
/** PR20 — `SELECT host FROM plugin_egress_hosts WHERE plugin_id = ? ORDER BY host` (`operatorEgressHosts`). Caller wraps this in its own try/catch — a slimmed test schema without this table never blocks activation. */
async listHostsForPlugin(pluginId: string): Promise<string[]> {
const rows = await this.find({ plugin_id: pluginId }, { fields: ['host'], orderBy: { host: 'asc' } });
return rows.map((r) => r.host);
}
/** PS1 — `SELECT COUNT(*) AS n FROM plugin_egress_hosts WHERE plugin_id = ?` (`plugins.service.ts#egressHostCount`). Caller wraps this in its own try/catch → 0. */
async countForPlugin(pluginId: string): Promise<number> {
return await this.count({ plugin_id: pluginId });
}
/**
* PR22/PR23 — `setOperatorEgressHosts`'s DELETE+loop-INSERT: `DELETE FROM
* plugin_egress_hosts WHERE plugin_id = ?` then, per host, `INSERT OR IGNORE INTO
* plugin_egress_hosts (plugin_id, host) VALUES (?, ?)`.
*
* **R-uninstall's ONE named transaction** (the plan's "For the user" line): the CALLER
* (`PluginRuntimeService.setOperatorEgressHosts`) wraps this call in `uow.transactional`
* — this method issues the same two-step DELETE-then-INSERT-loop shape the legacy code
* did, but now atomically, so a crash mid-write leaves the OLD host set intact instead
* of a plugin with zero egress hosts. `INSERT OR IGNORE` -> `onConflictAction: 'ignore'`
* against the `(plugin_id, host)` unique pair, unchanged from the legacy statement.
*/
async replaceAllForPlugin(pluginId: string, hosts: string[]): Promise<void> {
await this.nativeDelete({ plugin_id: pluginId });
for (const host of hosts) {
await this.upsert({ plugin_id: pluginId, host }, { onConflictFields: ['plugin_id', 'host'], onConflictAction: 'ignore' });
}
}
/** PR36 (uninstall cascade) — `DELETE FROM plugin_egress_hosts WHERE plugin_id = ?`. The caller wraps this in its own try/catch (table absent on a slimmed test schema is tolerated here). */
async deleteAllForPlugin(pluginId: string): Promise<void> {
await this.nativeDelete({ plugin_id: pluginId });
}
}
@@ -1,4 +1,9 @@
import type { PluginEntityMetadata } from '../entities/PluginEntityMetadata.entity';
import { TrekRepository } from './_shared/trek-repository';
export class PluginEntityMetadataRepository extends TrekRepository<PluginEntityMetadata> {}
export class PluginEntityMetadataRepository extends TrekRepository<PluginEntityMetadata> {
/** PR40 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_entity_metadata WHERE plugin_id = ?`. */
async deleteAllForPlugin(pluginId: string): Promise<void> {
await this.nativeDelete({ plugin_id: pluginId });
}
}
@@ -1,4 +1,57 @@
import type { PluginErrorLog } from '../entities/PluginErrorLog.entity';
import { TrekRepository } from './_shared/trek-repository';
export class PluginErrorLogRepository extends TrekRepository<PluginErrorLog> {}
/** PR2 delivery row / plugins.service.ts `errors()`'s own projection. */
export interface PluginErrorLogRow {
ts: string;
level: string;
message: string;
}
/** `pruneKeepingRecent`'s own `this.kysely()` escape hatch (`_shared/trek-repository.ts`) — the `id`/`plugin_id` columns its correlated subquery needs. */
interface PluginErrorLogKyselyDB {
plugin_error_log: { id: number; plugin_id: string };
}
export class PluginErrorLogRepository extends TrekRepository<PluginErrorLog> {
/** PR2 — `INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, ?, ?)` (the supervisor's `onLog` callback — §4a, lazily resolved, own try/catch swallow unchanged in the caller). */
async insertLog(pluginId: string, level: string, message: string): Promise<void> {
await this.insert({ plugin_id: pluginId, level, message });
}
/**
* PR3 — `` DELETE FROM plugin_error_log WHERE plugin_id = ? AND id NOT IN (
* SELECT id FROM plugin_error_log WHERE plugin_id = ? ORDER BY id DESC LIMIT ${retention}
* ) `` (`pruneErrorLog`'s retention trim). The `NOT IN (subquery … ORDER BY … LIMIT)`
* shape has no `nativeDelete`/`FilterQuery` equivalent (no correlated-subquery support),
* so this is one of the few statements per repository that goes through `this.kysely()`
* (`_shared/trek-repository.ts`'s escape hatch) rather than the QueryBuilder — the
* `retention` count is a `?`-bound `limit()` call, never interpolated into SQL text.
*/
async pruneKeepingRecent(pluginId: string, retention: number): Promise<void> {
const db = this.kysely<PluginErrorLogKyselyDB>();
await db
.deleteFrom('plugin_error_log')
.where('plugin_id', '=', pluginId)
.where(
'id',
'not in',
db.selectFrom('plugin_error_log').select('id').where('plugin_id', '=', pluginId).orderBy('id', 'desc').limit(retention),
)
.execute();
}
/** PR38 (uninstall cascade) / plugins.service.ts's `clearErrors` — `DELETE FROM plugin_error_log WHERE plugin_id = ?` (identical text, two call sites). */
async deleteAllForPlugin(pluginId: string): Promise<void> {
await this.nativeDelete({ plugin_id: pluginId });
}
/** plugins.service.ts `errors()` — `SELECT ts, level, message FROM plugin_error_log WHERE plugin_id = ? ORDER BY ts DESC, id DESC LIMIT 200`. */
async listRecent(pluginId: string, limit = 200): Promise<PluginErrorLogRow[]> {
const rows = await this.find(
{ plugin_id: pluginId },
{ fields: ['ts', 'level', 'message'], orderBy: [{ ts: 'desc' }, { id: 'desc' }], limit },
);
return rows.map((r) => ({ ts: r.ts ?? '', level: r.level, message: r.message ?? '' }));
}
}
@@ -1,4 +1,16 @@
import type { PluginMetaMigrations } from '../entities/PluginMetaMigrations.entity';
import { TrekRepository } from './_shared/trek-repository';
export class PluginMetaMigrationsRepository extends TrekRepository<PluginMetaMigrations> {}
/**
* Plan 3j Task 2 note: this repository's ONLY method today is the uninstall
* cascade's own delete (PR44) — the rest of this table's surface (a plugin's own
* `ctx.db.migrate` ledger) belongs to whichever later task converts `plugin-data
* .service.ts`'s host-side counterpart, if any (R-out-of-scope carve-out: the
* plugin's own sqlite file is untouched by this program).
*/
export class PluginMetaMigrationsRepository extends TrekRepository<PluginMetaMigrations> {
/** PR44 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_meta_migrations WHERE plugin_id = ?`. */
async deleteAllForPlugin(pluginId: string): Promise<void> {
await this.nativeDelete({ plugin_id: pluginId });
}
}
@@ -1,4 +1,14 @@
import type { PluginOauthState } from '../entities/PluginOauthState.entity';
import { TrekRepository } from './_shared/trek-repository';
export class PluginOauthStateRepository extends TrekRepository<PluginOauthState> {}
/**
* Plan 3j Task 2 note: this repository's ONLY method today is the uninstall
* cascade's own delete (PR43) — the rest of this table's surface belongs to
* Task 4's `oauth/plugin-oauth.service.ts`, which adds its own additive methods.
*/
export class PluginOauthStateRepository extends TrekRepository<PluginOauthState> {
/** PR43 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_oauth_state WHERE plugin_id = ?`. */
async deleteAllForPlugin(pluginId: string): Promise<void> {
await this.nativeDelete({ plugin_id: pluginId });
}
}
@@ -1,4 +1,23 @@
import type { PluginOauthTokens } from '../entities/PluginOauthTokens.entity';
import { TrekRepository } from './_shared/trek-repository';
export class PluginOauthTokensRepository extends TrekRepository<PluginOauthTokens> {}
/**
* Plan 3j Task 2 note: this repository's ONLY method today is the uninstall
* cascade's own delete (`plugin-runtime.service.ts`'s `uninstall()`, PR42) — the
* statement lives in Task 2's file even though the rest of this table's surface
* (`storeToken`'s COALESCE-preserving composite upsert, R-oauth-upsert) belongs to
* Task 4's `oauth/plugin-oauth.service.ts`. Task 4 adds its own methods to this
* same repository; this one is additive and does not collide with them.
*/
export class PluginOauthTokensRepository extends TrekRepository<PluginOauthTokens> {
/** PR42 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_oauth_tokens WHERE plugin_id = ?`. */
async deleteAllForPlugin(pluginId: string): Promise<void> {
await this.nativeDelete({ plugin_id: pluginId });
}
/** `exportUserData`'s GDPR fold — `SELECT DISTINCT plugin_id FROM plugin_oauth_tokens WHERE user_id = ?` (which plugins a user OAuth-linked; raw tokens are never exported). */
async listDistinctPluginIdsForUser(userId: number): Promise<string[]> {
const rows = await this.find({ user_id: userId }, { fields: ['plugin_id'] });
return [...new Set(rows.map((r) => r.plugin_id))];
}
}
@@ -1,4 +1,64 @@
import type { PluginScheduledTasks } from '../entities/PluginScheduledTasks.entity';
import { TrekRepository } from './_shared/trek-repository';
export class PluginScheduledTasksRepository extends TrekRepository<PluginScheduledTasks> {}
/** PR4 — the sweep's own due-task projection. */
export interface DueScheduledTaskRow {
id: number;
plugin_id: string;
name: string;
payload: string;
every_ms: number | null;
}
export class PluginScheduledTasksRepository extends TrekRepository<PluginScheduledTasks> {
/**
* PR4 — `` SELECT id, plugin_id, name, payload, every_ms FROM plugin_scheduled_tasks
* WHERE due_at <= ? AND plugin_id IN (${ph}) ORDER BY due_at LIMIT 200 `` (`fireDueScheduled`'s
* sweep window). `pluginIds` — never an empty array: the caller (`fireDueScheduled`)
* already returns early when `supervisor.activeIds()` is empty, the same short-circuit
* the legacy `IN (${ph})` string-building avoided running against.
*/
async findDueForPlugins(now: number, pluginIds: string[], limit = 200): Promise<DueScheduledTaskRow[]> {
const rows = await this.find(
{ due_at: { $lte: now }, plugin_id: { $in: pluginIds } },
{ fields: ['id', 'plugin_id', 'name', 'payload', 'every_ms'], orderBy: { due_at: 'asc' }, limit },
);
return rows.map((r) => ({ id: r.id, plugin_id: r.plugin_id, name: r.name, payload: r.payload, every_ms: r.every_ms ?? null }));
}
/**
* PR5 — `UPDATE plugin_scheduled_tasks SET due_at = ? WHERE id = ?` — re-arm BEFORE
* the fire (crash-safety ordering, preserved exactly by the caller).
*
* **Deviation, named (task-2-report.md), the SAME spirit as R-uninstall's egress-host
* transaction — a second, small, deliberate correctness fix, not silent parity**: the
* legacy statement had no `due_at` guard, but the legacy CALL SITE never needed one —
* `better-sqlite3` is synchronous and the claim-then-act loop had no `await` between
* the SELECT and this UPDATE, so two "concurrent" JS calls to `fireDueScheduled` could
* never actually interleave at the DB level (the first ran to completion before the
* second's own SELECT even started). Converting the SELECT and this UPDATE into
* separately-awaited repository calls removes that accidental atomicity: two
* overlapping ticks (a real possibility once a tick can run longer than the 30s
* cadence) can both read the SAME due row before either claims it, delivering it
* TWICE (proven by `RACE-SCHED-001` before this guard was added). Adding `due_at <=
* now` here (the SAME condition the SELECT already used) makes the claim atomic and
* idempotent per row: whichever pass's UPDATE lands first moves `due_at` forward, so
* the LOSING pass's own `due_at <= now` no longer matches and its `nativeUpdate`
* affects zero rows — the caller uses that to skip delivery rather than double-fire.
*/
async rearm(id: number, dueAt: number, claimedFromDueAt: number): Promise<boolean> {
const changed = await this.nativeUpdate({ id, due_at: { $lte: claimedFromDueAt } }, { due_at: dueAt });
return changed > 0;
}
/** PR6 — `DELETE FROM plugin_scheduled_tasks WHERE id = ?` (a one-shot task, deleted before the fire). Same atomic-claim guard as `rearm` above, same reason. */
async deleteById(id: number, claimedFromDueAt: number): Promise<boolean> {
const changed = await this.nativeDelete({ id, due_at: { $lte: claimedFromDueAt } });
return changed > 0;
}
/** PR37 (uninstall cascade) — `DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ?`. */
async deleteAllForPlugin(pluginId: string): Promise<void> {
await this.nativeDelete({ plugin_id: pluginId });
}
}
@@ -1,4 +1,95 @@
import type { PluginSettingsFields } from '../entities/PluginSettingsFields.entity';
import { TrekRepository } from './_shared/trek-repository';
export class PluginSettingsFieldsRepository extends TrekRepository<PluginSettingsFields> {}
/** PR#499 — `notificationChannels`' secret-user-field scan, across ALL plugins. */
export interface PluginSecretFieldRow {
plugin_id: string;
field_key: string;
}
/** `plugins.service.ts#settingsFields`'s own full projection (mapped to `PluginSettingsField` in the service, which also resolves `default`/`options`). */
export interface PluginSettingsFieldRow {
field_key: string;
label: string | null;
input_type: string;
placeholder: string | null;
hint: string | null;
required: number;
secret: number;
options: string | null;
default_value: string | null;
}
export class PluginSettingsFieldsRepository extends TrekRepository<PluginSettingsFields> {
/** `notificationChannels`'s GDPR-export fold — `SELECT plugin_id, field_key FROM plugin_settings_fields WHERE scope = 'user' AND secret = 1`, across every plugin (no `plugin_id` filter — the caller groups by `plugin_id` itself). */
async listSecretUserFields(): Promise<PluginSecretFieldRow[]> {
return await this.find({ scope: 'user', secret: 1 }, { fields: ['plugin_id', 'field_key'] });
}
/** PS2 (`plugins.service.ts#instanceSettingsCount`) — `SELECT COUNT(*) AS n FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance'`. Caller wraps this in its own try/catch → 0. */
async countForPluginScope(pluginId: string, scope: 'instance' | 'user'): Promise<number> {
return await this.count({ plugin_id: pluginId, scope });
}
/**
* `updateInstanceConfig`'s secret-key set / `getInstanceConfig`'s secret-key set /
* `updateUserConfig`/`getUserConfig`'s `userSecretKeys` — `SELECT field_key FROM
* plugin_settings_fields WHERE plugin_id = ? AND scope = ? AND secret = 1` (identical
* text shape across both scopes; ONE method for all four call sites).
*/
async listSecretFieldKeys(pluginId: string, scope: 'instance' | 'user'): Promise<string[]> {
const rows = await this.find({ plugin_id: pluginId, scope, secret: 1 }, { fields: ['field_key'] });
return rows.map((r) => r.field_key);
}
/**
* `updateInstanceConfig`'s allowed-key set / `updateUserConfig`'s allowed-key set —
* `SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ?`
* (identical text shape across both scopes; ONE method for both call sites).
*/
async listFieldKeys(pluginId: string, scope: 'instance' | 'user'): Promise<string[]> {
const rows = await this.find({ plugin_id: pluginId, scope }, { fields: ['field_key'] });
return rows.map((r) => r.field_key);
}
/**
* `plugins.service.ts#settingsFields` (backs `userSettingsFields`/`instanceSettingsFields`) —
* `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`.
*/
async listFields(pluginId: string, scope: 'instance' | 'user'): Promise<PluginSettingsFieldRow[]> {
const rows = await this.find(
{ plugin_id: pluginId, scope },
{
fields: ['field_key', 'label', 'input_type', 'placeholder', 'hint', 'required', 'secret', 'options', 'default_value'],
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
},
);
return rows.map((r) => ({
field_key: r.field_key,
label: r.label ?? null,
input_type: r.input_type,
placeholder: r.placeholder ?? null,
hint: r.hint ?? null,
required: r.required,
secret: r.secret,
options: r.options ?? null,
default_value: r.default_value ?? null,
}));
}
/** `assertRequiredFilled` — `SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? AND required = 1 AND input_type != 'checkbox'`. */
async listRequiredFieldKeys(pluginId: string, scope: 'instance' | 'user'): Promise<string[]> {
const rows = await this.find(
{ plugin_id: pluginId, scope, required: 1, input_type: { $ne: 'checkbox' } },
{ fields: ['field_key'] },
);
return rows.map((r) => r.field_key);
}
/** PR34 (uninstall cascade) — `DELETE FROM plugin_settings_fields WHERE plugin_id = ?`. Unlike several of its cascade siblings, this one is NOT wrapped in try/catch by the caller — a slimmed test schema is expected to have this table. */
async deleteAllForPlugin(pluginId: string): Promise<void> {
await this.nativeDelete({ plugin_id: pluginId });
}
}
@@ -1,4 +1,51 @@
import type { PluginUserConfig } from '../entities/PluginUserConfig.entity';
import { currentTimestamp } from '../dialect/sql-functions';
import { TrekRepository } from './_shared/trek-repository';
export class PluginUserConfigRepository extends TrekRepository<PluginUserConfig> {}
/** `exportUserData`'s own per-user config scan. */
export interface PluginUserConfigRow {
plugin_id: string;
config: string;
}
export class PluginUserConfigRepository extends TrekRepository<PluginUserConfig> {
/**
* PS7/PS9/PS11 — `SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id
* = ?`, byte-identical text at THREE call sites in `plugins.service.ts` (`getUserConfig`'s
* own read, `setUserConfig`'s pre-write existence check, `getUserConfigDecrypted`'s read)
* — collapsed onto one method per the brief's own naming of this triple-duplicate.
*/
async findConfig(pluginId: string, userId: number): Promise<string | null> {
const row = await this.findOne({ plugin_id: pluginId, user_id: userId }, { fields: ['config'] });
return row?.config ?? null;
}
/** `exportUserData`'s GDPR fold — `SELECT plugin_id, config FROM plugin_user_config WHERE user_id = ?`. */
async listForUser(userId: number): Promise<PluginUserConfigRow[]> {
const rows = await this.find({ user_id: userId }, { fields: ['plugin_id', 'config'] });
return rows.map((r) => ({ plugin_id: r.plugin_id, config: r.config }));
}
/**
* PS10 — `` 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 `` — the composite-key
* upsert on `(plugin_id, user_id)`, the entity's own primary key. `currentTimestamp`
* (`src/db/dialect/sql-functions.ts`) renders the same `CURRENT_TIMESTAMP`/`datetime('now')`
* text SQLite already treats as equivalent, bound once per statement execution so the
* inserted row and the `excluded.updated_at` merge target agree, matching the legacy
* single-statement `datetime('now')` call exactly.
*/
async upsertConfig(pluginId: string, userId: number, config: string): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.upsert(
{ plugin_id: pluginId, user_id: userId, config, updated_at: currentTimestamp(platform) },
{ onConflictFields: ['plugin_id', 'user_id'], onConflictAction: 'merge', onConflictMergeFields: ['config', 'updated_at'] },
);
}
/** PR41 (uninstall cascade) — `DELETE FROM plugin_user_config WHERE plugin_id = ?`. */
async deleteAllForPlugin(pluginId: string): Promise<void> {
await this.nativeDelete({ plugin_id: pluginId });
}
}
@@ -1,4 +1,65 @@
import type { PluginUserErasureQueue } from '../entities/PluginUserErasureQueue.entity';
import { TrekRepository } from './_shared/trek-repository';
export class PluginUserErasureQueueRepository extends TrekRepository<PluginUserErasureQueue> {}
/** The drain's own pending-row projection. */
export interface PendingErasureRow {
id: number;
plugin_id: string;
user_id: number;
}
/** `plugin_id`/`id` — the columns the orphan-scan's correlated `NOT IN (SELECT id FROM plugins)` subquery needs. */
interface PluginUserErasureQueueKyselyDB {
plugin_user_erasure_queue: { id: number; plugin_id: string; user_id: number };
plugins: { id: string };
}
export class PluginUserErasureQueueRepository extends TrekRepository<PluginUserErasureQueue> {
/** PR8 — `INSERT OR IGNORE INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)` (idempotent — a duplicate enqueue for the same pair is a no-op). */
async insertIgnore(pluginId: string, userId: number): Promise<void> {
await this.upsert({ plugin_id: pluginId, user_id: userId }, { onConflictFields: ['plugin_id', 'user_id'], onConflictAction: 'ignore' });
}
/**
* `runDrainOnce`'s orphan scan — `SELECT DISTINCT plugin_id FROM plugin_user_erasure_queue
* WHERE plugin_id NOT IN (SELECT id FROM plugins)`. A cross-table correlated `NOT IN`
* has no `FilterQuery` equivalent, so this goes through `this.kysely()`.
*/
async listOrphanPluginIds(): Promise<string[]> {
const db = this.kysely<PluginUserErasureQueueKyselyDB>();
const rows = await db
.selectFrom('plugin_user_erasure_queue')
.select('plugin_id')
.distinct()
.where('plugin_id', 'not in', db.selectFrom('plugins').select('id'))
.execute();
return rows.map((r) => r.plugin_id);
}
/**
* `runDrainOnce`'s reap (a truly orphaned data dir) / `uninstall`'s `deleteData` branch —
* `DELETE FROM plugin_user_erasure_queue WHERE plugin_id = ?` (identical text, two call sites).
*/
async deleteAllForPlugin(pluginId: string): Promise<void> {
await this.nativeDelete({ plugin_id: pluginId });
}
/**
* `runDrainOnce`'s delivery window — `` SELECT id, plugin_id, user_id FROM
* plugin_user_erasure_queue WHERE plugin_id IN (${ph}) ORDER BY id LIMIT 200 ``.
* `pluginIds` — never empty: the caller already returns early when
* `supervisor.activeIds()` is empty.
*/
async findPendingForPlugins(pluginIds: string[], limit = 200): Promise<PendingErasureRow[]> {
const rows = await this.find(
{ plugin_id: { $in: pluginIds } },
{ fields: ['id', 'plugin_id', 'user_id'], orderBy: { id: 'asc' }, limit },
);
return rows.map((r) => ({ id: r.id, plugin_id: r.plugin_id, user_id: r.user_id }));
}
/** `runDrainOnce`'s ACK — `DELETE FROM plugin_user_erasure_queue WHERE id = ?` (a delivered row). */
async deleteById(id: number): Promise<void> {
await this.nativeDelete({ id });
}
}
@@ -1,4 +1,391 @@
import type { Plugins } from '../entities/Plugins.entity';
import { currentTimestamp } from '../dialect/sql-functions';
import { TrekRepository } from './_shared/trek-repository';
export class PluginsRepository extends TrekRepository<Plugins> {}
/**
* Plan 3j Task 2 — `plugins` is this plan's most-reused table (`plugin-runtime.service.ts`
* ~28 of 53 sites, `plugins.service.ts` 5 of 13). Every method below is named after the
* statement it replaces (PR#/PS# per `plan3j-sql-inventory.md` §4/§5); several methods
* serve more than one call site that shared byte-identical SQL text in the legacy file
* (documented per method).
*/
/** PR15/PR16/PR25 — `SELECT id, version, enabled, dependencies FROM plugins`, the shape
* `dependencies.ts`'s dependency-graph helpers reason over (structurally compatible with
* `PluginDepRow` there; not imported here to keep `db/repositories` free of `nest/` imports). */
export interface PluginDepListRow {
id: string;
version: string | null;
enabled: number;
dependencies: string | null;
}
/** PR7/PR11 — `SELECT id, permissions FROM plugins` (the GDPR erasure enqueue's + the export's own full-table scan). */
export interface PluginIdPermissionRow {
id: string;
permissions: string | null;
}
/** PR17 — `SELECT permissions, granted_permissions, dependencies, trek_range, api_version FROM plugins WHERE id = ?` (`assertActivatable`'s one-row activation gate). */
export interface PluginActivationGateRow {
permissions: string;
granted_permissions: string;
dependencies: string | null;
trek_range: string | null;
api_version: number | null;
}
/** PR18 — `SELECT permissions, config FROM plugins WHERE id = ?` (`spawnActivated`). */
export interface PluginPermissionsConfigRow {
permissions: string;
config: string;
}
/** PR26 — `SELECT enabled, granted_permissions, version FROM plugins WHERE id = ?` (`update`'s pre-update snapshot). */
export interface PluginPreUpdateRow {
enabled: number;
granted_permissions: string;
version: string | null;
}
/** PR51 — `SELECT name, capabilities FROM plugins WHERE id = ?` (`notificationChannels`). */
export interface PluginNameCapabilitiesRow {
name: string;
capabilities: string;
}
/** PS-list — `list()`'s admin projection, every column `PluginsService.list()` re-shapes. */
export interface PluginAdminRow {
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;
operator_egress: number;
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 class PluginsRepository extends TrekRepository<Plugins> {
// -----------------------------------------------------------------------
// PR1 — status/log persistence (onStatus callback, lazily resolved — §4a)
// -----------------------------------------------------------------------
/** PR1 — `UPDATE plugins SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`. */
async updateStatusAndError(id: string, status: string, lastError: string | null): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { status, last_error: lastError, updated_at: currentTimestamp(platform) });
}
// -----------------------------------------------------------------------
// PR7 / PR11 — GDPR erasure enqueue + export's own full-table scan
// -----------------------------------------------------------------------
/** PR7/PR11 — `SELECT id, permissions FROM plugins` (identical text, two call sites: `enqueueUserErasure`, `exportUserData`). */
async listIdsAndPermissions(): Promise<PluginIdPermissionRow[]> {
const rows = await this.find({}, { fields: ['id', 'permissions'] });
// `Plugins.id` is typed `string | null` at the entity level (accommodates a
// pre-insert state MikroORM's own typing requires) but is the table's real
// PRIMARY KEY — never null on a row read back from the DB (same shape as
// `listForAdmin`'s `id: r.id ?? ''` below).
return rows.map((r) => ({ id: r.id ?? '', permissions: r.permissions ?? null }));
}
// -----------------------------------------------------------------------
// PR15 / PR16 / PR25 — dependency-graph reads
// -----------------------------------------------------------------------
/** PR15/PR16/PR25 — `SELECT id, version, enabled, dependencies FROM plugins` (identical text, three call sites: `installedDepRows`, `deactivateForDisabledAddon`, `deactivateWithDependents`). */
async listDepRows(): Promise<PluginDepListRow[]> {
const rows = await this.find({}, { fields: ['id', 'version', 'enabled', 'dependencies'] });
return rows.map((r) => ({ id: r.id ?? '', version: r.version ?? null, enabled: r.enabled, dependencies: r.dependencies ?? null }));
}
// -----------------------------------------------------------------------
// PR17 — activation gate
// -----------------------------------------------------------------------
/** PR17 — `SELECT permissions, granted_permissions, dependencies, trek_range, api_version FROM plugins WHERE id = ?`. */
async findActivationGate(id: string): Promise<PluginActivationGateRow | null> {
const row = await this.findOne({ id }, { fields: ['permissions', 'granted_permissions', 'dependencies', 'trek_range', 'api_version'] });
return row
? {
permissions: row.permissions ?? '[]',
granted_permissions: row.granted_permissions ?? '',
dependencies: row.dependencies ?? null,
trek_range: row.trek_range ?? null,
api_version: row.api_version ?? null,
}
: null;
}
// -----------------------------------------------------------------------
// PR18 — spawnActivated
// -----------------------------------------------------------------------
/** PR18 — `SELECT permissions, config FROM plugins WHERE id = ?`. */
async findPermissionsAndConfig(id: string): Promise<PluginPermissionsConfigRow | null> {
const row = await this.findOne({ id }, { fields: ['permissions', 'config'] });
return row ? { permissions: row.permissions ?? '[]', config: row.config ?? '{}' } : null;
}
// -----------------------------------------------------------------------
// PR19 — spawnActivated's admin-intent write
// -----------------------------------------------------------------------
/** PR19 — `UPDATE plugins SET granted_permissions = ?, enabled = 1 WHERE id = ?`. */
async grantPermissionsAndEnable(id: string, grantedPermissionsJson: string): Promise<void> {
await this.nativeUpdate({ id }, { granted_permissions: grantedPermissionsJson, enabled: 1 });
}
// -----------------------------------------------------------------------
// PR21 — wantsOperatorEgress
// -----------------------------------------------------------------------
/** PR21 — `SELECT operator_egress FROM plugins WHERE id = ?`. */
async findOperatorEgressFlag(id: string): Promise<number | null> {
const row = await this.findOne({ id }, { fields: ['operator_egress'] });
return row?.operator_egress ?? null;
}
// -----------------------------------------------------------------------
// PR24 — deactivate / markInactive
// -----------------------------------------------------------------------
/** PR24 — `UPDATE plugins SET status = 'inactive', enabled = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?`. */
async markInactive(id: string): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { status: 'inactive', enabled: 0, updated_at: currentTimestamp(platform) });
}
// -----------------------------------------------------------------------
// PR26 — update's pre-update snapshot
// -----------------------------------------------------------------------
/** PR26 — `SELECT enabled, granted_permissions, version FROM plugins WHERE id = ?`. */
async findPreUpdateSnapshot(id: string): Promise<PluginPreUpdateRow | null> {
const row = await this.findOne({ id }, { fields: ['enabled', 'granted_permissions', 'version'] });
return row ? { enabled: row.enabled, granted_permissions: row.granted_permissions ?? '', version: row.version ?? null } : null;
}
// -----------------------------------------------------------------------
// PR27 — update's post-install re-read of declared permissions
// -----------------------------------------------------------------------
/** PR27 — `SELECT permissions FROM plugins WHERE id = ?`. */
async findPermissions(id: string): Promise<string | null> {
const row = await this.findOne({ id }, { fields: ['permissions'] });
return row?.permissions ?? null;
}
// -----------------------------------------------------------------------
// PR28 — retrust's signing-key TOFU comparison
// -----------------------------------------------------------------------
/** PR28 — `SELECT author_pubkey FROM plugins WHERE id = ?`. Security-sensitive: feeds the TOFU comparison. */
async findAuthorPubkey(id: string): Promise<string | null> {
const row = await this.findOne({ id }, { fields: ['author_pubkey'] });
return row?.author_pubkey ?? null;
}
// -----------------------------------------------------------------------
// PR29 — sideload's replace-existing check
// -----------------------------------------------------------------------
/** PR29 — `SELECT id FROM plugins WHERE id = ?`, as a boolean existence check. */
async existsById(id: string): Promise<boolean> {
const row = await this.findOne({ id }, { fields: ['id'] });
return row !== null;
}
// -----------------------------------------------------------------------
// PR30 / PR32 — dev-link install + reload's source_repo read
// -----------------------------------------------------------------------
/**
* PR30/PR32 — `SELECT source_repo FROM plugins WHERE id = ?` (identical text, two call
* sites: `link`, `reload`). Returns the ROW, not the bare column: both callers need "no
* row" (`null`) told apart from "row present with `source_repo` unset" (`{ source_repo:
* null }`) — a plugin installed through the registry/sideload (never dev-linked) has a
* real row with a NULL `source_repo`, and both legacy call sites treat that as "already
* installed"/"not dev-linked", not as "not found".
*/
async findSourceRepoRow(id: string): Promise<{ source_repo: string | null } | null> {
const row = await this.findOne({ id }, { fields: ['source_repo'] });
return row ? { source_repo: row.source_repo ?? null } : null;
}
// -----------------------------------------------------------------------
// PR31 — dev-link install's cascade clear
// -----------------------------------------------------------------------
/**
* PR31 — `` UPDATE plugins SET source_repo = ?, source_commit = NULL, sha256 = NULL,
* author_pubkey = NULL, update_block_code = NULL, update_block_detail = NULL,
* update_block_version = NULL, status = 'inactive', enabled = 0 WHERE id = ? `` — the
* plugin has left the registry trust model, so every registry-trust column is cleared
* in the same statement that re-points `source_repo` to the dev-link marker.
*/
async clearForDevLink(id: string, sourceRepo: string): Promise<void> {
await this.nativeUpdate(
{ id },
{
source_repo: sourceRepo,
source_commit: null,
sha256: null,
author_pubkey: null,
update_block_code: null,
update_block_detail: null,
update_block_version: null,
status: 'inactive',
enabled: 0,
},
);
}
// -----------------------------------------------------------------------
// PR33 — uninstall cascade's own row
// -----------------------------------------------------------------------
/** PR33 — `DELETE FROM plugins WHERE id = ?` (the uninstall cascade's first statement). */
async deleteById(id: string): Promise<void> {
await this.nativeDelete({ id });
}
// -----------------------------------------------------------------------
// PR47 — the inter-plugin call/event router's grant check
// -----------------------------------------------------------------------
/** PR47 — `SELECT granted_permissions FROM plugins WHERE id = ?`. */
async findGrantedPermissions(id: string): Promise<string | null> {
const row = await this.findOne({ id }, { fields: ['granted_permissions'] });
return row?.granted_permissions ?? null;
}
// -----------------------------------------------------------------------
// PR48 / PR49 — dependsOnSatisfied (caller + target side)
// -----------------------------------------------------------------------
/** PR48 — `SELECT dependencies FROM plugins WHERE id = ?` (the caller side of `dependsOnSatisfied`). `null` return means "no row" — `dependsOnSatisfied` refuses on a missing caller the same as a missing target. */
async findDependenciesRow(id: string): Promise<{ dependencies: string | null } | null> {
const row = await this.findOne({ id }, { fields: ['dependencies'] });
return row ? { dependencies: row.dependencies ?? null } : null;
}
/** PR49 — `SELECT version FROM plugins WHERE id = ?` (the target side of `dependsOnSatisfied`). Distinct return shape from `findDependenciesColumn`: this one must tell "no row" apart from "row with a null version", which `dependsOnSatisfied` needs (a vanished target plugin refuses, a real one with no version falls back to `'0.0.0'` in the caller). */
async findVersionRow(id: string): Promise<{ version: string | null } | null> {
const row = await this.findOne({ id }, { fields: ['version'] });
return row ? { version: row.version ?? null } : null;
}
// -----------------------------------------------------------------------
// PR51 — notificationChannels
// -----------------------------------------------------------------------
/** PR51 — `SELECT name, capabilities FROM plugins WHERE id = ?`. */
async findNameAndCapabilities(id: string): Promise<PluginNameCapabilitiesRow | null> {
const row = await this.findOne({ id }, { fields: ['name', 'capabilities'] });
return row ? { name: row.name, capabilities: row.capabilities } : null;
}
// -----------------------------------------------------------------------
// PR52 / PR53 — capabilityList / mcpToolCapabilities
// -----------------------------------------------------------------------
/** PR52/PR53 — `SELECT capabilities FROM plugins WHERE id = ?` (identical text, two call sites: `capabilityList`, `mcpToolCapabilities`). */
async findCapabilities(id: string): Promise<string | null> {
const row = await this.findOne({ id }, { fields: ['capabilities'] });
return row?.capabilities ?? null;
}
// -----------------------------------------------------------------------
// PS-list — plugins.service.ts#list()
// -----------------------------------------------------------------------
/**
* PS-list — `plugins.service.ts#list()`'s own admin-listing SELECT: `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`.
*/
async listForAdmin(): Promise<PluginAdminRow[]> {
const rows = await this.find(
{},
{
fields: [
'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',
],
orderBy: [{ sort_order: 'asc' }, { name: 'asc' }],
},
);
return rows.map((r) => ({
id: r.id ?? '',
name: r.name,
description: r.description ?? null,
type: r.type,
icon: r.icon ?? null,
version: r.version ?? null,
status: r.status,
enabled: r.enabled,
last_error: r.last_error ?? null,
reviewed_at: r.reviewed_at ?? null,
source_repo: r.source_repo ?? null,
permissions: r.permissions ?? '[]',
capabilities: r.capabilities,
dependencies: r.dependencies ?? null,
operator_egress: r.operator_egress,
trek_range: r.trek_range ?? null,
author_pubkey: r.author_pubkey ?? null,
update_block_code: r.update_block_code ?? null,
update_block_detail: r.update_block_detail ?? null,
update_block_version: r.update_block_version ?? null,
update_hold: r.update_hold,
}));
}
// -----------------------------------------------------------------------
// PS4 — resumeUpdates / clearUpdateHold
// -----------------------------------------------------------------------
/** PS4 — `UPDATE plugins SET update_hold = 0 WHERE id = ?`, returning whether a row existed (`.changes > 0`). */
async clearUpdateHold(id: string): Promise<boolean> {
const changed = await this.nativeUpdate({ id }, { update_hold: 0 });
return changed > 0;
}
// -----------------------------------------------------------------------
// PS5 / PS13 — getInstanceConfig / updateInstanceConfig's config read
// -----------------------------------------------------------------------
/** PS5/PS13 — `SELECT config FROM plugins WHERE id = ?` (identical text, two call sites: `updateInstanceConfig`, `getInstanceConfig`). */
async findConfig(id: string): Promise<string | null> {
const row = await this.findOne({ id }, { fields: ['config'] });
return row?.config ?? null;
}
// -----------------------------------------------------------------------
// PS6 — updateInstanceConfig's write / plugins.service's setConfig
// -----------------------------------------------------------------------
/** PS6 — `UPDATE plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`. `config` already encrypted by the caller (3e's R6 pattern) — this repository never calls `encrypt_api_key`/`decrypt_api_key`. */
async setConfig(id: string, config: string): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { config, updated_at: currentTimestamp(platform) });
}
}
@@ -76,4 +76,17 @@ export class SettingsRepository extends TrekRepository<Settings> {
async upsertForUser(userId: number, key: string, value: string): Promise<void> {
await this.upsert({ user: userId, key, value }, { onConflictFields: ['user', 'key'], onConflictAction: 'merge' });
}
/**
* Plan 3j Task 2 — `plugin-runtime.service.ts#uninstall`'s `deleteData` branch
* (PR39): `` DELETE FROM settings WHERE key LIKE 'plugin:${id}:%' ``. No `user`
* filter — this deletes a plugin's settings across EVERY user, unlike every
* other method on this repository, which is why it takes a bare key-prefix
* `$like` pattern rather than the `(user, key)` shape the rest of the class
* uses. `prefix` is caller-built (`` `plugin:${id}:` ``) and bound as a plain
* parameter — never interpolated into SQL text.
*/
async deleteByKeyPrefix(prefix: string): Promise<number> {
return await this.nativeDelete({ key: { $like: `${prefix}%` } });
}
}
+203 -133
View File
@@ -1,4 +1,5 @@
import { Injectable, Optional, type OnApplicationBootstrap, type OnModuleDestroy } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import semver from 'semver';
import { MikroORM } from '@mikro-orm/core';
import { DatabaseService } from '../database/database.service';
@@ -6,6 +7,36 @@ import { UnitOfWork } from '../database/unit-of-work';
import { withRequestContext } from '../database/request-context';
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
import { logError } from '../audit/audit-log.logger';
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 { PluginScheduledTasks } from '../../db/entities/PluginScheduledTasks.entity';
import type { PluginScheduledTasksRepository } from '../../db/repositories/PluginScheduledTasks.repository';
import { PluginUserErasureQueue } from '../../db/entities/PluginUserErasureQueue.entity';
import type { PluginUserErasureQueueRepository } from '../../db/repositories/PluginUserErasureQueue.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 { PluginEntityMetadata } from '../../db/entities/PluginEntityMetadata.entity';
import type { PluginEntityMetadataRepository } from '../../db/repositories/PluginEntityMetadata.repository';
import { PluginOauthTokens } from '../../db/entities/PluginOauthTokens.entity';
import type { PluginOauthTokensRepository } from '../../db/repositories/PluginOauthTokens.repository';
import { PluginOauthState } from '../../db/entities/PluginOauthState.entity';
import type { PluginOauthStateRepository } from '../../db/repositories/PluginOauthState.repository';
import { PluginMetaMigrations } from '../../db/entities/PluginMetaMigrations.entity';
import type { PluginMetaMigrationsRepository } from '../../db/repositories/PluginMetaMigrations.repository';
import { PluginCapabilityAudit } from '../../db/entities/PluginCapabilityAudit.entity';
import type { PluginCapabilityAuditRepository } from '../../db/repositories/PluginCapabilityAudit.repository';
import { Settings } from '../../db/entities/Settings.entity';
import type { SettingsRepository } from '../../db/repositories/Settings.repository';
import { NotificationChannelPreferences } from '../../db/entities/NotificationChannelPreferences.entity';
import type { NotificationChannelPreferencesRepository } from '../../db/repositories/NotificationChannelPreferences.repository';
import { pluginsEnabled } from './kill-switch';
import { setPluginEventSink } from '../../plugin-event-sink';
import { setUserDeletedSink } from '../../plugin-user-lifecycle';
@@ -159,23 +190,38 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// restore the core DB is briefly CLOSED (closeDb → the this.db proxy throws on access), so a
// status/log write in that window would otherwise take the whole process down mid-
// restore. Swallow any DB error — a missed status row / log line is never worth a crash.
//
// Plan 3j Task 2 (§4a, inventory §8): these callbacks are plain EventEmitter hooks —
// NOT async, no enclosing request/tick — so there is no ambient request context a
// repository call could resolve through. Resolved lazily, per call, the same way
// `budgetFor(pluginId, this.db.connection)` resolves its own connection lazily today:
// `withRequestContext(this.orm, ...)` wraps EACH write attempt fresh (never a context
// built once at construction), and `.catch()` replaces the old synchronous try/catch
// (a repository call is a Promise, not a synchronous throw) — same swallow, same
// "a status/log write must never crash the host" guarantee, unchanged. No `this.orm`
// (a hand-built test instance without one) means nothing to write to safely — skipped,
// not thrown, mirroring `onApplicationBootstrap`'s own no-ORM branch.
onStatus: (id, status, error) => {
try {
this.db.prepare('UPDATE plugins SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(status, error ?? null, id);
} catch { /* DB unavailable (e.g. mid-restore) — a status write must never crash the host */ }
if (!this.orm) return;
void withRequestContext(this.orm, () => this.plugins.updateStatusAndError(id, status, error ?? null)).catch(() => {
/* DB unavailable (e.g. mid-restore) — a status write must never crash the host */
});
},
onLog: (id, level, msg) => {
if (level !== 'error' && level !== 'warn') return;
try {
this.db.prepare('INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, ?, ?)').run(id, level, msg);
// Retention: a crash-looping plugin emits a stderr line per restart, so an
// uncapped table grows without bound in the shared trek.db. Keep only the
// most recent LOG_RETENTION rows per plugin (the admin view shows 200).
// R1.5: this hook is a child-lifecycle EventEmitter callback the supervisor
// cannot await, so the now-async prune is fired off with its own handler —
// the same "a log line must never crash the host" rule as the catch below.
void this.pruneErrorLog(id).catch(() => { /* retention is best-effort */ });
} catch { /* DB unavailable — a log line must never crash the host */ }
if (!this.orm) return;
const orm = this.orm;
void withRequestContext(orm, () => this.pluginErrorLog.insertLog(id, level, msg))
.then(() => {
// Retention: a crash-looping plugin emits a stderr line per restart, so an
// uncapped table grows without bound in the shared trek.db. Keep only the
// most recent LOG_RETENTION rows per plugin (the admin view shows 200).
// R1.5: this hook is a child-lifecycle EventEmitter callback the supervisor
// cannot await, so the now-async prune is fired off with its own handler —
// the same "a log line must never crash the host" rule as the catch below.
void withRequestContext(orm, () => this.pruneErrorLog(id)).catch(() => { /* retention is best-effort */ });
})
.catch(() => { /* DB unavailable — a log line must never crash the host */ });
},
}, {}, () => this.orm);
@@ -198,6 +244,33 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// registry reads a recipient's own settings on every dispatch, so an absent one
// would be a TypeError at send time rather than a missing-provider error at boot.
private readonly userSettings: PluginUserSettingsService,
// Plan 3j Task 2 — every table this file's own statements (PR1-PR53) touch, now
// injected as repositories instead of raw prepared-statement calls on `this.db`. All 14 are
// REQUIRED (not `@Optional()`): virtually every method on this class reaches
// `plugins` or one of its satellite tables, so a hand-built test instance needs
// real ones regardless (`sharedTestOrm(...).repo(Entity)`, the same shape
// `PluginGuards`' `UsersRepository` injection (Task 1) already established for
// this plan). `PluginOauthTokens`/`PluginOauthState`/`PluginMetaMigrations`/
// `PluginCapabilityAudit` are used ONLY by this file's own uninstall cascade and
// GDPR export — Task 3/Task 4 add their own, disjoint methods to the same four
// repositories for their own files. `Settings`/`NotificationChannelPreferences`
// are cross-domain (3f) — additive methods only (`deleteByKeyPrefix`/
// `deleteAllForChannel`), never their own per-user CRUD surface.
@InjectRepository(Plugins) private readonly plugins: PluginsRepository,
@InjectRepository(PluginErrorLog) private readonly pluginErrorLog: PluginErrorLogRepository,
@InjectRepository(PluginScheduledTasks) private readonly pluginScheduledTasks: PluginScheduledTasksRepository,
@InjectRepository(PluginUserErasureQueue) private readonly pluginUserErasureQueue: PluginUserErasureQueueRepository,
@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(PluginEntityMetadata) private readonly pluginEntityMetadata: PluginEntityMetadataRepository,
@InjectRepository(PluginOauthTokens) private readonly pluginOauthTokens: PluginOauthTokensRepository,
@InjectRepository(PluginOauthState) private readonly pluginOauthState: PluginOauthStateRepository,
@InjectRepository(PluginMetaMigrations) private readonly pluginMetaMigrations: PluginMetaMigrationsRepository,
@InjectRepository(PluginCapabilityAudit) private readonly pluginCapabilityAudit: PluginCapabilityAuditRepository,
@InjectRepository(Settings) private readonly settingsRepo: SettingsRepository,
@InjectRepository(NotificationChannelPreferences) private readonly notificationChannelPreferences: NotificationChannelPreferencesRepository,
private readonly registry?: PluginRegistryService,
private readonly hostFactory?: PluginRpcHostFactory,
// Optional only because TypeScript forbids a required parameter after an
@@ -281,18 +354,21 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// boot must NEVER block app init, even in a context without plugin tables
// (e.g. a slimmed-down test app that only imports AdminModule).
try {
// Plan 3j Task 0 (R-scheduler, inventory §8): `discoverPlugins` + this dependency-
// order read are raw better-sqlite3 today and need no request context to run
// correctly, but the MOMENT either becomes a repository call (Task 2/3) this
// window needs one — landing the wrap now, ahead of that conversion, is the whole
// point of doing this in Task 0. Wrapped ONLY when an ORM is available: the
// no-ORM branch below still needs `order` computed (from THESE two reads) to name
// the skipped plugins in its own log line, so running them unwrapped when `this.
// orm` is absent preserves that already-tested behavior exactly (RT-BOOT-NOORM-001/
// 002) rather than gating discovery itself on the ORM's presence — a broader
// restructure (option (b): wrap the WHOLE onApplicationBootstrap body in one call,
// matching CronRegistrarService.runOnBoot's shape) was considered and rejected for
// this reason; see task-0-report.md.
// Plan 3j Task 0 (R-scheduler, inventory §8) landed this wrap AHEAD of Task 2's own
// conversion, exactly so this window would be ready once `installedDepRows` stopped
// being raw SQL. Plan 3j Task 2: `installedDepRows` now reads `plugins` through
// `PluginsRepository.listDepRows` (PR15/16/25 converted) — a genuine request-context
// dependency the no-ORM branch below cannot satisfy (there is no ORM to wrap with).
// `discoverPlugins` itself stays raw (Task 3's own file, unconverted) and safe to run
// unwrapped either way. DEVIATION from Task 0's own note (task-0-report.md) and from
// RT-BOOT-NOORM-001's prior assertion: the no-ORM branch can no longer read `plugins`
// at all, so it no longer names which plugin(s) it is skipping — it logs a single
// generic line instead. This is a strictly MORE conservative outcome (skip more,
// never throw out of onApplicationBootstrap, same "fail closed, never abort app.init()"
// invariant) than before, not a correctness regression — and it can only ever be
// observed by a hand-built test instance that constructs this service without the
// `orm` param its own `plugins`/other repositories are drawn from; production always
// provides both together from the same DI graph. See task-2-report.md.
let installed: Map<string, PluginDepRow>;
if (this.orm) {
installed = await withRequestContext(this.orm, async () => {
@@ -301,7 +377,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
});
} else {
await discoverPlugins(this.db);
installed = await this.installedDepRows();
installed = new Map();
}
const enabledIds = [...installed.values()].filter((r) => r.enabled).map((r) => r.id);
let order: string[];
@@ -325,9 +401,10 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// runOnBoot: no ORM means skip loudly and never call activate — never abort
// app.init() over a partially-wired test graph.
if (!this.orm) {
if (order.length) {
logError(`PluginRuntimeService.onApplicationBootstrap: no MikroORM available — plugin(s) not activated: ${order.join(', ')}`);
}
// Plan 3j Task 2: `order`/`enabledIds` are always empty here now (see the
// `installed = new Map()` note above) — the message can no longer name
// specific plugins, only that boot skipped activation entirely.
logError('PluginRuntimeService.onApplicationBootstrap: no MikroORM available — plugin activation skipped');
} else {
for (const id of order) {
withRequestContext(this.orm, () => this.activate(id)).catch((e) => {
@@ -375,14 +452,17 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
const active = this.supervisor.activeIds();
if (active.length === 0) return;
const now = Date.now();
const ph = active.map(() => '?').join(',');
const due = this.db
.prepare(`SELECT id, plugin_id, name, payload, every_ms FROM plugin_scheduled_tasks WHERE due_at <= ? AND plugin_id IN (${ph}) ORDER BY due_at LIMIT 200`)
.all(now, ...active) as Array<{ id: number; plugin_id: string; name: string; payload: string; every_ms: number | null }>;
const due = await this.pluginScheduledTasks.findDueForPlugins(now, active);
for (const t of due) {
if (!this.supervisor.isActive(t.plugin_id)) continue; // leave for a later sweep
if (t.every_ms) this.db.prepare('UPDATE plugin_scheduled_tasks SET due_at = ? WHERE id = ?').run(now + t.every_ms, t.id);
else this.db.prepare('DELETE FROM plugin_scheduled_tasks WHERE id = ?').run(t.id);
// Plan 3j Task 2 deviation (named, task-2-report.md — same spirit as R-uninstall's
// egress-host transaction): the claim is now atomic (`due_at <= now` guarded in
// the repository) — a second, overlapping tick's own claim on the SAME row loses
// and skips delivery entirely, rather than double-firing (RACE-SCHED-001).
const claimed = t.every_ms
? await this.pluginScheduledTasks.rearm(t.id, now + t.every_ms, now)
: await this.pluginScheduledTasks.deleteById(t.id, now);
if (!claimed) continue; // another concurrent pass already claimed this row
let payload: unknown = null;
try { payload = JSON.parse(t.payload); } catch { /* corrupt payload -> null */ }
this.supervisor.deliverScheduled(t.plugin_id, t.name, payload);
@@ -398,12 +478,11 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* throws — a bookkeeping error must not fail the account deletion that triggered it. */
private async enqueueUserErasure(userId: number): Promise<void> {
try {
const rows = this.db.prepare('SELECT id, permissions FROM plugins').all() as Array<{ id: string; permissions: string | null }>;
const insert = this.db.prepare('INSERT OR IGNORE INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)');
const rows = await this.plugins.listIdsAndPermissions();
for (const r of rows) {
let perms: unknown;
try { perms = JSON.parse(r.permissions ?? '[]'); } catch { perms = []; }
if (Array.isArray(perms) && perms.includes('hook:user-data')) insert.run(r.id, userId);
if (Array.isArray(perms) && perms.includes('hook:user-data')) await this.pluginUserErasureQueue.insertIgnore(r.id, userId);
}
} catch {
/* enqueue is best-effort; a later sweep reconciles from whatever landed */
@@ -434,26 +513,21 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// honour the erasure (see uninstall()); reaping on registry-absence alone would wipe
// exactly those preserved obligations. A deleteData=true uninstall already clears the
// rows itself, so this only ever needs to catch a truly orphaned data dir.
const orphans = this.db
.prepare('SELECT DISTINCT plugin_id FROM plugin_user_erasure_queue WHERE plugin_id NOT IN (SELECT id FROM plugins)')
.all() as Array<{ plugin_id: string }>;
for (const { plugin_id } of orphans) {
const orphans = await this.pluginUserErasureQueue.listOrphanPluginIds();
for (const plugin_id of orphans) {
if (!fs.existsSync(pluginDataDir(plugin_id))) {
this.db.prepare('DELETE FROM plugin_user_erasure_queue WHERE plugin_id = ?').run(plugin_id);
await this.pluginUserErasureQueue.deleteAllForPlugin(plugin_id);
}
}
// Only ACTIVE plugins can be delivered to; scope the window to them so a backlog
// of erasures for permanently-inactive plugins can't starve deliverable ones.
const active = this.supervisor.activeIds();
if (active.length === 0) return;
const ph = active.map(() => '?').join(',');
const pending = this.db
.prepare(`SELECT id, plugin_id, user_id FROM plugin_user_erasure_queue WHERE plugin_id IN (${ph}) ORDER BY id LIMIT 200`)
.all(...active) as Array<{ id: number; plugin_id: string; user_id: number }>;
const pending = await this.pluginUserErasureQueue.findPendingForPlugins(active);
for (const row of pending) {
if (!this.supervisor.isActive(row.plugin_id)) continue; // retry after it reactivates
const done = await this.supervisor.deliverUserErasure(row.plugin_id, row.user_id);
if (done) this.db.prepare('DELETE FROM plugin_user_erasure_queue WHERE id = ?').run(row.id);
if (done) await this.pluginUserErasureQueue.deleteById(row.id);
}
} catch {
/* a drain pass must never break the runtime */
@@ -466,7 +540,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
async exportUserData(userId: number): Promise<Array<{ pluginId: string; data?: unknown; pending?: boolean; settings?: Record<string, unknown>; oauthConnected?: boolean }>> {
const out: Array<{ pluginId: string; data?: unknown; pending?: boolean; settings?: Record<string, unknown>; oauthConnected?: boolean }> = [];
if (!pluginsEnabled()) return out;
const rows = this.db.prepare('SELECT id, permissions FROM plugins').all() as Array<{ id: string; permissions: string | null }>;
const rows = await this.plugins.listIdsAndPermissions();
for (const r of rows) {
if (this.supervisor.isActive(r.id)) {
const res = await this.supervisor.collectUserExport(r.id, userId);
@@ -496,12 +570,12 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
return e;
};
const secretKeys = new Map<string, Set<string>>();
for (const f of this.db.prepare("SELECT plugin_id, field_key FROM plugin_settings_fields WHERE scope = 'user' AND secret = 1").all() as Array<{ plugin_id: string; field_key: string }>) {
for (const f of await this.pluginSettingsFields.listSecretUserFields()) {
let s = secretKeys.get(f.plugin_id);
if (!s) { s = new Set(); secretKeys.set(f.plugin_id, s); }
s.add(f.field_key);
}
for (const c of this.db.prepare('SELECT plugin_id, config FROM plugin_user_config WHERE user_id = ?').all(userId) as Array<{ plugin_id: string; config: string }>) {
for (const c of await this.pluginUserConfig.listForUser(userId)) {
let cfg: Record<string, unknown> = {};
try { cfg = JSON.parse(c.config || '{}'); } catch { /* ignore */ }
const secrets = secretKeys.get(c.plugin_id) ?? new Set<string>();
@@ -509,8 +583,8 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
for (const [k, v] of Object.entries(cfg)) masked[k] = secrets.has(k) ? '***' : v;
entryFor(c.plugin_id).settings = masked;
}
for (const t of this.db.prepare('SELECT DISTINCT plugin_id FROM plugin_oauth_tokens WHERE user_id = ?').all(userId) as Array<{ plugin_id: string }>) {
entryFor(t.plugin_id).oauthConnected = true;
for (const pluginId of await this.pluginOauthTokens.listDistinctPluginIdsForUser(userId)) {
entryFor(pluginId).oauthConnected = true;
}
} catch (err) {
console.warn('[plugins] GDPR export: host-side settings/oauth fold failed', err);
@@ -525,7 +599,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* requires. Returns the ids actually deactivated.
*/
async deactivateForDisabledAddon(addonId: string): Promise<string[]> {
const rows = this.db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[];
const rows: PluginDepRow[] = await this.plugins.listDepRows();
const directlyAffected = rows
.filter((r) => r.enabled && parseDependencies(r.dependencies).requiredAddons.includes(addonId))
.map((r) => r.id);
@@ -593,7 +667,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
/** All plugin rows projected to what the dependency helpers reason over. */
private async installedDepRows(): Promise<Map<string, PluginDepRow>> {
const rows = this.db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[];
const rows: PluginDepRow[] = await this.plugins.listDepRows();
return new Map(rows.map((r) => [r.id, r]));
}
@@ -604,9 +678,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* missing/mismatched plugin dependency.
*/
private async assertActivatable(id: string, installed: Map<string, PluginDepRow>, consentWiden: boolean): Promise<void> {
const row = this.db.prepare('SELECT permissions, granted_permissions, dependencies, trek_range, api_version FROM plugins WHERE id = ?').get(id) as
| { permissions: string; granted_permissions: string; dependencies: string | null; trek_range: string | null; api_version: number | null }
| undefined;
const row = await this.plugins.findActivationGate(id);
if (!row) throw new Error(`plugin ${id} not found`);
// This runs FIRST, and before the consent check in particular: a plugin that cannot
@@ -674,13 +746,11 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
/** Mark a (pre-validated) plugin enabled and spawn its child. */
private async spawnActivated(id: string): Promise<void> {
const row = this.db.prepare('SELECT permissions, config FROM plugins WHERE id = ?').get(id) as
| { permissions: string; config: string }
| undefined;
const row = await this.plugins.findPermissionsAndConfig(id);
if (!row) throw new Error(`plugin ${id} not found`);
const declared = parseArray(row.permissions).filter(isKnownPermission);
// Mark it enabled (admin intent) so it reboots after restarts/crashes.
this.db.prepare('UPDATE plugins SET granted_permissions = ?, enabled = 1 WHERE id = ?').run(JSON.stringify(declared), id);
await this.plugins.grantPermissionsAndEnable(id, JSON.stringify(declared));
// Manifest defaults fill whatever the admin never set, so the child's ctx.config is
// the same effective value the settings form shows (see settings-defaults.ts).
const config = applySettingDefaults(decryptConfig(parseObject(row.config)), await settingDefaults(this.db, id, 'instance'));
@@ -699,8 +769,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
/** Hosts an admin added for this plugin (empty unless it declared `operatorEgress`). */
async operatorEgressHosts(id: string): Promise<string[]> {
try {
return (this.db.prepare('SELECT host FROM plugin_egress_hosts WHERE plugin_id = ? ORDER BY host').all(id) as Array<{ host: string }>)
.map((r) => r.host);
return await this.pluginEgressHosts.listHostsForPlugin(id);
} catch {
return []; // table absent (a slimmed test app) — never block activation
}
@@ -708,8 +777,8 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
/** Does this plugin's manifest declare that it needs operator-supplied hosts? */
async wantsOperatorEgress(id: string): Promise<boolean> {
const row = this.db.prepare('SELECT operator_egress FROM plugins WHERE id = ?').get(id) as { operator_egress: number } | undefined;
return row?.operator_egress === 1;
const flag = await this.plugins.findOperatorEgressFlag(id);
return flag === 1;
}
/**
@@ -731,10 +800,12 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
if (!clean.includes(host)) clean.push(host);
}
if (!this.uow) throw new Error('UnitOfWork not provided — tests that set egress hosts must pass one');
// PR22/PR23, R-uninstall's ONE named transaction: the delete-then-insert-loop is now
// atomic (`PluginEgressHostsRepository.replaceAllForPlugin`) — a crash mid-write
// leaves the OLD host set intact instead of a plugin with zero egress hosts, a
// deliberate behavior change ("For the user" in the plan document), not silent parity.
await this.uow.transactional(async () => {
this.db.prepare('DELETE FROM plugin_egress_hosts WHERE plugin_id = ?').run(id);
const ins = this.db.prepare('INSERT OR IGNORE INTO plugin_egress_hosts (plugin_id, host) VALUES (?, ?)');
for (const h of clean) ins.run(id, h);
await this.pluginEgressHosts.replaceAllForPlugin(id, clean);
});
// Re-spawn so a live child actually gets the new allow-list.
if (this.isActive(id)) {
@@ -760,7 +831,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
async deactivate(id: string): Promise<void> {
await this.supervisor.disable(id);
closePluginDataDb(id);
this.db.prepare("UPDATE plugins SET status = 'inactive', enabled = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(id);
await this.plugins.markInactive(id);
}
/**
@@ -773,7 +844,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* restart of update()/sideload() never disables a plugin's dependents.
*/
async deactivateWithDependents(id: string): Promise<string[]> {
const rows = this.db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[];
const rows: PluginDepRow[] = await this.plugins.listDepRows();
const enabledById = new Map(rows.map((r) => [r.id, r.enabled]));
// findDependentsTransitive returns nearest-first; reverse so the deepest dependent
// (the furthest caller) stops before the plugin it depends on.
@@ -798,9 +869,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
id: string,
opts?: { version?: string; retrustKey?: string },
): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[]; trekRangeBypassed: TrekRangeBypass | null }> {
const before = this.db.prepare('SELECT enabled, granted_permissions, version FROM plugins WHERE id = ?').get(id) as
| { enabled: number; granted_permissions: string; version: string | null }
| undefined;
const before = await this.plugins.findPreUpdateSnapshot(id);
if (!before) throw new Error(`plugin ${id} not found`);
if (!this.registry) throw new Error('registry service unavailable');
const wasEnabled = before.enabled === 1;
@@ -816,9 +885,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// swaps code + refreshes declared permissions; keeps granted
const res = await this.registry.install(id, { version: target, retrustKey: opts?.retrustKey });
const declared = parseArray(
(this.db.prepare('SELECT permissions FROM plugins WHERE id = ?').get(id) as { permissions: string }).permissions,
).filter(isKnownPermission);
const declared = parseArray(await this.plugins.findPermissions(id) ?? '[]').filter(isKnownPermission);
const newGrants = declared.filter((p) => !granted.has(p));
const newEgress = newGrants.filter((p) => p.startsWith(HTTP_OUTBOUND)).map((p) => p.slice(HTTP_OUTBOUND.length)).filter(Boolean);
const newPermissions = newGrants.filter((p) => !p.startsWith(HTTP_OUTBOUND));
@@ -875,7 +942,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
actor: { userId: number | null; ip?: string | null },
): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }> {
if (!this.registry) throw new Error('registry service unavailable');
const before = this.db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string | null } | undefined;
const beforePubkey = await this.plugins.findAuthorPubkey(id);
const entry = await this.registry.assertRetrustable(id, publicKey); // throws unless SIGNATURE_KEY_CHANGED
const res = await this.update(id, { version, retrustKey: publicKey });
@@ -891,7 +958,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
details: {
plugin: id,
version: res.version,
oldKeyFingerprint: keyFingerprint(before?.author_pubkey),
oldKeyFingerprint: keyFingerprint(beforePubkey),
newKeyFingerprint: keyFingerprint(entry.authorPublicKey),
},
ip: actor.ip ?? null,
@@ -910,7 +977,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
if (!this.registry) throw new Error('registry service unavailable');
const staged = this.registry.stageUpload(bytes);
try {
const replaced = !!this.db.prepare('SELECT id FROM plugins WHERE id = ?').get(staged.id);
const replaced = await this.plugins.existsById(staged.id);
// Force any replaced plugin INACTIVE before the swap: stop a running child
// (it holds file locks and would keep executing stale code) AND clear the
// active flag, so replaced code can never keep running — or even show active
@@ -947,7 +1014,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
if (scanForNativeBinaries(sourceDir).length) throw new Error('directory contains native binaries');
const id = manifest.id;
const existing = this.db.prepare('SELECT source_repo FROM plugins WHERE id = ?').get(id) as { source_repo?: string } | undefined;
const existing = await this.plugins.findSourceRepoRow(id);
// Never clobber a REAL installed plugin (registry/sideload) — only re-point a link.
if (existing && existing.source_repo !== DEV_LINK_SOURCE) {
throw new Error(`a plugin '${id}' is already installed — uninstall it before dev-linking that id`);
@@ -963,12 +1030,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
await discoverPlugins(this.db); // registers/updates the row from the linked manifest, INACTIVE
// Same as a sideload: the plugin has left the registry trust model, so a block that
// described a refused REGISTRY update no longer describes the code that will run.
this.db.prepare(
`UPDATE plugins SET source_repo = ?, source_commit = NULL, sha256 = NULL, author_pubkey = NULL,
update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL,
status = 'inactive', enabled = 0
WHERE id = ?`,
).run(DEV_LINK_SOURCE, id);
await this.plugins.clearForDevLink(id, DEV_LINK_SOURCE);
this.watchLinked(id, sourceDir);
return { id, version: manifest.version, replaced, trekRangeBypassed };
}
@@ -982,7 +1044,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
*/
async reload(id: string): Promise<void> {
if (!devLinkEnabled()) throw new Error('dev-link is disabled (set TREK_PLUGINS_DEV_LINK=1)');
const row = this.db.prepare('SELECT source_repo FROM plugins WHERE id = ?').get(id) as { source_repo?: string } | undefined;
const row = await this.plugins.findSourceRepoRow(id);
if (!row) throw new Error(`plugin ${id} not found`);
if (row.source_repo !== DEV_LINK_SOURCE) throw new Error(`plugin ${id} is not dev-linked`);
const wasActive = this.isActive(id);
@@ -1019,6 +1081,16 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
}
/** Stop the plugin, remove its code, and optionally delete all its data. */
/**
* R-uninstall: the 9-table cascade converts with the SAME statement order and the SAME
* (non-)transactional scope the legacy code had — NOT wrapped in `uow.transactional`.
* Nine independent best-effort deletes, several inside their own try/catch tolerating an
* absent table on a slimmed test schema (`plugin_actions`, `plugin_egress_hosts`) —
* `plugins`/`plugin_settings_fields`/`plugin_scheduled_tasks`/`plugin_error_log`/
* `plugin_entity_metadata`/`plugin_user_config`/`plugin_oauth_tokens`/`plugin_oauth_state`/
* `plugin_meta_migrations`/`plugin_capability_audit`/`plugin_user_erasure_queue` do NOT
* tolerate an absent table, unchanged from the legacy statement order and shape.
*/
async uninstall(id: string, deleteData: boolean): Promise<void> {
await this.supervisor.disable(id);
this.stopWatch(id);
@@ -1026,16 +1098,16 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// Code always goes; the DB metadata + fields go so it disappears from the UI.
// Link-safe: a dev-linked plugin only drops the symlink, never the author's source.
removePluginCodeEntry(pluginCodeDir(id));
this.db.prepare('DELETE FROM plugins WHERE id = ?').run(id);
this.db.prepare('DELETE FROM plugin_settings_fields WHERE plugin_id = ?').run(id);
try { this.db.prepare('DELETE FROM plugin_actions WHERE plugin_id = ?').run(id); } catch { /* table absent */ }
await this.plugins.deleteById(id);
await this.pluginSettingsFields.deleteAllForPlugin(id);
try { await this.pluginActions.deleteAllForPlugin(id); } catch { /* table absent */ }
// The admin's egress consent dies with the plugin. Unconditional: leaving it would
// silently grant a LATER plugin that reuses this id the hosts the admin approved for
// a different one.
try { this.db.prepare('DELETE FROM plugin_egress_hosts WHERE plugin_id = ?').run(id); } catch { /* table absent */ }
try { await this.pluginEgressHosts.deleteAllForPlugin(id); } catch { /* table absent */ }
// Scheduled tasks are operational (not user data), so they go unconditionally —
// a scheduled callback for a plugin that no longer exists must never fire.
this.db.prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ?').run(id);
await this.pluginScheduledTasks.deleteAllForPlugin(id);
// If it was a notification channel, retire the channel too. Unconditional, for the
// same reason as the settings fields: these are TREK's config ABOUT the plugin, and
// leaving them means a later plugin that reuses this id silently inherits every
@@ -1043,23 +1115,23 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
await this.retireNotificationChannel(id);
if (deleteData) {
removePluginData(id);
this.db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id);
this.db.prepare("DELETE FROM settings WHERE key LIKE ?").run(`plugin:${id}:%`);
this.db.prepare('DELETE FROM plugin_entity_metadata WHERE plugin_id = ?').run(id);
await this.pluginErrorLog.deleteAllForPlugin(id);
await this.settingsRepo.deleteByKeyPrefix(`plugin:${id}:`);
await this.pluginEntityMetadata.deleteAllForPlugin(id);
// Per-user secrets + OAuth tokens/state live in their own tables, NOT under
// settings — without these a "delete all data" leaves encrypted API keys and
// refresh tokens behind, silently re-adopted if a plugin with the same id is
// reinstalled. The migration ledger goes too, so a reinstall re-runs cleanly.
this.db.prepare('DELETE FROM plugin_user_config WHERE plugin_id = ?').run(id);
this.db.prepare('DELETE FROM plugin_oauth_tokens WHERE plugin_id = ?').run(id);
this.db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ?').run(id);
this.db.prepare('DELETE FROM plugin_meta_migrations WHERE plugin_id = ?').run(id);
this.db.prepare('DELETE FROM plugin_capability_audit WHERE plugin_id = ?').run(id);
await this.pluginUserConfig.deleteAllForPlugin(id);
await this.pluginOauthTokens.deleteAllForPlugin(id);
await this.pluginOauthState.deleteAllForPlugin(id);
await this.pluginMetaMigrations.deleteAllForPlugin(id);
await this.pluginCapabilityAudit.deleteAllForPlugin(id);
// The plugin's data dir is gone now, so any pending GDPR erasure for it is moot.
// But when deleteData is FALSE we deliberately KEEP the queue rows: the data dir
// (which may still hold a deleted user's rows) survives, so the erasure obligation
// must survive too — a reinstall of the same id drains the queue and honours it.
this.db.prepare('DELETE FROM plugin_user_erasure_queue WHERE plugin_id = ?').run(id);
await this.pluginUserErasureQueue.deleteAllForPlugin(id);
}
}
@@ -1069,11 +1141,9 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
/** Declared outbound hosts (from http:outbound:<host> grants) for the frame CSP. */
async outboundHostsOf(id: string): Promise<string[]> {
const row = this.db.prepare('SELECT granted_permissions FROM plugins WHERE id = ?').get(id) as
| { granted_permissions: string }
| undefined;
if (!row) return [];
return parseArray(row.granted_permissions)
const granted = await this.plugins.findGrantedPermissions(id);
if (granted === null) return [];
return parseArray(granted)
.filter((p) => p.startsWith('http:outbound:'))
.map((p) => p.slice('http:outbound:'.length))
.filter(Boolean);
@@ -1155,8 +1225,8 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
/** True if `caller` declares `target` as a plugin dependency whose range the
* installed target version satisfies. */
private async dependsOnSatisfied(callerId: string, targetId: string): Promise<boolean> {
const caller = this.db.prepare('SELECT dependencies FROM plugins WHERE id = ?').get(callerId) as { dependencies: string | null } | undefined;
const target = this.db.prepare('SELECT version FROM plugins WHERE id = ?').get(targetId) as { version: string | null } | undefined;
const caller = await this.plugins.findDependenciesRow(callerId);
const target = await this.plugins.findVersionRow(targetId);
if (!caller || !target) return false;
const dep = parseDependencies(caller.dependencies).pluginDependencies.find((d) => d.id === targetId);
if (!dep) return false;
@@ -1176,7 +1246,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// without the notification tables (a slimmed test app that imports only the plugin
// module).
try {
this.db.prepare('DELETE FROM notification_channel_preferences WHERE channel = ?').run(pluginChannelId(id));
await this.notificationChannelPreferences.deleteAllForChannel(pluginChannelId(id));
} catch { /* no notifications schema here — nothing to retire */ }
}
@@ -1191,10 +1261,11 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
*/
async notificationChannels(): Promise<ExternalChannel[]> {
if (!pluginsEnabled()) return [];
return this.supervisor.providersOf('notificationChannel').map((id) => {
const row = this.db.prepare('SELECT name, capabilities FROM plugins WHERE id = ?').get(id) as
| { name: string; capabilities: string }
| undefined;
// A `map` callback cannot await (the same reason `PluginsService.list()` uses an
// explicit loop) — `findNameAndCapabilities` is now a repository call.
const out: ExternalChannel[] = [];
for (const id of this.supervisor.providersOf('notificationChannel')) {
const row = await this.plugins.findNameAndCapabilities(id);
let cap: { title?: string; events?: string[] } = {};
try {
cap = ((JSON.parse(row?.capabilities || '{}') as Record<string, unknown>).notificationChannel ?? {}) as typeof cap;
@@ -1202,7 +1273,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
const allowed = new Set(cap.events?.length ? cap.events : PLUGIN_CHANNEL_EVENTS);
return {
out.push({
id: pluginChannelId(id),
source: 'plugin' as const,
// A plugin-supplied display string, bounded and emoji-stripped like every other
@@ -1239,18 +1310,21 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
},
};
});
});
}
return out;
}
/** The settings-form action buttons a plugin declared for ONE scope (descriptors, from the DB). */
async actionsOf(id: string, scope: PluginActionScope): Promise<PluginActionDescriptor[]> {
try {
return (
this.db
.prepare('SELECT action_key, label, hint, danger, scope FROM plugin_actions WHERE plugin_id = ? AND scope = ? ORDER BY sort_order')
.all(id, scope) as Array<{ action_key: string; label: string; hint: string | null; danger: number; scope: PluginActionScope }>
).map((r) => ({ key: r.action_key, label: r.label, hint: r.hint ?? undefined, danger: r.danger === 1, scope: r.scope }));
return (await this.pluginActions.listForPluginScope(id, scope)).map((r) => ({
key: r.action_key,
label: r.label,
hint: r.hint ?? undefined,
danger: r.danger === 1,
scope: r.scope,
}));
} catch {
return []; // table absent (a slimmed test app)
}
@@ -1295,10 +1369,10 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* Public, unlike capabilityList, because PluginMcpToolsService reads it.
*/
async mcpToolCapabilities(id: string): Promise<McpToolCapability[]> {
const row = this.db.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(id) as { capabilities: string } | undefined;
if (!row) return [];
const capabilities = await this.plugins.findCapabilities(id);
if (capabilities === null) return [];
try {
const c = JSON.parse(row.capabilities || '{}') as Record<string, unknown>;
const c = JSON.parse(capabilities || '{}') as Record<string, unknown>;
if (c.mcpTools === undefined) return [];
return parseMcpToolCapabilities(c.mcpTools);
} catch {
@@ -1309,10 +1383,10 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
}
private async capabilityList(id: string, field: 'provides' | 'emits'): Promise<string[]> {
const row = this.db.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(id) as { capabilities: string } | undefined;
if (!row) return [];
const capabilities = await this.plugins.findCapabilities(id);
if (capabilities === null) return [];
try {
const c = JSON.parse(row.capabilities || '{}') as Record<string, unknown>;
const c = JSON.parse(capabilities || '{}') as Record<string, unknown>;
const v = c[field];
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
} catch {
@@ -1323,11 +1397,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
/** Trim a plugin's error log to the most recent LOG_RETENTION rows. Cheap: onLog
* only fires on warn/error, so this never runs on the hot path. */
private async pruneErrorLog(pluginId: string): Promise<void> {
this.db.prepare(
`DELETE FROM plugin_error_log WHERE plugin_id = ? AND id NOT IN (
SELECT id FROM plugin_error_log WHERE plugin_id = ? ORDER BY id DESC LIMIT ${LOG_RETENTION}
)`,
).run(pluginId, pluginId);
await this.pluginErrorLog.pruneKeepingRecent(pluginId, LOG_RETENTION);
}
}
@@ -1,5 +1,21 @@
import { Module } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Plugins } from '../../db/entities/Plugins.entity';
import { PluginErrorLog } from '../../db/entities/PluginErrorLog.entity';
import { PluginScheduledTasks } from '../../db/entities/PluginScheduledTasks.entity';
import { PluginUserErasureQueue } from '../../db/entities/PluginUserErasureQueue.entity';
import { PluginEgressHosts } from '../../db/entities/PluginEgressHosts.entity';
import { PluginSettingsFields } from '../../db/entities/PluginSettingsFields.entity';
import { PluginActions } from '../../db/entities/PluginActions.entity';
import { PluginUserConfig } from '../../db/entities/PluginUserConfig.entity';
import { PluginEntityMetadata } from '../../db/entities/PluginEntityMetadata.entity';
import { PluginOauthTokens } from '../../db/entities/PluginOauthTokens.entity';
import { PluginOauthState } from '../../db/entities/PluginOauthState.entity';
import { PluginMetaMigrations } from '../../db/entities/PluginMetaMigrations.entity';
import { PluginCapabilityAudit } from '../../db/entities/PluginCapabilityAudit.entity';
import { Settings } from '../../db/entities/Settings.entity';
import { NotificationChannelPreferences } from '../../db/entities/NotificationChannelPreferences.entity';
import { PluginsService } from './plugins.service';
import { PluginUserSettingsService } from './plugin-user-settings.service';
import { PluginRuntimeService } from './plugin-runtime.service';
@@ -59,6 +75,19 @@ import { SchedulingModule } from '../scheduling/scheduling.module';
imports: [
// What lets PluginRpcRegistryService find the @PluginController providers at boot.
DiscoveryModule,
// Plan 3j Task 2 — every table `PluginRuntimeService`/`PluginsService`'s own
// statements (PR1-PR53, PS1-PS13) touch, now repository-backed. This module is
// where both classes are actually constructed, so it (not an importer) owns the
// forFeature list — same reasoning `PluginGuardsModule`'s own docstring gives for
// `Users`. `Settings`/`NotificationChannelPreferences` are cross-domain (3f) —
// their OWN owning modules also register them; MikroOrmModule.forFeature is
// idempotent per entity within one DI graph.
MikroOrmModule.forFeature([
Plugins, PluginErrorLog, PluginScheduledTasks, PluginUserErasureQueue, PluginEgressHosts,
PluginSettingsFields, PluginActions, PluginUserConfig, PluginEntityMetadata,
PluginOauthTokens, PluginOauthState, PluginMetaMigrations, PluginCapabilityAudit,
Settings, NotificationChannelPreferences,
]),
// A leaf that hands the resource gates to the domain modules. It must not be this
// module: the domains would then have to import this one back and close a cycle.
PluginGuardsModule,
+83 -114
View File
@@ -1,5 +1,20 @@
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';
@@ -121,51 +136,57 @@ 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;
}
/** Hosts an admin has added for a plugin (0 unless it declared operatorEgress). */
/** PS1 — Hosts an admin has added for a plugin (0 unless it declared operatorEgress). */
private async egressHostCount(id: string): Promise<number> {
try {
return (this.db.prepare('SELECT COUNT(*) AS n FROM plugin_egress_hosts WHERE plugin_id = ?').get(id) as { n: number }).n;
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 (
this.db.prepare("SELECT COUNT(*) AS n FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance'").get(id) as { n: number }
).n;
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 (
this.db.prepare("SELECT COUNT(*) AS n FROM plugin_actions WHERE plugin_id = ? AND scope = 'instance'").get(id) as { n: number }
).n;
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 = 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 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 }]),
);
@@ -225,7 +246,7 @@ export class PluginsService {
* Returns whether the plugin row existed — the controller answers 404 otherwise.
*/
async resumeUpdates(id: string): Promise<boolean> {
return this.db.prepare('UPDATE plugins SET update_hold = 0 WHERE id = ?').run(id).changes > 0;
return await this.plugins.clearUpdateHold(id);
}
/**
@@ -235,26 +256,13 @@ export class PluginsService {
* Returns the config with secrets masked for the client.
*/
async updateInstanceConfig(id: string, patch: Record<string, unknown>): Promise<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 rowConfig = await this.plugins.findConfig(id);
if (rowConfig === null) 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 secretKeys = new Set(await this.pluginSettingsFields.listSecretFieldKeys(id, 'instance'));
const allowed = new Set(await this.pluginSettingsFields.listFieldKeys(id, 'instance'));
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);
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)) {
@@ -265,7 +273,7 @@ export class PluginsService {
}
}
await this.assertRequiredFilled(id, 'instance', config);
this.db.prepare('UPDATE plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(config), id);
await this.plugins.setConfig(id, JSON.stringify(config));
return maskSecrets(config, secretKeys);
}
@@ -280,63 +288,40 @@ export class PluginsService {
}
private async settingsFields(id: string, scope: 'user' | 'instance'): Promise<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,
};
});
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(
(
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),
);
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). */
/** 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 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 ?? '{}'), await this.userSecretKeys(id));
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(
(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 allowed = new Set(await this.pluginSettingsFields.listFieldKeys(id, 'user'));
const secretKeys = await 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 ?? '{}');
// 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)) {
@@ -347,20 +332,16 @@ export class PluginsService {
}
}
await 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));
// 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. */
* Never sent to a client; the acting user is resolved host-side. PS11. */
async getUserConfigDecrypted(id: string, userId: number): Promise<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 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;
@@ -369,37 +350,29 @@ export class PluginsService {
/** A plugin's error log, newest first. */
async errors(id: string): Promise<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 }>;
return await this.pluginErrorLog.listRecent(id);
}
async clearErrors(id: string): Promise<void> {
this.db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id);
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.db, id);
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.db);
return await pluginBudgetUsage(id, this.pluginCapabilityAudit);
}
/** Read the instance config with secret fields masked. */
async getInstanceConfig(id: string): Promise<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);
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);
}
/**
@@ -409,15 +382,11 @@ export class PluginsService {
* 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 = 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 required = await this.pluginSettingsFields.listRequiredFieldKeys(id, scope);
const defaults = await settingDefaults(this.db, id, scope);
for (const f of required) {
for (const fieldKey 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);
if (!isFilled(config[fieldKey] ?? defaults[fieldKey])) throw new MissingRequiredSettingError(fieldKey);
}
}
}
+31
View File
@@ -92,6 +92,21 @@ import {
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
import { Users } from '../../src/db/entities/Users.entity';
import { Plugins } from '../../src/db/entities/Plugins.entity';
import { PluginErrorLog } from '../../src/db/entities/PluginErrorLog.entity';
import { PluginScheduledTasks } from '../../src/db/entities/PluginScheduledTasks.entity';
import { PluginUserErasureQueue } from '../../src/db/entities/PluginUserErasureQueue.entity';
import { PluginEgressHosts } from '../../src/db/entities/PluginEgressHosts.entity';
import { PluginSettingsFields } from '../../src/db/entities/PluginSettingsFields.entity';
import { PluginActions } from '../../src/db/entities/PluginActions.entity';
import { PluginUserConfig } from '../../src/db/entities/PluginUserConfig.entity';
import { PluginEntityMetadata } from '../../src/db/entities/PluginEntityMetadata.entity';
import { PluginOauthTokens } from '../../src/db/entities/PluginOauthTokens.entity';
import { PluginOauthState } from '../../src/db/entities/PluginOauthState.entity';
import { PluginMetaMigrations } from '../../src/db/entities/PluginMetaMigrations.entity';
import { PluginCapabilityAudit } from '../../src/db/entities/PluginCapabilityAudit.entity';
import { Settings } from '../../src/db/entities/Settings.entity';
import { NotificationChannelPreferences } from '../../src/db/entities/NotificationChannelPreferences.entity';
import { createTestTripFilesRepo, createTestFileLinksRepo, createTestBudgetItemsRepo } from './files-repos';
import { budgetRepoArgs } from './budget-repos';
import { createTestShareTokensRepo } from './share-repos';
@@ -322,6 +337,22 @@ export async function createPluginRuntime(dbs: DatabaseService, registry?: Plugi
new AuditService(orm.repo(AuditLog), orm.repo(Users)),
await createTestAddonsService(dbs.connection, dbs),
new PluginUserSettingsService(dbs),
// Plan 3j Task 2 — PR1-PR53's own tables, all repository-backed now.
orm.repo(Plugins),
orm.repo(PluginErrorLog),
orm.repo(PluginScheduledTasks),
orm.repo(PluginUserErasureQueue),
orm.repo(PluginEgressHosts),
orm.repo(PluginSettingsFields),
orm.repo(PluginActions),
orm.repo(PluginUserConfig),
orm.repo(PluginEntityMetadata),
orm.repo(PluginOauthTokens),
orm.repo(PluginOauthState),
orm.repo(PluginMetaMigrations),
orm.repo(PluginCapabilityAudit),
orm.repo(Settings),
orm.repo(NotificationChannelPreferences),
registry,
await createPluginRpcHostFactory(dbs),
await createTestUnitOfWork(dbs.connection),
@@ -32,7 +32,7 @@ const { testDb } = vi.hoisted(() => {
trek_range TEXT DEFAULT '>=3.0.0',
source_repo TEXT, author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER, default_value TEXT);
CREATE TABLE plugin_settings_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER, default_value TEXT);
CREATE TABLE settings (user_id INTEGER, key TEXT, value TEXT);
CREATE TABLE plugin_entity_metadata (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, entity_type TEXT, entity_id INTEGER, key TEXT, value TEXT, updated_at TEXT);
CREATE TABLE plugin_user_config (plugin_id TEXT, user_id INTEGER, field_key TEXT, value TEXT, PRIMARY KEY (plugin_id, user_id, field_key));
@@ -67,6 +67,21 @@ import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
import { Users } from '../../../src/db/entities/Users.entity';
import { Plugins } from '../../../src/db/entities/Plugins.entity';
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
import { PluginScheduledTasks } from '../../../src/db/entities/PluginScheduledTasks.entity';
import { PluginUserErasureQueue } from '../../../src/db/entities/PluginUserErasureQueue.entity';
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
import { PluginEntityMetadata } from '../../../src/db/entities/PluginEntityMetadata.entity';
import { PluginOauthTokens } from '../../../src/db/entities/PluginOauthTokens.entity';
import { PluginOauthState } from '../../../src/db/entities/PluginOauthState.entity';
import { PluginMetaMigrations } from '../../../src/db/entities/PluginMetaMigrations.entity';
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
import { Settings } from '../../../src/db/entities/Settings.entity';
import { NotificationChannelPreferences } from '../../../src/db/entities/NotificationChannelPreferences.entity';
let codeRoot: string;
let dataRoot: string;
@@ -131,7 +146,32 @@ describe('plugin boot vs registry scan ordering', () => {
// onLoad) when resolveOrm returns undefined, instead of running it
// unwrapped, so this hand-built double needs the same real ORM the
// repositories above (auditLogRepo/usersRepo) already share.
useFactory: () => new PluginRuntimeService(dbs, new AuditService(auditLogRepo, usersRepo), addonsService, userSettings, undefined, hostFactory, undefined, t?.orm),
useFactory: () =>
new PluginRuntimeService(
dbs,
new AuditService(auditLogRepo, usersRepo),
addonsService,
userSettings,
(t as TestOrm).repo(Plugins),
(t as TestOrm).repo(PluginErrorLog),
(t as TestOrm).repo(PluginScheduledTasks),
(t as TestOrm).repo(PluginUserErasureQueue),
(t as TestOrm).repo(PluginEgressHosts),
(t as TestOrm).repo(PluginSettingsFields),
(t as TestOrm).repo(PluginActions),
(t as TestOrm).repo(PluginUserConfig),
(t as TestOrm).repo(PluginEntityMetadata),
(t as TestOrm).repo(PluginOauthTokens),
(t as TestOrm).repo(PluginOauthState),
(t as TestOrm).repo(PluginMetaMigrations),
(t as TestOrm).repo(PluginCapabilityAudit),
(t as TestOrm).repo(Settings),
(t as TestOrm).repo(NotificationChannelPreferences),
undefined,
hostFactory,
undefined,
t?.orm,
),
},
{
provide: 'REGISTRY_SCAN',
@@ -220,7 +260,31 @@ describe('plugin boot vs registry scan ordering', () => {
{
provide: PluginRuntimeService,
useFactory: () =>
new PluginRuntimeService(dbs2, new AuditService(auditLogRepo2, usersRepo2), addonsService2, userSettings2, undefined, hostFactory2, undefined, t2.orm),
new PluginRuntimeService(
dbs2,
new AuditService(auditLogRepo2, usersRepo2),
addonsService2,
userSettings2,
t2.repo(Plugins),
t2.repo(PluginErrorLog),
t2.repo(PluginScheduledTasks),
t2.repo(PluginUserErasureQueue),
t2.repo(PluginEgressHosts),
t2.repo(PluginSettingsFields),
t2.repo(PluginActions),
t2.repo(PluginUserConfig),
t2.repo(PluginEntityMetadata),
t2.repo(PluginOauthTokens),
t2.repo(PluginOauthState),
t2.repo(PluginMetaMigrations),
t2.repo(PluginCapabilityAudit),
t2.repo(Settings),
t2.repo(NotificationChannelPreferences),
undefined,
hostFactory2,
undefined,
t2.orm,
),
},
],
}).compile();
@@ -0,0 +1,413 @@
/**
* Plan 3j Task 2 — mutation/parity proofs the brief names explicitly, on top of the
* pre-existing (and still-green) `plugin-runtime.test.ts`/`plugins-service.test.ts`
* suites: R-install-gates' three named accept+refuse pairs (PR17/PR26/PR28), the
* 9-table uninstall cascade's preserved NON-transactional partial-failure shape
* (R-uninstall), the egress-host DELETE+loop-INSERT's new transaction rollback proof
* (R-uninstall's ONE named exception), PS7/PS9/PS11's triple-duplicate SELECT
* collapsing onto one `PluginUserConfigRepository.findConfig` method, and a two-tick
* race for the scheduler sweep + the GDPR erasure drain.
*
* Full migrated schema (`createSnapshotTestDb`) — the same real-schema harness
* `plugin-scheduler-sweep.test.ts`/`plugin-runtime.boot-no-orm.test.ts` already use —
* so every one of the 14 repositories this task injects reads/writes real columns,
* not a hand-slimmed fixture table.
*/
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { SchedulerRegistry } from '@nestjs/schedule';
import { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service';
import { PluginRuntimeService, PluginDependencyError } from '../../../src/nest/plugins/plugin-runtime.service';
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { AuditService } from '../../../src/nest/audit/audit.service';
import { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
import { UnitOfWork } from '../../../src/nest/database/unit-of-work';
import { createTestAddonsService } from '../../helpers/test-addons';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
import { Users } from '../../../src/db/entities/Users.entity';
import { Plugins } from '../../../src/db/entities/Plugins.entity';
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
import { PluginScheduledTasks } from '../../../src/db/entities/PluginScheduledTasks.entity';
import { PluginUserErasureQueue } from '../../../src/db/entities/PluginUserErasureQueue.entity';
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
import { PluginEntityMetadata } from '../../../src/db/entities/PluginEntityMetadata.entity';
import { PluginOauthTokens } from '../../../src/db/entities/PluginOauthTokens.entity';
import { PluginOauthState } from '../../../src/db/entities/PluginOauthState.entity';
import { PluginMetaMigrations } from '../../../src/db/entities/PluginMetaMigrations.entity';
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
import { Settings } from '../../../src/db/entities/Settings.entity';
import { NotificationChannelPreferences } from '../../../src/db/entities/NotificationChannelPreferences.entity';
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let codeRoot: string;
let dataRoot: string;
beforeAll(async () => {
t = await createTestOrm(testDb);
process.env.TREK_PLUGINS_ENABLED = 'true';
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-t2-code-'));
dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-t2-data-'));
process.env.TREK_PLUGINS_DIR = codeRoot;
process.env.TREK_PLUGINS_DATA_DIR = dataRoot;
});
afterAll(async () => {
delete process.env.TREK_PLUGINS_ENABLED;
delete process.env.TREK_PLUGINS_DIR;
delete process.env.TREK_PLUGINS_DATA_DIR;
await t.close();
testDb.close();
fs.rmSync(codeRoot, { recursive: true, force: true });
fs.rmSync(dataRoot, { recursive: true, force: true });
});
async function buildRuntime(registrar?: CronRegistrarService): Promise<PluginRuntimeService> {
const dbs = new DatabaseService(testDb);
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
const addons = await createTestAddonsService(testDb, dbs);
const userSettings = new PluginUserSettingsService(dbs);
const uow = new UnitOfWork(t.em);
return new PluginRuntimeService(
dbs, audit, addons, userSettings,
t.repo(Plugins), t.repo(PluginErrorLog), t.repo(PluginScheduledTasks), t.repo(PluginUserErasureQueue),
t.repo(PluginEgressHosts), t.repo(PluginSettingsFields), t.repo(PluginActions), t.repo(PluginUserConfig),
t.repo(PluginEntityMetadata), t.repo(PluginOauthTokens), t.repo(PluginOauthState), t.repo(PluginMetaMigrations),
t.repo(PluginCapabilityAudit), t.repo(Settings), t.repo(NotificationChannelPreferences),
undefined, undefined, uow, t.orm, registrar,
);
}
function buildPluginsService(): PluginsService {
return new PluginsService(
new DatabaseService(testDb),
// A real AddonsService is not needed by any method this file calls.
{ isAddonEnabled: async () => true } as never,
t.repo(Plugins), t.repo(PluginEgressHosts), t.repo(PluginSettingsFields), t.repo(PluginActions),
t.repo(PluginUserConfig), t.repo(PluginErrorLog), t.repo(PluginCapabilityAudit),
);
}
function seedPlugin(id: string, overrides: Partial<{ enabled: number; granted_permissions: string; permissions: string; trek_range: string; api_version: number; version: string; author_pubkey: string }> = {}) {
testDb
.prepare(
`INSERT INTO plugins (id, name, status, enabled, version, trek_range, api_version, permissions, granted_permissions, capabilities, config, author_pubkey)
VALUES (?, ?, 'inactive', ?, ?, ?, ?, ?, ?, '{}', '{}', ?)`,
)
.run(
id, id,
overrides.enabled ?? 0,
overrides.version ?? '1.0.0',
overrides.trek_range ?? '>=3.0.0',
overrides.api_version ?? 1,
overrides.permissions ?? '["db:own"]',
overrides.granted_permissions ?? '["db:own"]',
overrides.author_pubkey ?? null,
);
}
describe('Plan 3j Task 2 — R-install-gates named accept+refuse pairs (PR17/PR26/PR28)', () => {
let rt: PluginRuntimeService;
beforeAll(async () => { rt = await buildRuntime(); });
describe('PR17 — version-range + permission-grant gate (assertActivatable)', () => {
it('INSTALL-GATE-PR17-ACCEPT: a plugin whose declared range admits this host, with no permission widening, passes the gate (activate resolves, never PluginDependencyError)', async () => {
seedPlugin('pr17-accept', { trek_range: '>=3.0.0 <99.0.0' });
// The gate itself (assertActivatable, reading PR17's row through
// PluginsRepository.findActivationGate) is the thing under test — the actual
// child spawn is stubbed out (already covered end to end by
// plugin-runtime.test.ts's own activation suite, real code on disk, real spawn).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supervisorSpy = vi.spyOn((rt as any).supervisor, 'activate').mockResolvedValue(undefined);
await expect(rt.activate('pr17-accept')).resolves.toBeUndefined();
expect(supervisorSpy).toHaveBeenCalledTimes(1); // the gate let it through to the spawn call
supervisorSpy.mockRestore();
});
it('INSTALL-GATE-PR17-REFUSE: a plugin whose declared range excludes this host is refused, never spawned', async () => {
seedPlugin('pr17-refuse', { trek_range: '>=99.0.0' });
const err = await rt.activate('pr17-refuse').catch((e) => e);
expect(err).toBeInstanceOf(PluginDependencyError);
expect(err).toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
expect(rt.isActive('pr17-refuse')).toBe(false);
});
});
describe('PR26 — update()\'s pre-update snapshot feeding the permission-widening diff', () => {
const fakeRegistry = (perms: string[]) =>
({
resolveVersion: vi.fn(async () => ({ version: '2.0.0' })),
install: vi.fn(async (id: string) => {
testDb.prepare('UPDATE plugins SET permissions = ? WHERE id = ?').run(JSON.stringify(perms), id);
return { id, version: '2.0.0' };
}),
}) as unknown as import('../../../src/nest/plugins/registry/registry.service').PluginRegistryService;
it('INSTALL-GATE-PR26-ACCEPT: no new permissions on update -> activated: true, PR26 snapshot read via the repository', async () => {
seedPlugin('pr26-accept2', { enabled: 1, permissions: '["db:own"]', granted_permissions: '["db:own"]' });
const dbs = new DatabaseService(testDb);
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
const addons = await createTestAddonsService(testDb, dbs);
const userSettings = new PluginUserSettingsService(dbs);
const rtU = new PluginRuntimeService(
dbs, audit, addons, userSettings,
t.repo(Plugins), t.repo(PluginErrorLog), t.repo(PluginScheduledTasks), t.repo(PluginUserErasureQueue),
t.repo(PluginEgressHosts), t.repo(PluginSettingsFields), t.repo(PluginActions), t.repo(PluginUserConfig),
t.repo(PluginEntityMetadata), t.repo(PluginOauthTokens), t.repo(PluginOauthState), t.repo(PluginMetaMigrations),
t.repo(PluginCapabilityAudit), t.repo(Settings), t.repo(NotificationChannelPreferences),
fakeRegistry(['db:own']),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn((rtU as any).supervisor, 'activate').mockResolvedValue(undefined);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn((rtU as any).supervisor, 'disable').mockResolvedValue(undefined);
await rtU.activate('pr26-accept2');
const res = await rtU.update('pr26-accept2');
expect(res).toMatchObject({ activated: true, newPermissions: [], newEgress: [] });
});
it('INSTALL-GATE-PR26-REFUSE: a widened permission set on update leaves the plugin inactive with the delta reported', async () => {
seedPlugin('pr26-refuse', { enabled: 1, permissions: '["db:own"]', granted_permissions: '["db:own"]' });
const dbs = new DatabaseService(testDb);
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
const addons = await createTestAddonsService(testDb, dbs);
const userSettings = new PluginUserSettingsService(dbs);
const rtU = new PluginRuntimeService(
dbs, audit, addons, userSettings,
t.repo(Plugins), t.repo(PluginErrorLog), t.repo(PluginScheduledTasks), t.repo(PluginUserErasureQueue),
t.repo(PluginEgressHosts), t.repo(PluginSettingsFields), t.repo(PluginActions), t.repo(PluginUserConfig),
t.repo(PluginEntityMetadata), t.repo(PluginOauthTokens), t.repo(PluginOauthState), t.repo(PluginMetaMigrations),
t.repo(PluginCapabilityAudit), t.repo(Settings), t.repo(NotificationChannelPreferences),
fakeRegistry(['db:own', 'db:read:trips']),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn((rtU as any).supervisor, 'activate').mockResolvedValue(undefined);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn((rtU as any).supervisor, 'disable').mockResolvedValue(undefined);
await rtU.activate('pr26-refuse');
const res = await rtU.update('pr26-refuse');
expect(res.activated).toBe(false);
expect(res.newPermissions).toEqual(['db:read:trips']);
});
});
describe('PR28 — retrust()\'s signing-key TOFU comparison', () => {
const registryFor = (id: string) =>
({
assertRetrustable: vi.fn(async (_id: string, key: string) => ({ authorPublicKey: key })),
install: vi.fn(async (_id: string, opts?: { retrustKey?: string }) => {
testDb.prepare("UPDATE plugins SET author_pubkey = ?, version = '2.0.0' WHERE id = ?").run(opts?.retrustKey, id);
return { id, version: '2.0.0' };
}),
}) as unknown as import('../../../src/nest/plugins/registry/registry.service').PluginRegistryService;
it('INSTALL-GATE-PR28-ACCEPT: a genuinely rotated key is pinned, read through PluginsRepository.findAuthorPubkey', async () => {
seedPlugin('pr28-accept', { enabled: 1, author_pubkey: 'OLDKEY' });
const dbs = new DatabaseService(testDb);
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
const addons = await createTestAddonsService(testDb, dbs);
const userSettings = new PluginUserSettingsService(dbs);
const registry = registryFor('pr28-accept');
const rtR = new PluginRuntimeService(
dbs, audit, addons, userSettings,
t.repo(Plugins), t.repo(PluginErrorLog), t.repo(PluginScheduledTasks), t.repo(PluginUserErasureQueue),
t.repo(PluginEgressHosts), t.repo(PluginSettingsFields), t.repo(PluginActions), t.repo(PluginUserConfig),
t.repo(PluginEntityMetadata), t.repo(PluginOauthTokens), t.repo(PluginOauthState), t.repo(PluginMetaMigrations),
t.repo(PluginCapabilityAudit), t.repo(Settings), t.repo(NotificationChannelPreferences),
registry,
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn((rtR as any).supervisor, 'activate').mockResolvedValue(undefined);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn((rtR as any).supervisor, 'disable').mockResolvedValue(undefined);
await rtR.retrust('pr28-accept', '2.0.0', 'NEWKEY', { userId: 1 });
expect(registry.assertRetrustable).toHaveBeenCalledWith('pr28-accept', 'NEWKEY');
const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='pr28-accept'").get() as { author_pubkey: string };
expect(row.author_pubkey).toBe('NEWKEY');
});
it('INSTALL-GATE-PR28-REFUSE: an invalid signature is not re-trustable — the pinned key is untouched', async () => {
seedPlugin('pr28-refuse', { enabled: 1, author_pubkey: 'OLDKEY' });
const dbs = new DatabaseService(testDb);
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
const addons = await createTestAddonsService(testDb, dbs);
const userSettings = new PluginUserSettingsService(dbs);
const registry = registryFor('pr28-refuse');
vi.mocked(registry.assertRetrustable).mockRejectedValue(new Error('nothing to re-trust'));
const rtR = new PluginRuntimeService(
dbs, audit, addons, userSettings,
t.repo(Plugins), t.repo(PluginErrorLog), t.repo(PluginScheduledTasks), t.repo(PluginUserErasureQueue),
t.repo(PluginEgressHosts), t.repo(PluginSettingsFields), t.repo(PluginActions), t.repo(PluginUserConfig),
t.repo(PluginEntityMetadata), t.repo(PluginOauthTokens), t.repo(PluginOauthState), t.repo(PluginMetaMigrations),
t.repo(PluginCapabilityAudit), t.repo(Settings), t.repo(NotificationChannelPreferences),
registry,
);
await expect(rtR.retrust('pr28-refuse', '2.0.0', 'NEWKEY', { userId: 1 })).rejects.toThrow(/nothing to re-trust/);
expect(registry.install).not.toHaveBeenCalled();
const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='pr28-refuse'").get() as { author_pubkey: string };
expect(row.author_pubkey).toBe('OLDKEY');
});
});
});
describe('Plan 3j Task 2 — R-uninstall: the 9-table cascade stays non-transactional (concurrency-pin)', () => {
it('CASCADE-PARTIAL-001: a failure partway through the cascade leaves EARLIER deletes committed and LATER ones never attempted — the CURRENT legacy shape, not hardened to all-or-nothing', async () => {
const rt = await buildRuntime();
const id = 'cascade-partial';
seedPlugin(id);
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope) VALUES (?, 'k', 'instance')").run(id);
testDb.prepare("INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at) VALUES (?, 'poll', 0)").run(id);
testDb.prepare("INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, 'error', 'x')").run(id);
testDb.prepare("INSERT INTO plugin_entity_metadata (plugin_id, entity_type, entity_id, key, value) VALUES (?, 'place', 1, 'k', 'v')").run(id);
// Simulate a crash INSIDE the cascade, between plugin_scheduled_tasks (deletes
// cleanly, unconditional, no try/catch) and plugin_error_log (the next
// unconditional delete, inside the deleteData branch).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errorLogRepo = (rt as any).pluginErrorLog;
const spy = vi.spyOn(errorLogRepo, 'deleteAllForPlugin').mockRejectedValueOnce(new Error('simulated mid-cascade crash'));
await expect(rt.uninstall(id, true)).rejects.toThrow(/simulated mid-cascade crash/);
spy.mockRestore();
// EVERYTHING before the simulated crash point is already gone — no transaction
// rolled it back, exactly matching the legacy sequential-statement shape.
expect(testDb.prepare("SELECT COUNT(*) c FROM plugins WHERE id=?").get(id)).toMatchObject({ c: 0 });
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_settings_fields WHERE plugin_id=?").get(id)).toMatchObject({ c: 0 });
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_scheduled_tasks WHERE plugin_id=?").get(id)).toMatchObject({ c: 0 });
// The row the mock made deleteAllForPlugin THROW for is untouched (never committed)...
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_error_log WHERE plugin_id=?").get(id)).toMatchObject({ c: 1 });
// ...and everything AFTER it in cascade order never even ran.
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_entity_metadata WHERE plugin_id=?").get(id)).toMatchObject({ c: 1 });
});
});
describe('Plan 3j Task 2 — R-uninstall\'s ONE named transaction: PR22/PR23 egress-host replace', () => {
it('EGRESS-TX-ROLLBACK-001: a simulated failure mid-write leaves the OLD egress-host set intact, not partially cleared', async () => {
const rt = await buildRuntime();
const id = 'egress-tx';
seedPlugin(id, { permissions: '["http:outbound"]', granted_permissions: '["http:outbound"]' });
testDb.prepare("UPDATE plugins SET operator_egress = 1 WHERE id = ?").run(id);
await rt.setOperatorEgressHosts(id, ['old-a.example.com', 'old-b.example.com']);
expect((await rt.operatorEgressHosts(id)).sort()).toEqual(['old-a.example.com', 'old-b.example.com']);
// Force the INSERT half of the DELETE+loop-INSERT to throw on the second host,
// inside the SAME uow.transactional call the runtime already wraps this in.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const egressRepo = (rt as any).pluginEgressHosts;
const originalReplace = egressRepo.replaceAllForPlugin.bind(egressRepo);
const spy = vi.spyOn(egressRepo, 'replaceAllForPlugin').mockImplementation(async (pluginId: string, hosts: string[]) => {
if (hosts.includes('boom')) throw new Error('simulated mid-write failure');
return originalReplace(pluginId, hosts);
});
await expect(rt.setOperatorEgressHosts(id, ['new-a.example.com', 'boom'])).rejects.toThrow(/simulated mid-write failure/);
spy.mockRestore();
// The OLD set survives untouched — the transaction rolled the DELETE back too,
// not a partially-cleared allow-list.
expect((await rt.operatorEgressHosts(id)).sort()).toEqual(['old-a.example.com', 'old-b.example.com']);
});
});
describe('Plan 3j Task 2 — PS7/PS9/PS11: the triple-duplicate SELECT collapses onto ONE PluginUserConfigRepository.findConfig call', () => {
it('PS-USERCONFIG-PARITY-001: getUserConfig, setUserConfig\'s pre-write check and getUserConfigDecrypted all read the SAME stored row identically', async () => {
const svc = buildPluginsService();
const id = 'ps-parity';
seedPlugin(id);
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES (?, 'apiKey', 'user', 1)").run(id);
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES (?, 'units', 'user', 0)").run(id);
await svc.updateUserConfig(id, 7, { apiKey: 'sk-live', units: 'metric' });
// PS7 (getUserConfig, masked) and PS11 (getUserConfigDecrypted, plaintext) both
// resolve through the SAME repository method (`findConfig`) as PS9's pre-write
// check inside updateUserConfig above — all three must agree on what is stored.
const masked = await svc.getUserConfig(id, 7);
const decrypted = await svc.getUserConfigDecrypted(id, 7);
expect(masked.units).toBe('metric');
expect(masked.apiKey).toBe('••••••••'); // PS7: secret masked
expect(decrypted.units).toBe('metric');
expect(decrypted.apiKey).toBe('sk-live'); // PS11: secret decrypted, host-only
});
});
describe('Plan 3j Task 2 — two racing ticks (scheduler claim/re-arm + erasure drain)', () => {
let registry: SchedulerRegistry;
let registrar: CronRegistrarService;
let rt: PluginRuntimeService;
beforeAll(async () => {
registry = new SchedulerRegistry();
registrar = new CronRegistrarService(registry, { isTest: () => false } as RuntimeEnvService, t.orm);
rt = await buildRuntime(registrar);
await rt.onApplicationBootstrap();
});
afterAll(async () => { await rt.onModuleDestroy(); });
it('RACE-SCHED-001: two concurrent fireDueScheduled passes deliver a due one-shot task exactly once', async () => {
const pluginId = 'race-sched';
seedPlugin(pluginId, { enabled: 1 });
testDb.prepare('INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at) VALUES (?, ?, ?)').run(pluginId, 'once', Date.now() - 1000);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supervisor = (rt as any).supervisor;
supervisor.running.set(pluginId, { id: pluginId, status: 'active' });
let delivered = 0;
const invokeSpy = vi.spyOn(supervisor, 'invoke').mockImplementation(async () => {
delivered += 1;
// Yield, so the second concurrent pass's own claim-read genuinely overlaps
// this one's window rather than running strictly after it.
await new Promise((r) => setTimeout(r, 5));
return {};
});
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await Promise.all([(rt as any).fireDueScheduled(), (rt as any).fireDueScheduled()]);
expect(delivered).toBe(1); // claimed (re-armed/deleted) by exactly one pass
const row = testDb.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(pluginId, 'once');
expect(row).toBeUndefined(); // deleted, not left dangling or double-inserted
} finally {
invokeSpy.mockRestore();
supervisor.running.delete(pluginId);
}
});
it('RACE-ERASURE-001: two concurrent drains coalesce onto the SAME in-flight pass (drainUserErasures\' own promise-sharing) — one ACK, one drop', async () => {
const pluginId = 'race-erasure';
seedPlugin(pluginId, { enabled: 1 });
testDb.prepare('INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)').run(pluginId, 99);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supervisor = (rt as any).supervisor;
supervisor.running.set(pluginId, { id: pluginId, status: 'active' });
let delivered = 0;
const deliverSpy = vi.spyOn(supervisor, 'deliverUserErasure').mockImplementation(async () => {
delivered += 1;
await new Promise((r) => setTimeout(r, 5));
return true;
});
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await Promise.all([(rt as any).drainUserErasures(), (rt as any).drainUserErasures()]);
expect(delivered).toBe(1); // drainUserErasures' own in-flight coalescing, unchanged by this task's conversion
const row = testDb.prepare('SELECT id FROM plugin_user_erasure_queue WHERE plugin_id=? AND user_id=?').get(pluginId, 99);
expect(row).toBeUndefined();
} finally {
deliverSpy.mockRestore();
supervisor.running.delete(pluginId);
}
});
});
@@ -21,7 +21,7 @@ const { testDb } = vi.hoisted(() => {
trek_range TEXT DEFAULT '>=3.0.0',
source_repo TEXT, author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER, default_value TEXT);
CREATE TABLE plugin_settings_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER, default_value TEXT);
CREATE TABLE settings (user_id INTEGER, key TEXT, value TEXT);
CREATE TABLE plugin_entity_metadata (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, entity_type TEXT, entity_id INTEGER, key TEXT, value TEXT, updated_at TEXT);
CREATE TABLE plugin_user_config (plugin_id TEXT, user_id INTEGER, field_key TEXT, value TEXT, PRIMARY KEY (plugin_id, user_id, field_key));
@@ -99,6 +99,21 @@ import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
import { Users } from '../../../src/db/entities/Users.entity';
import { Plugins } from '../../../src/db/entities/Plugins.entity';
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
import { PluginScheduledTasks } from '../../../src/db/entities/PluginScheduledTasks.entity';
import { PluginUserErasureQueue } from '../../../src/db/entities/PluginUserErasureQueue.entity';
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
import { PluginEntityMetadata } from '../../../src/db/entities/PluginEntityMetadata.entity';
import { PluginOauthTokens } from '../../../src/db/entities/PluginOauthTokens.entity';
import { PluginOauthState } from '../../../src/db/entities/PluginOauthState.entity';
import { PluginMetaMigrations } from '../../../src/db/entities/PluginMetaMigrations.entity';
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
import { Settings } from '../../../src/db/entities/Settings.entity';
import { NotificationChannelPreferences } from '../../../src/db/entities/NotificationChannelPreferences.entity';
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
const testDb = createSnapshotTestDb();
@@ -117,7 +132,32 @@ async function buildRuntime(registrar?: CronRegistrarService, orm?: TestOrm['orm
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
const addons = await createTestAddonsService(testDb, dbs);
const userSettings = new PluginUserSettingsService(dbs);
return new PluginRuntimeService(dbs, audit, addons, userSettings, undefined, undefined, undefined, orm, registrar);
return new PluginRuntimeService(
dbs,
audit,
addons,
userSettings,
t.repo(Plugins),
t.repo(PluginErrorLog),
t.repo(PluginScheduledTasks),
t.repo(PluginUserErasureQueue),
t.repo(PluginEgressHosts),
t.repo(PluginSettingsFields),
t.repo(PluginActions),
t.repo(PluginUserConfig),
t.repo(PluginEntityMetadata),
t.repo(PluginOauthTokens),
t.repo(PluginOauthState),
t.repo(PluginMetaMigrations),
t.repo(PluginCapabilityAudit),
t.repo(Settings),
t.repo(NotificationChannelPreferences),
undefined,
undefined,
undefined,
orm,
registrar,
);
}
beforeAll(async () => {
@@ -31,6 +31,14 @@ import { createPluginRuntime } from '../../helpers/plugin-host';
import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest';
import { makeHostAllow } from '../../../src/nest/plugins/runtime/egress-policy';
import { createTestAddonsService } from '../../helpers/test-addons';
import { sharedTestOrm } from '../../helpers/test-uow';
import { Plugins } from '../../../src/db/entities/Plugins.entity';
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
function install(id: string, operatorEgress: boolean, perms: string[] = ['http:outbound:gotify.net']) {
testDb.prepare(
@@ -174,7 +182,19 @@ describe('the admin list surfaces operator egress (so the chip can be shown)', (
// one over the same DB. These fixtures declare no dependencies, so it is never consulted.
const listPlugins = async () => {
const dbs = new DatabaseService(dbConn);
return (await new PluginsService(dbs, await createTestAddonsService(testDb, dbs)).list()).plugins;
const orm = await sharedTestOrm(dbConn);
const service = new PluginsService(
dbs,
await createTestAddonsService(testDb, dbs),
orm.repo(Plugins),
orm.repo(PluginEgressHosts),
orm.repo(PluginSettingsFields),
orm.repo(PluginActions),
orm.repo(PluginUserConfig),
orm.repo(PluginErrorLog),
orm.repo(PluginCapabilityAudit),
);
return (await service.list()).plugins;
};
const before = await listPlugins();
@@ -34,6 +34,21 @@ import type { AddonsService } from '../../../src/nest/addons/addons.service';
import { createTestAddonsService } from '../../helpers/test-addons';
import { createPluginRuntime } from '../../helpers/plugin-host';
import { discoverPlugins } from '../../../src/nest/plugins/install/discovery';
import { sharedTestOrm } from '../../helpers/test-uow';
import { Plugins } from '../../../src/db/entities/Plugins.entity';
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository';
import type { PluginEgressHostsRepository } from '../../../src/db/repositories/PluginEgressHosts.repository';
import type { PluginSettingsFieldsRepository } from '../../../src/db/repositories/PluginSettingsFields.repository';
import type { PluginActionsRepository } from '../../../src/db/repositories/PluginActions.repository';
import type { PluginUserConfigRepository } from '../../../src/db/repositories/PluginUserConfig.repository';
import type { PluginErrorLogRepository } from '../../../src/db/repositories/PluginErrorLog.repository';
import type { PluginCapabilityAuditRepository } from '../../../src/db/repositories/PluginCapabilityAudit.repository';
function install(id: string) {
testDb
@@ -54,7 +69,28 @@ function declareField(pluginId: string, key: string, scope: 'instance' | 'user',
}
let addonsService: AddonsService;
const svc = () => new PluginsService(new DatabaseService(dbConn), addonsService);
// Plan 3j Task 2 — PluginsService's own six repositories, resolved ONCE in `beforeAll`
// (the file's `testDb` is fixed for the whole suite) so `svc()` below can stay a plain
// synchronous factory — every one of its ~20 call sites below chains straight off it.
let pluginsRepo: PluginsRepository;
let pluginEgressHostsRepo: PluginEgressHostsRepository;
let pluginSettingsFieldsRepo: PluginSettingsFieldsRepository;
let pluginActionsRepo: PluginActionsRepository;
let pluginUserConfigRepo: PluginUserConfigRepository;
let pluginErrorLogRepo: PluginErrorLogRepository;
let pluginCapabilityAuditRepo: PluginCapabilityAuditRepository;
const svc = () =>
new PluginsService(
new DatabaseService(dbConn),
addonsService,
pluginsRepo,
pluginEgressHostsRepo,
pluginSettingsFieldsRepo,
pluginActionsRepo,
pluginUserConfigRepo,
pluginErrorLogRepo,
pluginCapabilityAuditRepo,
);
/**
* Installs a real plugin through the manifest -> discovery pipeline (not a direct
@@ -77,6 +113,14 @@ beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
addonsService = await createTestAddonsService(testDb, new DatabaseService(dbConn));
const orm = await sharedTestOrm(dbConn);
pluginsRepo = orm.repo(Plugins);
pluginEgressHostsRepo = orm.repo(PluginEgressHosts);
pluginSettingsFieldsRepo = orm.repo(PluginSettingsFields);
pluginActionsRepo = orm.repo(PluginActions);
pluginUserConfigRepo = orm.repo(PluginUserConfig);
pluginErrorLogRepo = orm.repo(PluginErrorLog);
pluginCapabilityAuditRepo = orm.repo(PluginCapabilityAudit);
});
beforeEach(() => {
testDb.prepare('DELETE FROM plugins').run();
@@ -22,6 +22,21 @@ import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
import { Users } from '../../../src/db/entities/Users.entity';
import { Plugins } from '../../../src/db/entities/Plugins.entity';
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
import { PluginScheduledTasks } from '../../../src/db/entities/PluginScheduledTasks.entity';
import { PluginUserErasureQueue } from '../../../src/db/entities/PluginUserErasureQueue.entity';
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
import { PluginEntityMetadata } from '../../../src/db/entities/PluginEntityMetadata.entity';
import { PluginOauthTokens } from '../../../src/db/entities/PluginOauthTokens.entity';
import { PluginOauthState } from '../../../src/db/entities/PluginOauthState.entity';
import { PluginMetaMigrations } from '../../../src/db/entities/PluginMetaMigrations.entity';
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
import { Settings } from '../../../src/db/entities/Settings.entity';
import { NotificationChannelPreferences } from '../../../src/db/entities/NotificationChannelPreferences.entity';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -50,21 +65,50 @@ async function buildRuntime(withOrm: boolean): Promise<PluginRuntimeService> {
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
const addons = await createTestAddonsService(testDb, dbs);
const userSettings = new PluginUserSettingsService(dbs);
return new PluginRuntimeService(dbs, audit, addons, userSettings, undefined, undefined, undefined, withOrm ? t.orm : undefined);
return new PluginRuntimeService(
dbs,
audit,
addons,
userSettings,
t.repo(Plugins),
t.repo(PluginErrorLog),
t.repo(PluginScheduledTasks),
t.repo(PluginUserErasureQueue),
t.repo(PluginEgressHosts),
t.repo(PluginSettingsFields),
t.repo(PluginActions),
t.repo(PluginUserConfig),
t.repo(PluginEntityMetadata),
t.repo(PluginOauthTokens),
t.repo(PluginOauthState),
t.repo(PluginMetaMigrations),
t.repo(PluginCapabilityAudit),
t.repo(Settings),
t.repo(NotificationChannelPreferences),
undefined,
undefined,
undefined,
withOrm ? t.orm : undefined,
);
}
describe('PluginRuntimeService.onApplicationBootstrap — absent-ORM boot branch (task-6-rereview2.md I-A)', () => {
it('RT-BOOT-NOORM-001: without an ORM, boot logs the skip line naming the plugin id and never calls activate', async () => {
it('RT-BOOT-NOORM-001: without an ORM, boot logs the skip line and never calls activate', async () => {
installEnabledPlugin('rtboot-noorm');
const rt = await buildRuntime(false);
const activateSpy = vi.spyOn(rt, 'activate');
await rt.onApplicationBootstrap();
// Plan 3j Task 2 (deviation, task-2-report.md): `installedDepRows` is now a
// repository read that needs a request context this branch cannot provide, so
// it is skipped rather than attempted unwrapped — the log line can no longer
// name which plugin(s) it skipped, only that it skipped activation entirely.
// Still fail-closed (never throws out of onApplicationBootstrap, never abandons
// app.init()), still logged exactly once, still never calls activate.
expect(logMock.logError).toHaveBeenCalledTimes(1);
const [message] = logMock.logError.mock.calls[0] as [string];
expect(message).toMatch(/PluginRuntimeService\.onApplicationBootstrap: no MikroORM available/);
expect(message).toContain('rtboot-noorm');
expect(activateSpy).not.toHaveBeenCalled();
});
@@ -22,6 +22,30 @@ import { createTestAddonsService } from '../../helpers/test-addons';
import Database from 'better-sqlite3';
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
import { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
import { sharedTestOrm } from '../../helpers/test-uow';
import { Plugins } from '../../../src/db/entities/Plugins.entity';
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
/** Plan 3j Task 2 — PluginsService's own six repositories, over whichever fresh `getDb.current` the caller just set. */
async function makePluginsService(dbs: DatabaseService): Promise<PluginsService> {
const orm = await sharedTestOrm(getDb.current as Database.Database);
return new PluginsService(
dbs,
await createTestAddonsService(getDb.current as Database.Database, dbs),
orm.repo(Plugins),
orm.repo(PluginEgressHosts),
orm.repo(PluginSettingsFields),
orm.repo(PluginActions),
orm.repo(PluginUserConfig),
orm.repo(PluginErrorLog),
orm.repo(PluginCapabilityAudit),
);
}
/** The host-side settings reads, over the same connection the test seeded. */
const userSettings = () => new PluginUserSettingsService(new DatabaseService(dbConn));
@@ -46,7 +70,7 @@ describe('per-user plugin settings', () => {
beforeEach(async () => {
getDb.current = freshDb();
const dbs = new DatabaseService(dbConn);
svc = new PluginsService(dbs, await createTestAddonsService(getDb.current as Database.Database, dbs));
svc = await makePluginsService(dbs);
});
it('lists only the user-scope fields, in order', async () => {
@@ -98,7 +122,7 @@ describe('manifest defaults reach the runtime reads', () => {
beforeEach(async () => {
getDb.current = freshDb();
const dbs = new DatabaseService(dbConn);
svc = new PluginsService(dbs, await createTestAddonsService(getDb.current as Database.Database, dbs));
svc = await makePluginsService(dbs);
const ins = (getDb.current as import('better-sqlite3').Database).prepare(
'INSERT INTO plugin_settings_fields (plugin_id, field_key, input_type, required, secret, scope, sort_order, default_value) VALUES (?,?,?,?,?,?,?,?)',
);
@@ -141,7 +165,7 @@ describe('hasRequired applies the same "filled" rule as the save gate', () => {
beforeEach(async () => {
getDb.current = freshDb();
const dbs = new DatabaseService(dbConn);
svc = new PluginsService(dbs, await createTestAddonsService(getDb.current as Database.Database, dbs));
svc = await makePluginsService(dbs);
(getDb.current as import('better-sqlite3').Database)
.prepare('INSERT INTO plugin_settings_fields (plugin_id, field_key, input_type, required, secret, scope, sort_order) VALUES (?,?,?,?,?,?,?)')
.run('p', 'consent', 'checkbox', 1, 0, 'user', 9);
@@ -12,8 +12,11 @@ const { testDb } = vi.hoisted(() => {
status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', permissions TEXT DEFAULT '[]', granted_permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, updated_at TEXT,
author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT,
trek_range TEXT, sort_order INTEGER DEFAULT 0, update_hold INTEGER NOT NULL DEFAULT 0);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER, required INTEGER DEFAULT 0, input_type TEXT DEFAULT 'text', default_value TEXT);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT DEFAULT '2026-01-01');`);
CREATE TABLE plugin_settings_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER, required INTEGER DEFAULT 0, input_type TEXT DEFAULT 'text', default_value TEXT, sort_order INTEGER DEFAULT 0);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT DEFAULT '2026-01-01');
CREATE TABLE plugin_actions (plugin_id TEXT, action_key TEXT, label TEXT, hint TEXT, danger INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0, scope TEXT DEFAULT 'user', PRIMARY KEY (plugin_id, action_key));
CREATE TABLE plugin_egress_hosts (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, host TEXT, created_at TEXT DEFAULT (datetime('now')));
CREATE TABLE plugin_user_config (plugin_id TEXT, user_id INTEGER, config TEXT DEFAULT '{}', updated_at TEXT DEFAULT (datetime('now')), PRIMARY KEY (plugin_id, user_id));`);
return { testDb: db };
});
vi.mock('../../../src/db/database', () => ({ db: testDb }));
@@ -22,6 +25,14 @@ import { DatabaseService } from '../../../src/nest/database/database.service';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import { createTestAddonsService } from '../../helpers/test-addons';
import { sharedTestOrm } from '../../helpers/test-uow';
import { Plugins } from '../../../src/db/entities/Plugins.entity';
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
import { PluginsController } from '../../../src/nest/plugins/plugins.controller';
import { PluginsFeedController } from '../../../src/nest/plugins/plugins-feed.controller';
@@ -35,6 +46,27 @@ beforeAll(async () => {
addonsService = await createTestAddonsService(testDb, new DatabaseService(dbConn));
});
/**
* Plan 3j Task 2 — PluginsService's own repository-backed constructor. One
* factory, reused by every call site below (`sharedTestOrm` memoises the ORM
* per connection, so this is cheap): the six PS1-PS13 repositories, resolved
* over the SAME `testDb` handle every raw-SQL fixture line above writes to.
*/
async function makeService(): Promise<PluginsService> {
const orm = await sharedTestOrm(testDb);
return new PluginsService(
new DatabaseService(dbConn),
addonsService,
orm.repo(Plugins),
orm.repo(PluginEgressHosts),
orm.repo(PluginSettingsFields),
orm.repo(PluginActions),
orm.repo(PluginUserConfig),
orm.repo(PluginErrorLog),
orm.repo(PluginCapabilityAudit),
);
}
beforeEach(() => {
testDb.exec('DELETE FROM plugins');
testDb.exec('DELETE FROM plugin_settings_fields');
@@ -51,7 +83,7 @@ describe('PluginsService.list', () => {
.run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0');
process.env.TREK_PLUGINS_ENABLED = 'true';
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
const out = await (await makeService()).list();
expect(out.enabled).toBe(true);
expect(out.plugins).toHaveLength(1);
expect(out.plugins[0]).toMatchObject({ id: 'flight', name: 'Flight', status: 'inactive' });
@@ -72,7 +104,7 @@ describe('PluginsService.list', () => {
it('reports the switch off and an outgrown plugin as hostIncompatible by default', async () => {
process.env.APP_VERSION = '4.1.0';
seed();
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
const out = await (await makeService()).list();
expect(out.ignoreTrekRange).toBe(false);
expect(out.plugins[0]).toMatchObject({ dependencyStatus: 'hostIncompatible', trekRangeBypassed: null });
});
@@ -81,7 +113,7 @@ describe('PluginsService.list', () => {
process.env.APP_VERSION = '4.1.0';
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
seed();
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
const out = await (await makeService()).list();
expect(out.ignoreTrekRange).toBe(true);
expect(out.plugins[0]).toMatchObject({
dependencyStatus: 'ok',
@@ -93,7 +125,7 @@ describe('PluginsService.list', () => {
process.env.APP_VERSION = '3.5.0';
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
seed();
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
const out = await (await makeService()).list();
expect(out.plugins[0]).toMatchObject({ dependencyStatus: 'ok', trekRangeBypassed: null });
});
});
@@ -106,7 +138,7 @@ describe('PluginsService.list', () => {
.prepare("INSERT INTO plugins (id, name, type, status, version) VALUES ('free','Free','widget','inactive','1.0.0')")
.run();
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
const out = await (await makeService()).list();
expect(out.plugins.find((p) => p.id === 'held')).toMatchObject({ updateHold: true });
expect(out.plugins.find((p) => p.id === 'free')).toMatchObject({ updateHold: false });
});
@@ -115,7 +147,7 @@ describe('PluginsService.list', () => {
testDb
.prepare("INSERT INTO plugins (id, name, type, status, version, update_hold) VALUES ('held','Held','widget','inactive','1.0.0',1)")
.run();
const svc = new PluginsService(new DatabaseService(dbConn), addonsService);
const svc = await makeService();
expect(await svc.resumeUpdates('held')).toBe(true);
expect(testDb.prepare("SELECT update_hold FROM plugins WHERE id='held'").get()).toMatchObject({ update_hold: 0 });
@@ -127,14 +159,14 @@ describe('PluginsService.list', () => {
.prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)')
.run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0');
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
const out = await (await makeService()).list();
expect(out.enabled).toBe(true);
expect(out.plugins).toHaveLength(1);
});
it('reports disabled when the kill switch is off (TREK_PLUGINS_ENABLED=false)', async () => {
process.env.TREK_PLUGINS_ENABLED = 'false';
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
const out = await (await makeService()).list();
expect(out.enabled).toBe(false);
expect(out.plugins).toEqual([]);
});
@@ -154,7 +186,7 @@ describe('PluginsService.list', () => {
const key = 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd';
insert('signed-one', 'acme/signed-one', key);
const p = (await new PluginsService(new DatabaseService(dbConn), addonsService).list()).plugins[0];
const p = (await (await makeService()).list()).plugins[0];
expect(p.signed).toBe(true);
// Short head…tail, for eyeballing against what the author reads out over the
// phone. NOT a confidentiality measure — the key is public, and the re-trust
@@ -164,7 +196,7 @@ describe('PluginsService.list', () => {
it('reports unsigned for a registry plugin with no pinned key', async () => {
insert('plain', 'acme/plain', null);
const p = (await new PluginsService(new DatabaseService(dbConn), addonsService).list()).plugins[0];
const p = (await (await makeService()).list()).plugins[0];
expect(p.signed).toBe(false);
expect(p.keyFingerprint).toBeNull();
});
@@ -172,7 +204,7 @@ describe('PluginsService.list', () => {
it('reports unsigned for a sideloaded and a dev-linked plugin (they carry no key)', async () => {
insert('uploaded', 'local:upload', null);
insert('linked', 'local:link', null);
const plugins = (await new PluginsService(new DatabaseService(dbConn), addonsService).list()).plugins;
const plugins = (await (await makeService()).list()).plugins;
expect(plugins.map((p) => [p.id, p.signed, p.source_repo])).toEqual([
['linked', false, 'local:link'],
['uploaded', false, 'local:upload'],
@@ -186,7 +218,7 @@ describe('PluginsService.list', () => {
.prepare('UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?')
.run('SIGNATURE_KEY_CHANGED', 'the key changed', '2.0.0', 'blocked');
const byId = Object.fromEntries((await new PluginsService(new DatabaseService(dbConn), addonsService).list()).plugins.map((p) => [p.id, p]));
const byId = Object.fromEntries((await (await makeService()).list()).plugins.map((p) => [p.id, p]));
expect(byId.blocked.updateBlock).toEqual({ code: 'SIGNATURE_KEY_CHANGED', detail: 'the key changed', version: '2.0.0' });
expect(byId.fine.updateBlock).toBeNull();
});
@@ -196,7 +228,7 @@ describe('PluginsService.list', () => {
// PluginListItem is an interface, so it has no implicit index signature and cannot
// be narrowed to a record directly. Widening through unknown is what lets this case
// probe for a key the contract deliberately does not declare.
const p = (await new PluginsService(new DatabaseService(dbConn), addonsService).list())
const p = (await (await makeService()).list())
.plugins[0] as unknown as Record<string, unknown>;
expect(p.author_pubkey).toBeUndefined();
});
@@ -323,7 +355,7 @@ describe('PluginsService instance config', () => {
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('x','api_key','instance',1)").run();
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('x','server','instance',0)").run();
const svc = new PluginsService(new DatabaseService(dbConn), addonsService);
const svc = await makeService();
const masked = await svc.updateInstanceConfig('x', { api_key: 'super-secret', server: 'https://h' });
// client gets the masked view
expect(masked.api_key).toBe('••••••••');
@@ -347,7 +379,7 @@ describe('PluginsService instance config', () => {
testDb.prepare("INSERT INTO plugins (id, name, status, config) VALUES ('y','Y','inactive','{}')").run();
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('y','server','instance',0)").run();
const svc = new PluginsService(new DatabaseService(dbConn), addonsService);
const svc = await makeService();
const masked = await svc.updateInstanceConfig('y', { server: 'https://h', smuggled: 'nope' });
expect(masked.server).toBe('https://h');
@@ -357,8 +389,8 @@ describe('PluginsService instance config', () => {
});
it('throws for an unknown plugin', async () => {
await expect(new PluginsService(new DatabaseService(dbConn), addonsService).updateInstanceConfig('nope', {})).rejects.toThrow(/not found/);
await expect(new PluginsService(new DatabaseService(dbConn), addonsService).getInstanceConfig('nope')).rejects.toThrow(/not found/);
await expect((await makeService()).updateInstanceConfig('nope', {})).rejects.toThrow(/not found/);
await expect((await makeService()).getInstanceConfig('nope')).rejects.toThrow(/not found/);
});
});
@@ -366,7 +398,7 @@ describe('PluginsService error log', () => {
beforeEach(() => testDb.exec('DELETE FROM plugin_error_log'));
it('lists and clears a plugin error log', async () => {
testDb.prepare("INSERT INTO plugin_error_log (plugin_id, level, message) VALUES ('p','error','boom')").run();
const svc = new PluginsService(new DatabaseService(dbConn), addonsService);
const svc = await makeService();
expect(await svc.errors('p')).toEqual([{ ts: '2026-01-01', level: 'error', message: 'boom' }]);
await svc.clearErrors('p');
expect(await svc.errors('p')).toEqual([]);
@@ -31,6 +31,21 @@ import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
import { Users } from '../../../src/db/entities/Users.entity';
import { Plugins } from '../../../src/db/entities/Plugins.entity';
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
import { PluginScheduledTasks } from '../../../src/db/entities/PluginScheduledTasks.entity';
import { PluginUserErasureQueue } from '../../../src/db/entities/PluginUserErasureQueue.entity';
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
import { PluginEntityMetadata } from '../../../src/db/entities/PluginEntityMetadata.entity';
import { PluginOauthTokens } from '../../../src/db/entities/PluginOauthTokens.entity';
import { PluginOauthState } from '../../../src/db/entities/PluginOauthState.entity';
import { PluginMetaMigrations } from '../../../src/db/entities/PluginMetaMigrations.entity';
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
import { Settings } from '../../../src/db/entities/Settings.entity';
import { NotificationChannelPreferences } from '../../../src/db/entities/NotificationChannelPreferences.entity';
/** The host-side settings reads, over the same connection the test seeded. */
const userSettings = () => new PluginUserSettingsService(new DatabaseService(dbConn));
@@ -140,6 +155,21 @@ describe('a plugin channel label is bounded by the host', () => {
new AuditService(t.repo(AuditLog), t.repo(Users)),
await createTestAddonsService(dbConn),
userSettings(),
t.repo(Plugins),
t.repo(PluginErrorLog),
t.repo(PluginScheduledTasks),
t.repo(PluginUserErasureQueue),
t.repo(PluginEgressHosts),
t.repo(PluginSettingsFields),
t.repo(PluginActions),
t.repo(PluginUserConfig),
t.repo(PluginEntityMetadata),
t.repo(PluginOauthTokens),
t.repo(PluginOauthState),
t.repo(PluginMetaMigrations),
t.repo(PluginCapabilityAudit),
t.repo(Settings),
t.repo(NotificationChannelPreferences),
);
// Stand the plugin up as a granted, active notificationChannel provider.
(rt as unknown as { supervisor: { running: Map<string, unknown> } }).supervisor.running.set('loud', {