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

This commit is contained in:
jubnl
2026-09-24 03:24:23 +02:00
parent f29216c5af
commit d522882590
30 changed files with 834 additions and 313 deletions
@@ -30,4 +30,29 @@ export class PluginActionsRepository extends TrekRepository<PluginActions> {
async deleteAllForPlugin(pluginId: string): Promise<void> {
await this.nativeDelete({ plugin_id: pluginId });
}
/**
* DI6 (Plan 3j Task 3, `install/discovery.ts#upsert`) — `INSERT INTO plugin_actions
* (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?,?,?,?,?,?,?)`,
* looped once per manifest action, paired with DI5's `deleteAllForPlugin` above (the
* same delete-then-reinsert re-declare sequence). `insertMany` over a per-row loop:
* one batched native insert, same rows the legacy per-row `.run()` loop produced. A
* no-op on an empty manifest `actions[]`.
*/
async insertActions(pluginId: string, actions: NewPluginActionRow[]): Promise<void> {
if (!actions.length) return;
await this.insertMany(
actions.map((a) => ({ plugin_id: pluginId, action_key: a.action_key, label: a.label, hint: a.hint, danger: a.danger, scope: a.scope, sort_order: a.sort_order })),
);
}
}
/** DI6's own row shape — `discoverPlugins#upsert`'s per-manifest-action insert. */
export interface NewPluginActionRow {
action_key: string;
label: string;
hint: string | null;
danger: number;
scope: PluginActionScope;
sort_order: number;
}
@@ -388,4 +388,154 @@ export class PluginsRepository extends TrekRepository<Plugins> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { config, updated_at: currentTimestamp(platform) });
}
// -----------------------------------------------------------------------
// Plan 3j Task 3 — install/discovery.ts's manifest ingestion (DI2–DI4;
// DI2's existence check reuses `existsById` above)
// -----------------------------------------------------------------------
/** DI3/DI4 — the manifest-derived column set `discoverPlugins#upsert` writes on both the new-plugin INSERT and the existing-plugin UPDATE branches (the UPDATE branch is a strict subset — see `updateManifestFields`). */
async insertManifest(m: PluginManifestRow): Promise<void> {
await this.insert({
id: m.id,
name: m.name,
description: m.description,
type: m.type,
icon: m.icon,
version: m.version,
api_version: m.api_version,
min_trek_version: m.min_trek_version,
trek_range: m.trek_range,
permissions: m.permissions,
capabilities: m.capabilities,
dependencies: m.dependencies,
operator_egress: m.operator_egress,
// '' (empty, not '[]') marks "never consented" so the first activation is
// distinguishable from a plugin consented to zero permissions.
granted_permissions: '',
status: 'inactive',
});
}
/**
* DI4 — `` UPDATE plugins SET name = ?, description = ?, type = ?, icon = ?, version = ?,
* api_version = ?, min_trek_version = ?, trek_range = ?, permissions = ?, capabilities = ?,
* dependencies = ?, operator_egress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? ``.
* Deliberately narrower than `insertManifest`'s column set — `status`/`granted_permissions`/
* `config` are NEVER touched here, so a re-discovery of an already-installed plugin never
* silently re-activates it or wipes its settings/consent.
*/
async updateManifestFields(id: string, m: Omit<PluginManifestRow, 'id'>): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate(
{ id },
{
name: m.name,
description: m.description,
type: m.type,
icon: m.icon,
version: m.version,
api_version: m.api_version,
min_trek_version: m.min_trek_version,
trek_range: m.trek_range,
permissions: m.permissions,
capabilities: m.capabilities,
dependencies: m.dependencies,
operator_egress: m.operator_egress,
updated_at: currentTimestamp(platform),
},
);
}
// -----------------------------------------------------------------------
// Plan 3j Task 3 — signature-status.ts's setUpdateBlock/clearUpdateBlock (SG1/SG2)
// -----------------------------------------------------------------------
/** SG1 — `UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?` (`setUpdateBlock`, a signature-verification failure). */
async setUpdateBlockColumns(id: string, code: string, detail: string, version: string | null): Promise<void> {
await this.nativeUpdate({ id }, { update_block_code: code, update_block_detail: detail, update_block_version: version });
}
/** SG2 — `UPDATE plugins SET update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL WHERE id = ?` (`clearUpdateBlock`, the inverse — a successful install/update). */
async clearUpdateBlockColumns(id: string): Promise<void> {
await this.nativeUpdate({ id }, { update_block_code: null, update_block_detail: null, update_block_version: null });
}
// -----------------------------------------------------------------------
// Plan 3j Task 3 — registry/registry.service.ts's own statements (RG#).
// RG9 (`verifySignatureAndTofu`'s pinned-key read) reuses `findAuthorPubkey`
// above (identical text to PR28) rather than adding a duplicate method.
// -----------------------------------------------------------------------
/** RG2 (`install`'s post-discover provenance write) — `UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?`. */
async setInstallProvenance(id: string, sourceRepo: string, sourceCommit: string, sha256: string, reviewedAt: string | null): Promise<void> {
await this.nativeUpdate({ id }, { source_repo: sourceRepo, source_commit: sourceCommit, sha256, reviewed_at: reviewedAt });
}
/** RG3 (`install`'s TOFU pin) — `UPDATE plugins SET author_pubkey = ? WHERE id = ?`. Security-sensitive: only ever called with a key the artifact just verified under (see `registry.service.ts#install`'s own docstring) — never cleared to NULL by this method. */
async setAuthorPubkey(id: string, authorPubkey: string): Promise<void> {
await this.nativeUpdate({ id }, { author_pubkey: authorPubkey });
}
/** RG5 (`installWithDependencies`'s already-installed set) — `SELECT id FROM plugins`. */
async listAllIds(): Promise<string[]> {
const rows = await this.find({}, { fields: ['id'] });
return rows.map((r) => r.id ?? '');
}
/** RG6 (`recomputeUpdateHold`) — `UPDATE plugins SET update_hold = ? WHERE id = ?`. */
async setUpdateHold(id: string, hold: boolean): Promise<void> {
await this.nativeUpdate({ id }, { update_hold: hold ? 1 : 0 });
}
/**
* RG8 (`commitUpload`, sideload step 2) — `` UPDATE plugins SET source_repo = ?,
* source_commit = NULL, sha256 = NULL, reviewed_at = NULL, author_pubkey = NULL,
* update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL,
* status = 'inactive', enabled = 0 WHERE id = ? ``. The sideload twin of PR31's
* `clearForDevLink` above — same registry-trust-column clear, plus `reviewed_at`
* (a sideload was never registry-reviewed) — kept as its own method rather than
* widening `clearForDevLink`, since the two back separate legacy statements with
* different column sets, not one shared text.
*/
async clearForSideload(id: string, sourceRepo: string): Promise<void> {
await this.nativeUpdate(
{ id },
{
source_repo: sourceRepo,
source_commit: null,
sha256: null,
reviewed_at: null,
author_pubkey: null,
update_block_code: null,
update_block_detail: null,
update_block_version: null,
status: 'inactive',
enabled: 0,
},
);
}
/** RG11 (`assertRetrustable`) — `SELECT source_repo, author_pubkey FROM plugins WHERE id = ?`. */
async findSourceRepoAndAuthorPubkey(id: string): Promise<{ source_repo: string | null; author_pubkey: string | null } | null> {
const row = await this.findOne({ id }, { fields: ['source_repo', 'author_pubkey'] });
return row ? { source_repo: row.source_repo ?? null, author_pubkey: row.author_pubkey ?? null } : null;
}
}
/** DI3/DI4 — the manifest-derived column set `discoverPlugins#upsert` writes on both the new-plugin INSERT and the existing-plugin UPDATE branches (the UPDATE branch is a strict subset — see `updateManifestFields`). */
export interface PluginManifestRow {
id: string;
name: string;
description: string | null;
type: string;
icon: string;
version: string | null;
api_version: number;
min_trek_version: string | null;
trek_range: string | null;
permissions: string;
capabilities: string;
dependencies: string;
operator_egress: number;
}
@@ -1,6 +1,5 @@
import { stripEmoji } from '../text-sanitize';
import type Database from 'better-sqlite3';
import type { PluginsRepository } from '../../../db/repositories/Plugins.repository';
type Tone = 'default' | 'success' | 'warn' | 'danger';
@@ -120,12 +119,19 @@ export function normalize(
return { pluginId, profile, coordinates, distance, duration, legs, viaPoints };
}
export async function declaredProfiles(conn: Database.Database, pluginId: string): Promise<string[]> {
/**
* SV4 (R-survivors) — was `SELECT capabilities FROM plugins WHERE id = ?` over a
* raw connection; now `PluginsRepository#findCapabilities` (PR52/PR53, Plan 3j
* Task 2's `plugin-runtime.service.ts#capabilityList`/`#mcpToolCapabilities`
* conversion of the SAME column read — reused here rather than adding a fourth
* near-identical method). Three external callers (`roadtrip-router.service.ts`'s
* RRT1/RRT2, `plugin-routes.controller.ts`) switch from the raw-connection
* signature to this repository-typed one — Task 5's.
*/
export async function declaredProfiles(plugins: PluginsRepository, pluginId: string): Promise<string[]> {
try {
const row = conn.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(pluginId) as
| { capabilities?: string }
| undefined;
const c = JSON.parse(row?.capabilities || '{}') as { routeProfiles?: Array<{ id?: unknown }> };
const capabilities = await plugins.findCapabilities(pluginId);
const c = JSON.parse(capabilities || '{}') as { routeProfiles?: Array<{ id?: unknown }> };
if (!Array.isArray(c.routeProfiles)) return [];
return c.routeProfiles
.map((p) => (p && typeof p === 'object' && typeof p.id === 'string' ? p.id : ''))
@@ -1,6 +1,9 @@
import { Body, Controller, Param, Post, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { InjectRepository } from '@mikro-orm/nestjs';
import { DatabaseService } from '../../database/database.service';
import { Plugins } from '../../../db/entities/Plugins.entity';
import type { PluginsRepository } from '../../../db/repositories/Plugins.repository';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { pluginsEnabled } from '../kill-switch';
import { PluginHooks } from '../plugin-hooks.service';
@@ -33,6 +36,7 @@ export class PluginRoutesController {
constructor(
private readonly hooks: PluginHooks,
private readonly dbs: DatabaseService,
@InjectRepository(Plugins) private readonly plugins: PluginsRepository,
) {}
@Post(':pluginId/:profileId')
@@ -56,7 +60,7 @@ export class PluginRoutesController {
// one the manifest declared — re-validated from the DB row like the plugins feed,
// so a hand-edited capabilities blob can't invent profiles.
if (!this.hooks.providersOf('routeProvider').includes(pluginId)) return { route: null };
if (!(await declaredProfiles(this.dbs.connection, pluginId)).includes(profileId)) return { route: null };
if (!(await declaredProfiles(this.plugins, pluginId)).includes(profileId)) return { route: null };
try {
const raw = await this.hooks.route(pluginId, { tripId, dayId, profile: profileId, waypoints }, userId);
+62 -36
View File
@@ -1,5 +1,6 @@
import crypto from 'node:crypto';
import { METHOD_PERMISSION } from '../protocol/envelope';
import type { PluginCapabilityAuditRepository, AuditForPluginRow, AuditForUserRow } from '../../../db/repositories/PluginCapabilityAudit.repository';
/**
* Host-side, hash-chained capability audit (#plugins, L1 hardening).
@@ -12,14 +13,6 @@ import { METHOD_PERMISSION } from '../protocol/envelope';
* TREK grant broad reads to large addons without raising risk.
*/
interface AuditDb {
prepare(sql: string): {
get(...args: unknown[]): unknown;
run(...args: unknown[]): unknown;
all(...args: unknown[]): unknown[];
};
}
export interface AuditEntry {
pluginId: string;
actingUserId?: number;
@@ -98,30 +91,42 @@ function envInt(name: string, def: number): number {
/** Keep only the newest `MAX_AUDIT_ROWS` rows for a plugin. Called amortised from
* appendAudit; exported for tests. No-op when disabled or under the cap. */
export async function pruneAudit(db: AuditDb, pluginId: string, keep = MAX_AUDIT_ROWS): Promise<void> {
export async function pruneAudit(audit: PluginCapabilityAuditRepository, pluginId: string, keep = MAX_AUDIT_ROWS): Promise<void> {
if (keep <= 0) return;
db.prepare(
`DELETE FROM plugin_capability_audit WHERE plugin_id = ? AND id NOT IN
(SELECT id FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id DESC LIMIT ?)`,
).run(pluginId, pluginId, keep);
await audit.pruneKeepingNewest(pluginId, keep);
}
/** Append one entry to the per-plugin hash chain. The statements themselves are
* synchronous (better-sqlite3); the method is async for the ORM seam. */
export async function appendAudit(db: AuditDb, e: AuditEntry): Promise<void> {
const prev =
(db.prepare('SELECT hash FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id DESC LIMIT 1').get(e.pluginId) as
| { hash: string }
| undefined)?.hash ?? '';
/**
* Append one entry to the per-plugin hash chain.
*
* R-hash-chain: the hash input is `prev_hash + JSON.stringify([pluginId,
* actingUserId ?? null, method, resource ?? null, code, ts])`, in that field
* order, with `ts` computed in JS as `new Date().toISOString()` BEFORE the
* hash (never a DB-generated timestamp — see {@link verifyChain}'s docstring
* for why that would make the chain non-reproducible). This construction is
* byte-for-byte unchanged from the pre-conversion function; only the
* persistence calls (the previous-hash read, the row write, the amortised
* prune) now go through the repository instead of a raw `better-sqlite3`
* handle.
*/
export async function appendAudit(audit: PluginCapabilityAuditRepository, e: AuditEntry): Promise<void> {
const prev = (await audit.lastHash(e.pluginId)) ?? '';
const ts = new Date().toISOString();
const row = JSON.stringify([e.pluginId, e.actingUserId ?? null, e.method, e.resource ?? null, e.code, ts]);
const hash = crypto.createHash('sha256').update(prev + row).digest('hex');
db.prepare(
'INSERT INTO plugin_capability_audit (plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?)',
).run(e.pluginId, e.actingUserId ?? null, e.method, e.resource ?? null, e.code, ts, prev || null, hash);
await audit.insertRow({
plugin_id: e.pluginId,
acting_user_id: e.actingUserId ?? null,
method: e.method,
resource: e.resource ?? null,
code: e.code,
ts,
prev_hash: prev || null,
hash,
});
// Amortised retention: prune roughly every PRUNE_EVERY appends per plugin.
const n = (appendsSincePrune.get(e.pluginId) ?? 0) + 1;
if (n >= PRUNE_EVERY) { appendsSincePrune.set(e.pluginId, 0); await pruneAudit(db, e.pluginId); }
if (n >= PRUNE_EVERY) { appendsSincePrune.set(e.pluginId, 0); await pruneAudit(audit, e.pluginId); }
else appendsSincePrune.set(e.pluginId, n);
}
@@ -129,19 +134,40 @@ export async function appendAudit(db: AuditDb, e: AuditEntry): Promise<void> {
* "what have plugins done in my name?" view. This is what legitimizes the broad
* read grants: the user, not just the admin, can see every plugin action bound to
* them. Joined with the plugin name for display; capped. */
export async function readAuditForUser(db: AuditDb, userId: number, limit = 200): Promise<unknown[]> {
return db
.prepare(
`SELECT a.ts, a.plugin_id, p.name AS plugin_name, a.method, a.resource, a.code
FROM plugin_capability_audit a LEFT JOIN plugins p ON p.id = a.plugin_id
WHERE a.acting_user_id = ? ORDER BY a.id DESC LIMIT ?`,
)
.all(userId, limit);
export async function readAuditForUser(audit: PluginCapabilityAuditRepository, userId: number, limit = 200): Promise<AuditForUserRow[]> {
return audit.forUser(userId, limit);
}
/** Read the most recent audit rows for a plugin (admin view). */
export async function readAudit(db: AuditDb, pluginId: string, limit = 200): Promise<unknown[]> {
return db
.prepare('SELECT ts, acting_user_id, method, resource, code FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id DESC LIMIT ?')
.all(pluginId, limit);
export async function readAudit(audit: PluginCapabilityAuditRepository, pluginId: string, limit = 200): Promise<AuditForPluginRow[]> {
return audit.forPlugin(pluginId, limit);
}
/**
* Verify a hash chain's internal consistency: every row's `hash` reproduces
* from its OWN `prev_hash` + fields (the exact {@link appendAudit}
* construction above), and every row's `prev_hash` equals the immediately
* preceding row's `hash` (or `''`/absent for the first row in the given
* slice). `rows` must be ordered OLDEST FIRST (ascending `id`) — the reverse
* of {@link readAudit}/{@link readAuditForUser}, which return newest-first
* for display.
*
* Built to prove R-hash-chain's replay/extension property in tests (a
* mutation dropping one field from the hash input makes this return false
* on a chain that construction would otherwise still accept); kept as a
* permanent utility rather than a throwaway test fixture, since an eventual
* admin "verify audit integrity" surface would want exactly this.
*/
export function verifyChain(
rows: Array<{ plugin_id: string; acting_user_id: number | null; method: string; resource: string | null; code: string; ts: string; prev_hash: string | null; hash: string }>,
): boolean {
let prev = '';
for (const r of rows) {
if ((r.prev_hash ?? '') !== prev) return false;
const row = JSON.stringify([r.plugin_id, r.acting_user_id ?? null, r.method, r.resource ?? null, r.code, r.ts]);
const hash = crypto.createHash('sha256').update(prev + row).digest('hex');
if (hash !== r.hash) return false;
prev = r.hash;
}
return true;
}
@@ -1,6 +1,6 @@
import type Database from 'better-sqlite3';
import { PluginDataDb } from './plugin-data.service';
import { DailyBudget, DEFAULT_DAILY_BUDGET } from './daily-budget';
import type { PluginCapabilityAuditRepository } from '../../../db/repositories/PluginCapabilityAudit.repository';
/**
* Process-wide plugin host state, deliberately module-level (NOT a Nest
@@ -11,9 +11,10 @@ import { DailyBudget, DEFAULT_DAILY_BUDGET } from './daily-budget';
* this state into the injectable would create a provider cycle for no gain.
*
* It does NOT reach for the `db` singleton, though: the one read it needs is the
* budget seed, so the caller passes its own injected connection in. That keeps the
* module-level state (which is the point) without a second route to the database
* that no test can substitute.
* budget seed, so the caller passes its own injected `PluginCapabilityAuditRepository`
* in (Plan 3j Task 3 — was a raw `better-sqlite3` connection before conversion).
* That keeps the module-level state (which is the point) without a second route
* to the database that no test can substitute.
*/
const dataDbs = new Map<string, PluginDataDb>();
@@ -44,14 +45,12 @@ export function closePluginDataDb(id: string): void {
// nothing persisted or phoned home.
const budgets = new Map<string, DailyBudget>();
export async function budgetFor(id: string, conn: Database.Database): Promise<DailyBudget> {
export async function budgetFor(id: string, audit: PluginCapabilityAuditRepository): Promise<DailyBudget> {
let b = budgets.get(id);
if (!b) {
const now = Date.now();
const since = new Date(now).toISOString().slice(0, 10) + 'T00:00:00';
const rows = conn
.prepare("SELECT method, COUNT(*) AS n FROM plugin_capability_audit WHERE plugin_id = ? AND code = 'ok' AND ts >= ? AND method IN ('ai.complete','ai.extract','notify.send') GROUP BY method")
.all(id, since) as Array<{ method: string; n: number }>;
const rows = await audit.budgetSeed(id, since);
let ai = 0, notify = 0;
for (const r of rows) {
if (r.method === 'notify.send') notify += r.n;
@@ -64,6 +63,6 @@ export async function budgetFor(id: string, conn: Database.Database): Promise<Da
}
/** Today's broker usage for one plugin (admin view). Seeds the counter if unseen. */
export async function pluginBudgetUsage(id: string, conn: Database.Database): Promise<ReturnType<DailyBudget['used']>> {
return (await budgetFor(id, conn)).used(Date.now());
export async function pluginBudgetUsage(id: string, audit: PluginCapabilityAuditRepository): Promise<ReturnType<DailyBudget['used']>> {
return (await budgetFor(id, audit)).used(Date.now());
}
@@ -1,5 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { DatabaseService } from '../../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { PluginCapabilityAudit } from '../../../db/entities/PluginCapabilityAudit.entity';
import type { PluginCapabilityAuditRepository } from '../../../db/repositories/PluginCapabilityAudit.repository';
import { PluginRpcHost } from './rpc-host';
import type { PluginRpcRegistry } from './rpc-kit/registry';
import { PluginRpcRegistryService } from './rpc-kit/registry.service';
@@ -25,7 +27,7 @@ export interface PluginCallRouter {
@Injectable()
export class PluginRpcHostFactory {
constructor(
private readonly db: DatabaseService,
@InjectRepository(PluginCapabilityAudit) private readonly audit: PluginCapabilityAuditRepository,
// Injected by its concrete token, held as the base class: a no-Nest test can then
// hand in a createTestPluginRegistry() built from the instances it cares about.
@Inject(PluginRpcRegistryService) private readonly registry: PluginRpcRegistry,
@@ -51,7 +53,7 @@ export class PluginRpcHostFactory {
// The router binds this host's plugin id as the caller/source.
callPlugin: (targetId, fn, args, actingUserId) => router.callPlugin(id, targetId, fn, args, actingUserId),
emitPluginEvent: (event, payload) => router.emitPluginEvent(id, event, payload),
audit: (entry) => appendAudit(this.db.connection, entry),
audit: (entry) => appendAudit(this.audit, entry),
},
this.registry,
);
@@ -4,6 +4,9 @@ import { BadParams, ForbiddenResource } from '../rpc-errors';
import { asPayload, num, str } from '../rpc-params';
import type { PluginRpcContext } from '../rpc-kit/types';
import { budgetFor } from '../plugin-host-state';
import { InjectRepository } from '@mikro-orm/nestjs';
import { PluginCapabilityAudit } from '../../../../db/entities/PluginCapabilityAudit.entity';
import type { PluginCapabilityAuditRepository } from '../../../../db/repositories/PluginCapabilityAudit.repository';
import { DatabaseService } from '../../../database/database.service';
import { RealtimeService } from '../../../realtime/realtime.service';
import { NotificationsService } from '../../../notifications/notifications.service';
@@ -42,6 +45,7 @@ export class HostSurfaceRpc {
private readonly llmConfig: LlmConfigResolver,
private readonly oauth: PluginOAuthService,
private readonly guards: PluginGuards,
@InjectRepository(PluginCapabilityAudit) private readonly audit: PluginCapabilityAuditRepository,
) {}
@PluginMethod('users.getById', { permission: 'db:read:users' })
@@ -105,7 +109,7 @@ export class HostSurfaceRpc {
throw new ForbiddenResource('the acting user is not a member of that trip');
}
const link = this.safeLink(input.link);
if (!(await budgetFor(ctx.pluginId, this.db.connection)).take('notify', Date.now())) {
if (!(await budgetFor(ctx.pluginId, this.audit)).take('notify', Date.now())) {
throw new BadParams('daily notification budget exhausted (resets at UTC midnight)');
}
await this.notifications.send({
@@ -246,7 +250,7 @@ export class HostSurfaceRpc {
}
private async takeAiBudget(ctx: PluginRpcContext): Promise<void> {
if (!(await budgetFor(ctx.pluginId, this.db.connection)).take('ai', Date.now())) {
if (!(await budgetFor(ctx.pluginId, this.audit)).take('ai', Date.now())) {
throw new BadParams('daily AI budget exhausted (resets at UTC midnight)');
}
}
+56 -41
View File
@@ -1,10 +1,23 @@
import fs from 'node:fs';
import path from 'node:path';
import type BetterSqlite3 from 'better-sqlite3';
import { pluginsCodeRoot, pluginCodeDir } from '../paths';
import { parseJsonText, parseManifest, type PluginManifest } from './manifest';
import { scanForNativeBinaries } from './native-scan';
import { devLinkEnabled } from '../dev-link';
import type { PluginsRepository } from '../../../db/repositories/Plugins.repository';
import type { PluginActionsRepository } from '../../../db/repositories/PluginActions.repository';
import type { PluginSettingsFieldsRepository } from '../../../db/repositories/PluginSettingsFields.repository';
import type { PluginErrorLogRepository } from '../../../db/repositories/PluginErrorLog.repository';
/** `discoverPlugins`/`upsert`'s repository set — "swap the parameter type" (plan3j-inputs.md
* §0): the raw `BetterSqlite3.Database` parameter becomes this bundle of the four
* repositories the scan actually writes through. */
export interface DiscoveryRepos {
plugins: PluginsRepository;
actions: PluginActionsRepository;
settingsFields: PluginSettingsFieldsRepository;
errorLog: PluginErrorLogRepository;
}
/**
* Discover plugins placed on the /plugins volume (#plugins, M4, "install from
@@ -14,7 +27,7 @@ import { devLinkEnabled } from '../dev-link';
* manifest is invalid or that ships native binaries is skipped (recorded to its
* error log if it already existed).
*/
export async function discoverPlugins(db: BetterSqlite3.Database): Promise<{ discovered: string[]; skipped: string[] }> {
export async function discoverPlugins(repos: DiscoveryRepos): Promise<{ discovered: string[]; skipped: string[] }> {
const root = pluginsCodeRoot();
const discovered: string[] = [];
const skipped: string[] = [];
@@ -50,62 +63,64 @@ export async function discoverPlugins(db: BetterSqlite3.Database): Promise<{ dis
const manifest = parseManifest(parseJsonText(fs.readFileSync(manifestPath, 'utf8')));
if (manifest.id !== entry.name) throw new Error(`manifest id "${manifest.id}" != directory "${entry.name}"`);
if (scanForNativeBinaries(dir).length) throw new Error('directory contains native binaries');
await upsert(db, manifest);
await upsert(repos, manifest);
discovered.push(manifest.id);
} catch (e) {
skipped.push(entry.name);
const msg = e instanceof Error ? e.message : 'invalid plugin';
db.prepare('INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, ?, ?)').run(entry.name, 'error', `discovery: ${msg}`);
await repos.errorLog.insertLog(entry.name, 'error', `discovery: ${msg}`);
}
}
return { discovered, skipped };
}
async function upsert(db: BetterSqlite3.Database, m: PluginManifest): Promise<void> {
async function upsert(repos: DiscoveryRepos, m: PluginManifest): Promise<void> {
const dependencies = JSON.stringify({ requiredAddons: m.requiredAddons, pluginDependencies: m.pluginDependencies });
const existing = db.prepare('SELECT id FROM plugins WHERE id = ?').get(m.id) as { id: string } | undefined;
const manifestRow = {
name: m.name,
description: m.description ?? null,
type: m.type,
icon: m.icon ?? 'Blocks',
version: m.version,
api_version: m.apiVersion,
min_trek_version: m.minTrekVersion ?? null,
trek_range: m.trekRange,
permissions: JSON.stringify(m.permissions),
capabilities: JSON.stringify(m.capabilities),
dependencies,
operator_egress: m.operatorEgress ? 1 : 0,
};
const existing = await repos.plugins.existsById(m.id);
if (existing) {
db.prepare(
`UPDATE plugins SET name = ?, description = ?, type = ?, icon = ?, version = ?, api_version = ?,
min_trek_version = ?, trek_range = ?, permissions = ?, capabilities = ?, dependencies = ?, operator_egress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, m.trekRange, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0, m.id);
await repos.plugins.updateManifestFields(m.id, manifestRow);
} else {
db.prepare(
// granted_permissions '' (empty, not '[]') marks "never consented" so the
// first activation is distinguishable from a plugin consented to zero perms.
`INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, trek_range, permissions, capabilities, dependencies, operator_egress, granted_permissions, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'inactive')`,
).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, m.trekRange, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0);
await repos.plugins.insertManifest({ id: m.id, ...manifestRow });
}
// Refresh the settings-page action descriptors from the manifest.
db.prepare('DELETE FROM plugin_actions WHERE plugin_id = ?').run(m.id);
const insertAction = db.prepare(
'INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)',
await repos.actions.deleteAllForPlugin(m.id);
await repos.actions.insertActions(
m.id,
m.actions.map((a, i) => ({ action_key: a.key, label: a.label, hint: a.hint ?? null, danger: a.danger ? 1 : 0, scope: a.scope, sort_order: i })),
);
m.actions.forEach((a, i) => insertAction.run(m.id, a.key, a.label, a.hint ?? null, a.danger ? 1 : 0, a.scope, i));
// Refresh the settings-field descriptors from the manifest.
db.prepare('DELETE FROM plugin_settings_fields WHERE plugin_id = ?').run(m.id);
const insert = db.prepare(
`INSERT INTO plugin_settings_fields (plugin_id, field_key, label, input_type, placeholder, hint, required, secret, scope, options, oauth_config, default_value, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
await repos.settingsFields.deleteAllForPlugin(m.id);
await repos.settingsFields.insertFields(
m.id,
m.settings.map((f, i) => ({
field_key: f.key,
label: f.label ?? f.key,
input_type: f.input_type ?? 'text',
placeholder: f.placeholder ?? null,
hint: f.hint ?? null,
required: f.required ? 1 : 0,
secret: f.secret ? 1 : 0,
scope: f.scope ?? 'instance',
options: f.options ? JSON.stringify(f.options) : null,
oauth_config: f.oauth ? JSON.stringify(f.oauth) : null,
default_value: f.default === undefined ? null : JSON.stringify(f.default),
sort_order: i,
})),
);
m.settings.forEach((f, i) => {
insert.run(
m.id,
f.key,
f.label ?? f.key,
f.input_type ?? 'text',
f.placeholder ?? null,
f.hint ?? null,
f.required ? 1 : 0,
f.secret ? 1 : 0,
f.scope ?? 'instance',
f.options ? JSON.stringify(f.options) : null,
f.oauth ? JSON.stringify(f.oauth) : null,
f.default === undefined ? null : JSON.stringify(f.default),
i,
);
});
}
@@ -1,6 +1,8 @@
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { DatabaseService } from '../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { PluginCapabilityAudit } from '../../db/entities/PluginCapabilityAudit.entity';
import type { PluginCapabilityAuditRepository } from '../../db/repositories/PluginCapabilityAudit.repository';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { pluginsEnabled } from './kill-switch';
import { readAuditForUser } from './host/plugin-audit';
@@ -18,7 +20,7 @@ import { readAuditForUser } from './host/plugin-audit';
@Controller('api/plugin-activity')
@UseGuards(JwtAuthGuard)
export class PluginActivityController {
constructor(private readonly dbs: DatabaseService) {}
constructor(@InjectRepository(PluginCapabilityAudit) private readonly audit: PluginCapabilityAuditRepository) {}
@Get()
async mine(@Req() req: Request & { user?: { id: number } }, @Query('limit') limitRaw?: string): Promise<{ activity: unknown[] }> {
@@ -28,6 +30,6 @@ export class PluginActivityController {
// Math.floor so a non-integer (e.g. ?limit=2.5) can't reach SQLite's LIMIT and 500;
// Math.floor(NaN) stays NaN so the `|| 200` fallback still applies.
const limit = Math.min(Math.max(Math.floor(Number(limitRaw)) || 200, 1), 500);
return { activity: await readAuditForUser(this.dbs.connection, userId, limit) };
return { activity: await readAuditForUser(this.audit, userId, limit) };
}
}
@@ -56,7 +56,7 @@ import { closePluginDataDb } from './host/plugin-host-state';
import { ForbiddenResource } from './host/rpc-host';
import { removePluginData } from './host/plugin-data.service';
import { isKnownPermission } from './protocol/envelope';
import { discoverPlugins } from './install/discovery';
import { discoverPlugins, type DiscoveryRepos } from './install/discovery';
import { parseJsonText, parseManifest, parseMcpToolCapabilities } from './install/manifest';
import { scanForNativeBinaries } from './install/native-scan';
import { devLinkEnabled, DEV_LINK_SOURCE } from './dev-link';
@@ -313,6 +313,12 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
return this.dbs.connection;
}
// Plan 3j Task 3: `discoverPlugins` composes `DiscoveryRepos` from the repositories
// this class already injects for its own PR-numbered statements — no new params.
private get discoveryRepos(): DiscoveryRepos {
return { plugins: this.plugins, actions: this.pluginActions, settingsFields: this.pluginSettingsFields, errorLog: this.pluginErrorLog };
}
// onApplicationBootstrap, NOT onModuleInit: boot activation builds each plugin's
// rpc host synchronously, and the host snapshots PluginRpcRegistryService at
// construction (bindInto). Same-module onModuleInit hooks fire in providers-array
@@ -359,24 +365,32 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// being raw SQL. Plan 3j Task 2: `installedDepRows` now reads `plugins` through
// `PluginsRepository.listDepRows` (PR15/16/25 converted) — a genuine request-context
// dependency the no-ORM branch below cannot satisfy (there is no ORM to wrap with).
// `discoverPlugins` itself stays raw (Task 3's own file, unconverted) and safe to run
// unwrapped either way. DEVIATION from Task 0's own note (task-0-report.md) and from
// RT-BOOT-NOORM-001's prior assertion: the no-ORM branch can no longer read `plugins`
// at all, so it no longer names which plugin(s) it is skipping — it logs a single
// generic line instead. This is a strictly MORE conservative outcome (skip more,
// never throw out of onApplicationBootstrap, same "fail closed, never abort app.init()"
// invariant) than before, not a correctness regression — and it can only ever be
// observed by a hand-built test instance that constructs this service without the
// `orm` param its own `plugins`/other repositories are drawn from; production always
// provides both together from the same DI graph. See task-2-report.md.
// Plan 3j Task 3: `discoverPlugins` is repository-backed too now (DI1–DI8), so it
// joins `installedDepRows` inside the SAME `withRequestContext` wrap — both need one,
// and both come from the same DI graph, so one wrap covers both. In the no-ORM
// branch `discoverPlugins` is skipped along with `installedDepRows`, not called
// unwrapped: a repository read/write outside a request context now fails closed
// (`cannotUseGlobalContext`) instead of running, so calling it there would only
// trade an explicit skip for a caught-and-swallowed throw — the outer try/catch
// still yields the same "boot must never block app init" outcome either way, but
// the explicit skip (matching `installedDepRows`'s own shape) is the intended one.
// DEVIATION from Task 0's own note (task-0-report.md) and from RT-BOOT-NOORM-001's
// prior assertion: the no-ORM branch can no longer read OR write `plugins` at all,
// so it no longer names which plugin(s) it is skipping and never discovers new ones
// on disk — it logs a single generic line instead. This is a strictly MORE
// conservative outcome (skip more, never throw out of onApplicationBootstrap, same
// "fail closed, never abort app.init()" invariant) than before, not a correctness
// regression — and it can only ever be observed by a hand-built test instance that
// constructs this service without the `orm` param its own `plugins`/other
// repositories are drawn from; production always provides both together from the
// same DI graph. See task-2-report.md.
let installed: Map<string, PluginDepRow>;
if (this.orm) {
installed = await withRequestContext(this.orm, async () => {
await discoverPlugins(this.db);
await discoverPlugins(this.discoveryRepos);
return this.installedDepRows();
});
} else {
await discoverPlugins(this.db);
installed = new Map();
}
const enabledIds = [...installed.values()].filter((r) => r.enabled).map((r) => r.id);
@@ -613,7 +627,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
/** Re-scan the plugins volume on demand (admin action). */
async rescan(): Promise<{ discovered: string[]; skipped: string[] }> {
return await discoverPlugins(this.db);
return await discoverPlugins(this.discoveryRepos);
}
async onModuleDestroy(): Promise<void> {
@@ -753,7 +767,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
await this.plugins.grantPermissionsAndEnable(id, JSON.stringify(declared));
// Manifest defaults fill whatever the admin never set, so the child's ctx.config is
// the same effective value the settings form shows (see settings-defaults.ts).
const config = applySettingDefaults(decryptConfig(parseObject(row.config)), await settingDefaults(this.db, id, 'instance'));
const config = applySettingDefaults(decryptConfig(parseObject(row.config)), await settingDefaults(this.pluginSettingsFields, id, 'instance'));
const manifestHosts = declared.filter((p) => p.startsWith(HTTP_OUTBOUND)).map((p) => p.slice(HTTP_OUTBOUND.length));
// Union in the hosts the ADMIN added post-install. A plugin that talks to a
// self-hosted service can't name the operator's hostname in its manifest, so without
@@ -1027,7 +1041,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
removePluginCodeEntry(dest); // drop any prior link — never follows into the author's source
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.symlinkSync(sourceDir, dest, 'junction'); // Windows junction (no elevation); POSIX ignores the type -> dir symlink
await discoverPlugins(this.db); // registers/updates the row from the linked manifest, INACTIVE
await discoverPlugins(this.discoveryRepos); // registers/updates the row from the linked manifest, INACTIVE
// Same as a sideload: the plugin has left the registry trust model, so a block that
// described a refused REGISTRY update no longer describes the code that will run.
await this.plugins.clearForDevLink(id, DEV_LINK_SOURCE);
+9 -1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { PluginsController } from './plugins.controller';
import { PluginsFeedController } from './plugins-feed.controller';
import { PluginsProxyController } from './plugins-proxy.controller';
@@ -9,6 +10,7 @@ import { PluginsRuntimeModule } from './plugins-runtime.module';
import { PluginOAuthModule } from './oauth/plugin-oauth.module';
import { PluginContributionsModule } from './contributions/plugin-contributions.module';
import { AppConfigModule } from '../app-config/app-config.module';
import { PluginCapabilityAudit } from '../../db/entities/PluginCapabilityAudit.entity';
/**
* Plugin system (#plugins), composition root.
@@ -26,9 +28,15 @@ import { AppConfigModule } from '../app-config/app-config.module';
* What is left here is the CRUD and delivery surface: install/activate/configure, the
* feed the client reads, the proxy to a child's HTTP routes, and the sandboxed frame
* that serves page and widget assets.
*
* `MikroOrmModule.forFeature([PluginCapabilityAudit])` (Plan 3j Task 3):
* `PluginActivityController`'s own `@InjectRepository` param — `PluginsRuntimeModule`
* registers the same entity for ITS OWN providers but does not export the repository
* token, so this module (the one that constructs the controller) needs its own entry,
* same reasoning `PluginContributionsModule`'s own docstring gives for `JourneyEntries`.
*/
@Module({
imports: [AppConfigModule, PluginsRuntimeModule, PluginOAuthModule, PluginContributionsModule],
imports: [AppConfigModule, PluginsRuntimeModule, PluginOAuthModule, PluginContributionsModule, MikroOrmModule.forFeature([PluginCapabilityAudit])],
controllers: [
PluginsController,
PluginsFeedController,
+1 -1
View File
@@ -383,7 +383,7 @@ export class PluginsService {
*/
private async assertRequiredFilled(id: string, scope: 'instance' | 'user', config: Record<string, unknown>): Promise<void> {
const required = await this.pluginSettingsFields.listRequiredFieldKeys(id, scope);
const defaults = await settingDefaults(this.db, id, scope);
const defaults = await settingDefaults(this.pluginSettingsFields, id, scope);
for (const fieldKey of required) {
// The runtime resolves the default too, so it counts as filled here as well.
if (!isFilled(config[fieldKey] ?? defaults[fieldKey])) throw new MissingRequiredSettingError(fieldKey);
@@ -1,6 +1,5 @@
import { readEnv } from '../../../app-config';
import { DatabaseService } from '../../database/database.service';
import { discoverPlugins } from '../install/discovery';
import { discoverPlugins, type DiscoveryRepos } from '../install/discovery';
import { bypassedRange, hostSatisfies, hostVersion, normalizedHost, trekRangeBypassed, warnRangeBypass } from '../install/host-compat';
import type { TrekRangeBypass } from '../install/host-compat';
import type { PluginDependency } from '../install/manifest';
@@ -12,6 +11,15 @@ import { verifyAuthorSignature, SignatureError } from '../install/verify-signatu
import { pluginCodeDir, pluginsCodeRoot, pluginsDataRoot } from '../paths';
import { clearUpdateBlock, isSignatureCode, setUpdateBlock, RETRUSTABLE_CODE } from '../signature-status';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Plugins } from '../../../db/entities/Plugins.entity';
import type { PluginsRepository } from '../../../db/repositories/Plugins.repository';
import { PluginActions } from '../../../db/entities/PluginActions.entity';
import type { PluginActionsRepository } from '../../../db/repositories/PluginActions.repository';
import { PluginSettingsFields } from '../../../db/entities/PluginSettingsFields.entity';
import type { PluginSettingsFieldsRepository } from '../../../db/repositories/PluginSettingsFields.repository';
import { PluginErrorLog } from '../../../db/entities/PluginErrorLog.entity';
import type { PluginErrorLogRepository } from '../../../db/repositories/PluginErrorLog.repository';
import fs from 'node:fs';
import path from 'node:path';
@@ -165,10 +173,19 @@ export class RegistryError extends Error {
@Injectable()
export class PluginRegistryService {
constructor(private readonly dbs: DatabaseService) {}
constructor(
@InjectRepository(Plugins) private readonly plugins: PluginsRepository,
// The remaining three are needed only to compose `DiscoveryRepos` for the
// `discoverPlugins` calls below (install()'s post-download register, commitUpload's
// sideload register) — this service issues no statement of its own against any of
// these three tables.
@InjectRepository(PluginActions) private readonly pluginActions: PluginActionsRepository,
@InjectRepository(PluginSettingsFields) private readonly pluginSettingsFields: PluginSettingsFieldsRepository,
@InjectRepository(PluginErrorLog) private readonly pluginErrorLog: PluginErrorLogRepository,
) {}
private get db() {
return this.dbs.connection;
private get discoveryRepos(): DiscoveryRepos {
return { plugins: this.plugins, actions: this.pluginActions, settingsFields: this.pluginSettingsFields, errorLog: this.pluginErrorLog };
}
/**
@@ -448,7 +465,7 @@ export class PluginRegistryService {
try {
await this.verifySignatureAndTofu(id, bytes, entry, ver, opts?.retrustKey);
} catch (e) {
if (e instanceof RegistryError && isSignatureCode(e.code)) await setUpdateBlock(this.dbs.connection, id, e.code, e.message, ver.version);
if (e instanceof RegistryError && isSignatureCode(e.code)) await setUpdateBlock(this.plugins, id, e.code, e.message, ver.version);
throw e;
}
@@ -480,24 +497,18 @@ export class PluginRegistryService {
fs.renameSync(pluginRoot, dest);
// 7. register INACTIVE (record provenance)
await discoverPlugins(this.db);
this.db.prepare('UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?').run(
entry.repo,
ver.commitSha,
ver.sha256,
entry.reviewedAt ?? null,
id,
);
await discoverPlugins(this.discoveryRepos);
await this.plugins.setInstallProvenance(id, entry.repo, ver.commitSha, ver.sha256, entry.reviewedAt ?? null);
// Pin the author key on first successful install of a signed plugin (TOFU) —
// and, after a re-trust, re-pin to the new key the admin blessed. Only ever set
// to a key the artifact just verified under; NEVER cleared to NULL, because a
// NULL pin re-opens the "was never signed" path that accepts an unsigned update.
if (entry.authorPublicKey) {
this.db.prepare('UPDATE plugins SET author_pubkey = ? WHERE id = ?').run(entry.authorPublicKey, id);
await this.plugins.setAuthorPubkey(id, entry.authorPublicKey);
}
// The plugin is now on new code that passed every check — whatever refusal was
// recorded before no longer describes reality.
await clearUpdateBlock(this.dbs.connection, id);
await clearUpdateBlock(this.plugins, id);
return { id, version: ver.version, trekRangeBypassed };
} finally {
fs.rmSync(staging, { recursive: true, force: true });
@@ -516,9 +527,7 @@ export class PluginRegistryService {
constraint?: string,
): Promise<{ installed: string[]; requiredAddons: string[]; trekRangeBypassed: TrekRangeBypass | null }> {
let trekRangeBypassed: TrekRangeBypass | null = null;
const installedNow = new Set(
(this.db.prepare('SELECT id FROM plugins').all() as Array<{ id: string }>).map((r) => r.id),
);
const installedNow = new Set(await this.plugins.listAllIds());
const done = new Set<string>();
const installed: string[] = [];
const requiredAddons = new Set<string>();
@@ -566,7 +575,7 @@ export class PluginRegistryService {
hold = false;
}
}
this.db.prepare('UPDATE plugins SET update_hold = ? WHERE id = ?').run(hold ? 1 : 0, id);
await this.plugins.setUpdateHold(id, hold);
return hold;
}
@@ -609,7 +618,7 @@ export class PluginRegistryService {
fs.mkdirSync(pluginsCodeRoot(), { recursive: true });
fs.rmSync(dest, { recursive: true, force: true });
fs.renameSync(staged.root, dest);
await discoverPlugins(this.db);
await discoverPlugins(this.discoveryRepos);
// Provenance for a sideload, plus a hard INACTIVE floor: discoverPlugins keeps
// an existing row's status, so replacing a plugin that was active must not
// leave the new code marked active — the admin re-activates (and re-consents
@@ -619,12 +628,7 @@ export class PluginRegistryService {
// plugin has just left the registry trust model entirely — the code is now whatever
// the admin uploaded. Leaving the block would have the row insist an update was
// blocked over a signing key that no longer applies to the code that is running.
this.db.prepare(
`UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ?, author_pubkey = NULL,
update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL,
status = 'inactive', enabled = 0
WHERE id = ?`,
).run('local:upload', null, null, null, staged.id);
await this.plugins.clearForSideload(staged.id, 'local:upload');
} finally {
fs.rmSync(staged.stagingDir, { recursive: true, force: true });
}
@@ -657,9 +661,7 @@ export class PluginRegistryService {
ver: RegistryVersion,
retrustKey?: string,
): Promise<void> {
const pinned =
(this.db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string } | undefined)
?.author_pubkey ?? null;
const pinned = await this.plugins.findAuthorPubkey(id);
if (!entry.authorPublicKey && !ver.signature) {
if (pinned) {
@@ -710,9 +712,7 @@ export class PluginRegistryService {
* rendered, the admin would be blessing a key they never saw.
*/
async assertRetrustable(id: string, publicKey: string): Promise<RegistryEntry> {
const row = this.db.prepare('SELECT source_repo, author_pubkey FROM plugins WHERE id = ?').get(id) as
| { source_repo?: string | null; author_pubkey?: string | null }
| undefined;
const row = await this.plugins.findSourceRepoAndAuthorPubkey(id);
if (!row) throw new RegistryError(`plugin ${id} not found`, 'NOT_FOUND');
if (!row.source_repo || row.source_repo === 'local:upload' || row.source_repo === 'local:link') {
throw new RegistryError('only a registry-installed plugin can be re-trusted', 'RETRUST_NOT_APPLICABLE');
+3 -7
View File
@@ -1,4 +1,4 @@
import type BetterSqlite3 from 'better-sqlite3';
import type { PluginSettingsFieldsRepository } from '../../db/repositories/PluginSettingsFields.repository';
/** What a settings-field `default` may be — the JSON scalars the manifest accepts. */
export type SettingDefault = string | number | boolean;
@@ -32,15 +32,11 @@ export function parseDefaultValue(raw: unknown): SettingDefault | undefined {
/** The declared defaults for one plugin and scope, keyed by field. Null-prototype so a
* field key can never resolve off Object.prototype (see plugin-config-parse.ts). */
export async function settingDefaults(
db: BetterSqlite3.Database,
settingsFields: PluginSettingsFieldsRepository,
pluginId: string,
scope: 'instance' | 'user',
): Promise<Record<string, SettingDefault>> {
const rows = db
.prepare(
'SELECT field_key, default_value FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? AND secret = 0 AND default_value IS NOT NULL',
)
.all(pluginId, scope) as Array<{ field_key: string; default_value: string }>;
const rows = await settingsFields.listDefaults(pluginId, scope);
const out: Record<string, SettingDefault> = Object.create(null);
for (const r of rows) {
const v = parseDefaultValue(r.default_value);
+5 -10
View File
@@ -1,4 +1,4 @@
import type Database from 'better-sqlite3';
import type { PluginsRepository } from '../../db/repositories/Plugins.repository';
/**
* Signature/trust status shared between the registry installer, the read-side
@@ -67,14 +67,9 @@ export function keyFingerprint(pubkey: string | null | undefined): string | null
* old code. A blocked update is not a broken runtime, and conflating them would
* make the isolation-health dot lie.
*/
export async function setUpdateBlock(conn: Database.Database, id: string, code: SignatureCode, detail: string, version: string | null): Promise<void> {
export async function setUpdateBlock(plugins: PluginsRepository, id: string, code: SignatureCode, detail: string, version: string | null): Promise<void> {
try {
conn.prepare('UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?').run(
code,
detail,
version,
id,
);
await plugins.setUpdateBlockColumns(id, code, detail, version);
} catch {
// Columns absent (a slimmed test app) — the block is a nicety, never a gate.
}
@@ -84,9 +79,9 @@ export async function setUpdateBlock(conn: Database.Database, id: string, code:
* activating the plugin at its OLD version resolves nothing, and letting an off/on
* toggle erase the warning is exactly the silent-stops-updating failure this exists
* to prevent. (Uninstall drops the row entirely, so it needs no explicit clear.) */
export async function clearUpdateBlock(conn: Database.Database, id: string): Promise<void> {
export async function clearUpdateBlock(plugins: PluginsRepository, id: string): Promise<void> {
try {
conn.prepare('UPDATE plugins SET update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL WHERE id = ?').run(id);
await plugins.clearUpdateBlockColumns(id);
} catch {
// See setUpdateBlock.
}
@@ -1,6 +1,8 @@
import { readCappedJson, discardBody } from '../../utils/cappedFetch';
import { safeFetchAdminConfigured } from '../../utils/ssrfGuard';
import { DatabaseService } from '../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Plugins } from '../../db/entities/Plugins.entity';
import type { PluginsRepository } from '../../db/repositories/Plugins.repository';
import { normalize, declaredProfiles } from '../plugins/contributions/plugin-route-normalize';
import { pluginsEnabled } from '../plugins/kill-switch';
import { PluginHooks } from '../plugins/plugin-hooks.service';
@@ -99,7 +101,7 @@ export class RoadtripRouterService {
constructor(
private readonly settings: SettingsService,
private readonly hooks: PluginHooks,
private readonly db: DatabaseService,
@InjectRepository(Plugins) private readonly plugins: PluginsRepository,
) {}
async profiles(): Promise<string[]> {
@@ -108,8 +110,8 @@ export class RoadtripRouterService {
// R1.4: a `flatMap` callback cannot await the now-async profile read, so the
// fan-out runs as an explicit loop — same providers, same order.
for (const id of this.hooks.providersOf('routeProvider')) {
// RRT1 — `plugins` is Plan 3j's table; stays a raw hand-off of the connection.
for (const profile of await declaredProfiles(this.db.connection, id)) plugin.push(`plugin:${id}/${profile}`); // Plan 3j
// RRT1 — `plugins` is Plan 3j's table; `declaredProfiles` now converted (Task 3).
for (const profile of await declaredProfiles(this.plugins, id)) plugin.push(`plugin:${id}/${profile}`); // Plan 3j
}
}
return ['driving', 'walking', 'cycling', ...plugin];
@@ -176,7 +178,7 @@ export class RoadtripRouterService {
points.length > 30 ||
!pluginsEnabled() ||
!this.hooks.providersOf('routeProvider').includes(id) ||
!(await declaredProfiles(this.db.connection, id)).includes(name) // RRT2 — Plan 3j
!(await declaredProfiles(this.plugins, id)).includes(name) // RRT2 — Plan 3j, converted (Task 3)
)
throw new Error('Routing plugin is unavailable or has too many waypoints');
const raw = await this.hooks.route(id, { tripId, dayId, profile: name, waypoints: points }, userId);
+5 -1
View File
@@ -43,6 +43,7 @@ import { RoadtripVias } from '../../db/entities/RoadtripVias.entity';
import { RoadtripDayTracks } from '../../db/entities/RoadtripDayTracks.entity';
import { RoadtripPreferences } from '../../db/entities/RoadtripPreferences.entity';
import { RoadtripDayBoundaries } from '../../db/entities/RoadtripDayBoundaries.entity';
import { Plugins } from '../../db/entities/Plugins.entity';
/** Road trip domain (#1797): the points a drive is routed through. Registered in AppModule. */
@Module({
@@ -60,6 +61,9 @@ import { RoadtripDayBoundaries } from '../../db/entities/RoadtripDayBoundaries.e
// `DayAssignmentsRepository.findInTrip`/`listRoadtripVisits`) all need
// their own entry here, even though `PlacesModule`/`AssignmentsModule`
// already register some of the same entities for THEIR OWN providers.
// `Plugins` (Plan 3j Task 3): `RoadtripRouterService`'s RRT1/RRT2 call
// `declaredProfiles`, now `PluginsRepository`-typed; `PluginsRuntimeModule`
// registers the same entity but does not export the repository token.
imports: [
McpSharedModule,
PermissionsModule,
@@ -69,7 +73,7 @@ import { RoadtripDayBoundaries } from '../../db/entities/RoadtripDayBoundaries.e
SettingsModule,
PluginsRuntimeModule,
MapsModule, PlacesModule, AssignmentsModule,
MikroOrmModule.forFeature([Trips, Days, Places, Users, DayAssignments, RoadtripVias, RoadtripDayTracks, RoadtripPreferences, RoadtripDayBoundaries]),
MikroOrmModule.forFeature([Trips, Days, Places, Users, DayAssignments, RoadtripVias, RoadtripDayTracks, RoadtripPreferences, RoadtripDayBoundaries, Plugins]),
],
controllers: [ChargingController, ChargingLookupController, GoogleRouteController, RoadtripSearchController, RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController],
providers: [ChargingMcp, ChargingService, GoogleRouteService, GoogleRouteMcp,