mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
refactor(plugins): install-time gates, the capability-audit hash chain and the registry through repositories
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
/**
|
||||
|
||||
@@ -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> = {}) => ({
|
||||
|
||||
Reference in New Issue
Block a user