From ab63eaf9ad1edac7f1c46b13139e89e29487fe4d Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 24 Sep 2026 02:46:01 +0200 Subject: [PATCH] refactor(plugins): runtime lifecycle, config CRUD and the uninstall cascade through repositories --- ...tificationChannelPreferences.repository.ts | 12 + .../repositories/PluginActions.repository.ts | 31 +- .../PluginCapabilityAudit.repository.ts | 179 +++++++- .../PluginEgressHosts.repository.ts | 37 +- .../PluginEntityMetadata.repository.ts | 7 +- .../repositories/PluginErrorLog.repository.ts | 55 ++- .../PluginMetaMigrations.repository.ts | 14 +- .../PluginOauthState.repository.ts | 12 +- .../PluginOauthTokens.repository.ts | 21 +- .../PluginScheduledTasks.repository.ts | 62 ++- .../PluginSettingsFields.repository.ts | 93 +++- .../PluginUserConfig.repository.ts | 49 ++- .../PluginUserErasureQueue.repository.ts | 63 ++- .../src/db/repositories/Plugins.repository.ts | 389 ++++++++++++++++- .../db/repositories/Settings.repository.ts | 13 + .../nest/plugins/plugin-runtime.service.ts | 336 ++++++++------ .../nest/plugins/plugins-runtime.module.ts | 29 ++ server/src/nest/plugins/plugins.service.ts | 197 ++++----- server/tests/helpers/plugin-host.ts | 31 ++ .../plugins/boot-registry-order.test.ts | 70 ++- .../plugin-runtime.task2-conversion.test.ts | 413 ++++++++++++++++++ .../plugins/plugin-runtime.test.ts | 2 +- .../plugins/plugin-scheduler-sweep.test.ts | 42 +- .../unit/plugins/operator-egress.test.ts | 22 +- .../plugins/plugin-instance-settings.test.ts | 46 +- .../plugin-runtime.boot-no-orm.test.ts | 50 ++- .../unit/plugins/plugin-user-settings.test.ts | 30 +- .../unit/plugins/plugins-service.test.ts | 72 ++- .../unit/plugins/settings-isolation.test.ts | 30 ++ 29 files changed, 2114 insertions(+), 293 deletions(-) create mode 100644 server/tests/integration/plugins/plugin-runtime.task2-conversion.test.ts diff --git a/server/src/db/repositories/NotificationChannelPreferences.repository.ts b/server/src/db/repositories/NotificationChannelPreferences.repository.ts index 9a3444afc..f409562fa 100644 --- a/server/src/db/repositories/NotificationChannelPreferences.repository.ts +++ b/server/src/db/repositories/NotificationChannelPreferences.repository.ts @@ -56,4 +56,16 @@ export class NotificationChannelPreferencesRepository extends TrekRepository { 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 { + await this.nativeDelete({ channel }); + } } diff --git a/server/src/db/repositories/PluginActions.repository.ts b/server/src/db/repositories/PluginActions.repository.ts index 4a4a40aa0..0c23d15a8 100644 --- a/server/src/db/repositories/PluginActions.repository.ts +++ b/server/src/db/repositories/PluginActions.repository.ts @@ -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 {} +/** `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 { + /** 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 { + 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 { + 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 { + await this.nativeDelete({ plugin_id: pluginId }); + } +} diff --git a/server/src/db/repositories/PluginCapabilityAudit.repository.ts b/server/src/db/repositories/PluginCapabilityAudit.repository.ts index 9bea2a678..fe3a5030d 100644 --- a/server/src/db/repositories/PluginCapabilityAudit.repository.ts +++ b/server/src/db/repositories/PluginCapabilityAudit.repository.ts @@ -1,4 +1,181 @@ import type { PluginCapabilityAudit } from '../entities/PluginCapabilityAudit.entity'; import { TrekRepository } from './_shared/trek-repository'; -export class PluginCapabilityAuditRepository extends TrekRepository {} +/** + * 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 { + /** PR45 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_capability_audit WHERE plugin_id = ?`. */ + async deleteAllForPlugin(pluginId: string): Promise { + 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 { + const db = this.kysely(); + 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 { + 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 { + 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 { + const rows = await this.kysely() + .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 { + 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> { + const rows = await this.kysely() + .selectFrom('plugin_capability_audit') + .select(({ fn }) => ['method', fn.count('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 }; +} diff --git a/server/src/db/repositories/PluginEgressHosts.repository.ts b/server/src/db/repositories/PluginEgressHosts.repository.ts index a2be28f26..582ae9ce0 100644 --- a/server/src/db/repositories/PluginEgressHosts.repository.ts +++ b/server/src/db/repositories/PluginEgressHosts.repository.ts @@ -1,4 +1,39 @@ import type { PluginEgressHosts } from '../entities/PluginEgressHosts.entity'; import { TrekRepository } from './_shared/trek-repository'; -export class PluginEgressHostsRepository extends TrekRepository {} +export class PluginEgressHostsRepository extends TrekRepository { + /** 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 { + 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 { + 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 { + 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 { + await this.nativeDelete({ plugin_id: pluginId }); + } +} diff --git a/server/src/db/repositories/PluginEntityMetadata.repository.ts b/server/src/db/repositories/PluginEntityMetadata.repository.ts index b69635715..87ab037ef 100644 --- a/server/src/db/repositories/PluginEntityMetadata.repository.ts +++ b/server/src/db/repositories/PluginEntityMetadata.repository.ts @@ -1,4 +1,9 @@ import type { PluginEntityMetadata } from '../entities/PluginEntityMetadata.entity'; import { TrekRepository } from './_shared/trek-repository'; -export class PluginEntityMetadataRepository extends TrekRepository {} +export class PluginEntityMetadataRepository extends TrekRepository { + /** PR40 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_entity_metadata WHERE plugin_id = ?`. */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } +} diff --git a/server/src/db/repositories/PluginErrorLog.repository.ts b/server/src/db/repositories/PluginErrorLog.repository.ts index 49e129263..164210bd1 100644 --- a/server/src/db/repositories/PluginErrorLog.repository.ts +++ b/server/src/db/repositories/PluginErrorLog.repository.ts @@ -1,4 +1,57 @@ import type { PluginErrorLog } from '../entities/PluginErrorLog.entity'; import { TrekRepository } from './_shared/trek-repository'; -export class PluginErrorLogRepository extends TrekRepository {} +/** 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 { + /** 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 { + 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 { + const db = this.kysely(); + 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 { + 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 { + 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 ?? '' })); + } +} diff --git a/server/src/db/repositories/PluginMetaMigrations.repository.ts b/server/src/db/repositories/PluginMetaMigrations.repository.ts index fd17f19d7..1c32b44b6 100644 --- a/server/src/db/repositories/PluginMetaMigrations.repository.ts +++ b/server/src/db/repositories/PluginMetaMigrations.repository.ts @@ -1,4 +1,16 @@ import type { PluginMetaMigrations } from '../entities/PluginMetaMigrations.entity'; import { TrekRepository } from './_shared/trek-repository'; -export class PluginMetaMigrationsRepository extends TrekRepository {} +/** + * 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 { + /** PR44 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_meta_migrations WHERE plugin_id = ?`. */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } +} diff --git a/server/src/db/repositories/PluginOauthState.repository.ts b/server/src/db/repositories/PluginOauthState.repository.ts index 1e4a43258..08a2a6a1a 100644 --- a/server/src/db/repositories/PluginOauthState.repository.ts +++ b/server/src/db/repositories/PluginOauthState.repository.ts @@ -1,4 +1,14 @@ import type { PluginOauthState } from '../entities/PluginOauthState.entity'; import { TrekRepository } from './_shared/trek-repository'; -export class PluginOauthStateRepository extends TrekRepository {} +/** + * 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 { + /** PR43 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_oauth_state WHERE plugin_id = ?`. */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } +} diff --git a/server/src/db/repositories/PluginOauthTokens.repository.ts b/server/src/db/repositories/PluginOauthTokens.repository.ts index 17fdaba37..7a9fbb360 100644 --- a/server/src/db/repositories/PluginOauthTokens.repository.ts +++ b/server/src/db/repositories/PluginOauthTokens.repository.ts @@ -1,4 +1,23 @@ import type { PluginOauthTokens } from '../entities/PluginOauthTokens.entity'; import { TrekRepository } from './_shared/trek-repository'; -export class PluginOauthTokensRepository extends TrekRepository {} +/** + * 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 { + /** PR42 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_oauth_tokens WHERE plugin_id = ?`. */ + async deleteAllForPlugin(pluginId: string): Promise { + 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 { + const rows = await this.find({ user_id: userId }, { fields: ['plugin_id'] }); + return [...new Set(rows.map((r) => r.plugin_id))]; + } +} diff --git a/server/src/db/repositories/PluginScheduledTasks.repository.ts b/server/src/db/repositories/PluginScheduledTasks.repository.ts index 84f1e63e5..4d43feb6d 100644 --- a/server/src/db/repositories/PluginScheduledTasks.repository.ts +++ b/server/src/db/repositories/PluginScheduledTasks.repository.ts @@ -1,4 +1,64 @@ import type { PluginScheduledTasks } from '../entities/PluginScheduledTasks.entity'; import { TrekRepository } from './_shared/trek-repository'; -export class PluginScheduledTasksRepository extends TrekRepository {} +/** 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 { + /** + * 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 { + 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 { + 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 { + 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 { + await this.nativeDelete({ plugin_id: pluginId }); + } +} diff --git a/server/src/db/repositories/PluginSettingsFields.repository.ts b/server/src/db/repositories/PluginSettingsFields.repository.ts index ca6bd42d2..ede6c9378 100644 --- a/server/src/db/repositories/PluginSettingsFields.repository.ts +++ b/server/src/db/repositories/PluginSettingsFields.repository.ts @@ -1,4 +1,95 @@ import type { PluginSettingsFields } from '../entities/PluginSettingsFields.entity'; import { TrekRepository } from './_shared/trek-repository'; -export class PluginSettingsFieldsRepository extends TrekRepository {} +/** 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 { + /** `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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.nativeDelete({ plugin_id: pluginId }); + } +} diff --git a/server/src/db/repositories/PluginUserConfig.repository.ts b/server/src/db/repositories/PluginUserConfig.repository.ts index 9923d6e78..f80ac6a48 100644 --- a/server/src/db/repositories/PluginUserConfig.repository.ts +++ b/server/src/db/repositories/PluginUserConfig.repository.ts @@ -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 {} +/** `exportUserData`'s own per-user config scan. */ +export interface PluginUserConfigRow { + plugin_id: string; + config: string; +} + +export class PluginUserConfigRepository extends TrekRepository { + /** + * 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 { + 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 { + 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 { + 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 { + await this.nativeDelete({ plugin_id: pluginId }); + } +} diff --git a/server/src/db/repositories/PluginUserErasureQueue.repository.ts b/server/src/db/repositories/PluginUserErasureQueue.repository.ts index de058f174..772a452b2 100644 --- a/server/src/db/repositories/PluginUserErasureQueue.repository.ts +++ b/server/src/db/repositories/PluginUserErasureQueue.repository.ts @@ -1,4 +1,65 @@ import type { PluginUserErasureQueue } from '../entities/PluginUserErasureQueue.entity'; import { TrekRepository } from './_shared/trek-repository'; -export class PluginUserErasureQueueRepository extends TrekRepository {} +/** 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 { + /** 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 { + 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 { + const db = this.kysely(); + 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 { + 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 { + 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 { + await this.nativeDelete({ id }); + } +} diff --git a/server/src/db/repositories/Plugins.repository.ts b/server/src/db/repositories/Plugins.repository.ts index 9db1d716c..d00b4c02c 100644 --- a/server/src/db/repositories/Plugins.repository.ts +++ b/server/src/db/repositories/Plugins.repository.ts @@ -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 {} +/** + * 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 { + // ----------------------------------------------------------------------- + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.nativeUpdate({ id }, { granted_permissions: grantedPermissionsJson, enabled: 1 }); + } + + // ----------------------------------------------------------------------- + // PR21 — wantsOperatorEgress + // ----------------------------------------------------------------------- + + /** PR21 — `SELECT operator_egress FROM plugins WHERE id = ?`. */ + async findOperatorEgressFlag(id: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { config, updated_at: currentTimestamp(platform) }); + } +} diff --git a/server/src/db/repositories/Settings.repository.ts b/server/src/db/repositories/Settings.repository.ts index 7f8080795..0a65440a5 100644 --- a/server/src/db/repositories/Settings.repository.ts +++ b/server/src/db/repositories/Settings.repository.ts @@ -76,4 +76,17 @@ export class SettingsRepository extends TrekRepository { async upsertForUser(userId: number, key: string, value: string): Promise { 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 { + return await this.nativeDelete({ key: { $like: `${prefix}%` } }); + } } diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index 14241584a..bc8907bfa 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -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; 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 { 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; oauthConnected?: boolean }>> { const out: Array<{ pluginId: string; data?: unknown; pending?: boolean; settings?: Record; 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>(); - 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 = {}; try { cfg = JSON.parse(c.config || '{}'); } catch { /* ignore */ } const secrets = secretKeys.get(c.plugin_id) ?? new Set(); @@ -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 { - 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> { - 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, consentWiden: boolean): Promise { - 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 { - 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 { 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 { - 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 { 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 { - 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 { 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 { 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: grants) for the frame CSP. */ async outboundHostsOf(id: string): Promise { - 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 { - 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 { 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).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 { 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 { - 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; + const c = JSON.parse(capabilities || '{}') as Record; 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 { - 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; + const c = JSON.parse(capabilities || '{}') as Record; 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 { - 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); } } diff --git a/server/src/nest/plugins/plugins-runtime.module.ts b/server/src/nest/plugins/plugins-runtime.module.ts index 3eb05174a..78494c521 100644 --- a/server/src/nest/plugins/plugins-runtime.module.ts +++ b/server/src/nest/plugins/plugins-runtime.module.ts @@ -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, diff --git a/server/src/nest/plugins/plugins.service.ts b/server/src/nest/plugins/plugins.service.ts index 80f7c7044..6937fc937 100644 --- a/server/src/nest/plugins/plugins.service.ts +++ b/server/src/nest/plugins/plugins.service.ts @@ -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 { 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 { 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 { 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( 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 { - 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): Promise> { - 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 { - 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; - 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> { - 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> { - 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): Promise> { - 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> { - 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 = {}; 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> { - 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 { - 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 { - 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 { - 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> { - 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): Promise { - 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); } } } diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 8a7d19a7b..b042e391c 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -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), diff --git a/server/tests/integration/plugins/boot-registry-order.test.ts b/server/tests/integration/plugins/boot-registry-order.test.ts index dfd294976..554b3276d 100644 --- a/server/tests/integration/plugins/boot-registry-order.test.ts +++ b/server/tests/integration/plugins/boot-registry-order.test.ts @@ -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(); diff --git a/server/tests/integration/plugins/plugin-runtime.task2-conversion.test.ts b/server/tests/integration/plugins/plugin-runtime.task2-conversion.test.ts new file mode 100644 index 000000000..ac55c18ae --- /dev/null +++ b/server/tests/integration/plugins/plugin-runtime.task2-conversion.test.ts @@ -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 { + 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); + } + }); +}); diff --git a/server/tests/integration/plugins/plugin-runtime.test.ts b/server/tests/integration/plugins/plugin-runtime.test.ts index 1e11ed587..c25031bd3 100644 --- a/server/tests/integration/plugins/plugin-runtime.test.ts +++ b/server/tests/integration/plugins/plugin-runtime.test.ts @@ -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)); diff --git a/server/tests/integration/plugins/plugin-scheduler-sweep.test.ts b/server/tests/integration/plugins/plugin-scheduler-sweep.test.ts index dc2f0c883..96effdbb8 100644 --- a/server/tests/integration/plugins/plugin-scheduler-sweep.test.ts +++ b/server/tests/integration/plugins/plugin-scheduler-sweep.test.ts @@ -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 () => { diff --git a/server/tests/unit/plugins/operator-egress.test.ts b/server/tests/unit/plugins/operator-egress.test.ts index a4a5954de..043313607 100644 --- a/server/tests/unit/plugins/operator-egress.test.ts +++ b/server/tests/unit/plugins/operator-egress.test.ts @@ -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(); diff --git a/server/tests/unit/plugins/plugin-instance-settings.test.ts b/server/tests/unit/plugins/plugin-instance-settings.test.ts index 7361c3b3b..909896b01 100644 --- a/server/tests/unit/plugins/plugin-instance-settings.test.ts +++ b/server/tests/unit/plugins/plugin-instance-settings.test.ts @@ -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(); diff --git a/server/tests/unit/plugins/plugin-runtime.boot-no-orm.test.ts b/server/tests/unit/plugins/plugin-runtime.boot-no-orm.test.ts index e08150a27..0f407e365 100644 --- a/server/tests/unit/plugins/plugin-runtime.boot-no-orm.test.ts +++ b/server/tests/unit/plugins/plugin-runtime.boot-no-orm.test.ts @@ -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 { 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(); }); diff --git a/server/tests/unit/plugins/plugin-user-settings.test.ts b/server/tests/unit/plugins/plugin-user-settings.test.ts index db12fa8db..c8d88f4a1 100644 --- a/server/tests/unit/plugins/plugin-user-settings.test.ts +++ b/server/tests/unit/plugins/plugin-user-settings.test.ts @@ -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 { + 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); diff --git a/server/tests/unit/plugins/plugins-service.test.ts b/server/tests/unit/plugins/plugins-service.test.ts index 1ae3317a7..c4f02a60b 100644 --- a/server/tests/unit/plugins/plugins-service.test.ts +++ b/server/tests/unit/plugins/plugins-service.test.ts @@ -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 { + 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; 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([]); diff --git a/server/tests/unit/plugins/settings-isolation.test.ts b/server/tests/unit/plugins/settings-isolation.test.ts index 2473fcaca..914020e74 100644 --- a/server/tests/unit/plugins/settings-isolation.test.ts +++ b/server/tests/unit/plugins/settings-isolation.test.ts @@ -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 } }).supervisor.running.set('loud', {