refactor(plugins): install-time gates, the capability-audit hash chain and the registry through repositories

This commit is contained in:
jubnl
2026-09-24 03:24:23 +02:00
parent f29216c5af
commit d522882590
30 changed files with 834 additions and 313 deletions
@@ -30,4 +30,29 @@ export class PluginActionsRepository extends TrekRepository<PluginActions> {
async deleteAllForPlugin(pluginId: string): Promise<void> {
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<void> {
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;
}
@@ -388,4 +388,154 @@ export class PluginsRepository extends TrekRepository<Plugins> {
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<void> {
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<PluginManifestRow, 'id'>): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await this.nativeUpdate({ id }, { author_pubkey: authorPubkey });
}
/** RG5 (`installWithDependencies`'s already-installed set) — `SELECT id FROM plugins`. */
async listAllIds(): Promise<string[]> {
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<void> {
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<void> {
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;
}
@@ -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<string[]> {
/**
* 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<string[]> {
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 : ''))
@@ -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);
+62 -36
View File
@@ -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<void> {
export async function pruneAudit(audit: PluginCapabilityAuditRepository, pluginId: string, keep = MAX_AUDIT_ROWS): Promise<void> {
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<void> {
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<void> {
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<void> {
* "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<unknown[]> {
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<AuditForUserRow[]> {
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<unknown[]> {
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<AuditForPluginRow[]> {
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;
}
@@ -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<string, PluginDataDb>();
@@ -44,14 +45,12 @@ export function closePluginDataDb(id: string): void {
// nothing persisted or phoned home.
const budgets = new Map<string, DailyBudget>();
export async function budgetFor(id: string, conn: Database.Database): Promise<DailyBudget> {
export async function budgetFor(id: string, audit: PluginCapabilityAuditRepository): Promise<DailyBudget> {
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<Da
}
/** Today's broker usage for one plugin (admin view). Seeds the counter if unseen. */
export async function pluginBudgetUsage(id: string, conn: Database.Database): Promise<ReturnType<DailyBudget['used']>> {
return (await budgetFor(id, conn)).used(Date.now());
export async function pluginBudgetUsage(id: string, audit: PluginCapabilityAuditRepository): Promise<ReturnType<DailyBudget['used']>> {
return (await budgetFor(id, audit)).used(Date.now());
}
@@ -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,
);
@@ -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<void> {
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)');
}
}
+56 -41
View File
@@ -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<void> {
async function upsert(repos: DiscoveryRepos, m: PluginManifest): Promise<void> {
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,
);
});
}
@@ -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) };
}
}
@@ -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<string, PluginDepRow>;
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<void> {
@@ -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);
+9 -1
View File
@@ -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,
+1 -1
View File
@@ -383,7 +383,7 @@ export class PluginsService {
*/
private async assertRequiredFilled(id: string, scope: 'instance' | 'user', config: Record<string, unknown>): Promise<void> {
const required = await this.pluginSettingsFields.listRequiredFieldKeys(id, scope);
const defaults = await settingDefaults(this.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);
@@ -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<string>();
const installed: string[] = [];
const requiredAddons = new Set<string>();
@@ -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<void> {
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<RegistryEntry> {
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');
+3 -7
View File
@@ -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<Record<string, SettingDefault>> {
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<string, SettingDefault> = Object.create(null);
for (const r of rows) {
const v = parseDefaultValue(r.default_value);
+5 -10
View File
@@ -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<void> {
export async function setUpdateBlock(plugins: PluginsRepository, id: string, code: SignatureCode, detail: string, version: string | null): Promise<void> {
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<void> {
export async function clearUpdateBlock(plugins: PluginsRepository, id: string): Promise<void> {
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.
}
@@ -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<string[]> {
@@ -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);
+5 -1
View File
@@ -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,
+9 -5
View File
@@ -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),
+1 -1
View File
@@ -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()),
];
@@ -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(
@@ -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', () => {
@@ -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 () => {
+50 -40
View File
@@ -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<string, unknown>, extra?: () => void) {
@@ -21,24 +34,21 @@ function writePlugin(id: string, manifest: Record<string, unknown>, 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' });
});
});
});
+36 -2
View File
@@ -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);
/**
+128 -43
View File
@@ -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<typeof makeDb>;
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<Record<string, unknown>>;
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);
});
});
@@ -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);
});
});
@@ -106,7 +106,9 @@ async function installFixturePlugin(opts: { settings: Array<Record<string, unkno
JSON.stringify({ id: 'fixture-id', name: 'Fixture', version: '1.0.0', type: 'integration', trek: '>=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 () => {
@@ -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([]);
});
});
@@ -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<string, unknown> = {}) => ({