From d5228825905d7bbc8d483d190aa08f3460c780d5 Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 24 Sep 2026 03:24:23 +0200 Subject: [PATCH] refactor(plugins): install-time gates, the capability-audit hash chain and the registry through repositories --- .../repositories/PluginActions.repository.ts | 25 +++ .../src/db/repositories/Plugins.repository.ts | 150 +++++++++++++++ .../contributions/plugin-route-normalize.ts | 20 +- .../contributions/plugin-routes.controller.ts | 6 +- server/src/nest/plugins/host/plugin-audit.ts | 98 ++++++---- .../nest/plugins/host/plugin-host-state.ts | 19 +- .../plugins/host/plugin-rpc-host.factory.ts | 8 +- .../nest/plugins/host/rpc/host-surface.rpc.ts | 8 +- server/src/nest/plugins/install/discovery.ts | 97 +++++----- .../plugins/plugin-activity.controller.ts | 8 +- .../nest/plugins/plugin-runtime.service.ts | 46 +++-- server/src/nest/plugins/plugins.module.ts | 10 +- server/src/nest/plugins/plugins.service.ts | 2 +- .../nest/plugins/registry/registry.service.ts | 66 +++---- server/src/nest/plugins/settings-defaults.ts | 10 +- server/src/nest/plugins/signature-status.ts | 15 +- .../nest/roadtrip/roadtrip-router.service.ts | 12 +- server/src/nest/roadtrip/roadtrip.module.ts | 6 +- server/tests/helpers/plugin-host.ts | 14 +- server/tests/helpers/rpc-host-deps.ts | 2 +- .../plugins/boot-registry-order.test.ts | 10 +- .../integration/plugins/dev-link.test.ts | 32 ++-- .../integration/plugins/registry.test.ts | 51 ++++-- server/tests/unit/plugins/discovery.test.ts | 90 +++++---- server/tests/unit/plugins/host-rpc.test.ts | 38 +++- .../tests/unit/plugins/plugin-audit.test.ts | 171 +++++++++++++----- .../unit/plugins/plugin-host-state.test.ts | 69 +++++++ .../plugins/plugin-instance-settings.test.ts | 4 +- .../plugins/plugin-route-normalize.test.ts | 51 ++++++ .../plugins/plugin-routes.controller.test.ts | 9 +- 30 files changed, 834 insertions(+), 313 deletions(-) create mode 100644 server/tests/unit/plugins/plugin-host-state.test.ts create mode 100644 server/tests/unit/plugins/plugin-route-normalize.test.ts diff --git a/server/src/db/repositories/PluginActions.repository.ts b/server/src/db/repositories/PluginActions.repository.ts index 0c23d15a8..226bc9b6d 100644 --- a/server/src/db/repositories/PluginActions.repository.ts +++ b/server/src/db/repositories/PluginActions.repository.ts @@ -30,4 +30,29 @@ export class PluginActionsRepository extends TrekRepository { async deleteAllForPlugin(pluginId: string): Promise { await this.nativeDelete({ plugin_id: pluginId }); } + + /** + * DI6 (Plan 3j Task 3, `install/discovery.ts#upsert`) — `INSERT INTO plugin_actions + * (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?,?,?,?,?,?,?)`, + * looped once per manifest action, paired with DI5's `deleteAllForPlugin` above (the + * same delete-then-reinsert re-declare sequence). `insertMany` over a per-row loop: + * one batched native insert, same rows the legacy per-row `.run()` loop produced. A + * no-op on an empty manifest `actions[]`. + */ + async insertActions(pluginId: string, actions: NewPluginActionRow[]): Promise { + if (!actions.length) return; + await this.insertMany( + actions.map((a) => ({ plugin_id: pluginId, action_key: a.action_key, label: a.label, hint: a.hint, danger: a.danger, scope: a.scope, sort_order: a.sort_order })), + ); + } +} + +/** DI6's own row shape — `discoverPlugins#upsert`'s per-manifest-action insert. */ +export interface NewPluginActionRow { + action_key: string; + label: string; + hint: string | null; + danger: number; + scope: PluginActionScope; + sort_order: number; } diff --git a/server/src/db/repositories/Plugins.repository.ts b/server/src/db/repositories/Plugins.repository.ts index d00b4c02c..5ab76e855 100644 --- a/server/src/db/repositories/Plugins.repository.ts +++ b/server/src/db/repositories/Plugins.repository.ts @@ -388,4 +388,154 @@ export class PluginsRepository extends TrekRepository { const platform = this.getEntityManager().getPlatform(); await this.nativeUpdate({ id }, { config, updated_at: currentTimestamp(platform) }); } + + // ----------------------------------------------------------------------- + // Plan 3j Task 3 — install/discovery.ts's manifest ingestion (DI2–DI4; + // DI2's existence check reuses `existsById` above) + // ----------------------------------------------------------------------- + + /** DI3/DI4 — the manifest-derived column set `discoverPlugins#upsert` writes on both the new-plugin INSERT and the existing-plugin UPDATE branches (the UPDATE branch is a strict subset — see `updateManifestFields`). */ + async insertManifest(m: PluginManifestRow): Promise { + await this.insert({ + id: m.id, + name: m.name, + description: m.description, + type: m.type, + icon: m.icon, + version: m.version, + api_version: m.api_version, + min_trek_version: m.min_trek_version, + trek_range: m.trek_range, + permissions: m.permissions, + capabilities: m.capabilities, + dependencies: m.dependencies, + operator_egress: m.operator_egress, + // '' (empty, not '[]') marks "never consented" so the first activation is + // distinguishable from a plugin consented to zero permissions. + granted_permissions: '', + status: 'inactive', + }); + } + + /** + * DI4 — `` UPDATE plugins SET name = ?, description = ?, type = ?, icon = ?, version = ?, + * api_version = ?, min_trek_version = ?, trek_range = ?, permissions = ?, capabilities = ?, + * dependencies = ?, operator_egress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? ``. + * Deliberately narrower than `insertManifest`'s column set — `status`/`granted_permissions`/ + * `config` are NEVER touched here, so a re-discovery of an already-installed plugin never + * silently re-activates it or wipes its settings/consent. + */ + async updateManifestFields(id: string, m: Omit): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { + name: m.name, + description: m.description, + type: m.type, + icon: m.icon, + version: m.version, + api_version: m.api_version, + min_trek_version: m.min_trek_version, + trek_range: m.trek_range, + permissions: m.permissions, + capabilities: m.capabilities, + dependencies: m.dependencies, + operator_egress: m.operator_egress, + updated_at: currentTimestamp(platform), + }, + ); + } + + // ----------------------------------------------------------------------- + // Plan 3j Task 3 — signature-status.ts's setUpdateBlock/clearUpdateBlock (SG1/SG2) + // ----------------------------------------------------------------------- + + /** SG1 — `UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?` (`setUpdateBlock`, a signature-verification failure). */ + async setUpdateBlockColumns(id: string, code: string, detail: string, version: string | null): Promise { + await this.nativeUpdate({ id }, { update_block_code: code, update_block_detail: detail, update_block_version: version }); + } + + /** SG2 — `UPDATE plugins SET update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL WHERE id = ?` (`clearUpdateBlock`, the inverse — a successful install/update). */ + async clearUpdateBlockColumns(id: string): Promise { + await this.nativeUpdate({ id }, { update_block_code: null, update_block_detail: null, update_block_version: null }); + } + + // ----------------------------------------------------------------------- + // Plan 3j Task 3 — registry/registry.service.ts's own statements (RG#). + // RG9 (`verifySignatureAndTofu`'s pinned-key read) reuses `findAuthorPubkey` + // above (identical text to PR28) rather than adding a duplicate method. + // ----------------------------------------------------------------------- + + /** RG2 (`install`'s post-discover provenance write) — `UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?`. */ + async setInstallProvenance(id: string, sourceRepo: string, sourceCommit: string, sha256: string, reviewedAt: string | null): Promise { + await this.nativeUpdate({ id }, { source_repo: sourceRepo, source_commit: sourceCommit, sha256, reviewed_at: reviewedAt }); + } + + /** RG3 (`install`'s TOFU pin) — `UPDATE plugins SET author_pubkey = ? WHERE id = ?`. Security-sensitive: only ever called with a key the artifact just verified under (see `registry.service.ts#install`'s own docstring) — never cleared to NULL by this method. */ + async setAuthorPubkey(id: string, authorPubkey: string): Promise { + await this.nativeUpdate({ id }, { author_pubkey: authorPubkey }); + } + + /** RG5 (`installWithDependencies`'s already-installed set) — `SELECT id FROM plugins`. */ + async listAllIds(): Promise { + const rows = await this.find({}, { fields: ['id'] }); + return rows.map((r) => r.id ?? ''); + } + + /** RG6 (`recomputeUpdateHold`) — `UPDATE plugins SET update_hold = ? WHERE id = ?`. */ + async setUpdateHold(id: string, hold: boolean): Promise { + await this.nativeUpdate({ id }, { update_hold: hold ? 1 : 0 }); + } + + /** + * RG8 (`commitUpload`, sideload step 2) — `` UPDATE plugins SET source_repo = ?, + * source_commit = NULL, sha256 = NULL, reviewed_at = NULL, author_pubkey = NULL, + * update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL, + * status = 'inactive', enabled = 0 WHERE id = ? ``. The sideload twin of PR31's + * `clearForDevLink` above — same registry-trust-column clear, plus `reviewed_at` + * (a sideload was never registry-reviewed) — kept as its own method rather than + * widening `clearForDevLink`, since the two back separate legacy statements with + * different column sets, not one shared text. + */ + async clearForSideload(id: string, sourceRepo: string): Promise { + await this.nativeUpdate( + { id }, + { + source_repo: sourceRepo, + source_commit: null, + sha256: null, + reviewed_at: null, + author_pubkey: null, + update_block_code: null, + update_block_detail: null, + update_block_version: null, + status: 'inactive', + enabled: 0, + }, + ); + } + + /** RG11 (`assertRetrustable`) — `SELECT source_repo, author_pubkey FROM plugins WHERE id = ?`. */ + async findSourceRepoAndAuthorPubkey(id: string): Promise<{ source_repo: string | null; author_pubkey: string | null } | null> { + const row = await this.findOne({ id }, { fields: ['source_repo', 'author_pubkey'] }); + return row ? { source_repo: row.source_repo ?? null, author_pubkey: row.author_pubkey ?? null } : null; + } +} + +/** DI3/DI4 — the manifest-derived column set `discoverPlugins#upsert` writes on both the new-plugin INSERT and the existing-plugin UPDATE branches (the UPDATE branch is a strict subset — see `updateManifestFields`). */ +export interface PluginManifestRow { + id: string; + name: string; + description: string | null; + type: string; + icon: string; + version: string | null; + api_version: number; + min_trek_version: string | null; + trek_range: string | null; + permissions: string; + capabilities: string; + dependencies: string; + operator_egress: number; } diff --git a/server/src/nest/plugins/contributions/plugin-route-normalize.ts b/server/src/nest/plugins/contributions/plugin-route-normalize.ts index c02c2846d..912df5006 100644 --- a/server/src/nest/plugins/contributions/plugin-route-normalize.ts +++ b/server/src/nest/plugins/contributions/plugin-route-normalize.ts @@ -1,6 +1,5 @@ import { stripEmoji } from '../text-sanitize'; - -import type Database from 'better-sqlite3'; +import type { PluginsRepository } from '../../../db/repositories/Plugins.repository'; type Tone = 'default' | 'success' | 'warn' | 'danger'; @@ -120,12 +119,19 @@ export function normalize( return { pluginId, profile, coordinates, distance, duration, legs, viaPoints }; } -export async function declaredProfiles(conn: Database.Database, pluginId: string): Promise { +/** + * SV4 (R-survivors) — was `SELECT capabilities FROM plugins WHERE id = ?` over a + * raw connection; now `PluginsRepository#findCapabilities` (PR52/PR53, Plan 3j + * Task 2's `plugin-runtime.service.ts#capabilityList`/`#mcpToolCapabilities` + * conversion of the SAME column read — reused here rather than adding a fourth + * near-identical method). Three external callers (`roadtrip-router.service.ts`'s + * RRT1/RRT2, `plugin-routes.controller.ts`) switch from the raw-connection + * signature to this repository-typed one — Task 5's. + */ +export async function declaredProfiles(plugins: PluginsRepository, pluginId: string): Promise { try { - const row = conn.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(pluginId) as - | { capabilities?: string } - | undefined; - const c = JSON.parse(row?.capabilities || '{}') as { routeProfiles?: Array<{ id?: unknown }> }; + const capabilities = await plugins.findCapabilities(pluginId); + const c = JSON.parse(capabilities || '{}') as { routeProfiles?: Array<{ id?: unknown }> }; if (!Array.isArray(c.routeProfiles)) return []; return c.routeProfiles .map((p) => (p && typeof p === 'object' && typeof p.id === 'string' ? p.id : '')) diff --git a/server/src/nest/plugins/contributions/plugin-routes.controller.ts b/server/src/nest/plugins/contributions/plugin-routes.controller.ts index 44b15bf14..13b37730f 100644 --- a/server/src/nest/plugins/contributions/plugin-routes.controller.ts +++ b/server/src/nest/plugins/contributions/plugin-routes.controller.ts @@ -1,6 +1,9 @@ import { Body, Controller, Param, Post, Req, UseGuards } from '@nestjs/common'; import type { Request } from 'express'; +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 { JwtAuthGuard } from '../../auth/jwt-auth.guard'; import { pluginsEnabled } from '../kill-switch'; import { PluginHooks } from '../plugin-hooks.service'; @@ -33,6 +36,7 @@ export class PluginRoutesController { constructor( private readonly hooks: PluginHooks, private readonly dbs: DatabaseService, + @InjectRepository(Plugins) private readonly plugins: PluginsRepository, ) {} @Post(':pluginId/:profileId') @@ -56,7 +60,7 @@ export class PluginRoutesController { // one the manifest declared — re-validated from the DB row like the plugins feed, // so a hand-edited capabilities blob can't invent profiles. if (!this.hooks.providersOf('routeProvider').includes(pluginId)) return { route: null }; - if (!(await declaredProfiles(this.dbs.connection, pluginId)).includes(profileId)) return { route: null }; + if (!(await declaredProfiles(this.plugins, pluginId)).includes(profileId)) return { route: null }; try { const raw = await this.hooks.route(pluginId, { tripId, dayId, profile: profileId, waypoints }, userId); diff --git a/server/src/nest/plugins/host/plugin-audit.ts b/server/src/nest/plugins/host/plugin-audit.ts index 5b886eff7..6b46c8fc2 100644 --- a/server/src/nest/plugins/host/plugin-audit.ts +++ b/server/src/nest/plugins/host/plugin-audit.ts @@ -1,5 +1,6 @@ import crypto from 'node:crypto'; import { METHOD_PERMISSION } from '../protocol/envelope'; +import type { PluginCapabilityAuditRepository, AuditForPluginRow, AuditForUserRow } from '../../../db/repositories/PluginCapabilityAudit.repository'; /** * Host-side, hash-chained capability audit (#plugins, L1 hardening). @@ -12,14 +13,6 @@ import { METHOD_PERMISSION } from '../protocol/envelope'; * TREK grant broad reads to large addons without raising risk. */ -interface AuditDb { - prepare(sql: string): { - get(...args: unknown[]): unknown; - run(...args: unknown[]): unknown; - all(...args: unknown[]): unknown[]; - }; -} - export interface AuditEntry { pluginId: string; actingUserId?: number; @@ -98,30 +91,42 @@ function envInt(name: string, def: number): number { /** Keep only the newest `MAX_AUDIT_ROWS` rows for a plugin. Called amortised from * appendAudit; exported for tests. No-op when disabled or under the cap. */ -export async function pruneAudit(db: AuditDb, pluginId: string, keep = MAX_AUDIT_ROWS): Promise { +export async function pruneAudit(audit: PluginCapabilityAuditRepository, pluginId: string, keep = MAX_AUDIT_ROWS): Promise { if (keep <= 0) return; - db.prepare( - `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 ?)`, - ).run(pluginId, pluginId, keep); + await audit.pruneKeepingNewest(pluginId, keep); } -/** Append one entry to the per-plugin hash chain. The statements themselves are - * synchronous (better-sqlite3); the method is async for the ORM seam. */ -export async function appendAudit(db: AuditDb, e: AuditEntry): Promise { - const prev = - (db.prepare('SELECT hash FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id DESC LIMIT 1').get(e.pluginId) as - | { hash: string } - | undefined)?.hash ?? ''; +/** + * Append one entry to the per-plugin hash chain. + * + * R-hash-chain: the hash input is `prev_hash + JSON.stringify([pluginId, + * actingUserId ?? null, method, resource ?? null, code, ts])`, in that field + * order, with `ts` computed in JS as `new Date().toISOString()` BEFORE the + * hash (never a DB-generated timestamp — see {@link verifyChain}'s docstring + * for why that would make the chain non-reproducible). This construction is + * byte-for-byte unchanged from the pre-conversion function; only the + * persistence calls (the previous-hash read, the row write, the amortised + * prune) now go through the repository instead of a raw `better-sqlite3` + * handle. + */ +export async function appendAudit(audit: PluginCapabilityAuditRepository, e: AuditEntry): Promise { + const prev = (await audit.lastHash(e.pluginId)) ?? ''; const ts = new Date().toISOString(); const row = JSON.stringify([e.pluginId, e.actingUserId ?? null, e.method, e.resource ?? null, e.code, ts]); const hash = crypto.createHash('sha256').update(prev + row).digest('hex'); - db.prepare( - 'INSERT INTO plugin_capability_audit (plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?)', - ).run(e.pluginId, e.actingUserId ?? null, e.method, e.resource ?? null, e.code, ts, prev || null, hash); + await audit.insertRow({ + plugin_id: e.pluginId, + acting_user_id: e.actingUserId ?? null, + method: e.method, + resource: e.resource ?? null, + code: e.code, + ts, + prev_hash: prev || null, + hash, + }); // Amortised retention: prune roughly every PRUNE_EVERY appends per plugin. const n = (appendsSincePrune.get(e.pluginId) ?? 0) + 1; - if (n >= PRUNE_EVERY) { appendsSincePrune.set(e.pluginId, 0); await pruneAudit(db, e.pluginId); } + if (n >= PRUNE_EVERY) { appendsSincePrune.set(e.pluginId, 0); await pruneAudit(audit, e.pluginId); } else appendsSincePrune.set(e.pluginId, n); } @@ -129,19 +134,40 @@ export async function appendAudit(db: AuditDb, e: AuditEntry): Promise { * "what have plugins done in my name?" view. This is what legitimizes the broad * read grants: the user, not just the admin, can see every plugin action bound to * them. Joined with the plugin name for display; capped. */ -export async function readAuditForUser(db: AuditDb, userId: number, limit = 200): Promise { - return db - .prepare( - `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 ?`, - ) - .all(userId, limit); +export async function readAuditForUser(audit: PluginCapabilityAuditRepository, userId: number, limit = 200): Promise { + return audit.forUser(userId, limit); } /** Read the most recent audit rows for a plugin (admin view). */ -export async function readAudit(db: AuditDb, pluginId: string, limit = 200): Promise { - return db - .prepare('SELECT ts, acting_user_id, method, resource, code FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id DESC LIMIT ?') - .all(pluginId, limit); +export async function readAudit(audit: PluginCapabilityAuditRepository, pluginId: string, limit = 200): Promise { + return audit.forPlugin(pluginId, limit); +} + +/** + * Verify a hash chain's internal consistency: every row's `hash` reproduces + * from its OWN `prev_hash` + fields (the exact {@link appendAudit} + * construction above), and every row's `prev_hash` equals the immediately + * preceding row's `hash` (or `''`/absent for the first row in the given + * slice). `rows` must be ordered OLDEST FIRST (ascending `id`) — the reverse + * of {@link readAudit}/{@link readAuditForUser}, which return newest-first + * for display. + * + * Built to prove R-hash-chain's replay/extension property in tests (a + * mutation dropping one field from the hash input makes this return false + * on a chain that construction would otherwise still accept); kept as a + * permanent utility rather than a throwaway test fixture, since an eventual + * admin "verify audit integrity" surface would want exactly this. + */ +export function verifyChain( + rows: Array<{ plugin_id: string; acting_user_id: number | null; method: string; resource: string | null; code: string; ts: string; prev_hash: string | null; hash: string }>, +): boolean { + let prev = ''; + for (const r of rows) { + if ((r.prev_hash ?? '') !== prev) return false; + const row = JSON.stringify([r.plugin_id, r.acting_user_id ?? null, r.method, r.resource ?? null, r.code, r.ts]); + const hash = crypto.createHash('sha256').update(prev + row).digest('hex'); + if (hash !== r.hash) return false; + prev = r.hash; + } + return true; } diff --git a/server/src/nest/plugins/host/plugin-host-state.ts b/server/src/nest/plugins/host/plugin-host-state.ts index 2176e1604..b4f9261fe 100644 --- a/server/src/nest/plugins/host/plugin-host-state.ts +++ b/server/src/nest/plugins/host/plugin-host-state.ts @@ -1,6 +1,6 @@ -import type Database from 'better-sqlite3'; import { PluginDataDb } from './plugin-data.service'; import { DailyBudget, DEFAULT_DAILY_BUDGET } from './daily-budget'; +import type { PluginCapabilityAuditRepository } from '../../../db/repositories/PluginCapabilityAudit.repository'; /** * Process-wide plugin host state, deliberately module-level (NOT a Nest @@ -11,9 +11,10 @@ import { DailyBudget, DEFAULT_DAILY_BUDGET } from './daily-budget'; * this state into the injectable would create a provider cycle for no gain. * * It does NOT reach for the `db` singleton, though: the one read it needs is the - * budget seed, so the caller passes its own injected connection in. That keeps the - * module-level state (which is the point) without a second route to the database - * that no test can substitute. + * budget seed, so the caller passes its own injected `PluginCapabilityAuditRepository` + * in (Plan 3j Task 3 — was a raw `better-sqlite3` connection before conversion). + * That keeps the module-level state (which is the point) without a second route + * to the database that no test can substitute. */ const dataDbs = new Map(); @@ -44,14 +45,12 @@ export function closePluginDataDb(id: string): void { // nothing persisted or phoned home. const budgets = new Map(); -export async function budgetFor(id: string, conn: Database.Database): Promise { +export async function budgetFor(id: string, audit: PluginCapabilityAuditRepository): Promise { let b = budgets.get(id); if (!b) { const now = Date.now(); const since = new Date(now).toISOString().slice(0, 10) + 'T00:00:00'; - const rows = conn - .prepare("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") - .all(id, since) as Array<{ method: string; n: number }>; + const rows = await audit.budgetSeed(id, since); let ai = 0, notify = 0; for (const r of rows) { if (r.method === 'notify.send') notify += r.n; @@ -64,6 +63,6 @@ export async function budgetFor(id: string, conn: Database.Database): Promise> { - return (await budgetFor(id, conn)).used(Date.now()); +export async function pluginBudgetUsage(id: string, audit: PluginCapabilityAuditRepository): Promise> { + return (await budgetFor(id, audit)).used(Date.now()); } diff --git a/server/src/nest/plugins/host/plugin-rpc-host.factory.ts b/server/src/nest/plugins/host/plugin-rpc-host.factory.ts index 3a582265a..1f718f21b 100644 --- a/server/src/nest/plugins/host/plugin-rpc-host.factory.ts +++ b/server/src/nest/plugins/host/plugin-rpc-host.factory.ts @@ -1,5 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DatabaseService } from '../../database/database.service'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { PluginCapabilityAudit } from '../../../db/entities/PluginCapabilityAudit.entity'; +import type { PluginCapabilityAuditRepository } from '../../../db/repositories/PluginCapabilityAudit.repository'; import { PluginRpcHost } from './rpc-host'; import type { PluginRpcRegistry } from './rpc-kit/registry'; import { PluginRpcRegistryService } from './rpc-kit/registry.service'; @@ -25,7 +27,7 @@ export interface PluginCallRouter { @Injectable() export class PluginRpcHostFactory { constructor( - private readonly db: DatabaseService, + @InjectRepository(PluginCapabilityAudit) private readonly audit: PluginCapabilityAuditRepository, // Injected by its concrete token, held as the base class: a no-Nest test can then // hand in a createTestPluginRegistry() built from the instances it cares about. @Inject(PluginRpcRegistryService) private readonly registry: PluginRpcRegistry, @@ -51,7 +53,7 @@ export class PluginRpcHostFactory { // The router binds this host's plugin id as the caller/source. callPlugin: (targetId, fn, args, actingUserId) => router.callPlugin(id, targetId, fn, args, actingUserId), emitPluginEvent: (event, payload) => router.emitPluginEvent(id, event, payload), - audit: (entry) => appendAudit(this.db.connection, entry), + audit: (entry) => appendAudit(this.audit, entry), }, this.registry, ); diff --git a/server/src/nest/plugins/host/rpc/host-surface.rpc.ts b/server/src/nest/plugins/host/rpc/host-surface.rpc.ts index 564c2b0e3..aaf1a7c73 100644 --- a/server/src/nest/plugins/host/rpc/host-surface.rpc.ts +++ b/server/src/nest/plugins/host/rpc/host-surface.rpc.ts @@ -4,6 +4,9 @@ import { BadParams, ForbiddenResource } from '../rpc-errors'; import { asPayload, num, str } from '../rpc-params'; import type { PluginRpcContext } from '../rpc-kit/types'; import { budgetFor } from '../plugin-host-state'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { PluginCapabilityAudit } from '../../../../db/entities/PluginCapabilityAudit.entity'; +import type { PluginCapabilityAuditRepository } from '../../../../db/repositories/PluginCapabilityAudit.repository'; import { DatabaseService } from '../../../database/database.service'; import { RealtimeService } from '../../../realtime/realtime.service'; import { NotificationsService } from '../../../notifications/notifications.service'; @@ -42,6 +45,7 @@ export class HostSurfaceRpc { private readonly llmConfig: LlmConfigResolver, private readonly oauth: PluginOAuthService, private readonly guards: PluginGuards, + @InjectRepository(PluginCapabilityAudit) private readonly audit: PluginCapabilityAuditRepository, ) {} @PluginMethod('users.getById', { permission: 'db:read:users' }) @@ -105,7 +109,7 @@ export class HostSurfaceRpc { throw new ForbiddenResource('the acting user is not a member of that trip'); } const link = this.safeLink(input.link); - if (!(await budgetFor(ctx.pluginId, this.db.connection)).take('notify', Date.now())) { + if (!(await budgetFor(ctx.pluginId, this.audit)).take('notify', Date.now())) { throw new BadParams('daily notification budget exhausted (resets at UTC midnight)'); } await this.notifications.send({ @@ -246,7 +250,7 @@ export class HostSurfaceRpc { } private async takeAiBudget(ctx: PluginRpcContext): Promise { - if (!(await budgetFor(ctx.pluginId, this.db.connection)).take('ai', Date.now())) { + if (!(await budgetFor(ctx.pluginId, this.audit)).take('ai', Date.now())) { throw new BadParams('daily AI budget exhausted (resets at UTC midnight)'); } } diff --git a/server/src/nest/plugins/install/discovery.ts b/server/src/nest/plugins/install/discovery.ts index c6219afec..babefcc45 100644 --- a/server/src/nest/plugins/install/discovery.ts +++ b/server/src/nest/plugins/install/discovery.ts @@ -1,10 +1,23 @@ import fs from 'node:fs'; import path from 'node:path'; -import type BetterSqlite3 from 'better-sqlite3'; import { pluginsCodeRoot, pluginCodeDir } from '../paths'; import { parseJsonText, parseManifest, type PluginManifest } from './manifest'; import { scanForNativeBinaries } from './native-scan'; import { devLinkEnabled } from '../dev-link'; +import type { PluginsRepository } from '../../../db/repositories/Plugins.repository'; +import type { PluginActionsRepository } from '../../../db/repositories/PluginActions.repository'; +import type { PluginSettingsFieldsRepository } from '../../../db/repositories/PluginSettingsFields.repository'; +import type { PluginErrorLogRepository } from '../../../db/repositories/PluginErrorLog.repository'; + +/** `discoverPlugins`/`upsert`'s repository set — "swap the parameter type" (plan3j-inputs.md + * §0): the raw `BetterSqlite3.Database` parameter becomes this bundle of the four + * repositories the scan actually writes through. */ +export interface DiscoveryRepos { + plugins: PluginsRepository; + actions: PluginActionsRepository; + settingsFields: PluginSettingsFieldsRepository; + errorLog: PluginErrorLogRepository; +} /** * Discover plugins placed on the /plugins volume (#plugins, M4, "install from @@ -14,7 +27,7 @@ import { devLinkEnabled } from '../dev-link'; * manifest is invalid or that ships native binaries is skipped (recorded to its * error log if it already existed). */ -export async function discoverPlugins(db: BetterSqlite3.Database): Promise<{ discovered: string[]; skipped: string[] }> { +export async function discoverPlugins(repos: DiscoveryRepos): Promise<{ discovered: string[]; skipped: string[] }> { const root = pluginsCodeRoot(); const discovered: string[] = []; const skipped: string[] = []; @@ -50,62 +63,64 @@ export async function discoverPlugins(db: BetterSqlite3.Database): Promise<{ dis const manifest = parseManifest(parseJsonText(fs.readFileSync(manifestPath, 'utf8'))); if (manifest.id !== entry.name) throw new Error(`manifest id "${manifest.id}" != directory "${entry.name}"`); if (scanForNativeBinaries(dir).length) throw new Error('directory contains native binaries'); - await upsert(db, manifest); + await upsert(repos, manifest); discovered.push(manifest.id); } catch (e) { skipped.push(entry.name); const msg = e instanceof Error ? e.message : 'invalid plugin'; - db.prepare('INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, ?, ?)').run(entry.name, 'error', `discovery: ${msg}`); + await repos.errorLog.insertLog(entry.name, 'error', `discovery: ${msg}`); } } return { discovered, skipped }; } -async function upsert(db: BetterSqlite3.Database, m: PluginManifest): Promise { +async function upsert(repos: DiscoveryRepos, m: PluginManifest): Promise { const dependencies = JSON.stringify({ requiredAddons: m.requiredAddons, pluginDependencies: m.pluginDependencies }); - const existing = db.prepare('SELECT id FROM plugins WHERE id = ?').get(m.id) as { id: string } | undefined; + const manifestRow = { + name: m.name, + description: m.description ?? null, + type: m.type, + icon: m.icon ?? 'Blocks', + version: m.version, + api_version: m.apiVersion, + min_trek_version: m.minTrekVersion ?? null, + trek_range: m.trekRange, + permissions: JSON.stringify(m.permissions), + capabilities: JSON.stringify(m.capabilities), + dependencies, + operator_egress: m.operatorEgress ? 1 : 0, + }; + const existing = await repos.plugins.existsById(m.id); if (existing) { - db.prepare( - `UPDATE plugins SET name = ?, description = ?, type = ?, icon = ?, version = ?, api_version = ?, - min_trek_version = ?, trek_range = ?, permissions = ?, capabilities = ?, dependencies = ?, operator_egress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, - ).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, m.trekRange, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0, m.id); + await repos.plugins.updateManifestFields(m.id, manifestRow); } else { - db.prepare( - // granted_permissions '' (empty, not '[]') marks "never consented" so the - // first activation is distinguishable from a plugin consented to zero perms. - `INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, trek_range, permissions, capabilities, dependencies, operator_egress, granted_permissions, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'inactive')`, - ).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, m.trekRange, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0); + await repos.plugins.insertManifest({ id: m.id, ...manifestRow }); } // Refresh the settings-page action descriptors from the manifest. - db.prepare('DELETE FROM plugin_actions WHERE plugin_id = ?').run(m.id); - const insertAction = db.prepare( - 'INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)', + await repos.actions.deleteAllForPlugin(m.id); + await repos.actions.insertActions( + m.id, + m.actions.map((a, i) => ({ action_key: a.key, label: a.label, hint: a.hint ?? null, danger: a.danger ? 1 : 0, scope: a.scope, sort_order: i })), ); - m.actions.forEach((a, i) => insertAction.run(m.id, a.key, a.label, a.hint ?? null, a.danger ? 1 : 0, a.scope, i)); // Refresh the settings-field descriptors from the manifest. - db.prepare('DELETE FROM plugin_settings_fields WHERE plugin_id = ?').run(m.id); - const insert = db.prepare( - `INSERT INTO plugin_settings_fields (plugin_id, field_key, label, input_type, placeholder, hint, required, secret, scope, options, oauth_config, default_value, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + await repos.settingsFields.deleteAllForPlugin(m.id); + await repos.settingsFields.insertFields( + m.id, + m.settings.map((f, i) => ({ + field_key: f.key, + label: f.label ?? f.key, + input_type: f.input_type ?? 'text', + placeholder: f.placeholder ?? null, + hint: f.hint ?? null, + required: f.required ? 1 : 0, + secret: f.secret ? 1 : 0, + scope: f.scope ?? 'instance', + options: f.options ? JSON.stringify(f.options) : null, + oauth_config: f.oauth ? JSON.stringify(f.oauth) : null, + default_value: f.default === undefined ? null : JSON.stringify(f.default), + sort_order: i, + })), ); - m.settings.forEach((f, i) => { - insert.run( - m.id, - f.key, - f.label ?? f.key, - f.input_type ?? 'text', - f.placeholder ?? null, - f.hint ?? null, - f.required ? 1 : 0, - f.secret ? 1 : 0, - f.scope ?? 'instance', - f.options ? JSON.stringify(f.options) : null, - f.oauth ? JSON.stringify(f.oauth) : null, - f.default === undefined ? null : JSON.stringify(f.default), - i, - ); - }); } diff --git a/server/src/nest/plugins/plugin-activity.controller.ts b/server/src/nest/plugins/plugin-activity.controller.ts index 146076773..3ff673598 100644 --- a/server/src/nest/plugins/plugin-activity.controller.ts +++ b/server/src/nest/plugins/plugin-activity.controller.ts @@ -1,6 +1,8 @@ import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; import type { Request } from 'express'; -import { DatabaseService } from '../database/database.service'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { PluginCapabilityAudit } from '../../db/entities/PluginCapabilityAudit.entity'; +import type { PluginCapabilityAuditRepository } from '../../db/repositories/PluginCapabilityAudit.repository'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { pluginsEnabled } from './kill-switch'; import { readAuditForUser } from './host/plugin-audit'; @@ -18,7 +20,7 @@ import { readAuditForUser } from './host/plugin-audit'; @Controller('api/plugin-activity') @UseGuards(JwtAuthGuard) export class PluginActivityController { - constructor(private readonly dbs: DatabaseService) {} + constructor(@InjectRepository(PluginCapabilityAudit) private readonly audit: PluginCapabilityAuditRepository) {} @Get() async mine(@Req() req: Request & { user?: { id: number } }, @Query('limit') limitRaw?: string): Promise<{ activity: unknown[] }> { @@ -28,6 +30,6 @@ export class PluginActivityController { // Math.floor so a non-integer (e.g. ?limit=2.5) can't reach SQLite's LIMIT and 500; // Math.floor(NaN) stays NaN so the `|| 200` fallback still applies. const limit = Math.min(Math.max(Math.floor(Number(limitRaw)) || 200, 1), 500); - return { activity: await readAuditForUser(this.dbs.connection, userId, limit) }; + return { activity: await readAuditForUser(this.audit, userId, limit) }; } } diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index bc8907bfa..aae5e5125 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -56,7 +56,7 @@ import { closePluginDataDb } from './host/plugin-host-state'; import { ForbiddenResource } from './host/rpc-host'; import { removePluginData } from './host/plugin-data.service'; import { isKnownPermission } from './protocol/envelope'; -import { discoverPlugins } from './install/discovery'; +import { discoverPlugins, type DiscoveryRepos } from './install/discovery'; import { parseJsonText, parseManifest, parseMcpToolCapabilities } from './install/manifest'; import { scanForNativeBinaries } from './install/native-scan'; import { devLinkEnabled, DEV_LINK_SOURCE } from './dev-link'; @@ -313,6 +313,12 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes return this.dbs.connection; } + // Plan 3j Task 3: `discoverPlugins` composes `DiscoveryRepos` from the repositories + // this class already injects for its own PR-numbered statements — no new params. + private get discoveryRepos(): DiscoveryRepos { + return { plugins: this.plugins, actions: this.pluginActions, settingsFields: this.pluginSettingsFields, errorLog: this.pluginErrorLog }; + } + // onApplicationBootstrap, NOT onModuleInit: boot activation builds each plugin's // rpc host synchronously, and the host snapshots PluginRpcRegistryService at // construction (bindInto). Same-module onModuleInit hooks fire in providers-array @@ -359,24 +365,32 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes // 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. + // Plan 3j Task 3: `discoverPlugins` is repository-backed too now (DI1–DI8), so it + // joins `installedDepRows` inside the SAME `withRequestContext` wrap — both need one, + // and both come from the same DI graph, so one wrap covers both. In the no-ORM + // branch `discoverPlugins` is skipped along with `installedDepRows`, not called + // unwrapped: a repository read/write outside a request context now fails closed + // (`cannotUseGlobalContext`) instead of running, so calling it there would only + // trade an explicit skip for a caught-and-swallowed throw — the outer try/catch + // still yields the same "boot must never block app init" outcome either way, but + // the explicit skip (matching `installedDepRows`'s own shape) is the intended one. + // 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 OR write `plugins` at all, + // so it no longer names which plugin(s) it is skipping and never discovers new ones + // on disk — 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 () => { - await discoverPlugins(this.db); + await discoverPlugins(this.discoveryRepos); return this.installedDepRows(); }); } else { - await discoverPlugins(this.db); installed = new Map(); } const enabledIds = [...installed.values()].filter((r) => r.enabled).map((r) => r.id); @@ -613,7 +627,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes /** Re-scan the plugins volume on demand (admin action). */ async rescan(): Promise<{ discovered: string[]; skipped: string[] }> { - return await discoverPlugins(this.db); + return await discoverPlugins(this.discoveryRepos); } async onModuleDestroy(): Promise { @@ -753,7 +767,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes 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')); + const config = applySettingDefaults(decryptConfig(parseObject(row.config)), await settingDefaults(this.pluginSettingsFields, id, 'instance')); const manifestHosts = declared.filter((p) => p.startsWith(HTTP_OUTBOUND)).map((p) => p.slice(HTTP_OUTBOUND.length)); // Union in the hosts the ADMIN added post-install. A plugin that talks to a // self-hosted service can't name the operator's hostname in its manifest, so without @@ -1027,7 +1041,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes removePluginCodeEntry(dest); // drop any prior link — never follows into the author's source fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.symlinkSync(sourceDir, dest, 'junction'); // Windows junction (no elevation); POSIX ignores the type -> dir symlink - await discoverPlugins(this.db); // registers/updates the row from the linked manifest, INACTIVE + await discoverPlugins(this.discoveryRepos); // 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. await this.plugins.clearForDevLink(id, DEV_LINK_SOURCE); diff --git a/server/src/nest/plugins/plugins.module.ts b/server/src/nest/plugins/plugins.module.ts index b01e52882..f606d0f58 100644 --- a/server/src/nest/plugins/plugins.module.ts +++ b/server/src/nest/plugins/plugins.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; import { PluginsController } from './plugins.controller'; import { PluginsFeedController } from './plugins-feed.controller'; import { PluginsProxyController } from './plugins-proxy.controller'; @@ -9,6 +10,7 @@ import { PluginsRuntimeModule } from './plugins-runtime.module'; import { PluginOAuthModule } from './oauth/plugin-oauth.module'; import { PluginContributionsModule } from './contributions/plugin-contributions.module'; import { AppConfigModule } from '../app-config/app-config.module'; +import { PluginCapabilityAudit } from '../../db/entities/PluginCapabilityAudit.entity'; /** * Plugin system (#plugins), composition root. @@ -26,9 +28,15 @@ import { AppConfigModule } from '../app-config/app-config.module'; * What is left here is the CRUD and delivery surface: install/activate/configure, the * feed the client reads, the proxy to a child's HTTP routes, and the sandboxed frame * that serves page and widget assets. + * + * `MikroOrmModule.forFeature([PluginCapabilityAudit])` (Plan 3j Task 3): + * `PluginActivityController`'s own `@InjectRepository` param — `PluginsRuntimeModule` + * registers the same entity for ITS OWN providers but does not export the repository + * token, so this module (the one that constructs the controller) needs its own entry, + * same reasoning `PluginContributionsModule`'s own docstring gives for `JourneyEntries`. */ @Module({ - imports: [AppConfigModule, PluginsRuntimeModule, PluginOAuthModule, PluginContributionsModule], + imports: [AppConfigModule, PluginsRuntimeModule, PluginOAuthModule, PluginContributionsModule, MikroOrmModule.forFeature([PluginCapabilityAudit])], controllers: [ PluginsController, PluginsFeedController, diff --git a/server/src/nest/plugins/plugins.service.ts b/server/src/nest/plugins/plugins.service.ts index 6937fc937..520343762 100644 --- a/server/src/nest/plugins/plugins.service.ts +++ b/server/src/nest/plugins/plugins.service.ts @@ -383,7 +383,7 @@ export class PluginsService { */ private async assertRequiredFilled(id: string, scope: 'instance' | 'user', config: Record): Promise { const required = await this.pluginSettingsFields.listRequiredFieldKeys(id, scope); - const defaults = await settingDefaults(this.db, id, scope); + const defaults = await settingDefaults(this.pluginSettingsFields, id, scope); for (const fieldKey of required) { // The runtime resolves the default too, so it counts as filled here as well. if (!isFilled(config[fieldKey] ?? defaults[fieldKey])) throw new MissingRequiredSettingError(fieldKey); diff --git a/server/src/nest/plugins/registry/registry.service.ts b/server/src/nest/plugins/registry/registry.service.ts index 33fd0e1cd..060cc1f38 100644 --- a/server/src/nest/plugins/registry/registry.service.ts +++ b/server/src/nest/plugins/registry/registry.service.ts @@ -1,6 +1,5 @@ import { readEnv } from '../../../app-config'; -import { DatabaseService } from '../../database/database.service'; -import { discoverPlugins } from '../install/discovery'; +import { discoverPlugins, type DiscoveryRepos } from '../install/discovery'; import { bypassedRange, hostSatisfies, hostVersion, normalizedHost, trekRangeBypassed, warnRangeBypass } from '../install/host-compat'; import type { TrekRangeBypass } from '../install/host-compat'; import type { PluginDependency } from '../install/manifest'; @@ -12,6 +11,15 @@ import { verifyAuthorSignature, SignatureError } from '../install/verify-signatu import { pluginCodeDir, pluginsCodeRoot, pluginsDataRoot } from '../paths'; import { clearUpdateBlock, isSignatureCode, setUpdateBlock, RETRUSTABLE_CODE } from '../signature-status'; import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Plugins } from '../../../db/entities/Plugins.entity'; +import type { PluginsRepository } from '../../../db/repositories/Plugins.repository'; +import { PluginActions } from '../../../db/entities/PluginActions.entity'; +import type { PluginActionsRepository } from '../../../db/repositories/PluginActions.repository'; +import { PluginSettingsFields } from '../../../db/entities/PluginSettingsFields.entity'; +import type { PluginSettingsFieldsRepository } from '../../../db/repositories/PluginSettingsFields.repository'; +import { PluginErrorLog } from '../../../db/entities/PluginErrorLog.entity'; +import type { PluginErrorLogRepository } from '../../../db/repositories/PluginErrorLog.repository'; import fs from 'node:fs'; import path from 'node:path'; @@ -165,10 +173,19 @@ export class RegistryError extends Error { @Injectable() export class PluginRegistryService { - constructor(private readonly dbs: DatabaseService) {} + constructor( + @InjectRepository(Plugins) private readonly plugins: PluginsRepository, + // The remaining three are needed only to compose `DiscoveryRepos` for the + // `discoverPlugins` calls below (install()'s post-download register, commitUpload's + // sideload register) — this service issues no statement of its own against any of + // these three tables. + @InjectRepository(PluginActions) private readonly pluginActions: PluginActionsRepository, + @InjectRepository(PluginSettingsFields) private readonly pluginSettingsFields: PluginSettingsFieldsRepository, + @InjectRepository(PluginErrorLog) private readonly pluginErrorLog: PluginErrorLogRepository, + ) {} - private get db() { - return this.dbs.connection; + private get discoveryRepos(): DiscoveryRepos { + return { plugins: this.plugins, actions: this.pluginActions, settingsFields: this.pluginSettingsFields, errorLog: this.pluginErrorLog }; } /** @@ -448,7 +465,7 @@ export class PluginRegistryService { try { await this.verifySignatureAndTofu(id, bytes, entry, ver, opts?.retrustKey); } catch (e) { - if (e instanceof RegistryError && isSignatureCode(e.code)) await setUpdateBlock(this.dbs.connection, id, e.code, e.message, ver.version); + if (e instanceof RegistryError && isSignatureCode(e.code)) await setUpdateBlock(this.plugins, id, e.code, e.message, ver.version); throw e; } @@ -480,24 +497,18 @@ export class PluginRegistryService { fs.renameSync(pluginRoot, dest); // 7. register INACTIVE (record provenance) - await discoverPlugins(this.db); - this.db.prepare('UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?').run( - entry.repo, - ver.commitSha, - ver.sha256, - entry.reviewedAt ?? null, - id, - ); + await discoverPlugins(this.discoveryRepos); + await this.plugins.setInstallProvenance(id, entry.repo, ver.commitSha, ver.sha256, entry.reviewedAt ?? null); // Pin the author key on first successful install of a signed plugin (TOFU) — // and, after a re-trust, re-pin to the new key the admin blessed. Only ever set // to a key the artifact just verified under; NEVER cleared to NULL, because a // NULL pin re-opens the "was never signed" path that accepts an unsigned update. if (entry.authorPublicKey) { - this.db.prepare('UPDATE plugins SET author_pubkey = ? WHERE id = ?').run(entry.authorPublicKey, id); + await this.plugins.setAuthorPubkey(id, entry.authorPublicKey); } // The plugin is now on new code that passed every check — whatever refusal was // recorded before no longer describes reality. - await clearUpdateBlock(this.dbs.connection, id); + await clearUpdateBlock(this.plugins, id); return { id, version: ver.version, trekRangeBypassed }; } finally { fs.rmSync(staging, { recursive: true, force: true }); @@ -516,9 +527,7 @@ export class PluginRegistryService { constraint?: string, ): Promise<{ installed: string[]; requiredAddons: string[]; trekRangeBypassed: TrekRangeBypass | null }> { let trekRangeBypassed: TrekRangeBypass | null = null; - const installedNow = new Set( - (this.db.prepare('SELECT id FROM plugins').all() as Array<{ id: string }>).map((r) => r.id), - ); + const installedNow = new Set(await this.plugins.listAllIds()); const done = new Set(); const installed: string[] = []; const requiredAddons = new Set(); @@ -566,7 +575,7 @@ export class PluginRegistryService { hold = false; } } - this.db.prepare('UPDATE plugins SET update_hold = ? WHERE id = ?').run(hold ? 1 : 0, id); + await this.plugins.setUpdateHold(id, hold); return hold; } @@ -609,7 +618,7 @@ export class PluginRegistryService { fs.mkdirSync(pluginsCodeRoot(), { recursive: true }); fs.rmSync(dest, { recursive: true, force: true }); fs.renameSync(staged.root, dest); - await discoverPlugins(this.db); + await discoverPlugins(this.discoveryRepos); // Provenance for a sideload, plus a hard INACTIVE floor: discoverPlugins keeps // an existing row's status, so replacing a plugin that was active must not // leave the new code marked active — the admin re-activates (and re-consents @@ -619,12 +628,7 @@ export class PluginRegistryService { // plugin has just left the registry trust model entirely — the code is now whatever // the admin uploaded. Leaving the block would have the row insist an update was // blocked over a signing key that no longer applies to the code that is running. - this.db.prepare( - `UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ?, author_pubkey = NULL, - update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL, - status = 'inactive', enabled = 0 - WHERE id = ?`, - ).run('local:upload', null, null, null, staged.id); + await this.plugins.clearForSideload(staged.id, 'local:upload'); } finally { fs.rmSync(staged.stagingDir, { recursive: true, force: true }); } @@ -657,9 +661,7 @@ export class PluginRegistryService { ver: RegistryVersion, retrustKey?: string, ): Promise { - const pinned = - (this.db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string } | undefined) - ?.author_pubkey ?? null; + const pinned = await this.plugins.findAuthorPubkey(id); if (!entry.authorPublicKey && !ver.signature) { if (pinned) { @@ -710,9 +712,7 @@ export class PluginRegistryService { * rendered, the admin would be blessing a key they never saw. */ async assertRetrustable(id: string, publicKey: string): Promise { - const row = this.db.prepare('SELECT source_repo, author_pubkey FROM plugins WHERE id = ?').get(id) as - | { source_repo?: string | null; author_pubkey?: string | null } - | undefined; + const row = await this.plugins.findSourceRepoAndAuthorPubkey(id); if (!row) throw new RegistryError(`plugin ${id} not found`, 'NOT_FOUND'); if (!row.source_repo || row.source_repo === 'local:upload' || row.source_repo === 'local:link') { throw new RegistryError('only a registry-installed plugin can be re-trusted', 'RETRUST_NOT_APPLICABLE'); diff --git a/server/src/nest/plugins/settings-defaults.ts b/server/src/nest/plugins/settings-defaults.ts index bbbd5f74b..36ce30195 100644 --- a/server/src/nest/plugins/settings-defaults.ts +++ b/server/src/nest/plugins/settings-defaults.ts @@ -1,4 +1,4 @@ -import type BetterSqlite3 from 'better-sqlite3'; +import type { PluginSettingsFieldsRepository } from '../../db/repositories/PluginSettingsFields.repository'; /** What a settings-field `default` may be — the JSON scalars the manifest accepts. */ export type SettingDefault = string | number | boolean; @@ -32,15 +32,11 @@ export function parseDefaultValue(raw: unknown): SettingDefault | undefined { /** The declared defaults for one plugin and scope, keyed by field. Null-prototype so a * field key can never resolve off Object.prototype (see plugin-config-parse.ts). */ export async function settingDefaults( - db: BetterSqlite3.Database, + settingsFields: PluginSettingsFieldsRepository, pluginId: string, scope: 'instance' | 'user', ): Promise> { - const rows = db - .prepare( - 'SELECT field_key, default_value FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? AND secret = 0 AND default_value IS NOT NULL', - ) - .all(pluginId, scope) as Array<{ field_key: string; default_value: string }>; + const rows = await settingsFields.listDefaults(pluginId, scope); const out: Record = Object.create(null); for (const r of rows) { const v = parseDefaultValue(r.default_value); diff --git a/server/src/nest/plugins/signature-status.ts b/server/src/nest/plugins/signature-status.ts index 2ed320054..2bc9d7dd4 100644 --- a/server/src/nest/plugins/signature-status.ts +++ b/server/src/nest/plugins/signature-status.ts @@ -1,4 +1,4 @@ -import type Database from 'better-sqlite3'; +import type { PluginsRepository } from '../../db/repositories/Plugins.repository'; /** * Signature/trust status shared between the registry installer, the read-side @@ -67,14 +67,9 @@ export function keyFingerprint(pubkey: string | null | undefined): string | null * old code. A blocked update is not a broken runtime, and conflating them would * make the isolation-health dot lie. */ -export async function setUpdateBlock(conn: Database.Database, id: string, code: SignatureCode, detail: string, version: string | null): Promise { +export async function setUpdateBlock(plugins: PluginsRepository, id: string, code: SignatureCode, detail: string, version: string | null): Promise { try { - conn.prepare('UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?').run( - code, - detail, - version, - id, - ); + await plugins.setUpdateBlockColumns(id, code, detail, version); } catch { // Columns absent (a slimmed test app) — the block is a nicety, never a gate. } @@ -84,9 +79,9 @@ export async function setUpdateBlock(conn: Database.Database, id: string, code: * activating the plugin at its OLD version resolves nothing, and letting an off/on * toggle erase the warning is exactly the silent-stops-updating failure this exists * to prevent. (Uninstall drops the row entirely, so it needs no explicit clear.) */ -export async function clearUpdateBlock(conn: Database.Database, id: string): Promise { +export async function clearUpdateBlock(plugins: PluginsRepository, id: string): Promise { try { - conn.prepare('UPDATE plugins SET update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL WHERE id = ?').run(id); + await plugins.clearUpdateBlockColumns(id); } catch { // See setUpdateBlock. } diff --git a/server/src/nest/roadtrip/roadtrip-router.service.ts b/server/src/nest/roadtrip/roadtrip-router.service.ts index 9fe80fa37..bc8a73c16 100644 --- a/server/src/nest/roadtrip/roadtrip-router.service.ts +++ b/server/src/nest/roadtrip/roadtrip-router.service.ts @@ -1,6 +1,8 @@ import { readCappedJson, discardBody } from '../../utils/cappedFetch'; import { safeFetchAdminConfigured } from '../../utils/ssrfGuard'; -import { DatabaseService } from '../database/database.service'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Plugins } from '../../db/entities/Plugins.entity'; +import type { PluginsRepository } from '../../db/repositories/Plugins.repository'; import { normalize, declaredProfiles } from '../plugins/contributions/plugin-route-normalize'; import { pluginsEnabled } from '../plugins/kill-switch'; import { PluginHooks } from '../plugins/plugin-hooks.service'; @@ -99,7 +101,7 @@ export class RoadtripRouterService { constructor( private readonly settings: SettingsService, private readonly hooks: PluginHooks, - private readonly db: DatabaseService, + @InjectRepository(Plugins) private readonly plugins: PluginsRepository, ) {} async profiles(): Promise { @@ -108,8 +110,8 @@ export class RoadtripRouterService { // R1.4: a `flatMap` callback cannot await the now-async profile read, so the // fan-out runs as an explicit loop — same providers, same order. for (const id of this.hooks.providersOf('routeProvider')) { - // RRT1 — `plugins` is Plan 3j's table; stays a raw hand-off of the connection. - for (const profile of await declaredProfiles(this.db.connection, id)) plugin.push(`plugin:${id}/${profile}`); // Plan 3j + // RRT1 — `plugins` is Plan 3j's table; `declaredProfiles` now converted (Task 3). + for (const profile of await declaredProfiles(this.plugins, id)) plugin.push(`plugin:${id}/${profile}`); // Plan 3j } } return ['driving', 'walking', 'cycling', ...plugin]; @@ -176,7 +178,7 @@ export class RoadtripRouterService { points.length > 30 || !pluginsEnabled() || !this.hooks.providersOf('routeProvider').includes(id) || - !(await declaredProfiles(this.db.connection, id)).includes(name) // RRT2 — Plan 3j + !(await declaredProfiles(this.plugins, id)).includes(name) // RRT2 — Plan 3j, converted (Task 3) ) throw new Error('Routing plugin is unavailable or has too many waypoints'); const raw = await this.hooks.route(id, { tripId, dayId, profile: name, waypoints: points }, userId); diff --git a/server/src/nest/roadtrip/roadtrip.module.ts b/server/src/nest/roadtrip/roadtrip.module.ts index b199032f7..8a26541c3 100644 --- a/server/src/nest/roadtrip/roadtrip.module.ts +++ b/server/src/nest/roadtrip/roadtrip.module.ts @@ -43,6 +43,7 @@ import { RoadtripVias } from '../../db/entities/RoadtripVias.entity'; import { RoadtripDayTracks } from '../../db/entities/RoadtripDayTracks.entity'; import { RoadtripPreferences } from '../../db/entities/RoadtripPreferences.entity'; import { RoadtripDayBoundaries } from '../../db/entities/RoadtripDayBoundaries.entity'; +import { Plugins } from '../../db/entities/Plugins.entity'; /** Road trip domain (#1797): the points a drive is routed through. Registered in AppModule. */ @Module({ @@ -60,6 +61,9 @@ import { RoadtripDayBoundaries } from '../../db/entities/RoadtripDayBoundaries.e // `DayAssignmentsRepository.findInTrip`/`listRoadtripVisits`) all need // their own entry here, even though `PlacesModule`/`AssignmentsModule` // already register some of the same entities for THEIR OWN providers. + // `Plugins` (Plan 3j Task 3): `RoadtripRouterService`'s RRT1/RRT2 call + // `declaredProfiles`, now `PluginsRepository`-typed; `PluginsRuntimeModule` + // registers the same entity but does not export the repository token. imports: [ McpSharedModule, PermissionsModule, @@ -69,7 +73,7 @@ import { RoadtripDayBoundaries } from '../../db/entities/RoadtripDayBoundaries.e SettingsModule, PluginsRuntimeModule, MapsModule, PlacesModule, AssignmentsModule, - MikroOrmModule.forFeature([Trips, Days, Places, Users, DayAssignments, RoadtripVias, RoadtripDayTracks, RoadtripPreferences, RoadtripDayBoundaries]), + MikroOrmModule.forFeature([Trips, Days, Places, Users, DayAssignments, RoadtripVias, RoadtripDayTracks, RoadtripPreferences, RoadtripDayBoundaries, Plugins]), ], controllers: [ChargingController, ChargingLookupController, GoogleRouteController, RoadtripSearchController, RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController], providers: [ChargingMcp, ChargingService, GoogleRouteService, GoogleRouteMcp, diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index b042e391c..c27613b96 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -150,6 +150,9 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise< const generalStorage = makeStorageFixture('').storage; const appSettings = (await sharedTestOrm(dbs.connection)).repo(AppSettings); const usersRepo = (await sharedTestOrm(dbs.connection)).repo(Users); + // Plan 3j Task 3 — `PluginRpcHostFactory`'s `audit` callback and `HostSurfaceRpc`'s + // `budgetFor` calls now take `PluginCapabilityAuditRepository`, not a raw connection. + const pluginAuditRepo = (await sharedTestOrm(dbs.connection)).repo(PluginCapabilityAudit); const permissions = new PermissionsService(await createTestAppSettingsRepo(dbs.connection), await createTestUnitOfWork(dbs.connection)); const exchangeRates = new ExchangeRatesService(); const realtime = new RealtimeService(); @@ -257,7 +260,8 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise< const membership = new TripMembershipService(await createTestTripsRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection)); const notifications = await makeNotificationsService(dbs, realtime); const llmConfig = new LlmConfigResolver(new SettingsService(await createTestUnitOfWork(dbs.connection), appSettings, await createTestSettingsRepo(dbs.connection)), dbs, addons); - const oauth = new PluginOAuthService(dbs); + const pluginOrm = await sharedTestOrm(dbs.connection); + const oauth = new PluginOAuthService(pluginOrm.repo(Plugins), pluginOrm.repo(PluginOauthTokens), pluginOrm.repo(PluginOauthState), pluginOrm.repo(PluginSettingsFields)); const accommodations = new AccommodationsService( dbs, permissions, realtime, assignments, await createTestUnitOfWork(dbs.connection), await createTestDayAccommodationsRepo(dbs.connection), @@ -315,12 +319,12 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise< // gate (JR1) now injects `UsersRepository.getEmail`, not `DatabaseService`. new JournalRpc(journey, guards, generalStorage, { get: () => '*' } as never, { schedule: () => {} } as never, usersRepo), new CollectionsRpc(collections, guards), - new DbRpc(new PluginUserSettingsService(dbs)), + new DbRpc(new PluginUserSettingsService(pluginOrm.repo(PluginSettingsFields), pluginOrm.repo(PluginUserConfig))), new MetaRpc(dbs, guards), - new HostSurfaceRpc(dbs, realtime, notifications, llmConfig, oauth, guards), + new HostSurfaceRpc(dbs, realtime, notifications, llmConfig, oauth, guards, pluginAuditRepo), new PluginHooks(undefined as never), ]); - return new PluginRpcHostFactory(dbs, registry as unknown as PluginRpcRegistryService); + return new PluginRpcHostFactory(pluginAuditRepo, registry as unknown as PluginRpcRegistryService); } /** A PluginRuntimeService constructed the way Nest would: with a real host factory. */ @@ -336,7 +340,7 @@ export async function createPluginRuntime(dbs: DatabaseService, registry?: Plugi dbs, new AuditService(orm.repo(AuditLog), orm.repo(Users)), await createTestAddonsService(dbs.connection, dbs), - new PluginUserSettingsService(dbs), + new PluginUserSettingsService(orm.repo(PluginSettingsFields), orm.repo(PluginUserConfig)), // Plan 3j Task 2 — PR1-PR53's own tables, all repository-backed now. orm.repo(Plugins), orm.repo(PluginErrorLog), diff --git a/server/tests/helpers/rpc-host-deps.ts b/server/tests/helpers/rpc-host-deps.ts index f482ff364..9e2ea8047 100644 --- a/server/tests/helpers/rpc-host-deps.ts +++ b/server/tests/helpers/rpc-host-deps.ts @@ -82,7 +82,7 @@ export function allRpcControllers(): object[] { new CollectionsRpc(anyService(), anyService()), new DbRpc(anyService()), new MetaRpc(anyService(), anyService()), - new HostSurfaceRpc(anyService(), anyService(), anyService(), anyService(), anyService(), anyService()), + new HostSurfaceRpc(anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService()), // Owns no wire method, but declares all 18 hook contracts, which requireTotalCoverage checks too. new PluginHooks(anyService()), ]; diff --git a/server/tests/integration/plugins/boot-registry-order.test.ts b/server/tests/integration/plugins/boot-registry-order.test.ts index 554b3276d..b22ef8685 100644 --- a/server/tests/integration/plugins/boot-registry-order.test.ts +++ b/server/tests/integration/plugins/boot-registry-order.test.ts @@ -124,12 +124,14 @@ afterAll(async () => { describe('plugin boot vs registry scan ordering', () => { it('BOOT-REG-001 a plugin enabled before a restart activates cleanly even though the registry scan runs in a LATER provider\'s onModuleInit', async () => { const dbs = new DatabaseService(dbConn); - const userSettings = new PluginUserSettingsService(dbs); // Empty at construction — exactly what PluginRpcRegistryService is before its // own onModuleInit scan has run. const registry = new PluginRpcRegistry(); - const hostFactory = new PluginRpcHostFactory(dbs, registry as unknown as PluginRpcRegistryService); t = await createTestOrm(dbConn); + // Plan 3j Task 3: `PluginRpcHostFactory`'s `audit` callback now takes + // `PluginCapabilityAuditRepository`, not `DatabaseService`. + const hostFactory = new PluginRpcHostFactory(t.repo(PluginCapabilityAudit), registry as unknown as PluginRpcRegistryService); + const userSettings = new PluginUserSettingsService((t as TestOrm).repo(PluginSettingsFields), (t as TestOrm).repo(PluginUserConfig)); const auditLogRepo = t.repo(AuditLog); const usersRepo = t.repo(Users); const addonsService = await createTestAddonsService(dbConn, dbs); @@ -236,12 +238,12 @@ describe('plugin boot vs registry scan ordering', () => { let mod2: TestingModule | undefined; try { const dbs2 = new DatabaseService(dbConn); - const userSettings2 = new PluginUserSettingsService(dbs2); + const userSettings2 = new PluginUserSettingsService(t2.repo(PluginSettingsFields), t2.repo(PluginUserConfig)); const registry2 = new PluginRpcRegistry(); // Registry already scanned — this test is about the addon check, not the // registry-ordering bug BOOT-REG-001 pins. registry2.register(new DbRpc(userSettings2)); - const hostFactory2 = new PluginRpcHostFactory(dbs2, registry2 as unknown as PluginRpcRegistryService); + const hostFactory2 = new PluginRpcHostFactory(t2.repo(PluginCapabilityAudit), registry2 as unknown as PluginRpcRegistryService); // Built directly on t2 (not createTestAddonsService's sharedTestOrm) so // its repositories share t2's allowGlobalContext: false ORM. const addonsService2 = new AddonsService( diff --git a/server/tests/integration/plugins/dev-link.test.ts b/server/tests/integration/plugins/dev-link.test.ts index fdb5a9f94..05ce3d2de 100644 --- a/server/tests/integration/plugins/dev-link.test.ts +++ b/server/tests/integration/plugins/dev-link.test.ts @@ -9,31 +9,24 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; - -const { testDb } = vi.hoisted(() => { - const Database = require('better-sqlite3'); - const db = new Database(':memory:'); - db.exec(`CREATE TABLE plugins ( - id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT, api_version INTEGER, - min_trek_version TEXT, trek_range TEXT, permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', - operator_egress INTEGER DEFAULT 0, granted_permissions TEXT DEFAULT '', status TEXT, enabled INTEGER DEFAULT 0, config TEXT DEFAULT '{}', - source_repo TEXT, source_commit TEXT, sha256 TEXT, author_pubkey TEXT, reviewed_at TEXT, last_error TEXT, updated_at 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, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, default_value TEXT, sort_order INTEGER); - 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 addons (id TEXT PRIMARY KEY, enabled INTEGER DEFAULT 0);`); - return { testDb: db }; -}); -vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined })); -import { db as dbConn } from '../../../src/db/database'; +// Plan 3j Task 3: `discoverPlugins` (called from `runtime.link`) is repository-backed +// now (DI1–DI8) — a native `em.insert()` writes every `Opt`-defaulted entity column +// (`sort_order`, `installed_at`, `crash_count`, `update_hold`, …), not only the ones +// the legacy raw `INSERT` statement named, so a hand-trimmed `:memory:` table missing +// those columns throws inside `discoverPlugins`'s own try/catch (silently: the plugin +// lands in `skipped`, not `discovered`). A real MikroORM over the full migrated schema +// (`createSnapshotTestDb` + `createTestOrm`) replaces the old hand-rolled table set, +// same fix `registry.test.ts` needed. +import { createSnapshotTestDb } from '../../helpers/db-mock'; import { DatabaseService } from '../../../src/nest/database/database.service'; vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; import { createPluginRuntime } from '../../helpers/plugin-host'; +const testDb = createSnapshotTestDb(); +const dbConn = testDb; + let codeRoot: string; let dataRoot: string; let srcRoot: string; // the "developer's" source lives OUTSIDE the plugins volume @@ -71,6 +64,7 @@ afterAll(async () => { delete process.env.TREK_PLUGINS_DATA_DIR; delete process.env.TREK_PLUGINS_ENABLED; delete process.env.TREK_PLUGINS_DEV_LINK; + testDb.close(); }); describe('PluginRuntimeService dev-link', () => { diff --git a/server/tests/integration/plugins/registry.test.ts b/server/tests/integration/plugins/registry.test.ts index c81721c71..01801d423 100644 --- a/server/tests/integration/plugins/registry.test.ts +++ b/server/tests/integration/plugins/registry.test.ts @@ -3,7 +3,7 @@ * install a pinned version through the full verify -> extract -> validate -> * move -> register pipeline (with the network download mocked). */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -16,21 +16,20 @@ vi.mock('../../../src/nest/plugins/install/safe-fetch', async (orig) => ({ safeDownload, })); -const { testDb } = vi.hoisted(() => { - const Database = require('better-sqlite3'); - const db = new Database(':memory:'); - db.exec(` - CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT, - api_version INTEGER, min_trek_version TEXT, trek_range TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, enabled INTEGER DEFAULT 0, config TEXT, - source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at TEXT, author_pubkey TEXT, updated_at TEXT, - update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT, update_hold INTEGER NOT NULL DEFAULT 0); - CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, default_value TEXT, sort_order INTEGER); - CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`); - return { testDb: db }; -}); -vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined })); -import { db as dbConn } from '../../../src/db/database'; -import { DatabaseService } from '../../../src/nest/database/database.service'; +// Plan 3j Task 3: `PluginRegistryService`'s own statements (RG#) and its +// `discoverPlugins`/`setUpdateBlock`/`clearUpdateBlock` calls are all +// repository-backed now — no more raw `DatabaseService`/`db/database` mock. +// A real MikroORM over the full migrated schema (`createSnapshotTestDb` + +// `createTestOrm`) replaces the old hand-rolled `:memory:` table set; every +// `testDb.prepare(...)` fixture/assertion below still works unchanged since +// the real schema is a strict superset of the old one's columns. +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { resetTestDb } from '../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { Plugins } from '../../../src/db/entities/Plugins.entity'; +import { PluginActions } from '../../../src/db/entities/PluginActions.entity'; +import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity'; +import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity'; import { PluginRegistryService, RegistryError, __clearRegistryCacheForTests } from '../../../src/nest/plugins/registry/registry.service'; import type { ManifestPreview } from '../../../src/nest/plugins/registry/registry.service'; @@ -77,19 +76,31 @@ const REGISTRY = { ], }; +const testDb = createSnapshotTestDb(); +let t: TestOrm; + let dataRoot: string; let codeRoot: string; let svc: PluginRegistryService; +beforeAll(async () => { + t = await createTestOrm(testDb); +}); +afterAll(async () => { + await t.close(); + testDb.close(); +}); + beforeEach(() => { dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'reg-data-')); codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'reg-code-')); process.env.TREK_PLUGINS_DATA_DIR = dataRoot; process.env.TREK_PLUGINS_DIR = codeRoot; - testDb.exec('DELETE FROM plugins; DELETE FROM plugin_settings_fields; DELETE FROM plugin_error_log'); + resetTestDb(testDb); + t.clear(); __clearRegistryCacheForTests(); vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => REGISTRY }) as unknown as Response)); - svc = new PluginRegistryService(new DatabaseService(dbConn)); + svc = new PluginRegistryService(t.repo(Plugins), t.repo(PluginActions), t.repo(PluginSettingsFields), t.repo(PluginErrorLog)); }); afterEach(() => { vi.unstubAllGlobals(); @@ -275,7 +286,7 @@ describe('PluginRegistryService', () => { ], }; const seedRow = (hold = 0) => - testDb.prepare("INSERT INTO plugins (id, status, enabled, update_hold) VALUES ('flight-tracker','inactive',0,?)").run(hold); + testDb.prepare("INSERT INTO plugins (id, name, status, enabled, update_hold) VALUES ('flight-tracker','Flight','inactive',0,?)").run(hold); const holdInDb = () => (testDb.prepare("SELECT update_hold FROM plugins WHERE id='flight-tracker'").get() as { update_hold: number }).update_hold; @@ -377,7 +388,7 @@ describe('PluginRegistryService', () => { it('fetchRegistry soft-fails to an empty registry on a cold cache', async () => { __clearRegistryCacheForTests(); vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); })); - expect((await new PluginRegistryService(new DatabaseService(dbConn)).fetchRegistry()).plugins).toEqual([]); + expect((await new PluginRegistryService(t.repo(Plugins), t.repo(PluginActions), t.repo(PluginSettingsFields), t.repo(PluginErrorLog)).fetchRegistry()).plugins).toEqual([]); }); it('installs a pinned version end to end (verify -> extract -> register inactive)', async () => { diff --git a/server/tests/unit/plugins/discovery.test.ts b/server/tests/unit/plugins/discovery.test.ts index 310fbc427..9a9660dc1 100644 --- a/server/tests/unit/plugins/discovery.test.ts +++ b/server/tests/unit/plugins/discovery.test.ts @@ -2,15 +2,28 @@ * Plugin discovery (#plugins, M4, install-from-disk): scans the volume, upserts * rows as inactive, refreshes settings fields, keeps an existing plugin's status, * and skips invalid or native-carrying plugins (logging the reason). + * + * Plan 3j Task 3 — converted onto `PluginsRepository`/`PluginActionsRepository`/ + * `PluginSettingsFieldsRepository`/`PluginErrorLogRepository` (a `DiscoveryRepos` + * bundle), via a real MikroORM over the full migrated schema (`createSnapshotTestDb` + * + `createTestOrm`) rather than a hand-rolled `:memory:` table set. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import Database from 'better-sqlite3'; +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { discoverPlugins } from '../../../src/nest/plugins/install/discovery'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { resetTestDb } from '../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { Plugins } from '../../../src/db/entities/Plugins.entity'; +import { PluginActions } from '../../../src/db/entities/PluginActions.entity'; +import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity'; +import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity'; +import { discoverPlugins, type DiscoveryRepos } from '../../../src/nest/plugins/install/discovery'; -let db: Database.Database; +const testDb = createSnapshotTestDb(); +let t: TestOrm; +let repos: DiscoveryRepos; let codeRoot: string; function writePlugin(id: string, manifest: Record, extra?: () => void) { @@ -21,24 +34,21 @@ function writePlugin(id: string, manifest: Record, extra?: () = extra?.() } +beforeAll(async () => { + t = await createTestOrm(testDb); + repos = { plugins: t.repo(Plugins), actions: t.repo(PluginActions), settingsFields: t.repo(PluginSettingsFields), errorLog: t.repo(PluginErrorLog) }; +}); beforeEach(() => { + resetTestDb(testDb); + t.clear(); codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'disc-')); process.env.TREK_PLUGINS_DIR = codeRoot; - db = new Database(':memory:'); - db.exec(` - CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT, - api_version INTEGER, min_trek_version TEXT, trek_range TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT); - CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, - required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, default_value TEXT, sort_order INTEGER); - CREATE TABLE plugin_actions (plugin_id TEXT, action_key TEXT, label TEXT, hint TEXT, danger INTEGER, scope TEXT NOT NULL DEFAULT 'user', sort_order INTEGER, - PRIMARY KEY (plugin_id, action_key)); - CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`); }); afterEach(() => { delete process.env.TREK_PLUGINS_DIR; - db.close(); fs.rmSync(codeRoot, { recursive: true, force: true }); }); +afterAll(async () => { await t.close(); testDb.close(); }); describe('discoverPlugins', () => { it('registers a new plugin inactive with its settings fields', async () => { @@ -52,15 +62,15 @@ describe('discoverPlugins', () => { { key: 'oauth', input_type: 'oauth', scope: 'user', oauth: { initPath: '/o/start', callbackPath: '/o/cb' } }, ], }); - const res = await discoverPlugins(db); + const res = await discoverPlugins(repos); expect(res.discovered).toEqual(['flight-tracker']); - const row = db.prepare("SELECT status, type, permissions FROM plugins WHERE id='flight-tracker'").get() as { status: string; type: string; permissions: string }; + const row = testDb.prepare("SELECT status, type, permissions FROM plugins WHERE id='flight-tracker'").get() as { status: string; type: string; permissions: string }; expect(row.status).toBe('inactive'); expect(row.type).toBe('widget'); expect(JSON.parse(row.permissions)).toEqual(['db:own']); - const field = db.prepare("SELECT field_key, secret FROM plugin_settings_fields WHERE plugin_id='flight-tracker'").get() as { field_key: string; secret: number }; + const field = testDb.prepare("SELECT field_key, secret FROM plugin_settings_fields WHERE plugin_id='flight-tracker'").get() as { field_key: string; secret: number }; expect(field).toMatchObject({ field_key: 'api_key', secret: 1 }); }); @@ -69,8 +79,8 @@ describe('discoverPlugins', () => { name: 'Acts', type: 'integration', permissions: [], actions: [{ key: 'ping', label: 'Ping' }, { key: 'purge', label: 'Purge', scope: 'instance', danger: true }], }); - await discoverPlugins(db); - const rows = db.prepare("SELECT action_key, scope, danger FROM plugin_actions WHERE plugin_id='acts' ORDER BY sort_order").all(); + await discoverPlugins(repos); + const rows = testDb.prepare("SELECT action_key, scope, danger FROM plugin_actions WHERE plugin_id='acts' ORDER BY sort_order").all(); expect(rows).toEqual([ { action_key: 'ping', scope: 'user', danger: 0 }, { action_key: 'purge', scope: 'instance', danger: 1 }, @@ -78,10 +88,10 @@ describe('discoverPlugins', () => { }); it('keeps an existing plugin status + granted permissions on re-discovery', async () => { - db.prepare("INSERT INTO plugins (id, name, type, status, granted_permissions) VALUES ('keep','Keep','page','active','[\"db:own\"]')").run(); + testDb.prepare("INSERT INTO plugins (id, name, type, status, granted_permissions) VALUES ('keep','Keep','page','active','[\"db:own\"]')").run(); writePlugin('keep', { name: 'Keep v2', type: 'page', version: '2.0.0' }); - await discoverPlugins(db); - const row = db.prepare("SELECT status, version, granted_permissions FROM plugins WHERE id='keep'").get() as { status: string; version: string; granted_permissions: string }; + await discoverPlugins(repos); + const row = testDb.prepare("SELECT status, version, granted_permissions FROM plugins WHERE id='keep'").get() as { status: string; version: string; granted_permissions: string }; expect(row.status).toBe('active'); // not downgraded expect(row.version).toBe('2.0.0'); // metadata refreshed expect(JSON.parse(row.granted_permissions)).toEqual(['db:own']); // grants preserved @@ -90,23 +100,23 @@ describe('discoverPlugins', () => { it('tolerates a UTF-8 BOM in trek-plugin.json (Windows-authored plugins)', async () => { writePlugin('bom-plug', { type: 'integration' }); const mp = path.join(codeRoot, 'bom-plug', 'trek-plugin.json'); - fs.writeFileSync(mp, '\uFEFF' + fs.readFileSync(mp, 'utf8')); - expect((await discoverPlugins(db)).discovered).toEqual(['bom-plug']); + fs.writeFileSync(mp, '' + fs.readFileSync(mp, 'utf8')); + expect((await discoverPlugins(repos)).discovered).toEqual(['bom-plug']); }); it('skips an invalid manifest and logs the reason', async () => { writePlugin('bad', { type: 'not-a-type' }); - const res = await discoverPlugins(db); + const res = await discoverPlugins(repos); expect(res.skipped).toEqual(['bad']); - expect(db.prepare("SELECT COUNT(*) c FROM plugins WHERE id='bad'").get()).toMatchObject({ c: 0 }); - expect((db.prepare("SELECT message FROM plugin_error_log WHERE plugin_id='bad'").get() as { message: string }).message).toContain('discovery'); + expect(testDb.prepare("SELECT COUNT(*) c FROM plugins WHERE id='bad'").get()).toMatchObject({ c: 0 }); + expect((testDb.prepare("SELECT message FROM plugin_error_log WHERE plugin_id='bad'").get() as { message: string }).message).toContain('discovery'); }); it('skips a plugin that ships native binaries', async () => { writePlugin('native', { type: 'integration' }, () => { fs.writeFileSync(path.join(codeRoot, 'native', 'server', 'addon.node'), '\0'); }); - expect((await discoverPlugins(db)).skipped).toEqual(['native']); + expect((await discoverPlugins(repos)).skipped).toEqual(['native']); }); it('follows a symlinked dev-link plugin only when dev-link mode is on', async () => { @@ -121,13 +131,13 @@ describe('discoverPlugins', () => { // Off (default): a stale dev-link symlink is not discovered or registered. delete process.env.TREK_PLUGINS_DEV_LINK; - expect((await discoverPlugins(db)).discovered).toEqual([]); - expect(db.prepare("SELECT status FROM plugins WHERE id='linked'").get()).toBeUndefined(); + expect((await discoverPlugins(repos)).discovered).toEqual([]); + expect(testDb.prepare("SELECT status FROM plugins WHERE id='linked'").get()).toBeUndefined(); // On: the dev-link is followed and registered inactive. process.env.TREK_PLUGINS_DEV_LINK = '1'; - expect((await discoverPlugins(db)).discovered).toEqual(['linked']); - expect(db.prepare("SELECT status FROM plugins WHERE id='linked'").get()).toMatchObject({ status: 'inactive' }); + expect((await discoverPlugins(repos)).discovered).toEqual(['linked']); + expect(testDb.prepare("SELECT status FROM plugins WHERE id='linked'").get()).toMatchObject({ status: 'inactive' }); } finally { if (prev === undefined) delete process.env.TREK_PLUGINS_DEV_LINK; else process.env.TREK_PLUGINS_DEV_LINK = prev; fs.rmSync(srcRoot, { recursive: true, force: true }); @@ -136,14 +146,14 @@ describe('discoverPlugins', () => { it('is a no-op when the plugins dir is absent', async () => { process.env.TREK_PLUGINS_DIR = path.join(codeRoot, 'does-not-exist'); - expect(await discoverPlugins(db)).toEqual({ discovered: [], skipped: [] }); + expect(await discoverPlugins(repos)).toEqual({ discovered: [], skipped: [] }); }); describe('the TREK range', () => { it('persists the range and its lower bound', async () => { writePlugin('ranged', { trek: '>=3.2.0 <4.0.0' }); - await discoverPlugins(db); - expect(db.prepare("SELECT trek_range, min_trek_version FROM plugins WHERE id='ranged'").get()) + await discoverPlugins(repos); + expect(testDb.prepare("SELECT trek_range, min_trek_version FROM plugins WHERE id='ranged'").get()) .toMatchObject({ trek_range: '>=3.2.0 <4.0.0', min_trek_version: '3.2.0' }); }); @@ -153,16 +163,16 @@ describe('discoverPlugins', () => { // enabled=1 row to be spawned by the next boot — invisible AND running. The row is // registered with a null range and the activation gate refuses it (TREK_VERSION_UNKNOWN). writePlugin('rangeless', {}); - expect((await discoverPlugins(db)).discovered).toEqual(['rangeless']); - expect(db.prepare("SELECT trek_range FROM plugins WHERE id='rangeless'").get()).toMatchObject({ trek_range: null }); + expect((await discoverPlugins(repos)).discovered).toEqual(['rangeless']); + expect(testDb.prepare("SELECT trek_range FROM plugins WHERE id='rangeless'").get()).toMatchObject({ trek_range: null }); }); it('refreshes the range on re-discovery, so a plugin that narrowed its support is caught', async () => { writePlugin('shrink', { trek: '>=3.0.0' }); - await discoverPlugins(db); + await discoverPlugins(repos); writePlugin('shrink', { trek: '>=3.0.0 <3.1.0' }); - await discoverPlugins(db); - expect(db.prepare("SELECT trek_range FROM plugins WHERE id='shrink'").get()).toMatchObject({ trek_range: '>=3.0.0 <3.1.0' }); + await discoverPlugins(repos); + expect(testDb.prepare("SELECT trek_range FROM plugins WHERE id='shrink'").get()).toMatchObject({ trek_range: '>=3.0.0 <3.1.0' }); }); }); }); diff --git a/server/tests/unit/plugins/host-rpc.test.ts b/server/tests/unit/plugins/host-rpc.test.ts index 184f72dba..52606991f 100644 --- a/server/tests/unit/plugins/host-rpc.test.ts +++ b/server/tests/unit/plugins/host-rpc.test.ts @@ -73,6 +73,7 @@ import type { NotificationsService } from '../../../src/nest/notifications/notif import type { LlmConfigResolver } from '../../../src/nest/llm-parse/llm-config.resolver'; import type { PluginOAuthService } from '../../../src/nest/plugins/oauth/plugin-oauth.service'; import type { RpcError, RpcResponse } from '../../../src/nest/plugins/protocol/envelope'; +import type { PluginCapabilityAuditRepository } from '../../../src/db/repositories/PluginCapabilityAudit.repository'; // Typed from the real method rather than from the always-true body below, so a case that // swaps in an implementation reading the action key (HOSTRPC-015) still type-checks. @@ -96,6 +97,39 @@ const userSettings = { readOne: vi.fn((_pid: string, uid: number, key: string) => (uid === 5 && key === 'apiKey' ? 'k-5' : undefined)), } as unknown as PluginUserSettingsService; +// Plan 3j Task 3: `budgetFor`/`appendAudit` now take `PluginCapabilityAuditRepository`, +// not a raw connection. `plugin_capability_audit`'s hand-rolled table above (unlike +// `trips`/`users`) matches the real entity's full column set, so a real MikroORM +// repository would work here too — but every other cross-cutting dependency in this +// file (`canAccessTrip`, `getRole`) is a synchronous stub against this same `mockDb` +// for the same reason (no top-level await in this file), so this stays consistent: +// real raw SQL against `mockDb`, wrapped in the repository's own method shapes. +// HOSTRPC-031 seeds real rows through `mockDb.prepare(...)` and reads them back +// through `budgetSeed`, so this must be backed by the real table, not a canned stub. +const pluginAuditRepo = { + async budgetSeed(pluginId: string, since: string) { + return (mockDb as unknown as { prepare(s: string): { all(...a: unknown[]): unknown[] } }) + .prepare( + "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", + ) + .all(pluginId, since) as Array<{ method: string; n: number }>; + }, + async lastHash(pluginId: string) { + const row = (mockDb as unknown as { prepare(s: string): { get(...a: unknown[]): unknown } }) + .prepare('SELECT hash FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id DESC LIMIT 1') + .get(pluginId) as { hash: string } | undefined; + return row?.hash ?? null; + }, + async insertRow(entry: { plugin_id: string; acting_user_id: number | null; method: string; resource: string | null; code: string; ts: string; prev_hash: string | null; hash: string }) { + (mockDb as unknown as { prepare(s: string): { run(...a: unknown[]): unknown } }) + .prepare('INSERT INTO plugin_capability_audit (plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?)') + .run(entry.plugin_id, entry.acting_user_id, entry.method, entry.resource, entry.code, entry.ts, entry.prev_hash, entry.hash); + }, + async pruneKeepingNewest() { + // Not exercised in this suite (PRUNE_EVERY = 500 appends/plugin) — a no-op stub. + }, +} as unknown as PluginCapabilityAuditRepository; + const dbs = new DatabaseService(mockDb); // Plan 3c Task 0b: `canAccessTrip` is `TripsRepository.findAccessible` now, // which needs a real EntityManager over the full migrated schema — this @@ -123,9 +157,9 @@ const guards = new PluginGuards(dbs, permissions, addons, usersRepo); const registry = createTestPluginRegistry([ new DbRpc(userSettings), new MetaRpc(dbs, guards), - new HostSurfaceRpc(dbs, new RealtimeService(), notifications, llmConfig, oauth, guards), + new HostSurfaceRpc(dbs, new RealtimeService(), notifications, llmConfig, oauth, guards, pluginAuditRepo), ]); -const factory = new PluginRpcHostFactory(dbs, registry as unknown as PluginRpcRegistryService); +const factory = new PluginRpcHostFactory(pluginAuditRepo, registry as unknown as PluginRpcRegistryService); const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: async () => {} }; const makeHost = (id: string, ...perms: string[]) => factory.create(id, new Set(perms), stubRouter); /** diff --git a/server/tests/unit/plugins/plugin-audit.test.ts b/server/tests/unit/plugins/plugin-audit.test.ts index 9a3681801..552b872eb 100644 --- a/server/tests/unit/plugins/plugin-audit.test.ts +++ b/server/tests/unit/plugins/plugin-audit.test.ts @@ -1,18 +1,59 @@ /** * Host-side capability audit log (#plugins, L1 hardening): resource projection, * which methods are auditable, and the per-plugin hash chain. + * + * Plan 3j Task 3 — converted onto `PluginCapabilityAuditRepository`. R-hash-chain: + * `legacyAppendAudit` below is a byte-for-byte replica of the PRE-CONVERSION + * `appendAudit` body (a raw `better-sqlite3` INSERT, the exact hash construction + * this task's `plugin-audit.ts` now reproduces through the repository) — it exists + * SOLELY so these tests can produce a "legacy-written" chain to replay against the + * converted code, without keeping a second, unused copy of the production function + * around. */ -import { describe, it, expect, beforeEach } from 'vitest'; -import Database from 'better-sqlite3'; -import { appendAudit, readAudit, readAuditForUser, auditResource, isAuditable, pruneAudit } from '../../../src/nest/plugins/host/plugin-audit'; +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; +import crypto from 'node:crypto'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { resetTestDb } from '../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity'; +import type { PluginCapabilityAuditRepository } from '../../../src/db/repositories/PluginCapabilityAudit.repository'; +import { appendAudit, readAudit, readAuditForUser, auditResource, isAuditable, pruneAudit, verifyChain } from '../../../src/nest/plugins/host/plugin-audit'; -function makeDb() { - const db = new Database(':memory:'); - db.exec(`CREATE TABLE plugin_capability_audit ( - id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT NOT NULL, acting_user_id INTEGER, - method TEXT NOT NULL, resource TEXT, code TEXT NOT NULL, ts TEXT NOT NULL, - prev_hash TEXT, hash TEXT NOT NULL);`); - return db; +const testDb = createSnapshotTestDb(); +let t: TestOrm; +let audit: PluginCapabilityAuditRepository; + +beforeAll(async () => { + t = await createTestOrm(testDb); + audit = t.repo(PluginCapabilityAudit); +}); +beforeEach(() => { resetTestDb(testDb); t.clear(); }); +afterAll(async () => { await t.close(); testDb.close(); }); + +interface LegacyEntry { + pluginId: string; + actingUserId?: number; + method: string; + resource?: string | null; + code: string; +} + +/** The pre-conversion `appendAudit` body, verbatim, against a raw connection. */ +function legacyAppendAudit(prevHash: string, e: LegacyEntry): { ts: string; hash: string } { + const ts = new Date().toISOString(); + const row = JSON.stringify([e.pluginId, e.actingUserId ?? null, e.method, e.resource ?? null, e.code, ts]); + const hash = crypto.createHash('sha256').update(prevHash + row).digest('hex'); + testDb + .prepare('INSERT INTO plugin_capability_audit (plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?)') + .run(e.pluginId, e.actingUserId ?? null, e.method, e.resource ?? null, e.code, ts, prevHash || null, hash); + return { ts, hash }; +} + +/** Every row for a plugin, oldest first — the shape `verifyChain` needs. */ +function chainRows(pluginId: string) { + return testDb + .prepare('SELECT plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id ASC') + .all(pluginId) as Array<{ plugin_id: string; acting_user_id: number | null; method: string; resource: string | null; code: string; ts: string; prev_hash: string | null; hash: string }>; } describe('auditResource + isAuditable', () => { @@ -40,14 +81,11 @@ describe('auditResource + isAuditable', () => { }); }); -describe('appendAudit hash chain', () => { - let db: ReturnType; - beforeEach(() => { db = makeDb(); }); - +describe('appendAudit hash chain (converted, through PluginCapabilityAuditRepository)', () => { it('chains each entry off the previous hash (per plugin)', async () => { - await appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' }); - await appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:2', code: 'ok' }); - const rows = db.prepare('SELECT prev_hash, hash FROM plugin_capability_audit ORDER BY id').all() as Array<{ prev_hash: string | null; hash: string }>; + await appendAudit(audit, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' }); + await appendAudit(audit, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:2', code: 'ok' }); + const rows = testDb.prepare('SELECT prev_hash, hash FROM plugin_capability_audit ORDER BY id').all() as Array<{ prev_hash: string | null; hash: string }>; expect(rows).toHaveLength(2); expect(rows[0].prev_hash).toBeNull(); expect(rows[1].prev_hash).toBe(rows[0].hash); // chain links @@ -55,52 +93,99 @@ describe('appendAudit hash chain', () => { }); it('keeps separate chains per plugin', async () => { - await appendAudit(db, { pluginId: 'a', method: 'trips.getById', resource: 'trip:1', code: 'ok' }); - await appendAudit(db, { pluginId: 'b', method: 'trips.getById', resource: 'trip:1', code: 'ok' }); - const a = db.prepare("SELECT prev_hash FROM plugin_capability_audit WHERE plugin_id='b'").get() as { prev_hash: string | null }; - expect(a.prev_hash).toBeNull(); // b's first entry doesn't chain off a's + await appendAudit(audit, { pluginId: 'a', method: 'trips.getById', resource: 'trip:1', code: 'ok' }); + await appendAudit(audit, { pluginId: 'b', method: 'trips.getById', resource: 'trip:1', code: 'ok' }); + const b = testDb.prepare("SELECT prev_hash FROM plugin_capability_audit WHERE plugin_id='b'").get() as { prev_hash: string | null }; + expect(b.prev_hash).toBeNull(); // b's first entry doesn't chain off a's }); it('records denials too (code is the error code)', async () => { - await appendAudit(db, { pluginId: 'p', actingUserId: 99, method: 'trips.getById', resource: 'trip:1', code: 'RESOURCE_FORBIDDEN' }); - const row = (await readAudit(db, 'p'))[0] as { code: string; method: string }; + await appendAudit(audit, { pluginId: 'p', actingUserId: 99, method: 'trips.getById', resource: 'trip:1', code: 'RESOURCE_FORBIDDEN' }); + const row = (await readAudit(audit, 'p'))[0]; expect(row.code).toBe('RESOURCE_FORBIDDEN'); }); - it('readAuditForUser returns one user\'s actions across ALL plugins, newest first, with the plugin name', async () => { - db.exec("CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT)"); - db.prepare("INSERT INTO plugins (id, name) VALUES ('koffi','Koffi'), ('flight','Flight Tracker')").run(); - await appendAudit(db, { pluginId: 'koffi', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' }); - await appendAudit(db, { pluginId: 'flight', actingUserId: 42, method: 'reservations.create', resource: 'trip:1', code: 'ok' }); - await appendAudit(db, { pluginId: 'koffi', actingUserId: 99, method: 'trips.getById', resource: 'trip:2', code: 'ok' }); // another user - const mine = (await readAuditForUser(db, 42)) as Array<{ plugin_id: string; plugin_name: string; method: string }>; - expect(mine).toHaveLength(2); // only user 42's rows, across both plugins + it("readAuditForUser returns one user's actions across ALL plugins, newest first, with the plugin name", async () => { + testDb.prepare("INSERT INTO plugins (id, name) VALUES ('koffi','Koffi'), ('flight','Flight Tracker')").run(); + await appendAudit(audit, { pluginId: 'koffi', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' }); + await appendAudit(audit, { pluginId: 'flight', actingUserId: 42, method: 'reservations.create', resource: 'trip:1', code: 'ok' }); + await appendAudit(audit, { pluginId: 'koffi', actingUserId: 99, method: 'trips.getById', resource: 'trip:2', code: 'ok' }); // another user + const mine = await readAuditForUser(audit, 42); + expect(mine).toHaveLength(2); // only user 42's rows, across both plugins expect(mine[0]).toMatchObject({ plugin_id: 'flight', plugin_name: 'Flight Tracker', method: 'reservations.create' }); // newest first expect(mine[1]).toMatchObject({ plugin_id: 'koffi', plugin_name: 'Koffi' }); - expect(mine.some((r) => r.plugin_id === 'koffi' && r.method === 'trips.getById' && (r as { resource?: string }).resource === 'trip:2')).toBe(false); // never another user's + expect(mine.some((r) => r.plugin_id === 'koffi' && r.method === 'trips.getById' && r.resource === 'trip:2')).toBe(false); // never another user's }); it('readAudit returns newest first with the projected fields only', async () => { - await appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' }); - const rows = (await readAudit(db, 'p')) as Array>; + await appendAudit(audit, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' }); + const rows = await readAudit(audit, 'p'); expect(rows[0]).toHaveProperty('method', 'trips.getById'); expect(rows[0]).not.toHaveProperty('prev_hash'); // internal chain fields not exposed }); it('pruneAudit keeps only the newest N rows per plugin, leaving the retained window chain-consistent', async () => { - for (let i = 0; i < 50; i++) await appendAudit(db, { pluginId: 'p', actingUserId: 1, method: 'trips.getById', resource: `trip:${i}`, code: 'ok' }); - await appendAudit(db, { pluginId: 'other', actingUserId: 1, method: 'trips.getById', resource: 'trip:x', code: 'ok' }); - await pruneAudit(db, 'p', 10); - const rows = db.prepare("SELECT resource, prev_hash, hash FROM plugin_capability_audit WHERE plugin_id = 'p' ORDER BY id ASC").all() as Array<{ resource: string; prev_hash: string | null; hash: string }>; + for (let i = 0; i < 50; i++) await appendAudit(audit, { pluginId: 'p', actingUserId: 1, method: 'trips.getById', resource: `trip:${i}`, code: 'ok' }); + await appendAudit(audit, { pluginId: 'other', actingUserId: 1, method: 'trips.getById', resource: 'trip:x', code: 'ok' }); + await pruneAudit(audit, 'p', 10); + const rows = testDb.prepare("SELECT resource, prev_hash, hash FROM plugin_capability_audit WHERE plugin_id = 'p' ORDER BY id ASC").all() as Array<{ resource: string; prev_hash: string | null; hash: string }>; expect(rows).toHaveLength(10); expect(rows[rows.length - 1].resource).toBe('trip:49'); // newest kept // each retained row is still self-consistent: hash === sha256(prev_hash + row-content) // (proven by re-appending on top — the chain continues from the surviving tip) - await appendAudit(db, { pluginId: 'p', actingUserId: 1, method: 'trips.getById', resource: 'trip:new', code: 'ok' }); - expect(db.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='p'").get()).toMatchObject({ c: 11 }); + await appendAudit(audit, { pluginId: 'p', actingUserId: 1, method: 'trips.getById', resource: 'trip:new', code: 'ok' }); + expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='p'").get()).toMatchObject({ c: 11 }); // pruning one plugin never touches another's rows - expect(db.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='other'").get()).toMatchObject({ c: 1 }); - await expect(pruneAudit(db, 'p', 0)).resolves.toBeUndefined(); // 0 = disabled, no-op - expect(db.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='p'").get()).toMatchObject({ c: 11 }); + expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='other'").get()).toMatchObject({ c: 1 }); + await expect(pruneAudit(audit, 'p', 0)).resolves.toBeUndefined(); // 0 = disabled, no-op + expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='p'").get()).toMatchObject({ c: 11 }); + }); +}); + +describe('R-hash-chain: replay, extension, mutation', () => { + it('AUDITCHAIN-001 REPLAY+EXTENSION: verifies a legacy-produced chain, then extends it through the converted appendAudit and the WHOLE chain stays consistent', async () => { + // 1. Write three rows the way the PRE-CONVERSION code did: raw SQL, no repository. + let prev = ''; + for (let i = 0; i < 3; i++) { + const { hash } = legacyAppendAudit(prev, { pluginId: 'legacy-p', actingUserId: 7, method: 'trips.getById', resource: `trip:${i}`, code: 'ok' }); + prev = hash; + } + // 2. REPLAY: the converted verification logic (verifyChain) accepts the legacy-written rows. + expect(verifyChain(chainRows('legacy-p'))).toBe(true); + + // 3. EXTENSION: append two more rows through the CONVERTED appendAudit, on top of the + // legacy tip — the repository reads the legacy row's hash as its own prev_hash. + await appendAudit(audit, { pluginId: 'legacy-p', actingUserId: 7, method: 'trips.getById', resource: 'trip:3', code: 'ok' }); + await appendAudit(audit, { pluginId: 'legacy-p', actingUserId: 7, method: 'trips.getById', resource: 'trip:4', code: 'ok' }); + + const rows = chainRows('legacy-p'); + expect(rows).toHaveLength(5); + expect(rows[3].prev_hash).toBe(rows[2].hash); // the converted append linked onto the legacy tip + // 4. The WHOLE chain (3 legacy rows + 2 converted rows) verifies end to end. + expect(verifyChain(rows)).toBe(true); + }); + + it('AUDITCHAIN-002 MUTATION: a row whose stored hash omits one field from the hash input fails verification', async () => { + // A row constructed EXACTLY like appendAudit's real one, except `resource` is + // dropped from the JSON array fed to sha256 — the drift R-hash-chain warns + // never errors anywhere on write, only on a verification pass. + const pluginId = 'mutant'; + const actingUserId = 1; + const method = 'trips.getById'; + const resource = 'trip:1'; + const code = 'ok'; + const ts = new Date().toISOString(); + const mutatedRow = JSON.stringify([pluginId, actingUserId, method, code, ts]); // resource OMITTED + const mutatedHash = crypto.createHash('sha256').update('' + mutatedRow).digest('hex'); + testDb + .prepare('INSERT INTO plugin_capability_audit (plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?)') + .run(pluginId, actingUserId, method, resource, code, ts, null, mutatedHash); + + expect(verifyChain(chainRows(pluginId))).toBe(false); + + // Control: the same row, hashed with the REAL (resource-included) construction, verifies. + testDb.prepare('DELETE FROM plugin_capability_audit WHERE plugin_id = ?').run(pluginId); + legacyAppendAudit('', { pluginId, actingUserId, method, resource, code }); + expect(verifyChain(chainRows(pluginId))).toBe(true); }); }); diff --git a/server/tests/unit/plugins/plugin-host-state.test.ts b/server/tests/unit/plugins/plugin-host-state.test.ts new file mode 100644 index 000000000..2009ad3ba --- /dev/null +++ b/server/tests/unit/plugins/plugin-host-state.test.ts @@ -0,0 +1,69 @@ +/** + * HS1 (`plugin-host-state.ts#budgetFor`'s seed read) — Plan 3j Task 3. Converted + * from a raw `better-sqlite3` connection to `PluginCapabilityAuditRepository`; + * this covers the seed's grouping (ai.complete + ai.extract vs notify.send), + * the UTC-day floor, and the `code = 'ok'` filter (a denied call never counts + * against the budget). + */ +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { resetTestDb } from '../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity'; +import type { PluginCapabilityAuditRepository } from '../../../src/db/repositories/PluginCapabilityAudit.repository'; +import { appendAudit } from '../../../src/nest/plugins/host/plugin-audit'; +import { budgetFor, pluginBudgetUsage } from '../../../src/nest/plugins/host/plugin-host-state'; + +const testDb = createSnapshotTestDb(); +let t: TestOrm; +let audit: PluginCapabilityAuditRepository; + +beforeAll(async () => { + t = await createTestOrm(testDb); + audit = t.repo(PluginCapabilityAudit); +}); +beforeEach(() => { resetTestDb(testDb); t.clear(); }); +afterEach(() => { vi.restoreAllMocks(); }); +afterAll(async () => { await t.close(); testDb.close(); }); + +// `budgetFor`'s `budgets` Map is deliberately module-level (see plugin-host-state.ts's +// class docstring) — it is NOT reset by `resetTestDb`/`t.clear()`, so each test below +// uses its own plugin id to avoid one test's seed being served (cached) to the next. +describe('budgetFor / pluginBudgetUsage seed read', () => { + it('HS-001: seeds ai (ai.complete + ai.extract) and notify counts separately from today\'s already-audited rows', async () => { + await appendAudit(audit, { pluginId: 'hs1', actingUserId: 1, method: 'ai.complete', resource: null, code: 'ok' }); + await appendAudit(audit, { pluginId: 'hs1', actingUserId: 1, method: 'ai.extract', resource: null, code: 'ok' }); + await appendAudit(audit, { pluginId: 'hs1', actingUserId: 1, method: 'notify.send', resource: null, code: 'ok' }); + // A denied call must not count against the budget. + await appendAudit(audit, { pluginId: 'hs1', actingUserId: 1, method: 'ai.complete', resource: null, code: 'RESOURCE_FORBIDDEN' }); + // A different plugin's calls must not bleed into this one's budget. + await appendAudit(audit, { pluginId: 'hs1-other', actingUserId: 1, method: 'ai.complete', resource: null, code: 'ok' }); + + const usage = await pluginBudgetUsage('hs1', audit); + expect(usage.ai).toBe(2); + expect(usage.notify).toBe(1); + }); + + it('HS-002: seeded only once per plugin per process lifetime — a later append is NOT re-counted on a second read', async () => { + await appendAudit(audit, { pluginId: 'hs2', actingUserId: 1, method: 'ai.complete', resource: null, code: 'ok' }); + const first = await budgetFor('hs2', audit); + expect(first.used(Date.now()).ai).toBe(1); + // A direct .take() call (what a real dispatch does) advances the in-memory + // counter; the seed read itself is not re-run. + await appendAudit(audit, { pluginId: 'hs2', actingUserId: 1, method: 'ai.complete', resource: null, code: 'ok' }); + const second = await budgetFor('hs2', audit); + expect(second).toBe(first); // same cached DailyBudget instance — no re-seed + expect(second.used(Date.now()).ai).toBe(1); // still 1: the second append was never taken through the budget + }); + + it("HS-003: ignores yesterday's rows — only today's UTC window seeds the count", async () => { + await appendAudit(audit, { pluginId: 'hs3', actingUserId: 1, method: 'ai.complete', resource: null, code: 'ok' }); + // Backdate the row to yesterday, bypassing the repository (simulating a + // real prior day's audit history already on disk). + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + testDb.prepare("UPDATE plugin_capability_audit SET ts = ? WHERE plugin_id = 'hs3'").run(yesterday); + + const usage = await pluginBudgetUsage('hs3', audit); + expect(usage.ai).toBe(0); + }); +}); diff --git a/server/tests/unit/plugins/plugin-instance-settings.test.ts b/server/tests/unit/plugins/plugin-instance-settings.test.ts index 909896b01..e962175f3 100644 --- a/server/tests/unit/plugins/plugin-instance-settings.test.ts +++ b/server/tests/unit/plugins/plugin-instance-settings.test.ts @@ -106,7 +106,9 @@ async function installFixturePlugin(opts: { settings: Array=4.0.0 <5.0.0', settings: opts.settings }), ); fs.writeFileSync(path.join(dir, 'index.js'), 'module.exports={}'); - await discoverPlugins(testDb); + // Plan 3j Task 3: `discoverPlugins` takes a `DiscoveryRepos` bundle now, not a raw + // connection — the same repositories this file already resolves via `sharedTestOrm`. + await discoverPlugins({ plugins: pluginsRepo, actions: pluginActionsRepo, settingsFields: pluginSettingsFieldsRepo, errorLog: pluginErrorLogRepo }); } beforeAll(async () => { diff --git a/server/tests/unit/plugins/plugin-route-normalize.test.ts b/server/tests/unit/plugins/plugin-route-normalize.test.ts new file mode 100644 index 000000000..27e32ff28 --- /dev/null +++ b/server/tests/unit/plugins/plugin-route-normalize.test.ts @@ -0,0 +1,51 @@ +/** + * SV4 (R-survivors) — `declaredProfiles`, Plan 3j Task 3's conversion of the one + * literal `SELECT capabilities FROM plugins WHERE id = ?` backing all three + * external callers (`roadtrip-router.service.ts`'s RRT1/RRT2, + * `plugin-routes.controller.ts`), now via `PluginsRepository#findCapabilities` + * (PR52/PR53 — the same method `plugin-runtime.service.ts`'s + * `capabilityList`/`mcpToolCapabilities` use for the same column). + */ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { resetTestDb } from '../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { Plugins } from '../../../src/db/entities/Plugins.entity'; +import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository'; +import { declaredProfiles } from '../../../src/nest/plugins/contributions/plugin-route-normalize'; + +const testDb = createSnapshotTestDb(); +let t: TestOrm; +let plugins: PluginsRepository; + +beforeAll(async () => { + t = await createTestOrm(testDb); + plugins = t.repo(Plugins); +}); +beforeEach(() => { resetTestDb(testDb); t.clear(); }); +afterAll(async () => { await t.close(); testDb.close(); }); + +function seedPlugin(id: string, capabilities: unknown): void { + testDb.prepare('INSERT INTO plugins (id, name, capabilities) VALUES (?, ?, ?)').run(id, id, JSON.stringify(capabilities)); +} + +describe('declaredProfiles', () => { + it('RN-001: returns the declared routeProfiles ids, filtered to the safe id pattern', async () => { + seedPlugin('roadtrip-plugin', { routeProfiles: [{ id: 'eco' }, { id: 'fast-ev' }, { id: 'BAD ID' }, { id: '' }] }); + expect(await declaredProfiles(plugins, 'roadtrip-plugin')).toEqual(['eco', 'fast-ev']); + }); + + it('RN-002: no routeProfiles capability → empty list', async () => { + seedPlugin('no-routes', { widget: { slot: 'sidebar' } }); + expect(await declaredProfiles(plugins, 'no-routes')).toEqual([]); + }); + + it('RN-003: a missing plugin row → empty list, never throws', async () => { + expect(await declaredProfiles(plugins, 'does-not-exist')).toEqual([]); + }); + + it('RN-004: malformed capabilities JSON → empty list, never throws', async () => { + testDb.prepare('INSERT INTO plugins (id, name, capabilities) VALUES (?, ?, ?)').run('bad-json', 'bad-json', 'not json'); + expect(await declaredProfiles(plugins, 'bad-json')).toEqual([]); + }); +}); diff --git a/server/tests/unit/plugins/plugin-routes.controller.test.ts b/server/tests/unit/plugins/plugin-routes.controller.test.ts index 4700aa1bc..fa2539a60 100644 --- a/server/tests/unit/plugins/plugin-routes.controller.test.ts +++ b/server/tests/unit/plugins/plugin-routes.controller.test.ts @@ -21,6 +21,7 @@ vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); import { PluginRoutesController } from '../../../src/nest/plugins/contributions/plugin-routes.controller'; import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; +import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any; @@ -29,7 +30,13 @@ function controller(invoke: () => unknown, providers = ['ev-plug']) { providersOf: vi.fn(() => providers), route: vi.fn(async () => invoke()), } as unknown as PluginHooks; - return { c: new PluginRoutesController(runtime, { canAccessTrip, connection: { prepare: () => ({ get: () => (capabilitiesRow.value === undefined ? undefined : { capabilities: capabilitiesRow.value }) }) } } as unknown as DatabaseService), runtime }; + // Plan 3j Task 3: `declaredProfiles` now takes `PluginsRepository`, not the raw + // connection — `findCapabilities` reads the same `capabilitiesRow` fixture the + // old `connection.prepare(...).get()` stub above read. + const plugins = { + findCapabilities: vi.fn(async () => (capabilitiesRow.value === undefined ? null : capabilitiesRow.value)), + } as unknown as PluginsRepository; + return { c: new PluginRoutesController(runtime, { canAccessTrip } as unknown as DatabaseService, plugins), runtime }; } const wp = (n = 3) => Array.from({ length: n }, (_, i) => ({ lat: 48 + i * 0.1, lng: 2 + i * 0.1 })); const goodRoute = (n = 3, over: Record = {}) => ({