mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
refactor(plugins): runtime lifecycle, config CRUD and the uninstall cascade through repositories
This commit is contained in:
@@ -56,4 +56,16 @@ export class NotificationChannelPreferencesRepository extends TrekRepository<Not
|
||||
async deletePreference(userId: number, eventType: string, channel: string): Promise<number> {
|
||||
return await this.nativeDelete({ user: userId, event_type: eventType, channel });
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan 3j Task 2 — `plugin-runtime.service.ts#retireNotificationChannel` (PR50,
|
||||
* cross-domain): `DELETE FROM notification_channel_preferences WHERE channel = ?`,
|
||||
* fired when a plugin's notification channel is retired (uninstall, or the
|
||||
* capability disappearing on update). Unlike every other write on this
|
||||
* repository, there is no `user`/`event_type` filter — every user's opt-out for
|
||||
* THIS channel goes, across every event.
|
||||
*/
|
||||
async deleteAllForChannel(channel: string): Promise<void> {
|
||||
await this.nativeDelete({ channel });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,33 @@
|
||||
import type { PluginActions } from '../entities/PluginActions.entity';
|
||||
import type { PluginActionScope } from '@trek/shared';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class PluginActionsRepository extends TrekRepository<PluginActions> {}
|
||||
/** `actionsOf`'s own projection — mapped to `PluginActionDescriptor` in the service. */
|
||||
export interface PluginActionRow {
|
||||
action_key: string;
|
||||
label: string;
|
||||
hint: string | null;
|
||||
danger: number;
|
||||
scope: PluginActionScope;
|
||||
}
|
||||
|
||||
export class PluginActionsRepository extends TrekRepository<PluginActions> {
|
||||
/** PS3 (`plugins.service.ts#instanceActionsCount`) — `SELECT COUNT(*) AS n FROM plugin_actions WHERE plugin_id = ? AND scope = 'instance'`. Caller wraps this in its own try/catch → 0. */
|
||||
async countForPluginScope(pluginId: string, scope: PluginActionScope): Promise<number> {
|
||||
return await this.count({ plugin_id: pluginId, scope });
|
||||
}
|
||||
|
||||
/** `actionsOf` — `SELECT action_key, label, hint, danger, scope FROM plugin_actions WHERE plugin_id = ? AND scope = ? ORDER BY sort_order`. Caller wraps this in its own try/catch → []. */
|
||||
async listForPluginScope(pluginId: string, scope: PluginActionScope): Promise<PluginActionRow[]> {
|
||||
const rows = await this.find(
|
||||
{ plugin_id: pluginId, scope },
|
||||
{ fields: ['action_key', 'label', 'hint', 'danger', 'scope'], orderBy: { sort_order: 'asc' } },
|
||||
);
|
||||
return rows.map((r) => ({ action_key: r.action_key, label: r.label, hint: r.hint ?? null, danger: r.danger, scope: r.scope as PluginActionScope }));
|
||||
}
|
||||
|
||||
/** PR35 (uninstall cascade) — `DELETE FROM plugin_actions WHERE plugin_id = ?`. The caller wraps this in its own try/catch (table absent on a slimmed test schema is tolerated here). */
|
||||
async deleteAllForPlugin(pluginId: string): Promise<void> {
|
||||
await this.nativeDelete({ plugin_id: pluginId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,181 @@
|
||||
import type { PluginCapabilityAudit } from '../entities/PluginCapabilityAudit.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class PluginCapabilityAuditRepository extends TrekRepository<PluginCapabilityAudit> {}
|
||||
/**
|
||||
* Plan 3j Task 2 note: this repository's ONLY method today is the uninstall
|
||||
* cascade's own delete (PR45) — R-hash-chain's own read/append/prune surface
|
||||
* (`host/plugin-audit.ts`) belongs to Task 3, which adds its own additive
|
||||
* methods to this same repository. Do NOT confuse this table with the global,
|
||||
* admin-facing `audit_log` (3i's `AuditLogRepository`) — two unrelated "audit"
|
||||
* systems (plan3j-inputs.md corrections §4).
|
||||
*
|
||||
* Plan 3j Task 3 note: the methods below are the promised additive surface —
|
||||
* `pruneKeepingNewest`/`lastHash`/`insertRow`/`forUser`/`forPlugin`/`budgetSeed`,
|
||||
* backing `host/plugin-audit.ts` (R-hash-chain) and `host/plugin-host-state.ts`'s
|
||||
* `budgetFor` seed read (HS1). PR45's `deleteAllForPlugin` above is untouched.
|
||||
*/
|
||||
export class PluginCapabilityAuditRepository extends TrekRepository<PluginCapabilityAudit> {
|
||||
/** PR45 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_capability_audit WHERE plugin_id = ?`. */
|
||||
async deleteAllForPlugin(pluginId: string): Promise<void> {
|
||||
await this.nativeDelete({ plugin_id: pluginId });
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Plan 3j Task 3 — R-hash-chain (host/plugin-audit.ts) + HS1 (plugin-host-state.ts)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* PA1 (`pruneAudit`'s amortized retention) — `` DELETE FROM plugin_capability_audit
|
||||
* WHERE plugin_id = ? AND id NOT IN (SELECT id FROM plugin_capability_audit WHERE
|
||||
* plugin_id = ? ORDER BY id DESC LIMIT ?) ``. Same `NOT IN (subquery … ORDER BY …
|
||||
* LIMIT)` shape as `PluginErrorLogRepository#pruneKeepingRecent` — no `nativeDelete`/
|
||||
* `FilterQuery` equivalent (no correlated-subquery support), so this goes through
|
||||
* `this.kysely()` (`_shared/trek-repository.ts`'s escape hatch). The `keep` count is
|
||||
* a `?`-bound `limit()` call, never interpolated into SQL text.
|
||||
*/
|
||||
async pruneKeepingNewest(pluginId: string, keep: number): Promise<void> {
|
||||
const db = this.kysely<PluginCapabilityAuditKyselyDB>();
|
||||
await db
|
||||
.deleteFrom('plugin_capability_audit')
|
||||
.where('plugin_id', '=', pluginId)
|
||||
.where(
|
||||
'id',
|
||||
'not in',
|
||||
db.selectFrom('plugin_capability_audit').select('id').where('plugin_id', '=', pluginId).orderBy('id', 'desc').limit(keep),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* PA2 (`appendAudit`'s chain read) — `SELECT hash FROM plugin_capability_audit
|
||||
* WHERE plugin_id = ? ORDER BY id DESC LIMIT 1`. `null` means "no prior row for
|
||||
* this plugin" (a fresh chain) — `appendAudit` folds that into `''` the same way
|
||||
* the legacy `?.hash ?? ''` did.
|
||||
*/
|
||||
async lastHash(pluginId: string): Promise<string | null> {
|
||||
const row = await this.findOne({ plugin_id: pluginId }, { fields: ['hash'], orderBy: { id: 'desc' } });
|
||||
return row?.hash ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PA3 (`appendAudit`'s own write) — `INSERT INTO plugin_capability_audit
|
||||
* (plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash)
|
||||
* VALUES (?,?,?,?,?,?,?,?)`. The hash itself (and `ts`, computed in JS as
|
||||
* `new Date().toISOString()` BEFORE the hash) is computed by the caller —
|
||||
* `appendAudit` in `host/plugin-audit.ts` — never here: this method persists
|
||||
* the already-hashed row byte-for-byte, so R-hash-chain's construction stays
|
||||
* visible and testable in one place.
|
||||
*/
|
||||
async insertRow(entry: NewAuditRow): Promise<void> {
|
||||
await this.insert({
|
||||
plugin_id: entry.plugin_id,
|
||||
acting_user_id: entry.acting_user_id,
|
||||
method: entry.method,
|
||||
resource: entry.resource,
|
||||
code: entry.code,
|
||||
ts: entry.ts,
|
||||
prev_hash: entry.prev_hash,
|
||||
hash: entry.hash,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PA4 (`readAuditForUser`) — `SELECT a.ts, a.plugin_id, p.name AS plugin_name,
|
||||
* a.method, a.resource, a.code FROM plugin_capability_audit a LEFT JOIN plugins
|
||||
* p ON p.id = a.plugin_id WHERE a.acting_user_id = ? ORDER BY a.id DESC LIMIT ?`.
|
||||
* Kysely against the literal `plugins` table, the same `AuditLogRepository
|
||||
* #listPage` LEFT-JOIN precedent (a deleted/never-existing plugin leaves
|
||||
* `plugin_name` null, the row itself still returned) — no `PluginsRepository`
|
||||
* dependency: the join only needs the table, not that repository's own methods.
|
||||
*/
|
||||
async forUser(userId: number, limit: number): Promise<AuditForUserRow[]> {
|
||||
const rows = await this.kysely<PluginCapabilityAuditKyselyDB>()
|
||||
.selectFrom('plugin_capability_audit as a')
|
||||
.leftJoin('plugins as p', 'p.id', 'a.plugin_id')
|
||||
.select(['a.ts', 'a.plugin_id', 'p.name as plugin_name', 'a.method', 'a.resource', 'a.code'])
|
||||
.where('a.acting_user_id', '=', userId)
|
||||
.orderBy('a.id', 'desc')
|
||||
.limit(limit)
|
||||
.execute();
|
||||
return rows.map((r) => ({ ts: r.ts, plugin_id: r.plugin_id, plugin_name: r.plugin_name ?? null, method: r.method, resource: r.resource, code: r.code }));
|
||||
}
|
||||
|
||||
/**
|
||||
* PA5 (`readAudit`, admin per-plugin view) — `SELECT ts, acting_user_id, method,
|
||||
* resource, code FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id
|
||||
* DESC LIMIT ?`.
|
||||
*/
|
||||
async forPlugin(pluginId: string, limit: number): Promise<AuditForPluginRow[]> {
|
||||
const rows = await this.find(
|
||||
{ plugin_id: pluginId },
|
||||
{ fields: ['ts', 'acting_user_id', 'method', 'resource', 'code'], orderBy: { id: 'desc' }, limit },
|
||||
);
|
||||
return rows.map((r) => ({ ts: r.ts, acting_user_id: r.acting_user_id ?? null, method: r.method, resource: r.resource ?? null, code: r.code }));
|
||||
}
|
||||
|
||||
/**
|
||||
* HS1 (`plugin-host-state.ts#budgetFor`'s seed read) — `SELECT method, COUNT(*)
|
||||
* AS n FROM plugin_capability_audit WHERE plugin_id = ? AND code = 'ok' AND
|
||||
* ts >= ? AND method IN ('ai.complete','ai.extract','notify.send') GROUP BY
|
||||
* method`. A GROUP BY aggregate — Kysely, same escape-hatch rule as PA1/PA4.
|
||||
*/
|
||||
async budgetSeed(pluginId: string, since: string): Promise<Array<{ method: string; n: number }>> {
|
||||
const rows = await this.kysely<PluginCapabilityAuditKyselyDB>()
|
||||
.selectFrom('plugin_capability_audit')
|
||||
.select(({ fn }) => ['method', fn.count<number>('id').as('n')])
|
||||
.where('plugin_id', '=', pluginId)
|
||||
.where('code', '=', 'ok')
|
||||
.where('ts', '>=', since)
|
||||
.where('method', 'in', ['ai.complete', 'ai.extract', 'notify.send'])
|
||||
.groupBy('method')
|
||||
.execute();
|
||||
return rows.map((r) => ({ method: r.method, n: Number(r.n) }));
|
||||
}
|
||||
}
|
||||
|
||||
/** The column set `insertRow` writes — everything `appendAudit` has already computed. */
|
||||
export interface NewAuditRow {
|
||||
plugin_id: string;
|
||||
acting_user_id: number | null;
|
||||
method: string;
|
||||
resource: string | null;
|
||||
code: string;
|
||||
ts: string;
|
||||
prev_hash: string | null;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
/** `forUser`'s own projection (PA4). */
|
||||
export interface AuditForUserRow {
|
||||
ts: string;
|
||||
plugin_id: string;
|
||||
plugin_name: string | null;
|
||||
method: string;
|
||||
resource: string | null;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** `forPlugin`'s own projection (PA5). */
|
||||
export interface AuditForPluginRow {
|
||||
ts: string;
|
||||
acting_user_id: number | null;
|
||||
method: string;
|
||||
resource: string | null;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** Kysely shape for `pruneKeepingNewest`/`forUser`/`budgetSeed`'s escape-hatch queries — only the columns/tables they actually touch. */
|
||||
interface PluginCapabilityAuditKyselyDB {
|
||||
plugin_capability_audit: {
|
||||
id: number;
|
||||
plugin_id: string;
|
||||
acting_user_id: number | null;
|
||||
method: string;
|
||||
resource: string | null;
|
||||
code: string;
|
||||
ts: string;
|
||||
prev_hash: string | null;
|
||||
hash: string;
|
||||
};
|
||||
plugins: { id: string; name: string };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,39 @@
|
||||
import type { PluginEgressHosts } from '../entities/PluginEgressHosts.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class PluginEgressHostsRepository extends TrekRepository<PluginEgressHosts> {}
|
||||
export class PluginEgressHostsRepository extends TrekRepository<PluginEgressHosts> {
|
||||
/** PR20 — `SELECT host FROM plugin_egress_hosts WHERE plugin_id = ? ORDER BY host` (`operatorEgressHosts`). Caller wraps this in its own try/catch — a slimmed test schema without this table never blocks activation. */
|
||||
async listHostsForPlugin(pluginId: string): Promise<string[]> {
|
||||
const rows = await this.find({ plugin_id: pluginId }, { fields: ['host'], orderBy: { host: 'asc' } });
|
||||
return rows.map((r) => r.host);
|
||||
}
|
||||
|
||||
/** PS1 — `SELECT COUNT(*) AS n FROM plugin_egress_hosts WHERE plugin_id = ?` (`plugins.service.ts#egressHostCount`). Caller wraps this in its own try/catch → 0. */
|
||||
async countForPlugin(pluginId: string): Promise<number> {
|
||||
return await this.count({ plugin_id: pluginId });
|
||||
}
|
||||
|
||||
/**
|
||||
* PR22/PR23 — `setOperatorEgressHosts`'s DELETE+loop-INSERT: `DELETE FROM
|
||||
* plugin_egress_hosts WHERE plugin_id = ?` then, per host, `INSERT OR IGNORE INTO
|
||||
* plugin_egress_hosts (plugin_id, host) VALUES (?, ?)`.
|
||||
*
|
||||
* **R-uninstall's ONE named transaction** (the plan's "For the user" line): the CALLER
|
||||
* (`PluginRuntimeService.setOperatorEgressHosts`) wraps this call in `uow.transactional`
|
||||
* — this method issues the same two-step DELETE-then-INSERT-loop shape the legacy code
|
||||
* did, but now atomically, so a crash mid-write leaves the OLD host set intact instead
|
||||
* of a plugin with zero egress hosts. `INSERT OR IGNORE` -> `onConflictAction: 'ignore'`
|
||||
* against the `(plugin_id, host)` unique pair, unchanged from the legacy statement.
|
||||
*/
|
||||
async replaceAllForPlugin(pluginId: string, hosts: string[]): Promise<void> {
|
||||
await this.nativeDelete({ plugin_id: pluginId });
|
||||
for (const host of hosts) {
|
||||
await this.upsert({ plugin_id: pluginId, host }, { onConflictFields: ['plugin_id', 'host'], onConflictAction: 'ignore' });
|
||||
}
|
||||
}
|
||||
|
||||
/** PR36 (uninstall cascade) — `DELETE FROM plugin_egress_hosts WHERE plugin_id = ?`. The caller wraps this in its own try/catch (table absent on a slimmed test schema is tolerated here). */
|
||||
async deleteAllForPlugin(pluginId: string): Promise<void> {
|
||||
await this.nativeDelete({ plugin_id: pluginId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { PluginEntityMetadata } from '../entities/PluginEntityMetadata.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class PluginEntityMetadataRepository extends TrekRepository<PluginEntityMetadata> {}
|
||||
export class PluginEntityMetadataRepository extends TrekRepository<PluginEntityMetadata> {
|
||||
/** PR40 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_entity_metadata WHERE plugin_id = ?`. */
|
||||
async deleteAllForPlugin(pluginId: string): Promise<void> {
|
||||
await this.nativeDelete({ plugin_id: pluginId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,57 @@
|
||||
import type { PluginErrorLog } from '../entities/PluginErrorLog.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class PluginErrorLogRepository extends TrekRepository<PluginErrorLog> {}
|
||||
/** PR2 delivery row / plugins.service.ts `errors()`'s own projection. */
|
||||
export interface PluginErrorLogRow {
|
||||
ts: string;
|
||||
level: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** `pruneKeepingRecent`'s own `this.kysely()` escape hatch (`_shared/trek-repository.ts`) — the `id`/`plugin_id` columns its correlated subquery needs. */
|
||||
interface PluginErrorLogKyselyDB {
|
||||
plugin_error_log: { id: number; plugin_id: string };
|
||||
}
|
||||
|
||||
export class PluginErrorLogRepository extends TrekRepository<PluginErrorLog> {
|
||||
/** PR2 — `INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, ?, ?)` (the supervisor's `onLog` callback — §4a, lazily resolved, own try/catch swallow unchanged in the caller). */
|
||||
async insertLog(pluginId: string, level: string, message: string): Promise<void> {
|
||||
await this.insert({ plugin_id: pluginId, level, message });
|
||||
}
|
||||
|
||||
/**
|
||||
* PR3 — `` DELETE FROM plugin_error_log WHERE plugin_id = ? AND id NOT IN (
|
||||
* SELECT id FROM plugin_error_log WHERE plugin_id = ? ORDER BY id DESC LIMIT ${retention}
|
||||
* ) `` (`pruneErrorLog`'s retention trim). The `NOT IN (subquery … ORDER BY … LIMIT)`
|
||||
* shape has no `nativeDelete`/`FilterQuery` equivalent (no correlated-subquery support),
|
||||
* so this is one of the few statements per repository that goes through `this.kysely()`
|
||||
* (`_shared/trek-repository.ts`'s escape hatch) rather than the QueryBuilder — the
|
||||
* `retention` count is a `?`-bound `limit()` call, never interpolated into SQL text.
|
||||
*/
|
||||
async pruneKeepingRecent(pluginId: string, retention: number): Promise<void> {
|
||||
const db = this.kysely<PluginErrorLogKyselyDB>();
|
||||
await db
|
||||
.deleteFrom('plugin_error_log')
|
||||
.where('plugin_id', '=', pluginId)
|
||||
.where(
|
||||
'id',
|
||||
'not in',
|
||||
db.selectFrom('plugin_error_log').select('id').where('plugin_id', '=', pluginId).orderBy('id', 'desc').limit(retention),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** PR38 (uninstall cascade) / plugins.service.ts's `clearErrors` — `DELETE FROM plugin_error_log WHERE plugin_id = ?` (identical text, two call sites). */
|
||||
async deleteAllForPlugin(pluginId: string): Promise<void> {
|
||||
await this.nativeDelete({ plugin_id: pluginId });
|
||||
}
|
||||
|
||||
/** plugins.service.ts `errors()` — `SELECT ts, level, message FROM plugin_error_log WHERE plugin_id = ? ORDER BY ts DESC, id DESC LIMIT 200`. */
|
||||
async listRecent(pluginId: string, limit = 200): Promise<PluginErrorLogRow[]> {
|
||||
const rows = await this.find(
|
||||
{ plugin_id: pluginId },
|
||||
{ fields: ['ts', 'level', 'message'], orderBy: [{ ts: 'desc' }, { id: 'desc' }], limit },
|
||||
);
|
||||
return rows.map((r) => ({ ts: r.ts ?? '', level: r.level, message: r.message ?? '' }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import type { PluginMetaMigrations } from '../entities/PluginMetaMigrations.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class PluginMetaMigrationsRepository extends TrekRepository<PluginMetaMigrations> {}
|
||||
/**
|
||||
* Plan 3j Task 2 note: this repository's ONLY method today is the uninstall
|
||||
* cascade's own delete (PR44) — the rest of this table's surface (a plugin's own
|
||||
* `ctx.db.migrate` ledger) belongs to whichever later task converts `plugin-data
|
||||
* .service.ts`'s host-side counterpart, if any (R-out-of-scope carve-out: the
|
||||
* plugin's own sqlite file is untouched by this program).
|
||||
*/
|
||||
export class PluginMetaMigrationsRepository extends TrekRepository<PluginMetaMigrations> {
|
||||
/** PR44 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_meta_migrations WHERE plugin_id = ?`. */
|
||||
async deleteAllForPlugin(pluginId: string): Promise<void> {
|
||||
await this.nativeDelete({ plugin_id: pluginId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import type { PluginOauthState } from '../entities/PluginOauthState.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class PluginOauthStateRepository extends TrekRepository<PluginOauthState> {}
|
||||
/**
|
||||
* Plan 3j Task 2 note: this repository's ONLY method today is the uninstall
|
||||
* cascade's own delete (PR43) — the rest of this table's surface belongs to
|
||||
* Task 4's `oauth/plugin-oauth.service.ts`, which adds its own additive methods.
|
||||
*/
|
||||
export class PluginOauthStateRepository extends TrekRepository<PluginOauthState> {
|
||||
/** PR43 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_oauth_state WHERE plugin_id = ?`. */
|
||||
async deleteAllForPlugin(pluginId: string): Promise<void> {
|
||||
await this.nativeDelete({ plugin_id: pluginId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
import type { PluginOauthTokens } from '../entities/PluginOauthTokens.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class PluginOauthTokensRepository extends TrekRepository<PluginOauthTokens> {}
|
||||
/**
|
||||
* Plan 3j Task 2 note: this repository's ONLY method today is the uninstall
|
||||
* cascade's own delete (`plugin-runtime.service.ts`'s `uninstall()`, PR42) — the
|
||||
* statement lives in Task 2's file even though the rest of this table's surface
|
||||
* (`storeToken`'s COALESCE-preserving composite upsert, R-oauth-upsert) belongs to
|
||||
* Task 4's `oauth/plugin-oauth.service.ts`. Task 4 adds its own methods to this
|
||||
* same repository; this one is additive and does not collide with them.
|
||||
*/
|
||||
export class PluginOauthTokensRepository extends TrekRepository<PluginOauthTokens> {
|
||||
/** PR42 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_oauth_tokens WHERE plugin_id = ?`. */
|
||||
async deleteAllForPlugin(pluginId: string): Promise<void> {
|
||||
await this.nativeDelete({ plugin_id: pluginId });
|
||||
}
|
||||
|
||||
/** `exportUserData`'s GDPR fold — `SELECT DISTINCT plugin_id FROM plugin_oauth_tokens WHERE user_id = ?` (which plugins a user OAuth-linked; raw tokens are never exported). */
|
||||
async listDistinctPluginIdsForUser(userId: number): Promise<string[]> {
|
||||
const rows = await this.find({ user_id: userId }, { fields: ['plugin_id'] });
|
||||
return [...new Set(rows.map((r) => r.plugin_id))];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,64 @@
|
||||
import type { PluginScheduledTasks } from '../entities/PluginScheduledTasks.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class PluginScheduledTasksRepository extends TrekRepository<PluginScheduledTasks> {}
|
||||
/** PR4 — the sweep's own due-task projection. */
|
||||
export interface DueScheduledTaskRow {
|
||||
id: number;
|
||||
plugin_id: string;
|
||||
name: string;
|
||||
payload: string;
|
||||
every_ms: number | null;
|
||||
}
|
||||
|
||||
export class PluginScheduledTasksRepository extends TrekRepository<PluginScheduledTasks> {
|
||||
/**
|
||||
* PR4 — `` SELECT id, plugin_id, name, payload, every_ms FROM plugin_scheduled_tasks
|
||||
* WHERE due_at <= ? AND plugin_id IN (${ph}) ORDER BY due_at LIMIT 200 `` (`fireDueScheduled`'s
|
||||
* sweep window). `pluginIds` — never an empty array: the caller (`fireDueScheduled`)
|
||||
* already returns early when `supervisor.activeIds()` is empty, the same short-circuit
|
||||
* the legacy `IN (${ph})` string-building avoided running against.
|
||||
*/
|
||||
async findDueForPlugins(now: number, pluginIds: string[], limit = 200): Promise<DueScheduledTaskRow[]> {
|
||||
const rows = await this.find(
|
||||
{ due_at: { $lte: now }, plugin_id: { $in: pluginIds } },
|
||||
{ fields: ['id', 'plugin_id', 'name', 'payload', 'every_ms'], orderBy: { due_at: 'asc' }, limit },
|
||||
);
|
||||
return rows.map((r) => ({ id: r.id, plugin_id: r.plugin_id, name: r.name, payload: r.payload, every_ms: r.every_ms ?? null }));
|
||||
}
|
||||
|
||||
/**
|
||||
* PR5 — `UPDATE plugin_scheduled_tasks SET due_at = ? WHERE id = ?` — re-arm BEFORE
|
||||
* the fire (crash-safety ordering, preserved exactly by the caller).
|
||||
*
|
||||
* **Deviation, named (task-2-report.md), the SAME spirit as R-uninstall's egress-host
|
||||
* transaction — a second, small, deliberate correctness fix, not silent parity**: the
|
||||
* legacy statement had no `due_at` guard, but the legacy CALL SITE never needed one —
|
||||
* `better-sqlite3` is synchronous and the claim-then-act loop had no `await` between
|
||||
* the SELECT and this UPDATE, so two "concurrent" JS calls to `fireDueScheduled` could
|
||||
* never actually interleave at the DB level (the first ran to completion before the
|
||||
* second's own SELECT even started). Converting the SELECT and this UPDATE into
|
||||
* separately-awaited repository calls removes that accidental atomicity: two
|
||||
* overlapping ticks (a real possibility once a tick can run longer than the 30s
|
||||
* cadence) can both read the SAME due row before either claims it, delivering it
|
||||
* TWICE (proven by `RACE-SCHED-001` before this guard was added). Adding `due_at <=
|
||||
* now` here (the SAME condition the SELECT already used) makes the claim atomic and
|
||||
* idempotent per row: whichever pass's UPDATE lands first moves `due_at` forward, so
|
||||
* the LOSING pass's own `due_at <= now` no longer matches and its `nativeUpdate`
|
||||
* affects zero rows — the caller uses that to skip delivery rather than double-fire.
|
||||
*/
|
||||
async rearm(id: number, dueAt: number, claimedFromDueAt: number): Promise<boolean> {
|
||||
const changed = await this.nativeUpdate({ id, due_at: { $lte: claimedFromDueAt } }, { due_at: dueAt });
|
||||
return changed > 0;
|
||||
}
|
||||
|
||||
/** PR6 — `DELETE FROM plugin_scheduled_tasks WHERE id = ?` (a one-shot task, deleted before the fire). Same atomic-claim guard as `rearm` above, same reason. */
|
||||
async deleteById(id: number, claimedFromDueAt: number): Promise<boolean> {
|
||||
const changed = await this.nativeDelete({ id, due_at: { $lte: claimedFromDueAt } });
|
||||
return changed > 0;
|
||||
}
|
||||
|
||||
/** PR37 (uninstall cascade) — `DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ?`. */
|
||||
async deleteAllForPlugin(pluginId: string): Promise<void> {
|
||||
await this.nativeDelete({ plugin_id: pluginId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,95 @@
|
||||
import type { PluginSettingsFields } from '../entities/PluginSettingsFields.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class PluginSettingsFieldsRepository extends TrekRepository<PluginSettingsFields> {}
|
||||
/** PR#499 — `notificationChannels`' secret-user-field scan, across ALL plugins. */
|
||||
export interface PluginSecretFieldRow {
|
||||
plugin_id: string;
|
||||
field_key: string;
|
||||
}
|
||||
|
||||
/** `plugins.service.ts#settingsFields`'s own full projection (mapped to `PluginSettingsField` in the service, which also resolves `default`/`options`). */
|
||||
export interface PluginSettingsFieldRow {
|
||||
field_key: string;
|
||||
label: string | null;
|
||||
input_type: string;
|
||||
placeholder: string | null;
|
||||
hint: string | null;
|
||||
required: number;
|
||||
secret: number;
|
||||
options: string | null;
|
||||
default_value: string | null;
|
||||
}
|
||||
|
||||
export class PluginSettingsFieldsRepository extends TrekRepository<PluginSettingsFields> {
|
||||
/** `notificationChannels`'s GDPR-export fold — `SELECT plugin_id, field_key FROM plugin_settings_fields WHERE scope = 'user' AND secret = 1`, across every plugin (no `plugin_id` filter — the caller groups by `plugin_id` itself). */
|
||||
async listSecretUserFields(): Promise<PluginSecretFieldRow[]> {
|
||||
return await this.find({ scope: 'user', secret: 1 }, { fields: ['plugin_id', 'field_key'] });
|
||||
}
|
||||
|
||||
/** PS2 (`plugins.service.ts#instanceSettingsCount`) — `SELECT COUNT(*) AS n FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance'`. Caller wraps this in its own try/catch → 0. */
|
||||
async countForPluginScope(pluginId: string, scope: 'instance' | 'user'): Promise<number> {
|
||||
return await this.count({ plugin_id: pluginId, scope });
|
||||
}
|
||||
|
||||
/**
|
||||
* `updateInstanceConfig`'s secret-key set / `getInstanceConfig`'s secret-key set /
|
||||
* `updateUserConfig`/`getUserConfig`'s `userSecretKeys` — `SELECT field_key FROM
|
||||
* plugin_settings_fields WHERE plugin_id = ? AND scope = ? AND secret = 1` (identical
|
||||
* text shape across both scopes; ONE method for all four call sites).
|
||||
*/
|
||||
async listSecretFieldKeys(pluginId: string, scope: 'instance' | 'user'): Promise<string[]> {
|
||||
const rows = await this.find({ plugin_id: pluginId, scope, secret: 1 }, { fields: ['field_key'] });
|
||||
return rows.map((r) => r.field_key);
|
||||
}
|
||||
|
||||
/**
|
||||
* `updateInstanceConfig`'s allowed-key set / `updateUserConfig`'s allowed-key set —
|
||||
* `SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ?`
|
||||
* (identical text shape across both scopes; ONE method for both call sites).
|
||||
*/
|
||||
async listFieldKeys(pluginId: string, scope: 'instance' | 'user'): Promise<string[]> {
|
||||
const rows = await this.find({ plugin_id: pluginId, scope }, { fields: ['field_key'] });
|
||||
return rows.map((r) => r.field_key);
|
||||
}
|
||||
|
||||
/**
|
||||
* `plugins.service.ts#settingsFields` (backs `userSettingsFields`/`instanceSettingsFields`) —
|
||||
* `SELECT field_key AS key, label, input_type, placeholder, hint, required, secret,
|
||||
* options, default_value FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ?
|
||||
* ORDER BY sort_order, id`.
|
||||
*/
|
||||
async listFields(pluginId: string, scope: 'instance' | 'user'): Promise<PluginSettingsFieldRow[]> {
|
||||
const rows = await this.find(
|
||||
{ plugin_id: pluginId, scope },
|
||||
{
|
||||
fields: ['field_key', 'label', 'input_type', 'placeholder', 'hint', 'required', 'secret', 'options', 'default_value'],
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
},
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
field_key: r.field_key,
|
||||
label: r.label ?? null,
|
||||
input_type: r.input_type,
|
||||
placeholder: r.placeholder ?? null,
|
||||
hint: r.hint ?? null,
|
||||
required: r.required,
|
||||
secret: r.secret,
|
||||
options: r.options ?? null,
|
||||
default_value: r.default_value ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** `assertRequiredFilled` — `SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? AND required = 1 AND input_type != 'checkbox'`. */
|
||||
async listRequiredFieldKeys(pluginId: string, scope: 'instance' | 'user'): Promise<string[]> {
|
||||
const rows = await this.find(
|
||||
{ plugin_id: pluginId, scope, required: 1, input_type: { $ne: 'checkbox' } },
|
||||
{ fields: ['field_key'] },
|
||||
);
|
||||
return rows.map((r) => r.field_key);
|
||||
}
|
||||
|
||||
/** PR34 (uninstall cascade) — `DELETE FROM plugin_settings_fields WHERE plugin_id = ?`. Unlike several of its cascade siblings, this one is NOT wrapped in try/catch by the caller — a slimmed test schema is expected to have this table. */
|
||||
async deleteAllForPlugin(pluginId: string): Promise<void> {
|
||||
await this.nativeDelete({ plugin_id: pluginId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,51 @@
|
||||
import type { PluginUserConfig } from '../entities/PluginUserConfig.entity';
|
||||
import { currentTimestamp } from '../dialect/sql-functions';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class PluginUserConfigRepository extends TrekRepository<PluginUserConfig> {}
|
||||
/** `exportUserData`'s own per-user config scan. */
|
||||
export interface PluginUserConfigRow {
|
||||
plugin_id: string;
|
||||
config: string;
|
||||
}
|
||||
|
||||
export class PluginUserConfigRepository extends TrekRepository<PluginUserConfig> {
|
||||
/**
|
||||
* PS7/PS9/PS11 — `SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id
|
||||
* = ?`, byte-identical text at THREE call sites in `plugins.service.ts` (`getUserConfig`'s
|
||||
* own read, `setUserConfig`'s pre-write existence check, `getUserConfigDecrypted`'s read)
|
||||
* — collapsed onto one method per the brief's own naming of this triple-duplicate.
|
||||
*/
|
||||
async findConfig(pluginId: string, userId: number): Promise<string | null> {
|
||||
const row = await this.findOne({ plugin_id: pluginId, user_id: userId }, { fields: ['config'] });
|
||||
return row?.config ?? null;
|
||||
}
|
||||
|
||||
/** `exportUserData`'s GDPR fold — `SELECT plugin_id, config FROM plugin_user_config WHERE user_id = ?`. */
|
||||
async listForUser(userId: number): Promise<PluginUserConfigRow[]> {
|
||||
const rows = await this.find({ user_id: userId }, { fields: ['plugin_id', 'config'] });
|
||||
return rows.map((r) => ({ plugin_id: r.plugin_id, config: r.config }));
|
||||
}
|
||||
|
||||
/**
|
||||
* PS10 — `` INSERT INTO plugin_user_config (plugin_id, user_id, config, updated_at)
|
||||
* VALUES (?, ?, ?, datetime('now')) ON CONFLICT(plugin_id, user_id) DO UPDATE SET
|
||||
* config = excluded.config, updated_at = excluded.updated_at `` — the composite-key
|
||||
* upsert on `(plugin_id, user_id)`, the entity's own primary key. `currentTimestamp`
|
||||
* (`src/db/dialect/sql-functions.ts`) renders the same `CURRENT_TIMESTAMP`/`datetime('now')`
|
||||
* text SQLite already treats as equivalent, bound once per statement execution so the
|
||||
* inserted row and the `excluded.updated_at` merge target agree, matching the legacy
|
||||
* single-statement `datetime('now')` call exactly.
|
||||
*/
|
||||
async upsertConfig(pluginId: string, userId: number, config: string): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.upsert(
|
||||
{ plugin_id: pluginId, user_id: userId, config, updated_at: currentTimestamp(platform) },
|
||||
{ onConflictFields: ['plugin_id', 'user_id'], onConflictAction: 'merge', onConflictMergeFields: ['config', 'updated_at'] },
|
||||
);
|
||||
}
|
||||
|
||||
/** PR41 (uninstall cascade) — `DELETE FROM plugin_user_config WHERE plugin_id = ?`. */
|
||||
async deleteAllForPlugin(pluginId: string): Promise<void> {
|
||||
await this.nativeDelete({ plugin_id: pluginId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,65 @@
|
||||
import type { PluginUserErasureQueue } from '../entities/PluginUserErasureQueue.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class PluginUserErasureQueueRepository extends TrekRepository<PluginUserErasureQueue> {}
|
||||
/** The drain's own pending-row projection. */
|
||||
export interface PendingErasureRow {
|
||||
id: number;
|
||||
plugin_id: string;
|
||||
user_id: number;
|
||||
}
|
||||
|
||||
/** `plugin_id`/`id` — the columns the orphan-scan's correlated `NOT IN (SELECT id FROM plugins)` subquery needs. */
|
||||
interface PluginUserErasureQueueKyselyDB {
|
||||
plugin_user_erasure_queue: { id: number; plugin_id: string; user_id: number };
|
||||
plugins: { id: string };
|
||||
}
|
||||
|
||||
export class PluginUserErasureQueueRepository extends TrekRepository<PluginUserErasureQueue> {
|
||||
/** PR8 — `INSERT OR IGNORE INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)` (idempotent — a duplicate enqueue for the same pair is a no-op). */
|
||||
async insertIgnore(pluginId: string, userId: number): Promise<void> {
|
||||
await this.upsert({ plugin_id: pluginId, user_id: userId }, { onConflictFields: ['plugin_id', 'user_id'], onConflictAction: 'ignore' });
|
||||
}
|
||||
|
||||
/**
|
||||
* `runDrainOnce`'s orphan scan — `SELECT DISTINCT plugin_id FROM plugin_user_erasure_queue
|
||||
* WHERE plugin_id NOT IN (SELECT id FROM plugins)`. A cross-table correlated `NOT IN`
|
||||
* has no `FilterQuery` equivalent, so this goes through `this.kysely()`.
|
||||
*/
|
||||
async listOrphanPluginIds(): Promise<string[]> {
|
||||
const db = this.kysely<PluginUserErasureQueueKyselyDB>();
|
||||
const rows = await db
|
||||
.selectFrom('plugin_user_erasure_queue')
|
||||
.select('plugin_id')
|
||||
.distinct()
|
||||
.where('plugin_id', 'not in', db.selectFrom('plugins').select('id'))
|
||||
.execute();
|
||||
return rows.map((r) => r.plugin_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* `runDrainOnce`'s reap (a truly orphaned data dir) / `uninstall`'s `deleteData` branch —
|
||||
* `DELETE FROM plugin_user_erasure_queue WHERE plugin_id = ?` (identical text, two call sites).
|
||||
*/
|
||||
async deleteAllForPlugin(pluginId: string): Promise<void> {
|
||||
await this.nativeDelete({ plugin_id: pluginId });
|
||||
}
|
||||
|
||||
/**
|
||||
* `runDrainOnce`'s delivery window — `` SELECT id, plugin_id, user_id FROM
|
||||
* plugin_user_erasure_queue WHERE plugin_id IN (${ph}) ORDER BY id LIMIT 200 ``.
|
||||
* `pluginIds` — never empty: the caller already returns early when
|
||||
* `supervisor.activeIds()` is empty.
|
||||
*/
|
||||
async findPendingForPlugins(pluginIds: string[], limit = 200): Promise<PendingErasureRow[]> {
|
||||
const rows = await this.find(
|
||||
{ plugin_id: { $in: pluginIds } },
|
||||
{ fields: ['id', 'plugin_id', 'user_id'], orderBy: { id: 'asc' }, limit },
|
||||
);
|
||||
return rows.map((r) => ({ id: r.id, plugin_id: r.plugin_id, user_id: r.user_id }));
|
||||
}
|
||||
|
||||
/** `runDrainOnce`'s ACK — `DELETE FROM plugin_user_erasure_queue WHERE id = ?` (a delivered row). */
|
||||
async deleteById(id: number): Promise<void> {
|
||||
await this.nativeDelete({ id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,391 @@
|
||||
import type { Plugins } from '../entities/Plugins.entity';
|
||||
import { currentTimestamp } from '../dialect/sql-functions';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class PluginsRepository extends TrekRepository<Plugins> {}
|
||||
/**
|
||||
* Plan 3j Task 2 — `plugins` is this plan's most-reused table (`plugin-runtime.service.ts`
|
||||
* ~28 of 53 sites, `plugins.service.ts` 5 of 13). Every method below is named after the
|
||||
* statement it replaces (PR#/PS# per `plan3j-sql-inventory.md` §4/§5); several methods
|
||||
* serve more than one call site that shared byte-identical SQL text in the legacy file
|
||||
* (documented per method).
|
||||
*/
|
||||
|
||||
/** PR15/PR16/PR25 — `SELECT id, version, enabled, dependencies FROM plugins`, the shape
|
||||
* `dependencies.ts`'s dependency-graph helpers reason over (structurally compatible with
|
||||
* `PluginDepRow` there; not imported here to keep `db/repositories` free of `nest/` imports). */
|
||||
export interface PluginDepListRow {
|
||||
id: string;
|
||||
version: string | null;
|
||||
enabled: number;
|
||||
dependencies: string | null;
|
||||
}
|
||||
|
||||
/** PR7/PR11 — `SELECT id, permissions FROM plugins` (the GDPR erasure enqueue's + the export's own full-table scan). */
|
||||
export interface PluginIdPermissionRow {
|
||||
id: string;
|
||||
permissions: string | null;
|
||||
}
|
||||
|
||||
/** PR17 — `SELECT permissions, granted_permissions, dependencies, trek_range, api_version FROM plugins WHERE id = ?` (`assertActivatable`'s one-row activation gate). */
|
||||
export interface PluginActivationGateRow {
|
||||
permissions: string;
|
||||
granted_permissions: string;
|
||||
dependencies: string | null;
|
||||
trek_range: string | null;
|
||||
api_version: number | null;
|
||||
}
|
||||
|
||||
/** PR18 — `SELECT permissions, config FROM plugins WHERE id = ?` (`spawnActivated`). */
|
||||
export interface PluginPermissionsConfigRow {
|
||||
permissions: string;
|
||||
config: string;
|
||||
}
|
||||
|
||||
/** PR26 — `SELECT enabled, granted_permissions, version FROM plugins WHERE id = ?` (`update`'s pre-update snapshot). */
|
||||
export interface PluginPreUpdateRow {
|
||||
enabled: number;
|
||||
granted_permissions: string;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
/** PR51 — `SELECT name, capabilities FROM plugins WHERE id = ?` (`notificationChannels`). */
|
||||
export interface PluginNameCapabilitiesRow {
|
||||
name: string;
|
||||
capabilities: string;
|
||||
}
|
||||
|
||||
/** PS-list — `list()`'s admin projection, every column `PluginsService.list()` re-shapes. */
|
||||
export interface PluginAdminRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: string;
|
||||
icon: string | null;
|
||||
version: string | null;
|
||||
status: string;
|
||||
enabled: number;
|
||||
last_error: string | null;
|
||||
reviewed_at: string | null;
|
||||
source_repo: string | null;
|
||||
permissions: string;
|
||||
capabilities: string;
|
||||
dependencies: string | null;
|
||||
operator_egress: number;
|
||||
trek_range: string | null;
|
||||
author_pubkey: string | null;
|
||||
update_block_code: string | null;
|
||||
update_block_detail: string | null;
|
||||
update_block_version: string | null;
|
||||
update_hold: number;
|
||||
}
|
||||
|
||||
export class PluginsRepository extends TrekRepository<Plugins> {
|
||||
// -----------------------------------------------------------------------
|
||||
// PR1 — status/log persistence (onStatus callback, lazily resolved — §4a)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** PR1 — `UPDATE plugins SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`. */
|
||||
async updateStatusAndError(id: string, status: string, lastError: string | null): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.nativeUpdate({ id }, { status, last_error: lastError, updated_at: currentTimestamp(platform) });
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PR7 / PR11 — GDPR erasure enqueue + export's own full-table scan
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** PR7/PR11 — `SELECT id, permissions FROM plugins` (identical text, two call sites: `enqueueUserErasure`, `exportUserData`). */
|
||||
async listIdsAndPermissions(): Promise<PluginIdPermissionRow[]> {
|
||||
const rows = await this.find({}, { fields: ['id', 'permissions'] });
|
||||
// `Plugins.id` is typed `string | null` at the entity level (accommodates a
|
||||
// pre-insert state MikroORM's own typing requires) but is the table's real
|
||||
// PRIMARY KEY — never null on a row read back from the DB (same shape as
|
||||
// `listForAdmin`'s `id: r.id ?? ''` below).
|
||||
return rows.map((r) => ({ id: r.id ?? '', permissions: r.permissions ?? null }));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PR15 / PR16 / PR25 — dependency-graph reads
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** PR15/PR16/PR25 — `SELECT id, version, enabled, dependencies FROM plugins` (identical text, three call sites: `installedDepRows`, `deactivateForDisabledAddon`, `deactivateWithDependents`). */
|
||||
async listDepRows(): Promise<PluginDepListRow[]> {
|
||||
const rows = await this.find({}, { fields: ['id', 'version', 'enabled', 'dependencies'] });
|
||||
return rows.map((r) => ({ id: r.id ?? '', version: r.version ?? null, enabled: r.enabled, dependencies: r.dependencies ?? null }));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PR17 — activation gate
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** PR17 — `SELECT permissions, granted_permissions, dependencies, trek_range, api_version FROM plugins WHERE id = ?`. */
|
||||
async findActivationGate(id: string): Promise<PluginActivationGateRow | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['permissions', 'granted_permissions', 'dependencies', 'trek_range', 'api_version'] });
|
||||
return row
|
||||
? {
|
||||
permissions: row.permissions ?? '[]',
|
||||
granted_permissions: row.granted_permissions ?? '',
|
||||
dependencies: row.dependencies ?? null,
|
||||
trek_range: row.trek_range ?? null,
|
||||
api_version: row.api_version ?? null,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PR18 — spawnActivated
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** PR18 — `SELECT permissions, config FROM plugins WHERE id = ?`. */
|
||||
async findPermissionsAndConfig(id: string): Promise<PluginPermissionsConfigRow | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['permissions', 'config'] });
|
||||
return row ? { permissions: row.permissions ?? '[]', config: row.config ?? '{}' } : null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PR19 — spawnActivated's admin-intent write
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** PR19 — `UPDATE plugins SET granted_permissions = ?, enabled = 1 WHERE id = ?`. */
|
||||
async grantPermissionsAndEnable(id: string, grantedPermissionsJson: string): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { granted_permissions: grantedPermissionsJson, enabled: 1 });
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PR21 — wantsOperatorEgress
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** PR21 — `SELECT operator_egress FROM plugins WHERE id = ?`. */
|
||||
async findOperatorEgressFlag(id: string): Promise<number | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['operator_egress'] });
|
||||
return row?.operator_egress ?? null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PR24 — deactivate / markInactive
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** PR24 — `UPDATE plugins SET status = 'inactive', enabled = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?`. */
|
||||
async markInactive(id: string): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.nativeUpdate({ id }, { status: 'inactive', enabled: 0, updated_at: currentTimestamp(platform) });
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PR26 — update's pre-update snapshot
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** PR26 — `SELECT enabled, granted_permissions, version FROM plugins WHERE id = ?`. */
|
||||
async findPreUpdateSnapshot(id: string): Promise<PluginPreUpdateRow | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['enabled', 'granted_permissions', 'version'] });
|
||||
return row ? { enabled: row.enabled, granted_permissions: row.granted_permissions ?? '', version: row.version ?? null } : null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PR27 — update's post-install re-read of declared permissions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** PR27 — `SELECT permissions FROM plugins WHERE id = ?`. */
|
||||
async findPermissions(id: string): Promise<string | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['permissions'] });
|
||||
return row?.permissions ?? null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PR28 — retrust's signing-key TOFU comparison
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** PR28 — `SELECT author_pubkey FROM plugins WHERE id = ?`. Security-sensitive: feeds the TOFU comparison. */
|
||||
async findAuthorPubkey(id: string): Promise<string | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['author_pubkey'] });
|
||||
return row?.author_pubkey ?? null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PR29 — sideload's replace-existing check
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** PR29 — `SELECT id FROM plugins WHERE id = ?`, as a boolean existence check. */
|
||||
async existsById(id: string): Promise<boolean> {
|
||||
const row = await this.findOne({ id }, { fields: ['id'] });
|
||||
return row !== null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PR30 / PR32 — dev-link install + reload's source_repo read
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* PR30/PR32 — `SELECT source_repo FROM plugins WHERE id = ?` (identical text, two call
|
||||
* sites: `link`, `reload`). Returns the ROW, not the bare column: both callers need "no
|
||||
* row" (`null`) told apart from "row present with `source_repo` unset" (`{ source_repo:
|
||||
* null }`) — a plugin installed through the registry/sideload (never dev-linked) has a
|
||||
* real row with a NULL `source_repo`, and both legacy call sites treat that as "already
|
||||
* installed"/"not dev-linked", not as "not found".
|
||||
*/
|
||||
async findSourceRepoRow(id: string): Promise<{ source_repo: string | null } | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['source_repo'] });
|
||||
return row ? { source_repo: row.source_repo ?? null } : null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PR31 — dev-link install's cascade clear
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* PR31 — `` UPDATE plugins SET source_repo = ?, source_commit = NULL, sha256 = NULL,
|
||||
* author_pubkey = NULL, update_block_code = NULL, update_block_detail = NULL,
|
||||
* update_block_version = NULL, status = 'inactive', enabled = 0 WHERE id = ? `` — the
|
||||
* plugin has left the registry trust model, so every registry-trust column is cleared
|
||||
* in the same statement that re-points `source_repo` to the dev-link marker.
|
||||
*/
|
||||
async clearForDevLink(id: string, sourceRepo: string): Promise<void> {
|
||||
await this.nativeUpdate(
|
||||
{ id },
|
||||
{
|
||||
source_repo: sourceRepo,
|
||||
source_commit: null,
|
||||
sha256: null,
|
||||
author_pubkey: null,
|
||||
update_block_code: null,
|
||||
update_block_detail: null,
|
||||
update_block_version: null,
|
||||
status: 'inactive',
|
||||
enabled: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PR33 — uninstall cascade's own row
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** PR33 — `DELETE FROM plugins WHERE id = ?` (the uninstall cascade's first statement). */
|
||||
async deleteById(id: string): Promise<void> {
|
||||
await this.nativeDelete({ id });
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PR47 — the inter-plugin call/event router's grant check
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** PR47 — `SELECT granted_permissions FROM plugins WHERE id = ?`. */
|
||||
async findGrantedPermissions(id: string): Promise<string | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['granted_permissions'] });
|
||||
return row?.granted_permissions ?? null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PR48 / PR49 — dependsOnSatisfied (caller + target side)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** PR48 — `SELECT dependencies FROM plugins WHERE id = ?` (the caller side of `dependsOnSatisfied`). `null` return means "no row" — `dependsOnSatisfied` refuses on a missing caller the same as a missing target. */
|
||||
async findDependenciesRow(id: string): Promise<{ dependencies: string | null } | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['dependencies'] });
|
||||
return row ? { dependencies: row.dependencies ?? null } : null;
|
||||
}
|
||||
|
||||
/** PR49 — `SELECT version FROM plugins WHERE id = ?` (the target side of `dependsOnSatisfied`). Distinct return shape from `findDependenciesColumn`: this one must tell "no row" apart from "row with a null version", which `dependsOnSatisfied` needs (a vanished target plugin refuses, a real one with no version falls back to `'0.0.0'` in the caller). */
|
||||
async findVersionRow(id: string): Promise<{ version: string | null } | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['version'] });
|
||||
return row ? { version: row.version ?? null } : null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PR51 — notificationChannels
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** PR51 — `SELECT name, capabilities FROM plugins WHERE id = ?`. */
|
||||
async findNameAndCapabilities(id: string): Promise<PluginNameCapabilitiesRow | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['name', 'capabilities'] });
|
||||
return row ? { name: row.name, capabilities: row.capabilities } : null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PR52 / PR53 — capabilityList / mcpToolCapabilities
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** PR52/PR53 — `SELECT capabilities FROM plugins WHERE id = ?` (identical text, two call sites: `capabilityList`, `mcpToolCapabilities`). */
|
||||
async findCapabilities(id: string): Promise<string | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['capabilities'] });
|
||||
return row?.capabilities ?? null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PS-list — plugins.service.ts#list()
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* PS-list — `plugins.service.ts#list()`'s own admin-listing SELECT: `SELECT id, name,
|
||||
* description, type, icon, version, status, enabled, last_error, reviewed_at,
|
||||
* source_repo, permissions, capabilities, dependencies, operator_egress, trek_range,
|
||||
* author_pubkey, update_block_code, update_block_detail, update_block_version,
|
||||
* update_hold FROM plugins ORDER BY sort_order, name`.
|
||||
*/
|
||||
async listForAdmin(): Promise<PluginAdminRow[]> {
|
||||
const rows = await this.find(
|
||||
{},
|
||||
{
|
||||
fields: [
|
||||
'id', 'name', 'description', 'type', 'icon', 'version', 'status', 'enabled', 'last_error', 'reviewed_at', 'source_repo',
|
||||
'permissions', 'capabilities', 'dependencies', 'operator_egress', 'trek_range', 'author_pubkey',
|
||||
'update_block_code', 'update_block_detail', 'update_block_version', 'update_hold',
|
||||
],
|
||||
orderBy: [{ sort_order: 'asc' }, { name: 'asc' }],
|
||||
},
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
id: r.id ?? '',
|
||||
name: r.name,
|
||||
description: r.description ?? null,
|
||||
type: r.type,
|
||||
icon: r.icon ?? null,
|
||||
version: r.version ?? null,
|
||||
status: r.status,
|
||||
enabled: r.enabled,
|
||||
last_error: r.last_error ?? null,
|
||||
reviewed_at: r.reviewed_at ?? null,
|
||||
source_repo: r.source_repo ?? null,
|
||||
permissions: r.permissions ?? '[]',
|
||||
capabilities: r.capabilities,
|
||||
dependencies: r.dependencies ?? null,
|
||||
operator_egress: r.operator_egress,
|
||||
trek_range: r.trek_range ?? null,
|
||||
author_pubkey: r.author_pubkey ?? null,
|
||||
update_block_code: r.update_block_code ?? null,
|
||||
update_block_detail: r.update_block_detail ?? null,
|
||||
update_block_version: r.update_block_version ?? null,
|
||||
update_hold: r.update_hold,
|
||||
}));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PS4 — resumeUpdates / clearUpdateHold
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** PS4 — `UPDATE plugins SET update_hold = 0 WHERE id = ?`, returning whether a row existed (`.changes > 0`). */
|
||||
async clearUpdateHold(id: string): Promise<boolean> {
|
||||
const changed = await this.nativeUpdate({ id }, { update_hold: 0 });
|
||||
return changed > 0;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PS5 / PS13 — getInstanceConfig / updateInstanceConfig's config read
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** PS5/PS13 — `SELECT config FROM plugins WHERE id = ?` (identical text, two call sites: `updateInstanceConfig`, `getInstanceConfig`). */
|
||||
async findConfig(id: string): Promise<string | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['config'] });
|
||||
return row?.config ?? null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PS6 — updateInstanceConfig's write / plugins.service's setConfig
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** PS6 — `UPDATE plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`. `config` already encrypted by the caller (3e's R6 pattern) — this repository never calls `encrypt_api_key`/`decrypt_api_key`. */
|
||||
async setConfig(id: string, config: string): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.nativeUpdate({ id }, { config, updated_at: currentTimestamp(platform) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,4 +76,17 @@ export class SettingsRepository extends TrekRepository<Settings> {
|
||||
async upsertForUser(userId: number, key: string, value: string): Promise<void> {
|
||||
await this.upsert({ user: userId, key, value }, { onConflictFields: ['user', 'key'], onConflictAction: 'merge' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan 3j Task 2 — `plugin-runtime.service.ts#uninstall`'s `deleteData` branch
|
||||
* (PR39): `` DELETE FROM settings WHERE key LIKE 'plugin:${id}:%' ``. No `user`
|
||||
* filter — this deletes a plugin's settings across EVERY user, unlike every
|
||||
* other method on this repository, which is why it takes a bare key-prefix
|
||||
* `$like` pattern rather than the `(user, key)` shape the rest of the class
|
||||
* uses. `prefix` is caller-built (`` `plugin:${id}:` ``) and bound as a plain
|
||||
* parameter — never interpolated into SQL text.
|
||||
*/
|
||||
async deleteByKeyPrefix(prefix: string): Promise<number> {
|
||||
return await this.nativeDelete({ key: { $like: `${prefix}%` } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, Optional, type OnApplicationBootstrap, type OnModuleDestroy } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import semver from 'semver';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
@@ -6,6 +7,36 @@ import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { withRequestContext } from '../database/request-context';
|
||||
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
|
||||
import { logError } from '../audit/audit-log.logger';
|
||||
import { Plugins } from '../../db/entities/Plugins.entity';
|
||||
import type { PluginsRepository } from '../../db/repositories/Plugins.repository';
|
||||
import { PluginErrorLog } from '../../db/entities/PluginErrorLog.entity';
|
||||
import type { PluginErrorLogRepository } from '../../db/repositories/PluginErrorLog.repository';
|
||||
import { PluginScheduledTasks } from '../../db/entities/PluginScheduledTasks.entity';
|
||||
import type { PluginScheduledTasksRepository } from '../../db/repositories/PluginScheduledTasks.repository';
|
||||
import { PluginUserErasureQueue } from '../../db/entities/PluginUserErasureQueue.entity';
|
||||
import type { PluginUserErasureQueueRepository } from '../../db/repositories/PluginUserErasureQueue.repository';
|
||||
import { PluginEgressHosts } from '../../db/entities/PluginEgressHosts.entity';
|
||||
import type { PluginEgressHostsRepository } from '../../db/repositories/PluginEgressHosts.repository';
|
||||
import { PluginSettingsFields } from '../../db/entities/PluginSettingsFields.entity';
|
||||
import type { PluginSettingsFieldsRepository } from '../../db/repositories/PluginSettingsFields.repository';
|
||||
import { PluginActions } from '../../db/entities/PluginActions.entity';
|
||||
import type { PluginActionsRepository } from '../../db/repositories/PluginActions.repository';
|
||||
import { PluginUserConfig } from '../../db/entities/PluginUserConfig.entity';
|
||||
import type { PluginUserConfigRepository } from '../../db/repositories/PluginUserConfig.repository';
|
||||
import { PluginEntityMetadata } from '../../db/entities/PluginEntityMetadata.entity';
|
||||
import type { PluginEntityMetadataRepository } from '../../db/repositories/PluginEntityMetadata.repository';
|
||||
import { PluginOauthTokens } from '../../db/entities/PluginOauthTokens.entity';
|
||||
import type { PluginOauthTokensRepository } from '../../db/repositories/PluginOauthTokens.repository';
|
||||
import { PluginOauthState } from '../../db/entities/PluginOauthState.entity';
|
||||
import type { PluginOauthStateRepository } from '../../db/repositories/PluginOauthState.repository';
|
||||
import { PluginMetaMigrations } from '../../db/entities/PluginMetaMigrations.entity';
|
||||
import type { PluginMetaMigrationsRepository } from '../../db/repositories/PluginMetaMigrations.repository';
|
||||
import { PluginCapabilityAudit } from '../../db/entities/PluginCapabilityAudit.entity';
|
||||
import type { PluginCapabilityAuditRepository } from '../../db/repositories/PluginCapabilityAudit.repository';
|
||||
import { Settings } from '../../db/entities/Settings.entity';
|
||||
import type { SettingsRepository } from '../../db/repositories/Settings.repository';
|
||||
import { NotificationChannelPreferences } from '../../db/entities/NotificationChannelPreferences.entity';
|
||||
import type { NotificationChannelPreferencesRepository } from '../../db/repositories/NotificationChannelPreferences.repository';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { setPluginEventSink } from '../../plugin-event-sink';
|
||||
import { setUserDeletedSink } from '../../plugin-user-lifecycle';
|
||||
@@ -159,23 +190,38 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
// restore the core DB is briefly CLOSED (closeDb → the this.db proxy throws on access), so a
|
||||
// status/log write in that window would otherwise take the whole process down mid-
|
||||
// restore. Swallow any DB error — a missed status row / log line is never worth a crash.
|
||||
//
|
||||
// Plan 3j Task 2 (§4a, inventory §8): these callbacks are plain EventEmitter hooks —
|
||||
// NOT async, no enclosing request/tick — so there is no ambient request context a
|
||||
// repository call could resolve through. Resolved lazily, per call, the same way
|
||||
// `budgetFor(pluginId, this.db.connection)` resolves its own connection lazily today:
|
||||
// `withRequestContext(this.orm, ...)` wraps EACH write attempt fresh (never a context
|
||||
// built once at construction), and `.catch()` replaces the old synchronous try/catch
|
||||
// (a repository call is a Promise, not a synchronous throw) — same swallow, same
|
||||
// "a status/log write must never crash the host" guarantee, unchanged. No `this.orm`
|
||||
// (a hand-built test instance without one) means nothing to write to safely — skipped,
|
||||
// not thrown, mirroring `onApplicationBootstrap`'s own no-ORM branch.
|
||||
onStatus: (id, status, error) => {
|
||||
try {
|
||||
this.db.prepare('UPDATE plugins SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(status, error ?? null, id);
|
||||
} catch { /* DB unavailable (e.g. mid-restore) — a status write must never crash the host */ }
|
||||
if (!this.orm) return;
|
||||
void withRequestContext(this.orm, () => this.plugins.updateStatusAndError(id, status, error ?? null)).catch(() => {
|
||||
/* DB unavailable (e.g. mid-restore) — a status write must never crash the host */
|
||||
});
|
||||
},
|
||||
onLog: (id, level, msg) => {
|
||||
if (level !== 'error' && level !== 'warn') return;
|
||||
try {
|
||||
this.db.prepare('INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, ?, ?)').run(id, level, msg);
|
||||
// Retention: a crash-looping plugin emits a stderr line per restart, so an
|
||||
// uncapped table grows without bound in the shared trek.db. Keep only the
|
||||
// most recent LOG_RETENTION rows per plugin (the admin view shows 200).
|
||||
// R1.5: this hook is a child-lifecycle EventEmitter callback the supervisor
|
||||
// cannot await, so the now-async prune is fired off with its own handler —
|
||||
// the same "a log line must never crash the host" rule as the catch below.
|
||||
void this.pruneErrorLog(id).catch(() => { /* retention is best-effort */ });
|
||||
} catch { /* DB unavailable — a log line must never crash the host */ }
|
||||
if (!this.orm) return;
|
||||
const orm = this.orm;
|
||||
void withRequestContext(orm, () => this.pluginErrorLog.insertLog(id, level, msg))
|
||||
.then(() => {
|
||||
// Retention: a crash-looping plugin emits a stderr line per restart, so an
|
||||
// uncapped table grows without bound in the shared trek.db. Keep only the
|
||||
// most recent LOG_RETENTION rows per plugin (the admin view shows 200).
|
||||
// R1.5: this hook is a child-lifecycle EventEmitter callback the supervisor
|
||||
// cannot await, so the now-async prune is fired off with its own handler —
|
||||
// the same "a log line must never crash the host" rule as the catch below.
|
||||
void withRequestContext(orm, () => this.pruneErrorLog(id)).catch(() => { /* retention is best-effort */ });
|
||||
})
|
||||
.catch(() => { /* DB unavailable — a log line must never crash the host */ });
|
||||
},
|
||||
}, {}, () => this.orm);
|
||||
|
||||
@@ -198,6 +244,33 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
// registry reads a recipient's own settings on every dispatch, so an absent one
|
||||
// would be a TypeError at send time rather than a missing-provider error at boot.
|
||||
private readonly userSettings: PluginUserSettingsService,
|
||||
// Plan 3j Task 2 — every table this file's own statements (PR1-PR53) touch, now
|
||||
// injected as repositories instead of raw prepared-statement calls on `this.db`. All 14 are
|
||||
// REQUIRED (not `@Optional()`): virtually every method on this class reaches
|
||||
// `plugins` or one of its satellite tables, so a hand-built test instance needs
|
||||
// real ones regardless (`sharedTestOrm(...).repo(Entity)`, the same shape
|
||||
// `PluginGuards`' `UsersRepository` injection (Task 1) already established for
|
||||
// this plan). `PluginOauthTokens`/`PluginOauthState`/`PluginMetaMigrations`/
|
||||
// `PluginCapabilityAudit` are used ONLY by this file's own uninstall cascade and
|
||||
// GDPR export — Task 3/Task 4 add their own, disjoint methods to the same four
|
||||
// repositories for their own files. `Settings`/`NotificationChannelPreferences`
|
||||
// are cross-domain (3f) — additive methods only (`deleteByKeyPrefix`/
|
||||
// `deleteAllForChannel`), never their own per-user CRUD surface.
|
||||
@InjectRepository(Plugins) private readonly plugins: PluginsRepository,
|
||||
@InjectRepository(PluginErrorLog) private readonly pluginErrorLog: PluginErrorLogRepository,
|
||||
@InjectRepository(PluginScheduledTasks) private readonly pluginScheduledTasks: PluginScheduledTasksRepository,
|
||||
@InjectRepository(PluginUserErasureQueue) private readonly pluginUserErasureQueue: PluginUserErasureQueueRepository,
|
||||
@InjectRepository(PluginEgressHosts) private readonly pluginEgressHosts: PluginEgressHostsRepository,
|
||||
@InjectRepository(PluginSettingsFields) private readonly pluginSettingsFields: PluginSettingsFieldsRepository,
|
||||
@InjectRepository(PluginActions) private readonly pluginActions: PluginActionsRepository,
|
||||
@InjectRepository(PluginUserConfig) private readonly pluginUserConfig: PluginUserConfigRepository,
|
||||
@InjectRepository(PluginEntityMetadata) private readonly pluginEntityMetadata: PluginEntityMetadataRepository,
|
||||
@InjectRepository(PluginOauthTokens) private readonly pluginOauthTokens: PluginOauthTokensRepository,
|
||||
@InjectRepository(PluginOauthState) private readonly pluginOauthState: PluginOauthStateRepository,
|
||||
@InjectRepository(PluginMetaMigrations) private readonly pluginMetaMigrations: PluginMetaMigrationsRepository,
|
||||
@InjectRepository(PluginCapabilityAudit) private readonly pluginCapabilityAudit: PluginCapabilityAuditRepository,
|
||||
@InjectRepository(Settings) private readonly settingsRepo: SettingsRepository,
|
||||
@InjectRepository(NotificationChannelPreferences) private readonly notificationChannelPreferences: NotificationChannelPreferencesRepository,
|
||||
private readonly registry?: PluginRegistryService,
|
||||
private readonly hostFactory?: PluginRpcHostFactory,
|
||||
// Optional only because TypeScript forbids a required parameter after an
|
||||
@@ -281,18 +354,21 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
// boot must NEVER block app init, even in a context without plugin tables
|
||||
// (e.g. a slimmed-down test app that only imports AdminModule).
|
||||
try {
|
||||
// Plan 3j Task 0 (R-scheduler, inventory §8): `discoverPlugins` + this dependency-
|
||||
// order read are raw better-sqlite3 today and need no request context to run
|
||||
// correctly, but the MOMENT either becomes a repository call (Task 2/3) this
|
||||
// window needs one — landing the wrap now, ahead of that conversion, is the whole
|
||||
// point of doing this in Task 0. Wrapped ONLY when an ORM is available: the
|
||||
// no-ORM branch below still needs `order` computed (from THESE two reads) to name
|
||||
// the skipped plugins in its own log line, so running them unwrapped when `this.
|
||||
// orm` is absent preserves that already-tested behavior exactly (RT-BOOT-NOORM-001/
|
||||
// 002) rather than gating discovery itself on the ORM's presence — a broader
|
||||
// restructure (option (b): wrap the WHOLE onApplicationBootstrap body in one call,
|
||||
// matching CronRegistrarService.runOnBoot's shape) was considered and rejected for
|
||||
// this reason; see task-0-report.md.
|
||||
// Plan 3j Task 0 (R-scheduler, inventory §8) landed this wrap AHEAD of Task 2's own
|
||||
// conversion, exactly so this window would be ready once `installedDepRows` stopped
|
||||
// being raw SQL. Plan 3j Task 2: `installedDepRows` now reads `plugins` through
|
||||
// `PluginsRepository.listDepRows` (PR15/16/25 converted) — a genuine request-context
|
||||
// dependency the no-ORM branch below cannot satisfy (there is no ORM to wrap with).
|
||||
// `discoverPlugins` itself stays raw (Task 3's own file, unconverted) and safe to run
|
||||
// unwrapped either way. DEVIATION from Task 0's own note (task-0-report.md) and from
|
||||
// RT-BOOT-NOORM-001's prior assertion: the no-ORM branch can no longer read `plugins`
|
||||
// at all, so it no longer names which plugin(s) it is skipping — it logs a single
|
||||
// generic line instead. This is a strictly MORE conservative outcome (skip more,
|
||||
// never throw out of onApplicationBootstrap, same "fail closed, never abort app.init()"
|
||||
// invariant) than before, not a correctness regression — and it can only ever be
|
||||
// observed by a hand-built test instance that constructs this service without the
|
||||
// `orm` param its own `plugins`/other repositories are drawn from; production always
|
||||
// provides both together from the same DI graph. See task-2-report.md.
|
||||
let installed: Map<string, PluginDepRow>;
|
||||
if (this.orm) {
|
||||
installed = await withRequestContext(this.orm, async () => {
|
||||
@@ -301,7 +377,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
});
|
||||
} else {
|
||||
await discoverPlugins(this.db);
|
||||
installed = await this.installedDepRows();
|
||||
installed = new Map();
|
||||
}
|
||||
const enabledIds = [...installed.values()].filter((r) => r.enabled).map((r) => r.id);
|
||||
let order: string[];
|
||||
@@ -325,9 +401,10 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
// runOnBoot: no ORM means skip loudly and never call activate — never abort
|
||||
// app.init() over a partially-wired test graph.
|
||||
if (!this.orm) {
|
||||
if (order.length) {
|
||||
logError(`PluginRuntimeService.onApplicationBootstrap: no MikroORM available — plugin(s) not activated: ${order.join(', ')}`);
|
||||
}
|
||||
// Plan 3j Task 2: `order`/`enabledIds` are always empty here now (see the
|
||||
// `installed = new Map()` note above) — the message can no longer name
|
||||
// specific plugins, only that boot skipped activation entirely.
|
||||
logError('PluginRuntimeService.onApplicationBootstrap: no MikroORM available — plugin activation skipped');
|
||||
} else {
|
||||
for (const id of order) {
|
||||
withRequestContext(this.orm, () => this.activate(id)).catch((e) => {
|
||||
@@ -375,14 +452,17 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
const active = this.supervisor.activeIds();
|
||||
if (active.length === 0) return;
|
||||
const now = Date.now();
|
||||
const ph = active.map(() => '?').join(',');
|
||||
const due = this.db
|
||||
.prepare(`SELECT id, plugin_id, name, payload, every_ms FROM plugin_scheduled_tasks WHERE due_at <= ? AND plugin_id IN (${ph}) ORDER BY due_at LIMIT 200`)
|
||||
.all(now, ...active) as Array<{ id: number; plugin_id: string; name: string; payload: string; every_ms: number | null }>;
|
||||
const due = await this.pluginScheduledTasks.findDueForPlugins(now, active);
|
||||
for (const t of due) {
|
||||
if (!this.supervisor.isActive(t.plugin_id)) continue; // leave for a later sweep
|
||||
if (t.every_ms) this.db.prepare('UPDATE plugin_scheduled_tasks SET due_at = ? WHERE id = ?').run(now + t.every_ms, t.id);
|
||||
else this.db.prepare('DELETE FROM plugin_scheduled_tasks WHERE id = ?').run(t.id);
|
||||
// Plan 3j Task 2 deviation (named, task-2-report.md — same spirit as R-uninstall's
|
||||
// egress-host transaction): the claim is now atomic (`due_at <= now` guarded in
|
||||
// the repository) — a second, overlapping tick's own claim on the SAME row loses
|
||||
// and skips delivery entirely, rather than double-firing (RACE-SCHED-001).
|
||||
const claimed = t.every_ms
|
||||
? await this.pluginScheduledTasks.rearm(t.id, now + t.every_ms, now)
|
||||
: await this.pluginScheduledTasks.deleteById(t.id, now);
|
||||
if (!claimed) continue; // another concurrent pass already claimed this row
|
||||
let payload: unknown = null;
|
||||
try { payload = JSON.parse(t.payload); } catch { /* corrupt payload -> null */ }
|
||||
this.supervisor.deliverScheduled(t.plugin_id, t.name, payload);
|
||||
@@ -398,12 +478,11 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
* throws — a bookkeeping error must not fail the account deletion that triggered it. */
|
||||
private async enqueueUserErasure(userId: number): Promise<void> {
|
||||
try {
|
||||
const rows = this.db.prepare('SELECT id, permissions FROM plugins').all() as Array<{ id: string; permissions: string | null }>;
|
||||
const insert = this.db.prepare('INSERT OR IGNORE INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)');
|
||||
const rows = await this.plugins.listIdsAndPermissions();
|
||||
for (const r of rows) {
|
||||
let perms: unknown;
|
||||
try { perms = JSON.parse(r.permissions ?? '[]'); } catch { perms = []; }
|
||||
if (Array.isArray(perms) && perms.includes('hook:user-data')) insert.run(r.id, userId);
|
||||
if (Array.isArray(perms) && perms.includes('hook:user-data')) await this.pluginUserErasureQueue.insertIgnore(r.id, userId);
|
||||
}
|
||||
} catch {
|
||||
/* enqueue is best-effort; a later sweep reconciles from whatever landed */
|
||||
@@ -434,26 +513,21 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
// honour the erasure (see uninstall()); reaping on registry-absence alone would wipe
|
||||
// exactly those preserved obligations. A deleteData=true uninstall already clears the
|
||||
// rows itself, so this only ever needs to catch a truly orphaned data dir.
|
||||
const orphans = this.db
|
||||
.prepare('SELECT DISTINCT plugin_id FROM plugin_user_erasure_queue WHERE plugin_id NOT IN (SELECT id FROM plugins)')
|
||||
.all() as Array<{ plugin_id: string }>;
|
||||
for (const { plugin_id } of orphans) {
|
||||
const orphans = await this.pluginUserErasureQueue.listOrphanPluginIds();
|
||||
for (const plugin_id of orphans) {
|
||||
if (!fs.existsSync(pluginDataDir(plugin_id))) {
|
||||
this.db.prepare('DELETE FROM plugin_user_erasure_queue WHERE plugin_id = ?').run(plugin_id);
|
||||
await this.pluginUserErasureQueue.deleteAllForPlugin(plugin_id);
|
||||
}
|
||||
}
|
||||
// Only ACTIVE plugins can be delivered to; scope the window to them so a backlog
|
||||
// of erasures for permanently-inactive plugins can't starve deliverable ones.
|
||||
const active = this.supervisor.activeIds();
|
||||
if (active.length === 0) return;
|
||||
const ph = active.map(() => '?').join(',');
|
||||
const pending = this.db
|
||||
.prepare(`SELECT id, plugin_id, user_id FROM plugin_user_erasure_queue WHERE plugin_id IN (${ph}) ORDER BY id LIMIT 200`)
|
||||
.all(...active) as Array<{ id: number; plugin_id: string; user_id: number }>;
|
||||
const pending = await this.pluginUserErasureQueue.findPendingForPlugins(active);
|
||||
for (const row of pending) {
|
||||
if (!this.supervisor.isActive(row.plugin_id)) continue; // retry after it reactivates
|
||||
const done = await this.supervisor.deliverUserErasure(row.plugin_id, row.user_id);
|
||||
if (done) this.db.prepare('DELETE FROM plugin_user_erasure_queue WHERE id = ?').run(row.id);
|
||||
if (done) await this.pluginUserErasureQueue.deleteById(row.id);
|
||||
}
|
||||
} catch {
|
||||
/* a drain pass must never break the runtime */
|
||||
@@ -466,7 +540,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
async exportUserData(userId: number): Promise<Array<{ pluginId: string; data?: unknown; pending?: boolean; settings?: Record<string, unknown>; oauthConnected?: boolean }>> {
|
||||
const out: Array<{ pluginId: string; data?: unknown; pending?: boolean; settings?: Record<string, unknown>; oauthConnected?: boolean }> = [];
|
||||
if (!pluginsEnabled()) return out;
|
||||
const rows = this.db.prepare('SELECT id, permissions FROM plugins').all() as Array<{ id: string; permissions: string | null }>;
|
||||
const rows = await this.plugins.listIdsAndPermissions();
|
||||
for (const r of rows) {
|
||||
if (this.supervisor.isActive(r.id)) {
|
||||
const res = await this.supervisor.collectUserExport(r.id, userId);
|
||||
@@ -496,12 +570,12 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
return e;
|
||||
};
|
||||
const secretKeys = new Map<string, Set<string>>();
|
||||
for (const f of this.db.prepare("SELECT plugin_id, field_key FROM plugin_settings_fields WHERE scope = 'user' AND secret = 1").all() as Array<{ plugin_id: string; field_key: string }>) {
|
||||
for (const f of await this.pluginSettingsFields.listSecretUserFields()) {
|
||||
let s = secretKeys.get(f.plugin_id);
|
||||
if (!s) { s = new Set(); secretKeys.set(f.plugin_id, s); }
|
||||
s.add(f.field_key);
|
||||
}
|
||||
for (const c of this.db.prepare('SELECT plugin_id, config FROM plugin_user_config WHERE user_id = ?').all(userId) as Array<{ plugin_id: string; config: string }>) {
|
||||
for (const c of await this.pluginUserConfig.listForUser(userId)) {
|
||||
let cfg: Record<string, unknown> = {};
|
||||
try { cfg = JSON.parse(c.config || '{}'); } catch { /* ignore */ }
|
||||
const secrets = secretKeys.get(c.plugin_id) ?? new Set<string>();
|
||||
@@ -509,8 +583,8 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
for (const [k, v] of Object.entries(cfg)) masked[k] = secrets.has(k) ? '***' : v;
|
||||
entryFor(c.plugin_id).settings = masked;
|
||||
}
|
||||
for (const t of this.db.prepare('SELECT DISTINCT plugin_id FROM plugin_oauth_tokens WHERE user_id = ?').all(userId) as Array<{ plugin_id: string }>) {
|
||||
entryFor(t.plugin_id).oauthConnected = true;
|
||||
for (const pluginId of await this.pluginOauthTokens.listDistinctPluginIdsForUser(userId)) {
|
||||
entryFor(pluginId).oauthConnected = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[plugins] GDPR export: host-side settings/oauth fold failed', err);
|
||||
@@ -525,7 +599,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
* requires. Returns the ids actually deactivated.
|
||||
*/
|
||||
async deactivateForDisabledAddon(addonId: string): Promise<string[]> {
|
||||
const rows = this.db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[];
|
||||
const rows: PluginDepRow[] = await this.plugins.listDepRows();
|
||||
const directlyAffected = rows
|
||||
.filter((r) => r.enabled && parseDependencies(r.dependencies).requiredAddons.includes(addonId))
|
||||
.map((r) => r.id);
|
||||
@@ -593,7 +667,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
|
||||
/** All plugin rows projected to what the dependency helpers reason over. */
|
||||
private async installedDepRows(): Promise<Map<string, PluginDepRow>> {
|
||||
const rows = this.db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[];
|
||||
const rows: PluginDepRow[] = await this.plugins.listDepRows();
|
||||
return new Map(rows.map((r) => [r.id, r]));
|
||||
}
|
||||
|
||||
@@ -604,9 +678,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
* missing/mismatched plugin dependency.
|
||||
*/
|
||||
private async assertActivatable(id: string, installed: Map<string, PluginDepRow>, consentWiden: boolean): Promise<void> {
|
||||
const row = this.db.prepare('SELECT permissions, granted_permissions, dependencies, trek_range, api_version FROM plugins WHERE id = ?').get(id) as
|
||||
| { permissions: string; granted_permissions: string; dependencies: string | null; trek_range: string | null; api_version: number | null }
|
||||
| undefined;
|
||||
const row = await this.plugins.findActivationGate(id);
|
||||
if (!row) throw new Error(`plugin ${id} not found`);
|
||||
|
||||
// This runs FIRST, and before the consent check in particular: a plugin that cannot
|
||||
@@ -674,13 +746,11 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
|
||||
/** Mark a (pre-validated) plugin enabled and spawn its child. */
|
||||
private async spawnActivated(id: string): Promise<void> {
|
||||
const row = this.db.prepare('SELECT permissions, config FROM plugins WHERE id = ?').get(id) as
|
||||
| { permissions: string; config: string }
|
||||
| undefined;
|
||||
const row = await this.plugins.findPermissionsAndConfig(id);
|
||||
if (!row) throw new Error(`plugin ${id} not found`);
|
||||
const declared = parseArray(row.permissions).filter(isKnownPermission);
|
||||
// Mark it enabled (admin intent) so it reboots after restarts/crashes.
|
||||
this.db.prepare('UPDATE plugins SET granted_permissions = ?, enabled = 1 WHERE id = ?').run(JSON.stringify(declared), id);
|
||||
await this.plugins.grantPermissionsAndEnable(id, JSON.stringify(declared));
|
||||
// Manifest defaults fill whatever the admin never set, so the child's ctx.config is
|
||||
// the same effective value the settings form shows (see settings-defaults.ts).
|
||||
const config = applySettingDefaults(decryptConfig(parseObject(row.config)), await settingDefaults(this.db, id, 'instance'));
|
||||
@@ -699,8 +769,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
/** Hosts an admin added for this plugin (empty unless it declared `operatorEgress`). */
|
||||
async operatorEgressHosts(id: string): Promise<string[]> {
|
||||
try {
|
||||
return (this.db.prepare('SELECT host FROM plugin_egress_hosts WHERE plugin_id = ? ORDER BY host').all(id) as Array<{ host: string }>)
|
||||
.map((r) => r.host);
|
||||
return await this.pluginEgressHosts.listHostsForPlugin(id);
|
||||
} catch {
|
||||
return []; // table absent (a slimmed test app) — never block activation
|
||||
}
|
||||
@@ -708,8 +777,8 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
|
||||
/** Does this plugin's manifest declare that it needs operator-supplied hosts? */
|
||||
async wantsOperatorEgress(id: string): Promise<boolean> {
|
||||
const row = this.db.prepare('SELECT operator_egress FROM plugins WHERE id = ?').get(id) as { operator_egress: number } | undefined;
|
||||
return row?.operator_egress === 1;
|
||||
const flag = await this.plugins.findOperatorEgressFlag(id);
|
||||
return flag === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -731,10 +800,12 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
if (!clean.includes(host)) clean.push(host);
|
||||
}
|
||||
if (!this.uow) throw new Error('UnitOfWork not provided — tests that set egress hosts must pass one');
|
||||
// PR22/PR23, R-uninstall's ONE named transaction: the delete-then-insert-loop is now
|
||||
// atomic (`PluginEgressHostsRepository.replaceAllForPlugin`) — a crash mid-write
|
||||
// leaves the OLD host set intact instead of a plugin with zero egress hosts, a
|
||||
// deliberate behavior change ("For the user" in the plan document), not silent parity.
|
||||
await this.uow.transactional(async () => {
|
||||
this.db.prepare('DELETE FROM plugin_egress_hosts WHERE plugin_id = ?').run(id);
|
||||
const ins = this.db.prepare('INSERT OR IGNORE INTO plugin_egress_hosts (plugin_id, host) VALUES (?, ?)');
|
||||
for (const h of clean) ins.run(id, h);
|
||||
await this.pluginEgressHosts.replaceAllForPlugin(id, clean);
|
||||
});
|
||||
// Re-spawn so a live child actually gets the new allow-list.
|
||||
if (this.isActive(id)) {
|
||||
@@ -760,7 +831,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
async deactivate(id: string): Promise<void> {
|
||||
await this.supervisor.disable(id);
|
||||
closePluginDataDb(id);
|
||||
this.db.prepare("UPDATE plugins SET status = 'inactive', enabled = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(id);
|
||||
await this.plugins.markInactive(id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -773,7 +844,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
* restart of update()/sideload() never disables a plugin's dependents.
|
||||
*/
|
||||
async deactivateWithDependents(id: string): Promise<string[]> {
|
||||
const rows = this.db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[];
|
||||
const rows: PluginDepRow[] = await this.plugins.listDepRows();
|
||||
const enabledById = new Map(rows.map((r) => [r.id, r.enabled]));
|
||||
// findDependentsTransitive returns nearest-first; reverse so the deepest dependent
|
||||
// (the furthest caller) stops before the plugin it depends on.
|
||||
@@ -798,9 +869,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
id: string,
|
||||
opts?: { version?: string; retrustKey?: string },
|
||||
): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[]; trekRangeBypassed: TrekRangeBypass | null }> {
|
||||
const before = this.db.prepare('SELECT enabled, granted_permissions, version FROM plugins WHERE id = ?').get(id) as
|
||||
| { enabled: number; granted_permissions: string; version: string | null }
|
||||
| undefined;
|
||||
const before = await this.plugins.findPreUpdateSnapshot(id);
|
||||
if (!before) throw new Error(`plugin ${id} not found`);
|
||||
if (!this.registry) throw new Error('registry service unavailable');
|
||||
const wasEnabled = before.enabled === 1;
|
||||
@@ -816,9 +885,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
// swaps code + refreshes declared permissions; keeps granted
|
||||
const res = await this.registry.install(id, { version: target, retrustKey: opts?.retrustKey });
|
||||
|
||||
const declared = parseArray(
|
||||
(this.db.prepare('SELECT permissions FROM plugins WHERE id = ?').get(id) as { permissions: string }).permissions,
|
||||
).filter(isKnownPermission);
|
||||
const declared = parseArray(await this.plugins.findPermissions(id) ?? '[]').filter(isKnownPermission);
|
||||
const newGrants = declared.filter((p) => !granted.has(p));
|
||||
const newEgress = newGrants.filter((p) => p.startsWith(HTTP_OUTBOUND)).map((p) => p.slice(HTTP_OUTBOUND.length)).filter(Boolean);
|
||||
const newPermissions = newGrants.filter((p) => !p.startsWith(HTTP_OUTBOUND));
|
||||
@@ -875,7 +942,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
actor: { userId: number | null; ip?: string | null },
|
||||
): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }> {
|
||||
if (!this.registry) throw new Error('registry service unavailable');
|
||||
const before = this.db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string | null } | undefined;
|
||||
const beforePubkey = await this.plugins.findAuthorPubkey(id);
|
||||
const entry = await this.registry.assertRetrustable(id, publicKey); // throws unless SIGNATURE_KEY_CHANGED
|
||||
|
||||
const res = await this.update(id, { version, retrustKey: publicKey });
|
||||
@@ -891,7 +958,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
details: {
|
||||
plugin: id,
|
||||
version: res.version,
|
||||
oldKeyFingerprint: keyFingerprint(before?.author_pubkey),
|
||||
oldKeyFingerprint: keyFingerprint(beforePubkey),
|
||||
newKeyFingerprint: keyFingerprint(entry.authorPublicKey),
|
||||
},
|
||||
ip: actor.ip ?? null,
|
||||
@@ -910,7 +977,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
if (!this.registry) throw new Error('registry service unavailable');
|
||||
const staged = this.registry.stageUpload(bytes);
|
||||
try {
|
||||
const replaced = !!this.db.prepare('SELECT id FROM plugins WHERE id = ?').get(staged.id);
|
||||
const replaced = await this.plugins.existsById(staged.id);
|
||||
// Force any replaced plugin INACTIVE before the swap: stop a running child
|
||||
// (it holds file locks and would keep executing stale code) AND clear the
|
||||
// active flag, so replaced code can never keep running — or even show active
|
||||
@@ -947,7 +1014,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
if (scanForNativeBinaries(sourceDir).length) throw new Error('directory contains native binaries');
|
||||
|
||||
const id = manifest.id;
|
||||
const existing = this.db.prepare('SELECT source_repo FROM plugins WHERE id = ?').get(id) as { source_repo?: string } | undefined;
|
||||
const existing = await this.plugins.findSourceRepoRow(id);
|
||||
// Never clobber a REAL installed plugin (registry/sideload) — only re-point a link.
|
||||
if (existing && existing.source_repo !== DEV_LINK_SOURCE) {
|
||||
throw new Error(`a plugin '${id}' is already installed — uninstall it before dev-linking that id`);
|
||||
@@ -963,12 +1030,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
await discoverPlugins(this.db); // registers/updates the row from the linked manifest, INACTIVE
|
||||
// Same as a sideload: the plugin has left the registry trust model, so a block that
|
||||
// described a refused REGISTRY update no longer describes the code that will run.
|
||||
this.db.prepare(
|
||||
`UPDATE plugins SET source_repo = ?, source_commit = NULL, sha256 = NULL, author_pubkey = NULL,
|
||||
update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL,
|
||||
status = 'inactive', enabled = 0
|
||||
WHERE id = ?`,
|
||||
).run(DEV_LINK_SOURCE, id);
|
||||
await this.plugins.clearForDevLink(id, DEV_LINK_SOURCE);
|
||||
this.watchLinked(id, sourceDir);
|
||||
return { id, version: manifest.version, replaced, trekRangeBypassed };
|
||||
}
|
||||
@@ -982,7 +1044,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
*/
|
||||
async reload(id: string): Promise<void> {
|
||||
if (!devLinkEnabled()) throw new Error('dev-link is disabled (set TREK_PLUGINS_DEV_LINK=1)');
|
||||
const row = this.db.prepare('SELECT source_repo FROM plugins WHERE id = ?').get(id) as { source_repo?: string } | undefined;
|
||||
const row = await this.plugins.findSourceRepoRow(id);
|
||||
if (!row) throw new Error(`plugin ${id} not found`);
|
||||
if (row.source_repo !== DEV_LINK_SOURCE) throw new Error(`plugin ${id} is not dev-linked`);
|
||||
const wasActive = this.isActive(id);
|
||||
@@ -1019,6 +1081,16 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
}
|
||||
|
||||
/** Stop the plugin, remove its code, and optionally delete all its data. */
|
||||
/**
|
||||
* R-uninstall: the 9-table cascade converts with the SAME statement order and the SAME
|
||||
* (non-)transactional scope the legacy code had — NOT wrapped in `uow.transactional`.
|
||||
* Nine independent best-effort deletes, several inside their own try/catch tolerating an
|
||||
* absent table on a slimmed test schema (`plugin_actions`, `plugin_egress_hosts`) —
|
||||
* `plugins`/`plugin_settings_fields`/`plugin_scheduled_tasks`/`plugin_error_log`/
|
||||
* `plugin_entity_metadata`/`plugin_user_config`/`plugin_oauth_tokens`/`plugin_oauth_state`/
|
||||
* `plugin_meta_migrations`/`plugin_capability_audit`/`plugin_user_erasure_queue` do NOT
|
||||
* tolerate an absent table, unchanged from the legacy statement order and shape.
|
||||
*/
|
||||
async uninstall(id: string, deleteData: boolean): Promise<void> {
|
||||
await this.supervisor.disable(id);
|
||||
this.stopWatch(id);
|
||||
@@ -1026,16 +1098,16 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
// Code always goes; the DB metadata + fields go so it disappears from the UI.
|
||||
// Link-safe: a dev-linked plugin only drops the symlink, never the author's source.
|
||||
removePluginCodeEntry(pluginCodeDir(id));
|
||||
this.db.prepare('DELETE FROM plugins WHERE id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM plugin_settings_fields WHERE plugin_id = ?').run(id);
|
||||
try { this.db.prepare('DELETE FROM plugin_actions WHERE plugin_id = ?').run(id); } catch { /* table absent */ }
|
||||
await this.plugins.deleteById(id);
|
||||
await this.pluginSettingsFields.deleteAllForPlugin(id);
|
||||
try { await this.pluginActions.deleteAllForPlugin(id); } catch { /* table absent */ }
|
||||
// The admin's egress consent dies with the plugin. Unconditional: leaving it would
|
||||
// silently grant a LATER plugin that reuses this id the hosts the admin approved for
|
||||
// a different one.
|
||||
try { this.db.prepare('DELETE FROM plugin_egress_hosts WHERE plugin_id = ?').run(id); } catch { /* table absent */ }
|
||||
try { await this.pluginEgressHosts.deleteAllForPlugin(id); } catch { /* table absent */ }
|
||||
// Scheduled tasks are operational (not user data), so they go unconditionally —
|
||||
// a scheduled callback for a plugin that no longer exists must never fire.
|
||||
this.db.prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ?').run(id);
|
||||
await this.pluginScheduledTasks.deleteAllForPlugin(id);
|
||||
// If it was a notification channel, retire the channel too. Unconditional, for the
|
||||
// same reason as the settings fields: these are TREK's config ABOUT the plugin, and
|
||||
// leaving them means a later plugin that reuses this id silently inherits every
|
||||
@@ -1043,23 +1115,23 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
await this.retireNotificationChannel(id);
|
||||
if (deleteData) {
|
||||
removePluginData(id);
|
||||
this.db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id);
|
||||
this.db.prepare("DELETE FROM settings WHERE key LIKE ?").run(`plugin:${id}:%`);
|
||||
this.db.prepare('DELETE FROM plugin_entity_metadata WHERE plugin_id = ?').run(id);
|
||||
await this.pluginErrorLog.deleteAllForPlugin(id);
|
||||
await this.settingsRepo.deleteByKeyPrefix(`plugin:${id}:`);
|
||||
await this.pluginEntityMetadata.deleteAllForPlugin(id);
|
||||
// Per-user secrets + OAuth tokens/state live in their own tables, NOT under
|
||||
// settings — without these a "delete all data" leaves encrypted API keys and
|
||||
// refresh tokens behind, silently re-adopted if a plugin with the same id is
|
||||
// reinstalled. The migration ledger goes too, so a reinstall re-runs cleanly.
|
||||
this.db.prepare('DELETE FROM plugin_user_config WHERE plugin_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM plugin_oauth_tokens WHERE plugin_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM plugin_meta_migrations WHERE plugin_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM plugin_capability_audit WHERE plugin_id = ?').run(id);
|
||||
await this.pluginUserConfig.deleteAllForPlugin(id);
|
||||
await this.pluginOauthTokens.deleteAllForPlugin(id);
|
||||
await this.pluginOauthState.deleteAllForPlugin(id);
|
||||
await this.pluginMetaMigrations.deleteAllForPlugin(id);
|
||||
await this.pluginCapabilityAudit.deleteAllForPlugin(id);
|
||||
// The plugin's data dir is gone now, so any pending GDPR erasure for it is moot.
|
||||
// But when deleteData is FALSE we deliberately KEEP the queue rows: the data dir
|
||||
// (which may still hold a deleted user's rows) survives, so the erasure obligation
|
||||
// must survive too — a reinstall of the same id drains the queue and honours it.
|
||||
this.db.prepare('DELETE FROM plugin_user_erasure_queue WHERE plugin_id = ?').run(id);
|
||||
await this.pluginUserErasureQueue.deleteAllForPlugin(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1069,11 +1141,9 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
|
||||
/** Declared outbound hosts (from http:outbound:<host> grants) for the frame CSP. */
|
||||
async outboundHostsOf(id: string): Promise<string[]> {
|
||||
const row = this.db.prepare('SELECT granted_permissions FROM plugins WHERE id = ?').get(id) as
|
||||
| { granted_permissions: string }
|
||||
| undefined;
|
||||
if (!row) return [];
|
||||
return parseArray(row.granted_permissions)
|
||||
const granted = await this.plugins.findGrantedPermissions(id);
|
||||
if (granted === null) return [];
|
||||
return parseArray(granted)
|
||||
.filter((p) => p.startsWith('http:outbound:'))
|
||||
.map((p) => p.slice('http:outbound:'.length))
|
||||
.filter(Boolean);
|
||||
@@ -1155,8 +1225,8 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
/** True if `caller` declares `target` as a plugin dependency whose range the
|
||||
* installed target version satisfies. */
|
||||
private async dependsOnSatisfied(callerId: string, targetId: string): Promise<boolean> {
|
||||
const caller = this.db.prepare('SELECT dependencies FROM plugins WHERE id = ?').get(callerId) as { dependencies: string | null } | undefined;
|
||||
const target = this.db.prepare('SELECT version FROM plugins WHERE id = ?').get(targetId) as { version: string | null } | undefined;
|
||||
const caller = await this.plugins.findDependenciesRow(callerId);
|
||||
const target = await this.plugins.findVersionRow(targetId);
|
||||
if (!caller || !target) return false;
|
||||
const dep = parseDependencies(caller.dependencies).pluginDependencies.find((d) => d.id === targetId);
|
||||
if (!dep) return false;
|
||||
@@ -1176,7 +1246,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
// without the notification tables (a slimmed test app that imports only the plugin
|
||||
// module).
|
||||
try {
|
||||
this.db.prepare('DELETE FROM notification_channel_preferences WHERE channel = ?').run(pluginChannelId(id));
|
||||
await this.notificationChannelPreferences.deleteAllForChannel(pluginChannelId(id));
|
||||
} catch { /* no notifications schema here — nothing to retire */ }
|
||||
}
|
||||
|
||||
@@ -1191,10 +1261,11 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
*/
|
||||
async notificationChannels(): Promise<ExternalChannel[]> {
|
||||
if (!pluginsEnabled()) return [];
|
||||
return this.supervisor.providersOf('notificationChannel').map((id) => {
|
||||
const row = this.db.prepare('SELECT name, capabilities FROM plugins WHERE id = ?').get(id) as
|
||||
| { name: string; capabilities: string }
|
||||
| undefined;
|
||||
// A `map` callback cannot await (the same reason `PluginsService.list()` uses an
|
||||
// explicit loop) — `findNameAndCapabilities` is now a repository call.
|
||||
const out: ExternalChannel[] = [];
|
||||
for (const id of this.supervisor.providersOf('notificationChannel')) {
|
||||
const row = await this.plugins.findNameAndCapabilities(id);
|
||||
let cap: { title?: string; events?: string[] } = {};
|
||||
try {
|
||||
cap = ((JSON.parse(row?.capabilities || '{}') as Record<string, unknown>).notificationChannel ?? {}) as typeof cap;
|
||||
@@ -1202,7 +1273,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
|
||||
const allowed = new Set(cap.events?.length ? cap.events : PLUGIN_CHANNEL_EVENTS);
|
||||
|
||||
return {
|
||||
out.push({
|
||||
id: pluginChannelId(id),
|
||||
source: 'plugin' as const,
|
||||
// A plugin-supplied display string, bounded and emoji-stripped like every other
|
||||
@@ -1239,18 +1310,21 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The settings-form action buttons a plugin declared for ONE scope (descriptors, from the DB). */
|
||||
async actionsOf(id: string, scope: PluginActionScope): Promise<PluginActionDescriptor[]> {
|
||||
try {
|
||||
return (
|
||||
this.db
|
||||
.prepare('SELECT action_key, label, hint, danger, scope FROM plugin_actions WHERE plugin_id = ? AND scope = ? ORDER BY sort_order')
|
||||
.all(id, scope) as Array<{ action_key: string; label: string; hint: string | null; danger: number; scope: PluginActionScope }>
|
||||
).map((r) => ({ key: r.action_key, label: r.label, hint: r.hint ?? undefined, danger: r.danger === 1, scope: r.scope }));
|
||||
return (await this.pluginActions.listForPluginScope(id, scope)).map((r) => ({
|
||||
key: r.action_key,
|
||||
label: r.label,
|
||||
hint: r.hint ?? undefined,
|
||||
danger: r.danger === 1,
|
||||
scope: r.scope,
|
||||
}));
|
||||
} catch {
|
||||
return []; // table absent (a slimmed test app)
|
||||
}
|
||||
@@ -1295,10 +1369,10 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
* Public, unlike capabilityList, because PluginMcpToolsService reads it.
|
||||
*/
|
||||
async mcpToolCapabilities(id: string): Promise<McpToolCapability[]> {
|
||||
const row = this.db.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(id) as { capabilities: string } | undefined;
|
||||
if (!row) return [];
|
||||
const capabilities = await this.plugins.findCapabilities(id);
|
||||
if (capabilities === null) return [];
|
||||
try {
|
||||
const c = JSON.parse(row.capabilities || '{}') as Record<string, unknown>;
|
||||
const c = JSON.parse(capabilities || '{}') as Record<string, unknown>;
|
||||
if (c.mcpTools === undefined) return [];
|
||||
return parseMcpToolCapabilities(c.mcpTools);
|
||||
} catch {
|
||||
@@ -1309,10 +1383,10 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
}
|
||||
|
||||
private async capabilityList(id: string, field: 'provides' | 'emits'): Promise<string[]> {
|
||||
const row = this.db.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(id) as { capabilities: string } | undefined;
|
||||
if (!row) return [];
|
||||
const capabilities = await this.plugins.findCapabilities(id);
|
||||
if (capabilities === null) return [];
|
||||
try {
|
||||
const c = JSON.parse(row.capabilities || '{}') as Record<string, unknown>;
|
||||
const c = JSON.parse(capabilities || '{}') as Record<string, unknown>;
|
||||
const v = c[field];
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||||
} catch {
|
||||
@@ -1323,11 +1397,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
/** Trim a plugin's error log to the most recent LOG_RETENTION rows. Cheap: onLog
|
||||
* only fires on warn/error, so this never runs on the hot path. */
|
||||
private async pruneErrorLog(pluginId: string): Promise<void> {
|
||||
this.db.prepare(
|
||||
`DELETE FROM plugin_error_log WHERE plugin_id = ? AND id NOT IN (
|
||||
SELECT id FROM plugin_error_log WHERE plugin_id = ? ORDER BY id DESC LIMIT ${LOG_RETENTION}
|
||||
)`,
|
||||
).run(pluginId, pluginId);
|
||||
await this.pluginErrorLog.pruneKeepingRecent(pluginId, LOG_RETENTION);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DiscoveryModule } from '@nestjs/core';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { Plugins } from '../../db/entities/Plugins.entity';
|
||||
import { PluginErrorLog } from '../../db/entities/PluginErrorLog.entity';
|
||||
import { PluginScheduledTasks } from '../../db/entities/PluginScheduledTasks.entity';
|
||||
import { PluginUserErasureQueue } from '../../db/entities/PluginUserErasureQueue.entity';
|
||||
import { PluginEgressHosts } from '../../db/entities/PluginEgressHosts.entity';
|
||||
import { PluginSettingsFields } from '../../db/entities/PluginSettingsFields.entity';
|
||||
import { PluginActions } from '../../db/entities/PluginActions.entity';
|
||||
import { PluginUserConfig } from '../../db/entities/PluginUserConfig.entity';
|
||||
import { PluginEntityMetadata } from '../../db/entities/PluginEntityMetadata.entity';
|
||||
import { PluginOauthTokens } from '../../db/entities/PluginOauthTokens.entity';
|
||||
import { PluginOauthState } from '../../db/entities/PluginOauthState.entity';
|
||||
import { PluginMetaMigrations } from '../../db/entities/PluginMetaMigrations.entity';
|
||||
import { PluginCapabilityAudit } from '../../db/entities/PluginCapabilityAudit.entity';
|
||||
import { Settings } from '../../db/entities/Settings.entity';
|
||||
import { NotificationChannelPreferences } from '../../db/entities/NotificationChannelPreferences.entity';
|
||||
import { PluginsService } from './plugins.service';
|
||||
import { PluginUserSettingsService } from './plugin-user-settings.service';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
@@ -59,6 +75,19 @@ import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
imports: [
|
||||
// What lets PluginRpcRegistryService find the @PluginController providers at boot.
|
||||
DiscoveryModule,
|
||||
// Plan 3j Task 2 — every table `PluginRuntimeService`/`PluginsService`'s own
|
||||
// statements (PR1-PR53, PS1-PS13) touch, now repository-backed. This module is
|
||||
// where both classes are actually constructed, so it (not an importer) owns the
|
||||
// forFeature list — same reasoning `PluginGuardsModule`'s own docstring gives for
|
||||
// `Users`. `Settings`/`NotificationChannelPreferences` are cross-domain (3f) —
|
||||
// their OWN owning modules also register them; MikroOrmModule.forFeature is
|
||||
// idempotent per entity within one DI graph.
|
||||
MikroOrmModule.forFeature([
|
||||
Plugins, PluginErrorLog, PluginScheduledTasks, PluginUserErasureQueue, PluginEgressHosts,
|
||||
PluginSettingsFields, PluginActions, PluginUserConfig, PluginEntityMetadata,
|
||||
PluginOauthTokens, PluginOauthState, PluginMetaMigrations, PluginCapabilityAudit,
|
||||
Settings, NotificationChannelPreferences,
|
||||
]),
|
||||
// A leaf that hands the resource gates to the domain modules. It must not be this
|
||||
// module: the domains would then have to import this one back and close a cycle.
|
||||
PluginGuardsModule,
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { Plugins } from '../../db/entities/Plugins.entity';
|
||||
import type { PluginsRepository } from '../../db/repositories/Plugins.repository';
|
||||
import { PluginErrorLog } from '../../db/entities/PluginErrorLog.entity';
|
||||
import type { PluginErrorLogRepository } from '../../db/repositories/PluginErrorLog.repository';
|
||||
import { PluginEgressHosts } from '../../db/entities/PluginEgressHosts.entity';
|
||||
import type { PluginEgressHostsRepository } from '../../db/repositories/PluginEgressHosts.repository';
|
||||
import { PluginSettingsFields } from '../../db/entities/PluginSettingsFields.entity';
|
||||
import type { PluginSettingsFieldsRepository } from '../../db/repositories/PluginSettingsFields.repository';
|
||||
import { PluginActions } from '../../db/entities/PluginActions.entity';
|
||||
import type { PluginActionsRepository } from '../../db/repositories/PluginActions.repository';
|
||||
import { PluginUserConfig } from '../../db/entities/PluginUserConfig.entity';
|
||||
import type { PluginUserConfigRepository } from '../../db/repositories/PluginUserConfig.repository';
|
||||
import { PluginCapabilityAudit } from '../../db/entities/PluginCapabilityAudit.entity';
|
||||
import type { PluginCapabilityAuditRepository } from '../../db/repositories/PluginCapabilityAudit.repository';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { devLinkEnabled } from './dev-link';
|
||||
import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
@@ -121,51 +136,57 @@ export class PluginsService {
|
||||
constructor(
|
||||
private readonly dbs: DatabaseService,
|
||||
private readonly addons: AddonsService,
|
||||
// Plan 3j Task 2 — PS1-PS13's own tables, injected as repositories. All 6
|
||||
// REQUIRED (not `@Optional()`), matching `PluginRuntimeService`'s own ruling
|
||||
// (Task 1's `PluginGuards`/`UsersRepository` precedent): virtually every method
|
||||
// on this class reaches one of these tables, so a hand-built test instance
|
||||
// needs real ones regardless (`sharedTestOrm(...).repo(Entity)`).
|
||||
@InjectRepository(Plugins) private readonly plugins: PluginsRepository,
|
||||
@InjectRepository(PluginEgressHosts) private readonly pluginEgressHosts: PluginEgressHostsRepository,
|
||||
@InjectRepository(PluginSettingsFields) private readonly pluginSettingsFields: PluginSettingsFieldsRepository,
|
||||
@InjectRepository(PluginActions) private readonly pluginActions: PluginActionsRepository,
|
||||
@InjectRepository(PluginUserConfig) private readonly pluginUserConfig: PluginUserConfigRepository,
|
||||
@InjectRepository(PluginErrorLog) private readonly pluginErrorLog: PluginErrorLogRepository,
|
||||
// Plan 3j Task 3 landed concurrently and converted `readAudit`/`pluginBudgetUsage`
|
||||
// (host/plugin-audit.ts, host/plugin-host-state.ts) to take this repository instead
|
||||
// of a raw connection — this service's own `auditLog`/`budget` delegate calls need
|
||||
// it too, not a new site of its own.
|
||||
@InjectRepository(PluginCapabilityAudit) private readonly pluginCapabilityAudit: PluginCapabilityAuditRepository,
|
||||
) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
/** Hosts an admin has added for a plugin (0 unless it declared operatorEgress). */
|
||||
/** PS1 — Hosts an admin has added for a plugin (0 unless it declared operatorEgress). */
|
||||
private async egressHostCount(id: string): Promise<number> {
|
||||
try {
|
||||
return (this.db.prepare('SELECT COUNT(*) AS n FROM plugin_egress_hosts WHERE plugin_id = ?').get(id) as { n: number }).n;
|
||||
return await this.pluginEgressHosts.countForPlugin(id);
|
||||
} catch {
|
||||
return 0; // table absent (a slimmed test app)
|
||||
}
|
||||
}
|
||||
|
||||
/** PS2 */
|
||||
private async instanceSettingsCount(id: string): Promise<number> {
|
||||
try {
|
||||
return (
|
||||
this.db.prepare("SELECT COUNT(*) AS n FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance'").get(id) as { n: number }
|
||||
).n;
|
||||
return await this.pluginSettingsFields.countForPluginScope(id, 'instance');
|
||||
} catch {
|
||||
return 0; // table absent (a slimmed test app)
|
||||
}
|
||||
}
|
||||
|
||||
/** PS3 */
|
||||
private async instanceActionsCount(id: string): Promise<number> {
|
||||
try {
|
||||
return (
|
||||
this.db.prepare("SELECT COUNT(*) AS n FROM plugin_actions WHERE plugin_id = ? AND scope = 'instance'").get(id) as { n: number }
|
||||
).n;
|
||||
return await this.pluginActions.countForPluginScope(id, 'instance');
|
||||
} catch {
|
||||
return 0; // table absent (a slimmed test app)
|
||||
}
|
||||
}
|
||||
|
||||
async list(): Promise<{ enabled: boolean; devLink: boolean; ignoreTrekRange: boolean; plugins: PluginListItem[] }> {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo,
|
||||
permissions, capabilities, dependencies, operator_egress, trek_range,
|
||||
author_pubkey, update_block_code, update_block_detail, update_block_version, update_hold
|
||||
FROM plugins
|
||||
ORDER BY sort_order, name`,
|
||||
)
|
||||
.all() as PluginRawRow[];
|
||||
const rows = (await this.plugins.listForAdmin()) as PluginRawRow[];
|
||||
const installed = new Map<string, PluginDepRow>(
|
||||
rows.map((r) => [r.id, { id: r.id, version: r.version, enabled: r.enabled, dependencies: r.dependencies }]),
|
||||
);
|
||||
@@ -225,7 +246,7 @@ export class PluginsService {
|
||||
* Returns whether the plugin row existed — the controller answers 404 otherwise.
|
||||
*/
|
||||
async resumeUpdates(id: string): Promise<boolean> {
|
||||
return this.db.prepare('UPDATE plugins SET update_hold = 0 WHERE id = ?').run(id).changes > 0;
|
||||
return await this.plugins.clearUpdateHold(id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,26 +256,13 @@ export class PluginsService {
|
||||
* Returns the config with secrets masked for the client.
|
||||
*/
|
||||
async updateInstanceConfig(id: string, patch: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const row = this.db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined;
|
||||
if (!row) throw new Error(`plugin ${id} not found`);
|
||||
const rowConfig = await this.plugins.findConfig(id);
|
||||
if (rowConfig === null) throw new Error(`plugin ${id} not found`);
|
||||
|
||||
const secretKeys = new Set(
|
||||
(
|
||||
this.db
|
||||
.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance' AND secret = 1")
|
||||
.all(id) as Array<{ field_key: string }>
|
||||
).map((r) => r.field_key),
|
||||
);
|
||||
const secretKeys = new Set(await this.pluginSettingsFields.listSecretFieldKeys(id, 'instance'));
|
||||
const allowed = new Set(await this.pluginSettingsFields.listFieldKeys(id, 'instance'));
|
||||
|
||||
const allowed = new Set(
|
||||
(
|
||||
this.db
|
||||
.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance'")
|
||||
.all(id) as Array<{ field_key: string }>
|
||||
).map((r) => r.field_key),
|
||||
);
|
||||
|
||||
const config = safeParse(row.config);
|
||||
const config = safeParse(rowConfig);
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (!allowed.has(k)) continue; // never store an undeclared key
|
||||
if (secretKeys.has(k)) {
|
||||
@@ -265,7 +273,7 @@ export class PluginsService {
|
||||
}
|
||||
}
|
||||
await this.assertRequiredFilled(id, 'instance', config);
|
||||
this.db.prepare('UPDATE plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(config), id);
|
||||
await this.plugins.setConfig(id, JSON.stringify(config));
|
||||
return maskSecrets(config, secretKeys);
|
||||
}
|
||||
|
||||
@@ -280,63 +288,40 @@ export class PluginsService {
|
||||
}
|
||||
|
||||
private async settingsFields(id: string, scope: 'user' | 'instance'): Promise<PluginSettingsField[]> {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT field_key AS key, label, input_type, placeholder, hint, required, secret, options, default_value
|
||||
FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? ORDER BY sort_order, id`,
|
||||
)
|
||||
.all(id, scope)
|
||||
.map((r) => {
|
||||
const row = r as Record<string, unknown>;
|
||||
return {
|
||||
key: String(row.key),
|
||||
label: (row.label ?? null) as string | null,
|
||||
input_type: (row.input_type ?? 'text') as string,
|
||||
placeholder: (row.placeholder ?? null) as string | null,
|
||||
hint: (row.hint ?? null) as string | null,
|
||||
required: row.required === 1,
|
||||
secret: row.secret === 1,
|
||||
default: parseDefaultValue(row.default_value),
|
||||
// Stored as manifest-validated JSON ({value,label} pairs) — parse, don't re-check.
|
||||
options:
|
||||
typeof row.options === 'string' && row.options
|
||||
? (safeArray(row.options as string) as PluginSettingsField['options'])
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
const rows = await this.pluginSettingsFields.listFields(id, scope);
|
||||
return rows.map((row) => ({
|
||||
key: row.field_key,
|
||||
label: row.label,
|
||||
input_type: row.input_type,
|
||||
placeholder: row.placeholder,
|
||||
hint: row.hint,
|
||||
required: row.required === 1,
|
||||
secret: row.secret === 1,
|
||||
default: parseDefaultValue(row.default_value),
|
||||
// Stored as manifest-validated JSON ({value,label} pairs) — parse, don't re-check.
|
||||
options: typeof row.options === 'string' && row.options ? (safeArray(row.options) as PluginSettingsField['options']) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
private async userSecretKeys(id: string): Promise<Set<string>> {
|
||||
return new Set(
|
||||
(
|
||||
this.db
|
||||
.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user' AND secret = 1")
|
||||
.all(id) as Array<{ field_key: string }>
|
||||
).map((r) => r.field_key),
|
||||
);
|
||||
return new Set(await this.pluginSettingsFields.listSecretFieldKeys(id, 'user'));
|
||||
}
|
||||
|
||||
/** A user's own config for a plugin, secrets masked (safe to send to the client). */
|
||||
/** A user's own config for a plugin, secrets masked (safe to send to the client). PS7. */
|
||||
async getUserConfig(id: string, userId: number): Promise<Record<string, unknown>> {
|
||||
const row = this.db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as
|
||||
| { config: string }
|
||||
| undefined;
|
||||
return maskSecrets(safeParse(row?.config ?? '{}'), await this.userSecretKeys(id));
|
||||
const config = await this.pluginUserConfig.findConfig(id, userId);
|
||||
return maskSecrets(safeParse(config ?? '{}'), await this.userSecretKeys(id));
|
||||
}
|
||||
|
||||
/** Merge a user's own settings, encrypting secret fields (SECRET_MASK = keep stored
|
||||
* ciphertext). Only keys declared as `scope:'user'` fields are accepted. Returns masked. */
|
||||
async updateUserConfig(id: string, userId: number, patch: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const allowed = new Set(
|
||||
(this.db.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user'").all(id) as Array<{ field_key: string }>).map(
|
||||
(r) => r.field_key,
|
||||
),
|
||||
);
|
||||
const allowed = new Set(await this.pluginSettingsFields.listFieldKeys(id, 'user'));
|
||||
const secretKeys = await this.userSecretKeys(id);
|
||||
const existing = this.db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as
|
||||
| { config: string }
|
||||
| undefined;
|
||||
const config = safeParse(existing?.config ?? '{}');
|
||||
// PS9 — the pre-write existence check shares `PluginUserConfigRepository.findConfig`
|
||||
// with PS7/PS11 (the triple-duplicate text the brief names explicitly).
|
||||
const existingConfig = await this.pluginUserConfig.findConfig(id, userId);
|
||||
const config = safeParse(existingConfig ?? '{}');
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (!allowed.has(k)) continue; // never store an undeclared key
|
||||
if (secretKeys.has(k)) {
|
||||
@@ -347,20 +332,16 @@ export class PluginsService {
|
||||
}
|
||||
}
|
||||
await this.assertRequiredFilled(id, 'user', config);
|
||||
this.db.prepare(
|
||||
`INSERT INTO plugin_user_config (plugin_id, user_id, config, updated_at) VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(plugin_id, user_id) DO UPDATE SET config = excluded.config, updated_at = excluded.updated_at`,
|
||||
).run(id, userId, JSON.stringify(config));
|
||||
// PS10 — the composite-key upsert on (plugin_id, user_id).
|
||||
await this.pluginUserConfig.upsertConfig(id, userId, JSON.stringify(config));
|
||||
return maskSecrets(config, secretKeys);
|
||||
}
|
||||
|
||||
/** A user's own config with secrets DECRYPTED — host-only, for runtime `ctx.settings`.
|
||||
* Never sent to a client; the acting user is resolved host-side. */
|
||||
* Never sent to a client; the acting user is resolved host-side. PS11. */
|
||||
async getUserConfigDecrypted(id: string, userId: number): Promise<Record<string, unknown>> {
|
||||
const row = this.db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as
|
||||
| { config: string }
|
||||
| undefined;
|
||||
const config = safeParse(row?.config ?? '{}');
|
||||
const rowConfig = await this.pluginUserConfig.findConfig(id, userId);
|
||||
const config = safeParse(rowConfig ?? '{}');
|
||||
const secretKeys = await this.userSecretKeys(id);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(config)) out[k] = secretKeys.has(k) && v ? decrypt_api_key(v) : v;
|
||||
@@ -369,37 +350,29 @@ export class PluginsService {
|
||||
|
||||
/** A plugin's error log, newest first. */
|
||||
async errors(id: string): Promise<Array<{ ts: string; level: string; message: string }>> {
|
||||
return this.db
|
||||
.prepare('SELECT ts, level, message FROM plugin_error_log WHERE plugin_id = ? ORDER BY ts DESC, id DESC LIMIT 200')
|
||||
.all(id) as Array<{ ts: string; level: string; message: string }>;
|
||||
return await this.pluginErrorLog.listRecent(id);
|
||||
}
|
||||
|
||||
async clearErrors(id: string): Promise<void> {
|
||||
this.db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id);
|
||||
await this.pluginErrorLog.deleteAllForPlugin(id);
|
||||
}
|
||||
|
||||
/** A plugin's hash-chained capability audit log, newest first. */
|
||||
async auditLog(id: string): Promise<unknown[]> {
|
||||
return await readAudit(this.db, id);
|
||||
return await readAudit(this.pluginCapabilityAudit, id);
|
||||
}
|
||||
|
||||
/** A plugin's broker budget usage for today (AI + notification counts vs caps). */
|
||||
async budget(id: string): ReturnType<typeof pluginBudgetUsage> {
|
||||
return await pluginBudgetUsage(id, this.db);
|
||||
return await pluginBudgetUsage(id, this.pluginCapabilityAudit);
|
||||
}
|
||||
|
||||
/** Read the instance config with secret fields masked. */
|
||||
async getInstanceConfig(id: string): Promise<Record<string, unknown>> {
|
||||
const row = this.db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined;
|
||||
if (!row) throw new Error(`plugin ${id} not found`);
|
||||
const secretKeys = new Set(
|
||||
(
|
||||
this.db
|
||||
.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance' AND secret = 1")
|
||||
.all(id) as Array<{ field_key: string }>
|
||||
).map((r) => r.field_key),
|
||||
);
|
||||
return maskSecrets(safeParse(row.config), secretKeys);
|
||||
const rowConfig = await this.plugins.findConfig(id);
|
||||
if (rowConfig === null) throw new Error(`plugin ${id} not found`);
|
||||
const secretKeys = new Set(await this.pluginSettingsFields.listSecretFieldKeys(id, 'instance'));
|
||||
return maskSecrets(safeParse(rowConfig), secretKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -409,15 +382,11 @@ export class PluginsService {
|
||||
* would demand `true`, which is a consent flow, not a settings field.
|
||||
*/
|
||||
private async assertRequiredFilled(id: string, scope: 'instance' | 'user', config: Record<string, unknown>): Promise<void> {
|
||||
const required = this.db
|
||||
.prepare(
|
||||
"SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? AND required = 1 AND input_type != 'checkbox'",
|
||||
)
|
||||
.all(id, scope) as Array<{ field_key: string }>;
|
||||
const required = await this.pluginSettingsFields.listRequiredFieldKeys(id, scope);
|
||||
const defaults = await settingDefaults(this.db, id, scope);
|
||||
for (const f of required) {
|
||||
for (const fieldKey of required) {
|
||||
// The runtime resolves the default too, so it counts as filled here as well.
|
||||
if (!isFilled(config[f.field_key] ?? defaults[f.field_key])) throw new MissingRequiredSettingError(f.field_key);
|
||||
if (!isFilled(config[fieldKey] ?? defaults[fieldKey])) throw new MissingRequiredSettingError(fieldKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,21 @@ import {
|
||||
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
|
||||
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
|
||||
import { Users } from '../../src/db/entities/Users.entity';
|
||||
import { Plugins } from '../../src/db/entities/Plugins.entity';
|
||||
import { PluginErrorLog } from '../../src/db/entities/PluginErrorLog.entity';
|
||||
import { PluginScheduledTasks } from '../../src/db/entities/PluginScheduledTasks.entity';
|
||||
import { PluginUserErasureQueue } from '../../src/db/entities/PluginUserErasureQueue.entity';
|
||||
import { PluginEgressHosts } from '../../src/db/entities/PluginEgressHosts.entity';
|
||||
import { PluginSettingsFields } from '../../src/db/entities/PluginSettingsFields.entity';
|
||||
import { PluginActions } from '../../src/db/entities/PluginActions.entity';
|
||||
import { PluginUserConfig } from '../../src/db/entities/PluginUserConfig.entity';
|
||||
import { PluginEntityMetadata } from '../../src/db/entities/PluginEntityMetadata.entity';
|
||||
import { PluginOauthTokens } from '../../src/db/entities/PluginOauthTokens.entity';
|
||||
import { PluginOauthState } from '../../src/db/entities/PluginOauthState.entity';
|
||||
import { PluginMetaMigrations } from '../../src/db/entities/PluginMetaMigrations.entity';
|
||||
import { PluginCapabilityAudit } from '../../src/db/entities/PluginCapabilityAudit.entity';
|
||||
import { Settings } from '../../src/db/entities/Settings.entity';
|
||||
import { NotificationChannelPreferences } from '../../src/db/entities/NotificationChannelPreferences.entity';
|
||||
import { createTestTripFilesRepo, createTestFileLinksRepo, createTestBudgetItemsRepo } from './files-repos';
|
||||
import { budgetRepoArgs } from './budget-repos';
|
||||
import { createTestShareTokensRepo } from './share-repos';
|
||||
@@ -322,6 +337,22 @@ export async function createPluginRuntime(dbs: DatabaseService, registry?: Plugi
|
||||
new AuditService(orm.repo(AuditLog), orm.repo(Users)),
|
||||
await createTestAddonsService(dbs.connection, dbs),
|
||||
new PluginUserSettingsService(dbs),
|
||||
// Plan 3j Task 2 — PR1-PR53's own tables, all repository-backed now.
|
||||
orm.repo(Plugins),
|
||||
orm.repo(PluginErrorLog),
|
||||
orm.repo(PluginScheduledTasks),
|
||||
orm.repo(PluginUserErasureQueue),
|
||||
orm.repo(PluginEgressHosts),
|
||||
orm.repo(PluginSettingsFields),
|
||||
orm.repo(PluginActions),
|
||||
orm.repo(PluginUserConfig),
|
||||
orm.repo(PluginEntityMetadata),
|
||||
orm.repo(PluginOauthTokens),
|
||||
orm.repo(PluginOauthState),
|
||||
orm.repo(PluginMetaMigrations),
|
||||
orm.repo(PluginCapabilityAudit),
|
||||
orm.repo(Settings),
|
||||
orm.repo(NotificationChannelPreferences),
|
||||
registry,
|
||||
await createPluginRpcHostFactory(dbs),
|
||||
await createTestUnitOfWork(dbs.connection),
|
||||
|
||||
@@ -32,7 +32,7 @@ const { testDb } = vi.hoisted(() => {
|
||||
trek_range TEXT DEFAULT '>=3.0.0',
|
||||
source_repo TEXT, author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT);
|
||||
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);
|
||||
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER, default_value TEXT);
|
||||
CREATE TABLE plugin_settings_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER, default_value TEXT);
|
||||
CREATE TABLE settings (user_id INTEGER, key TEXT, value TEXT);
|
||||
CREATE TABLE plugin_entity_metadata (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, entity_type TEXT, entity_id INTEGER, key TEXT, value TEXT, updated_at TEXT);
|
||||
CREATE TABLE plugin_user_config (plugin_id TEXT, user_id INTEGER, field_key TEXT, value TEXT, PRIMARY KEY (plugin_id, user_id, field_key));
|
||||
@@ -67,6 +67,21 @@ import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
import { Plugins } from '../../../src/db/entities/Plugins.entity';
|
||||
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
|
||||
import { PluginScheduledTasks } from '../../../src/db/entities/PluginScheduledTasks.entity';
|
||||
import { PluginUserErasureQueue } from '../../../src/db/entities/PluginUserErasureQueue.entity';
|
||||
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
|
||||
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
|
||||
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
|
||||
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
|
||||
import { PluginEntityMetadata } from '../../../src/db/entities/PluginEntityMetadata.entity';
|
||||
import { PluginOauthTokens } from '../../../src/db/entities/PluginOauthTokens.entity';
|
||||
import { PluginOauthState } from '../../../src/db/entities/PluginOauthState.entity';
|
||||
import { PluginMetaMigrations } from '../../../src/db/entities/PluginMetaMigrations.entity';
|
||||
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
|
||||
import { Settings } from '../../../src/db/entities/Settings.entity';
|
||||
import { NotificationChannelPreferences } from '../../../src/db/entities/NotificationChannelPreferences.entity';
|
||||
|
||||
let codeRoot: string;
|
||||
let dataRoot: string;
|
||||
@@ -131,7 +146,32 @@ describe('plugin boot vs registry scan ordering', () => {
|
||||
// onLoad) when resolveOrm returns undefined, instead of running it
|
||||
// unwrapped, so this hand-built double needs the same real ORM the
|
||||
// repositories above (auditLogRepo/usersRepo) already share.
|
||||
useFactory: () => new PluginRuntimeService(dbs, new AuditService(auditLogRepo, usersRepo), addonsService, userSettings, undefined, hostFactory, undefined, t?.orm),
|
||||
useFactory: () =>
|
||||
new PluginRuntimeService(
|
||||
dbs,
|
||||
new AuditService(auditLogRepo, usersRepo),
|
||||
addonsService,
|
||||
userSettings,
|
||||
(t as TestOrm).repo(Plugins),
|
||||
(t as TestOrm).repo(PluginErrorLog),
|
||||
(t as TestOrm).repo(PluginScheduledTasks),
|
||||
(t as TestOrm).repo(PluginUserErasureQueue),
|
||||
(t as TestOrm).repo(PluginEgressHosts),
|
||||
(t as TestOrm).repo(PluginSettingsFields),
|
||||
(t as TestOrm).repo(PluginActions),
|
||||
(t as TestOrm).repo(PluginUserConfig),
|
||||
(t as TestOrm).repo(PluginEntityMetadata),
|
||||
(t as TestOrm).repo(PluginOauthTokens),
|
||||
(t as TestOrm).repo(PluginOauthState),
|
||||
(t as TestOrm).repo(PluginMetaMigrations),
|
||||
(t as TestOrm).repo(PluginCapabilityAudit),
|
||||
(t as TestOrm).repo(Settings),
|
||||
(t as TestOrm).repo(NotificationChannelPreferences),
|
||||
undefined,
|
||||
hostFactory,
|
||||
undefined,
|
||||
t?.orm,
|
||||
),
|
||||
},
|
||||
{
|
||||
provide: 'REGISTRY_SCAN',
|
||||
@@ -220,7 +260,31 @@ describe('plugin boot vs registry scan ordering', () => {
|
||||
{
|
||||
provide: PluginRuntimeService,
|
||||
useFactory: () =>
|
||||
new PluginRuntimeService(dbs2, new AuditService(auditLogRepo2, usersRepo2), addonsService2, userSettings2, undefined, hostFactory2, undefined, t2.orm),
|
||||
new PluginRuntimeService(
|
||||
dbs2,
|
||||
new AuditService(auditLogRepo2, usersRepo2),
|
||||
addonsService2,
|
||||
userSettings2,
|
||||
t2.repo(Plugins),
|
||||
t2.repo(PluginErrorLog),
|
||||
t2.repo(PluginScheduledTasks),
|
||||
t2.repo(PluginUserErasureQueue),
|
||||
t2.repo(PluginEgressHosts),
|
||||
t2.repo(PluginSettingsFields),
|
||||
t2.repo(PluginActions),
|
||||
t2.repo(PluginUserConfig),
|
||||
t2.repo(PluginEntityMetadata),
|
||||
t2.repo(PluginOauthTokens),
|
||||
t2.repo(PluginOauthState),
|
||||
t2.repo(PluginMetaMigrations),
|
||||
t2.repo(PluginCapabilityAudit),
|
||||
t2.repo(Settings),
|
||||
t2.repo(NotificationChannelPreferences),
|
||||
undefined,
|
||||
hostFactory2,
|
||||
undefined,
|
||||
t2.orm,
|
||||
),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* Plan 3j Task 2 — mutation/parity proofs the brief names explicitly, on top of the
|
||||
* pre-existing (and still-green) `plugin-runtime.test.ts`/`plugins-service.test.ts`
|
||||
* suites: R-install-gates' three named accept+refuse pairs (PR17/PR26/PR28), the
|
||||
* 9-table uninstall cascade's preserved NON-transactional partial-failure shape
|
||||
* (R-uninstall), the egress-host DELETE+loop-INSERT's new transaction rollback proof
|
||||
* (R-uninstall's ONE named exception), PS7/PS9/PS11's triple-duplicate SELECT
|
||||
* collapsing onto one `PluginUserConfigRepository.findConfig` method, and a two-tick
|
||||
* race for the scheduler sweep + the GDPR erasure drain.
|
||||
*
|
||||
* Full migrated schema (`createSnapshotTestDb`) — the same real-schema harness
|
||||
* `plugin-scheduler-sweep.test.ts`/`plugin-runtime.boot-no-orm.test.ts` already use —
|
||||
* so every one of the 14 repositories this task injects reads/writes real columns,
|
||||
* not a hand-slimmed fixture table.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
|
||||
import { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service';
|
||||
import { PluginRuntimeService, PluginDependencyError } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { AuditService } from '../../../src/nest/audit/audit.service';
|
||||
import { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
|
||||
import { UnitOfWork } from '../../../src/nest/database/unit-of-work';
|
||||
import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
import { Plugins } from '../../../src/db/entities/Plugins.entity';
|
||||
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
|
||||
import { PluginScheduledTasks } from '../../../src/db/entities/PluginScheduledTasks.entity';
|
||||
import { PluginUserErasureQueue } from '../../../src/db/entities/PluginUserErasureQueue.entity';
|
||||
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
|
||||
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
|
||||
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
|
||||
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
|
||||
import { PluginEntityMetadata } from '../../../src/db/entities/PluginEntityMetadata.entity';
|
||||
import { PluginOauthTokens } from '../../../src/db/entities/PluginOauthTokens.entity';
|
||||
import { PluginOauthState } from '../../../src/db/entities/PluginOauthState.entity';
|
||||
import { PluginMetaMigrations } from '../../../src/db/entities/PluginMetaMigrations.entity';
|
||||
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
|
||||
import { Settings } from '../../../src/db/entities/Settings.entity';
|
||||
import { NotificationChannelPreferences } from '../../../src/db/entities/NotificationChannelPreferences.entity';
|
||||
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let codeRoot: string;
|
||||
let dataRoot: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-t2-code-'));
|
||||
dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-t2-data-'));
|
||||
process.env.TREK_PLUGINS_DIR = codeRoot;
|
||||
process.env.TREK_PLUGINS_DATA_DIR = dataRoot;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.TREK_PLUGINS_ENABLED;
|
||||
delete process.env.TREK_PLUGINS_DIR;
|
||||
delete process.env.TREK_PLUGINS_DATA_DIR;
|
||||
await t.close();
|
||||
testDb.close();
|
||||
fs.rmSync(codeRoot, { recursive: true, force: true });
|
||||
fs.rmSync(dataRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function buildRuntime(registrar?: CronRegistrarService): Promise<PluginRuntimeService> {
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
|
||||
const addons = await createTestAddonsService(testDb, dbs);
|
||||
const userSettings = new PluginUserSettingsService(dbs);
|
||||
const uow = new UnitOfWork(t.em);
|
||||
return new PluginRuntimeService(
|
||||
dbs, audit, addons, userSettings,
|
||||
t.repo(Plugins), t.repo(PluginErrorLog), t.repo(PluginScheduledTasks), t.repo(PluginUserErasureQueue),
|
||||
t.repo(PluginEgressHosts), t.repo(PluginSettingsFields), t.repo(PluginActions), t.repo(PluginUserConfig),
|
||||
t.repo(PluginEntityMetadata), t.repo(PluginOauthTokens), t.repo(PluginOauthState), t.repo(PluginMetaMigrations),
|
||||
t.repo(PluginCapabilityAudit), t.repo(Settings), t.repo(NotificationChannelPreferences),
|
||||
undefined, undefined, uow, t.orm, registrar,
|
||||
);
|
||||
}
|
||||
|
||||
function buildPluginsService(): PluginsService {
|
||||
return new PluginsService(
|
||||
new DatabaseService(testDb),
|
||||
// A real AddonsService is not needed by any method this file calls.
|
||||
{ isAddonEnabled: async () => true } as never,
|
||||
t.repo(Plugins), t.repo(PluginEgressHosts), t.repo(PluginSettingsFields), t.repo(PluginActions),
|
||||
t.repo(PluginUserConfig), t.repo(PluginErrorLog), t.repo(PluginCapabilityAudit),
|
||||
);
|
||||
}
|
||||
|
||||
function seedPlugin(id: string, overrides: Partial<{ enabled: number; granted_permissions: string; permissions: string; trek_range: string; api_version: number; version: string; author_pubkey: string }> = {}) {
|
||||
testDb
|
||||
.prepare(
|
||||
`INSERT INTO plugins (id, name, status, enabled, version, trek_range, api_version, permissions, granted_permissions, capabilities, config, author_pubkey)
|
||||
VALUES (?, ?, 'inactive', ?, ?, ?, ?, ?, ?, '{}', '{}', ?)`,
|
||||
)
|
||||
.run(
|
||||
id, id,
|
||||
overrides.enabled ?? 0,
|
||||
overrides.version ?? '1.0.0',
|
||||
overrides.trek_range ?? '>=3.0.0',
|
||||
overrides.api_version ?? 1,
|
||||
overrides.permissions ?? '["db:own"]',
|
||||
overrides.granted_permissions ?? '["db:own"]',
|
||||
overrides.author_pubkey ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Plan 3j Task 2 — R-install-gates named accept+refuse pairs (PR17/PR26/PR28)', () => {
|
||||
let rt: PluginRuntimeService;
|
||||
beforeAll(async () => { rt = await buildRuntime(); });
|
||||
|
||||
describe('PR17 — version-range + permission-grant gate (assertActivatable)', () => {
|
||||
it('INSTALL-GATE-PR17-ACCEPT: a plugin whose declared range admits this host, with no permission widening, passes the gate (activate resolves, never PluginDependencyError)', async () => {
|
||||
seedPlugin('pr17-accept', { trek_range: '>=3.0.0 <99.0.0' });
|
||||
// The gate itself (assertActivatable, reading PR17's row through
|
||||
// PluginsRepository.findActivationGate) is the thing under test — the actual
|
||||
// child spawn is stubbed out (already covered end to end by
|
||||
// plugin-runtime.test.ts's own activation suite, real code on disk, real spawn).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const supervisorSpy = vi.spyOn((rt as any).supervisor, 'activate').mockResolvedValue(undefined);
|
||||
await expect(rt.activate('pr17-accept')).resolves.toBeUndefined();
|
||||
expect(supervisorSpy).toHaveBeenCalledTimes(1); // the gate let it through to the spawn call
|
||||
supervisorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('INSTALL-GATE-PR17-REFUSE: a plugin whose declared range excludes this host is refused, never spawned', async () => {
|
||||
seedPlugin('pr17-refuse', { trek_range: '>=99.0.0' });
|
||||
const err = await rt.activate('pr17-refuse').catch((e) => e);
|
||||
expect(err).toBeInstanceOf(PluginDependencyError);
|
||||
expect(err).toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
||||
expect(rt.isActive('pr17-refuse')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PR26 — update()\'s pre-update snapshot feeding the permission-widening diff', () => {
|
||||
const fakeRegistry = (perms: string[]) =>
|
||||
({
|
||||
resolveVersion: vi.fn(async () => ({ version: '2.0.0' })),
|
||||
install: vi.fn(async (id: string) => {
|
||||
testDb.prepare('UPDATE plugins SET permissions = ? WHERE id = ?').run(JSON.stringify(perms), id);
|
||||
return { id, version: '2.0.0' };
|
||||
}),
|
||||
}) as unknown as import('../../../src/nest/plugins/registry/registry.service').PluginRegistryService;
|
||||
|
||||
it('INSTALL-GATE-PR26-ACCEPT: no new permissions on update -> activated: true, PR26 snapshot read via the repository', async () => {
|
||||
seedPlugin('pr26-accept2', { enabled: 1, permissions: '["db:own"]', granted_permissions: '["db:own"]' });
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
|
||||
const addons = await createTestAddonsService(testDb, dbs);
|
||||
const userSettings = new PluginUserSettingsService(dbs);
|
||||
const rtU = new PluginRuntimeService(
|
||||
dbs, audit, addons, userSettings,
|
||||
t.repo(Plugins), t.repo(PluginErrorLog), t.repo(PluginScheduledTasks), t.repo(PluginUserErasureQueue),
|
||||
t.repo(PluginEgressHosts), t.repo(PluginSettingsFields), t.repo(PluginActions), t.repo(PluginUserConfig),
|
||||
t.repo(PluginEntityMetadata), t.repo(PluginOauthTokens), t.repo(PluginOauthState), t.repo(PluginMetaMigrations),
|
||||
t.repo(PluginCapabilityAudit), t.repo(Settings), t.repo(NotificationChannelPreferences),
|
||||
fakeRegistry(['db:own']),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.spyOn((rtU as any).supervisor, 'activate').mockResolvedValue(undefined);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.spyOn((rtU as any).supervisor, 'disable').mockResolvedValue(undefined);
|
||||
await rtU.activate('pr26-accept2');
|
||||
const res = await rtU.update('pr26-accept2');
|
||||
expect(res).toMatchObject({ activated: true, newPermissions: [], newEgress: [] });
|
||||
});
|
||||
|
||||
it('INSTALL-GATE-PR26-REFUSE: a widened permission set on update leaves the plugin inactive with the delta reported', async () => {
|
||||
seedPlugin('pr26-refuse', { enabled: 1, permissions: '["db:own"]', granted_permissions: '["db:own"]' });
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
|
||||
const addons = await createTestAddonsService(testDb, dbs);
|
||||
const userSettings = new PluginUserSettingsService(dbs);
|
||||
const rtU = new PluginRuntimeService(
|
||||
dbs, audit, addons, userSettings,
|
||||
t.repo(Plugins), t.repo(PluginErrorLog), t.repo(PluginScheduledTasks), t.repo(PluginUserErasureQueue),
|
||||
t.repo(PluginEgressHosts), t.repo(PluginSettingsFields), t.repo(PluginActions), t.repo(PluginUserConfig),
|
||||
t.repo(PluginEntityMetadata), t.repo(PluginOauthTokens), t.repo(PluginOauthState), t.repo(PluginMetaMigrations),
|
||||
t.repo(PluginCapabilityAudit), t.repo(Settings), t.repo(NotificationChannelPreferences),
|
||||
fakeRegistry(['db:own', 'db:read:trips']),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.spyOn((rtU as any).supervisor, 'activate').mockResolvedValue(undefined);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.spyOn((rtU as any).supervisor, 'disable').mockResolvedValue(undefined);
|
||||
await rtU.activate('pr26-refuse');
|
||||
const res = await rtU.update('pr26-refuse');
|
||||
expect(res.activated).toBe(false);
|
||||
expect(res.newPermissions).toEqual(['db:read:trips']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PR28 — retrust()\'s signing-key TOFU comparison', () => {
|
||||
const registryFor = (id: string) =>
|
||||
({
|
||||
assertRetrustable: vi.fn(async (_id: string, key: string) => ({ authorPublicKey: key })),
|
||||
install: vi.fn(async (_id: string, opts?: { retrustKey?: string }) => {
|
||||
testDb.prepare("UPDATE plugins SET author_pubkey = ?, version = '2.0.0' WHERE id = ?").run(opts?.retrustKey, id);
|
||||
return { id, version: '2.0.0' };
|
||||
}),
|
||||
}) as unknown as import('../../../src/nest/plugins/registry/registry.service').PluginRegistryService;
|
||||
|
||||
it('INSTALL-GATE-PR28-ACCEPT: a genuinely rotated key is pinned, read through PluginsRepository.findAuthorPubkey', async () => {
|
||||
seedPlugin('pr28-accept', { enabled: 1, author_pubkey: 'OLDKEY' });
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
|
||||
const addons = await createTestAddonsService(testDb, dbs);
|
||||
const userSettings = new PluginUserSettingsService(dbs);
|
||||
const registry = registryFor('pr28-accept');
|
||||
const rtR = new PluginRuntimeService(
|
||||
dbs, audit, addons, userSettings,
|
||||
t.repo(Plugins), t.repo(PluginErrorLog), t.repo(PluginScheduledTasks), t.repo(PluginUserErasureQueue),
|
||||
t.repo(PluginEgressHosts), t.repo(PluginSettingsFields), t.repo(PluginActions), t.repo(PluginUserConfig),
|
||||
t.repo(PluginEntityMetadata), t.repo(PluginOauthTokens), t.repo(PluginOauthState), t.repo(PluginMetaMigrations),
|
||||
t.repo(PluginCapabilityAudit), t.repo(Settings), t.repo(NotificationChannelPreferences),
|
||||
registry,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.spyOn((rtR as any).supervisor, 'activate').mockResolvedValue(undefined);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.spyOn((rtR as any).supervisor, 'disable').mockResolvedValue(undefined);
|
||||
await rtR.retrust('pr28-accept', '2.0.0', 'NEWKEY', { userId: 1 });
|
||||
expect(registry.assertRetrustable).toHaveBeenCalledWith('pr28-accept', 'NEWKEY');
|
||||
const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='pr28-accept'").get() as { author_pubkey: string };
|
||||
expect(row.author_pubkey).toBe('NEWKEY');
|
||||
});
|
||||
|
||||
it('INSTALL-GATE-PR28-REFUSE: an invalid signature is not re-trustable — the pinned key is untouched', async () => {
|
||||
seedPlugin('pr28-refuse', { enabled: 1, author_pubkey: 'OLDKEY' });
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
|
||||
const addons = await createTestAddonsService(testDb, dbs);
|
||||
const userSettings = new PluginUserSettingsService(dbs);
|
||||
const registry = registryFor('pr28-refuse');
|
||||
vi.mocked(registry.assertRetrustable).mockRejectedValue(new Error('nothing to re-trust'));
|
||||
const rtR = new PluginRuntimeService(
|
||||
dbs, audit, addons, userSettings,
|
||||
t.repo(Plugins), t.repo(PluginErrorLog), t.repo(PluginScheduledTasks), t.repo(PluginUserErasureQueue),
|
||||
t.repo(PluginEgressHosts), t.repo(PluginSettingsFields), t.repo(PluginActions), t.repo(PluginUserConfig),
|
||||
t.repo(PluginEntityMetadata), t.repo(PluginOauthTokens), t.repo(PluginOauthState), t.repo(PluginMetaMigrations),
|
||||
t.repo(PluginCapabilityAudit), t.repo(Settings), t.repo(NotificationChannelPreferences),
|
||||
registry,
|
||||
);
|
||||
await expect(rtR.retrust('pr28-refuse', '2.0.0', 'NEWKEY', { userId: 1 })).rejects.toThrow(/nothing to re-trust/);
|
||||
expect(registry.install).not.toHaveBeenCalled();
|
||||
const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='pr28-refuse'").get() as { author_pubkey: string };
|
||||
expect(row.author_pubkey).toBe('OLDKEY');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plan 3j Task 2 — R-uninstall: the 9-table cascade stays non-transactional (concurrency-pin)', () => {
|
||||
it('CASCADE-PARTIAL-001: a failure partway through the cascade leaves EARLIER deletes committed and LATER ones never attempted — the CURRENT legacy shape, not hardened to all-or-nothing', async () => {
|
||||
const rt = await buildRuntime();
|
||||
const id = 'cascade-partial';
|
||||
seedPlugin(id);
|
||||
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope) VALUES (?, 'k', 'instance')").run(id);
|
||||
testDb.prepare("INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at) VALUES (?, 'poll', 0)").run(id);
|
||||
testDb.prepare("INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, 'error', 'x')").run(id);
|
||||
testDb.prepare("INSERT INTO plugin_entity_metadata (plugin_id, entity_type, entity_id, key, value) VALUES (?, 'place', 1, 'k', 'v')").run(id);
|
||||
|
||||
// Simulate a crash INSIDE the cascade, between plugin_scheduled_tasks (deletes
|
||||
// cleanly, unconditional, no try/catch) and plugin_error_log (the next
|
||||
// unconditional delete, inside the deleteData branch).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const errorLogRepo = (rt as any).pluginErrorLog;
|
||||
const spy = vi.spyOn(errorLogRepo, 'deleteAllForPlugin').mockRejectedValueOnce(new Error('simulated mid-cascade crash'));
|
||||
|
||||
await expect(rt.uninstall(id, true)).rejects.toThrow(/simulated mid-cascade crash/);
|
||||
spy.mockRestore();
|
||||
|
||||
// EVERYTHING before the simulated crash point is already gone — no transaction
|
||||
// rolled it back, exactly matching the legacy sequential-statement shape.
|
||||
expect(testDb.prepare("SELECT COUNT(*) c FROM plugins WHERE id=?").get(id)).toMatchObject({ c: 0 });
|
||||
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_settings_fields WHERE plugin_id=?").get(id)).toMatchObject({ c: 0 });
|
||||
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_scheduled_tasks WHERE plugin_id=?").get(id)).toMatchObject({ c: 0 });
|
||||
// The row the mock made deleteAllForPlugin THROW for is untouched (never committed)...
|
||||
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_error_log WHERE plugin_id=?").get(id)).toMatchObject({ c: 1 });
|
||||
// ...and everything AFTER it in cascade order never even ran.
|
||||
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_entity_metadata WHERE plugin_id=?").get(id)).toMatchObject({ c: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plan 3j Task 2 — R-uninstall\'s ONE named transaction: PR22/PR23 egress-host replace', () => {
|
||||
it('EGRESS-TX-ROLLBACK-001: a simulated failure mid-write leaves the OLD egress-host set intact, not partially cleared', async () => {
|
||||
const rt = await buildRuntime();
|
||||
const id = 'egress-tx';
|
||||
seedPlugin(id, { permissions: '["http:outbound"]', granted_permissions: '["http:outbound"]' });
|
||||
testDb.prepare("UPDATE plugins SET operator_egress = 1 WHERE id = ?").run(id);
|
||||
await rt.setOperatorEgressHosts(id, ['old-a.example.com', 'old-b.example.com']);
|
||||
expect((await rt.operatorEgressHosts(id)).sort()).toEqual(['old-a.example.com', 'old-b.example.com']);
|
||||
|
||||
// Force the INSERT half of the DELETE+loop-INSERT to throw on the second host,
|
||||
// inside the SAME uow.transactional call the runtime already wraps this in.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const egressRepo = (rt as any).pluginEgressHosts;
|
||||
const originalReplace = egressRepo.replaceAllForPlugin.bind(egressRepo);
|
||||
const spy = vi.spyOn(egressRepo, 'replaceAllForPlugin').mockImplementation(async (pluginId: string, hosts: string[]) => {
|
||||
if (hosts.includes('boom')) throw new Error('simulated mid-write failure');
|
||||
return originalReplace(pluginId, hosts);
|
||||
});
|
||||
|
||||
await expect(rt.setOperatorEgressHosts(id, ['new-a.example.com', 'boom'])).rejects.toThrow(/simulated mid-write failure/);
|
||||
spy.mockRestore();
|
||||
|
||||
// The OLD set survives untouched — the transaction rolled the DELETE back too,
|
||||
// not a partially-cleared allow-list.
|
||||
expect((await rt.operatorEgressHosts(id)).sort()).toEqual(['old-a.example.com', 'old-b.example.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plan 3j Task 2 — PS7/PS9/PS11: the triple-duplicate SELECT collapses onto ONE PluginUserConfigRepository.findConfig call', () => {
|
||||
it('PS-USERCONFIG-PARITY-001: getUserConfig, setUserConfig\'s pre-write check and getUserConfigDecrypted all read the SAME stored row identically', async () => {
|
||||
const svc = buildPluginsService();
|
||||
const id = 'ps-parity';
|
||||
seedPlugin(id);
|
||||
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES (?, 'apiKey', 'user', 1)").run(id);
|
||||
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES (?, 'units', 'user', 0)").run(id);
|
||||
|
||||
await svc.updateUserConfig(id, 7, { apiKey: 'sk-live', units: 'metric' });
|
||||
|
||||
// PS7 (getUserConfig, masked) and PS11 (getUserConfigDecrypted, plaintext) both
|
||||
// resolve through the SAME repository method (`findConfig`) as PS9's pre-write
|
||||
// check inside updateUserConfig above — all three must agree on what is stored.
|
||||
const masked = await svc.getUserConfig(id, 7);
|
||||
const decrypted = await svc.getUserConfigDecrypted(id, 7);
|
||||
expect(masked.units).toBe('metric');
|
||||
expect(masked.apiKey).toBe('••••••••'); // PS7: secret masked
|
||||
expect(decrypted.units).toBe('metric');
|
||||
expect(decrypted.apiKey).toBe('sk-live'); // PS11: secret decrypted, host-only
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plan 3j Task 2 — two racing ticks (scheduler claim/re-arm + erasure drain)', () => {
|
||||
let registry: SchedulerRegistry;
|
||||
let registrar: CronRegistrarService;
|
||||
let rt: PluginRuntimeService;
|
||||
|
||||
beforeAll(async () => {
|
||||
registry = new SchedulerRegistry();
|
||||
registrar = new CronRegistrarService(registry, { isTest: () => false } as RuntimeEnvService, t.orm);
|
||||
rt = await buildRuntime(registrar);
|
||||
await rt.onApplicationBootstrap();
|
||||
});
|
||||
afterAll(async () => { await rt.onModuleDestroy(); });
|
||||
|
||||
it('RACE-SCHED-001: two concurrent fireDueScheduled passes deliver a due one-shot task exactly once', async () => {
|
||||
const pluginId = 'race-sched';
|
||||
seedPlugin(pluginId, { enabled: 1 });
|
||||
testDb.prepare('INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at) VALUES (?, ?, ?)').run(pluginId, 'once', Date.now() - 1000);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const supervisor = (rt as any).supervisor;
|
||||
supervisor.running.set(pluginId, { id: pluginId, status: 'active' });
|
||||
let delivered = 0;
|
||||
const invokeSpy = vi.spyOn(supervisor, 'invoke').mockImplementation(async () => {
|
||||
delivered += 1;
|
||||
// Yield, so the second concurrent pass's own claim-read genuinely overlaps
|
||||
// this one's window rather than running strictly after it.
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
return {};
|
||||
});
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await Promise.all([(rt as any).fireDueScheduled(), (rt as any).fireDueScheduled()]);
|
||||
expect(delivered).toBe(1); // claimed (re-armed/deleted) by exactly one pass
|
||||
const row = testDb.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(pluginId, 'once');
|
||||
expect(row).toBeUndefined(); // deleted, not left dangling or double-inserted
|
||||
} finally {
|
||||
invokeSpy.mockRestore();
|
||||
supervisor.running.delete(pluginId);
|
||||
}
|
||||
});
|
||||
|
||||
it('RACE-ERASURE-001: two concurrent drains coalesce onto the SAME in-flight pass (drainUserErasures\' own promise-sharing) — one ACK, one drop', async () => {
|
||||
const pluginId = 'race-erasure';
|
||||
seedPlugin(pluginId, { enabled: 1 });
|
||||
testDb.prepare('INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)').run(pluginId, 99);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const supervisor = (rt as any).supervisor;
|
||||
supervisor.running.set(pluginId, { id: pluginId, status: 'active' });
|
||||
let delivered = 0;
|
||||
const deliverSpy = vi.spyOn(supervisor, 'deliverUserErasure').mockImplementation(async () => {
|
||||
delivered += 1;
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
return true;
|
||||
});
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await Promise.all([(rt as any).drainUserErasures(), (rt as any).drainUserErasures()]);
|
||||
expect(delivered).toBe(1); // drainUserErasures' own in-flight coalescing, unchanged by this task's conversion
|
||||
const row = testDb.prepare('SELECT id FROM plugin_user_erasure_queue WHERE plugin_id=? AND user_id=?').get(pluginId, 99);
|
||||
expect(row).toBeUndefined();
|
||||
} finally {
|
||||
deliverSpy.mockRestore();
|
||||
supervisor.running.delete(pluginId);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ const { testDb } = vi.hoisted(() => {
|
||||
trek_range TEXT DEFAULT '>=3.0.0',
|
||||
source_repo TEXT, author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT);
|
||||
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);
|
||||
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER, default_value TEXT);
|
||||
CREATE TABLE plugin_settings_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER, default_value TEXT);
|
||||
CREATE TABLE settings (user_id INTEGER, key TEXT, value TEXT);
|
||||
CREATE TABLE plugin_entity_metadata (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, entity_type TEXT, entity_id INTEGER, key TEXT, value TEXT, updated_at TEXT);
|
||||
CREATE TABLE plugin_user_config (plugin_id TEXT, user_id INTEGER, field_key TEXT, value TEXT, PRIMARY KEY (plugin_id, user_id, field_key));
|
||||
|
||||
@@ -99,6 +99,21 @@ import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
import { Plugins } from '../../../src/db/entities/Plugins.entity';
|
||||
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
|
||||
import { PluginScheduledTasks } from '../../../src/db/entities/PluginScheduledTasks.entity';
|
||||
import { PluginUserErasureQueue } from '../../../src/db/entities/PluginUserErasureQueue.entity';
|
||||
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
|
||||
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
|
||||
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
|
||||
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
|
||||
import { PluginEntityMetadata } from '../../../src/db/entities/PluginEntityMetadata.entity';
|
||||
import { PluginOauthTokens } from '../../../src/db/entities/PluginOauthTokens.entity';
|
||||
import { PluginOauthState } from '../../../src/db/entities/PluginOauthState.entity';
|
||||
import { PluginMetaMigrations } from '../../../src/db/entities/PluginMetaMigrations.entity';
|
||||
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
|
||||
import { Settings } from '../../../src/db/entities/Settings.entity';
|
||||
import { NotificationChannelPreferences } from '../../../src/db/entities/NotificationChannelPreferences.entity';
|
||||
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
@@ -117,7 +132,32 @@ async function buildRuntime(registrar?: CronRegistrarService, orm?: TestOrm['orm
|
||||
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
|
||||
const addons = await createTestAddonsService(testDb, dbs);
|
||||
const userSettings = new PluginUserSettingsService(dbs);
|
||||
return new PluginRuntimeService(dbs, audit, addons, userSettings, undefined, undefined, undefined, orm, registrar);
|
||||
return new PluginRuntimeService(
|
||||
dbs,
|
||||
audit,
|
||||
addons,
|
||||
userSettings,
|
||||
t.repo(Plugins),
|
||||
t.repo(PluginErrorLog),
|
||||
t.repo(PluginScheduledTasks),
|
||||
t.repo(PluginUserErasureQueue),
|
||||
t.repo(PluginEgressHosts),
|
||||
t.repo(PluginSettingsFields),
|
||||
t.repo(PluginActions),
|
||||
t.repo(PluginUserConfig),
|
||||
t.repo(PluginEntityMetadata),
|
||||
t.repo(PluginOauthTokens),
|
||||
t.repo(PluginOauthState),
|
||||
t.repo(PluginMetaMigrations),
|
||||
t.repo(PluginCapabilityAudit),
|
||||
t.repo(Settings),
|
||||
t.repo(NotificationChannelPreferences),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
orm,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
|
||||
@@ -31,6 +31,14 @@ import { createPluginRuntime } from '../../helpers/plugin-host';
|
||||
import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest';
|
||||
import { makeHostAllow } from '../../../src/nest/plugins/runtime/egress-policy';
|
||||
import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
import { sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { Plugins } from '../../../src/db/entities/Plugins.entity';
|
||||
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
|
||||
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
|
||||
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
|
||||
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
|
||||
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
|
||||
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
|
||||
|
||||
function install(id: string, operatorEgress: boolean, perms: string[] = ['http:outbound:gotify.net']) {
|
||||
testDb.prepare(
|
||||
@@ -174,7 +182,19 @@ describe('the admin list surfaces operator egress (so the chip can be shown)', (
|
||||
// one over the same DB. These fixtures declare no dependencies, so it is never consulted.
|
||||
const listPlugins = async () => {
|
||||
const dbs = new DatabaseService(dbConn);
|
||||
return (await new PluginsService(dbs, await createTestAddonsService(testDb, dbs)).list()).plugins;
|
||||
const orm = await sharedTestOrm(dbConn);
|
||||
const service = new PluginsService(
|
||||
dbs,
|
||||
await createTestAddonsService(testDb, dbs),
|
||||
orm.repo(Plugins),
|
||||
orm.repo(PluginEgressHosts),
|
||||
orm.repo(PluginSettingsFields),
|
||||
orm.repo(PluginActions),
|
||||
orm.repo(PluginUserConfig),
|
||||
orm.repo(PluginErrorLog),
|
||||
orm.repo(PluginCapabilityAudit),
|
||||
);
|
||||
return (await service.list()).plugins;
|
||||
};
|
||||
|
||||
const before = await listPlugins();
|
||||
|
||||
@@ -34,6 +34,21 @@ import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
import { createPluginRuntime } from '../../helpers/plugin-host';
|
||||
import { discoverPlugins } from '../../../src/nest/plugins/install/discovery';
|
||||
import { sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { Plugins } from '../../../src/db/entities/Plugins.entity';
|
||||
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
|
||||
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
|
||||
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
|
||||
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
|
||||
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
|
||||
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
|
||||
import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository';
|
||||
import type { PluginEgressHostsRepository } from '../../../src/db/repositories/PluginEgressHosts.repository';
|
||||
import type { PluginSettingsFieldsRepository } from '../../../src/db/repositories/PluginSettingsFields.repository';
|
||||
import type { PluginActionsRepository } from '../../../src/db/repositories/PluginActions.repository';
|
||||
import type { PluginUserConfigRepository } from '../../../src/db/repositories/PluginUserConfig.repository';
|
||||
import type { PluginErrorLogRepository } from '../../../src/db/repositories/PluginErrorLog.repository';
|
||||
import type { PluginCapabilityAuditRepository } from '../../../src/db/repositories/PluginCapabilityAudit.repository';
|
||||
|
||||
function install(id: string) {
|
||||
testDb
|
||||
@@ -54,7 +69,28 @@ function declareField(pluginId: string, key: string, scope: 'instance' | 'user',
|
||||
}
|
||||
|
||||
let addonsService: AddonsService;
|
||||
const svc = () => new PluginsService(new DatabaseService(dbConn), addonsService);
|
||||
// Plan 3j Task 2 — PluginsService's own six repositories, resolved ONCE in `beforeAll`
|
||||
// (the file's `testDb` is fixed for the whole suite) so `svc()` below can stay a plain
|
||||
// synchronous factory — every one of its ~20 call sites below chains straight off it.
|
||||
let pluginsRepo: PluginsRepository;
|
||||
let pluginEgressHostsRepo: PluginEgressHostsRepository;
|
||||
let pluginSettingsFieldsRepo: PluginSettingsFieldsRepository;
|
||||
let pluginActionsRepo: PluginActionsRepository;
|
||||
let pluginUserConfigRepo: PluginUserConfigRepository;
|
||||
let pluginErrorLogRepo: PluginErrorLogRepository;
|
||||
let pluginCapabilityAuditRepo: PluginCapabilityAuditRepository;
|
||||
const svc = () =>
|
||||
new PluginsService(
|
||||
new DatabaseService(dbConn),
|
||||
addonsService,
|
||||
pluginsRepo,
|
||||
pluginEgressHostsRepo,
|
||||
pluginSettingsFieldsRepo,
|
||||
pluginActionsRepo,
|
||||
pluginUserConfigRepo,
|
||||
pluginErrorLogRepo,
|
||||
pluginCapabilityAuditRepo,
|
||||
);
|
||||
|
||||
/**
|
||||
* Installs a real plugin through the manifest -> discovery pipeline (not a direct
|
||||
@@ -77,6 +113,14 @@ beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
addonsService = await createTestAddonsService(testDb, new DatabaseService(dbConn));
|
||||
const orm = await sharedTestOrm(dbConn);
|
||||
pluginsRepo = orm.repo(Plugins);
|
||||
pluginEgressHostsRepo = orm.repo(PluginEgressHosts);
|
||||
pluginSettingsFieldsRepo = orm.repo(PluginSettingsFields);
|
||||
pluginActionsRepo = orm.repo(PluginActions);
|
||||
pluginUserConfigRepo = orm.repo(PluginUserConfig);
|
||||
pluginErrorLogRepo = orm.repo(PluginErrorLog);
|
||||
pluginCapabilityAuditRepo = orm.repo(PluginCapabilityAudit);
|
||||
});
|
||||
beforeEach(() => {
|
||||
testDb.prepare('DELETE FROM plugins').run();
|
||||
|
||||
@@ -22,6 +22,21 @@ import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
import { Plugins } from '../../../src/db/entities/Plugins.entity';
|
||||
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
|
||||
import { PluginScheduledTasks } from '../../../src/db/entities/PluginScheduledTasks.entity';
|
||||
import { PluginUserErasureQueue } from '../../../src/db/entities/PluginUserErasureQueue.entity';
|
||||
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
|
||||
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
|
||||
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
|
||||
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
|
||||
import { PluginEntityMetadata } from '../../../src/db/entities/PluginEntityMetadata.entity';
|
||||
import { PluginOauthTokens } from '../../../src/db/entities/PluginOauthTokens.entity';
|
||||
import { PluginOauthState } from '../../../src/db/entities/PluginOauthState.entity';
|
||||
import { PluginMetaMigrations } from '../../../src/db/entities/PluginMetaMigrations.entity';
|
||||
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
|
||||
import { Settings } from '../../../src/db/entities/Settings.entity';
|
||||
import { NotificationChannelPreferences } from '../../../src/db/entities/NotificationChannelPreferences.entity';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
@@ -50,21 +65,50 @@ async function buildRuntime(withOrm: boolean): Promise<PluginRuntimeService> {
|
||||
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
|
||||
const addons = await createTestAddonsService(testDb, dbs);
|
||||
const userSettings = new PluginUserSettingsService(dbs);
|
||||
return new PluginRuntimeService(dbs, audit, addons, userSettings, undefined, undefined, undefined, withOrm ? t.orm : undefined);
|
||||
return new PluginRuntimeService(
|
||||
dbs,
|
||||
audit,
|
||||
addons,
|
||||
userSettings,
|
||||
t.repo(Plugins),
|
||||
t.repo(PluginErrorLog),
|
||||
t.repo(PluginScheduledTasks),
|
||||
t.repo(PluginUserErasureQueue),
|
||||
t.repo(PluginEgressHosts),
|
||||
t.repo(PluginSettingsFields),
|
||||
t.repo(PluginActions),
|
||||
t.repo(PluginUserConfig),
|
||||
t.repo(PluginEntityMetadata),
|
||||
t.repo(PluginOauthTokens),
|
||||
t.repo(PluginOauthState),
|
||||
t.repo(PluginMetaMigrations),
|
||||
t.repo(PluginCapabilityAudit),
|
||||
t.repo(Settings),
|
||||
t.repo(NotificationChannelPreferences),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
withOrm ? t.orm : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
describe('PluginRuntimeService.onApplicationBootstrap — absent-ORM boot branch (task-6-rereview2.md I-A)', () => {
|
||||
it('RT-BOOT-NOORM-001: without an ORM, boot logs the skip line naming the plugin id and never calls activate', async () => {
|
||||
it('RT-BOOT-NOORM-001: without an ORM, boot logs the skip line and never calls activate', async () => {
|
||||
installEnabledPlugin('rtboot-noorm');
|
||||
const rt = await buildRuntime(false);
|
||||
const activateSpy = vi.spyOn(rt, 'activate');
|
||||
|
||||
await rt.onApplicationBootstrap();
|
||||
|
||||
// Plan 3j Task 2 (deviation, task-2-report.md): `installedDepRows` is now a
|
||||
// repository read that needs a request context this branch cannot provide, so
|
||||
// it is skipped rather than attempted unwrapped — the log line can no longer
|
||||
// name which plugin(s) it skipped, only that it skipped activation entirely.
|
||||
// Still fail-closed (never throws out of onApplicationBootstrap, never abandons
|
||||
// app.init()), still logged exactly once, still never calls activate.
|
||||
expect(logMock.logError).toHaveBeenCalledTimes(1);
|
||||
const [message] = logMock.logError.mock.calls[0] as [string];
|
||||
expect(message).toMatch(/PluginRuntimeService\.onApplicationBootstrap: no MikroORM available/);
|
||||
expect(message).toContain('rtboot-noorm');
|
||||
expect(activateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,30 @@ import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
import Database from 'better-sqlite3';
|
||||
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
|
||||
import { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
|
||||
import { sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { Plugins } from '../../../src/db/entities/Plugins.entity';
|
||||
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
|
||||
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
|
||||
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
|
||||
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
|
||||
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
|
||||
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
|
||||
|
||||
/** Plan 3j Task 2 — PluginsService's own six repositories, over whichever fresh `getDb.current` the caller just set. */
|
||||
async function makePluginsService(dbs: DatabaseService): Promise<PluginsService> {
|
||||
const orm = await sharedTestOrm(getDb.current as Database.Database);
|
||||
return new PluginsService(
|
||||
dbs,
|
||||
await createTestAddonsService(getDb.current as Database.Database, dbs),
|
||||
orm.repo(Plugins),
|
||||
orm.repo(PluginEgressHosts),
|
||||
orm.repo(PluginSettingsFields),
|
||||
orm.repo(PluginActions),
|
||||
orm.repo(PluginUserConfig),
|
||||
orm.repo(PluginErrorLog),
|
||||
orm.repo(PluginCapabilityAudit),
|
||||
);
|
||||
}
|
||||
/** The host-side settings reads, over the same connection the test seeded. */
|
||||
const userSettings = () => new PluginUserSettingsService(new DatabaseService(dbConn));
|
||||
|
||||
@@ -46,7 +70,7 @@ describe('per-user plugin settings', () => {
|
||||
beforeEach(async () => {
|
||||
getDb.current = freshDb();
|
||||
const dbs = new DatabaseService(dbConn);
|
||||
svc = new PluginsService(dbs, await createTestAddonsService(getDb.current as Database.Database, dbs));
|
||||
svc = await makePluginsService(dbs);
|
||||
});
|
||||
|
||||
it('lists only the user-scope fields, in order', async () => {
|
||||
@@ -98,7 +122,7 @@ describe('manifest defaults reach the runtime reads', () => {
|
||||
beforeEach(async () => {
|
||||
getDb.current = freshDb();
|
||||
const dbs = new DatabaseService(dbConn);
|
||||
svc = new PluginsService(dbs, await createTestAddonsService(getDb.current as Database.Database, dbs));
|
||||
svc = await makePluginsService(dbs);
|
||||
const ins = (getDb.current as import('better-sqlite3').Database).prepare(
|
||||
'INSERT INTO plugin_settings_fields (plugin_id, field_key, input_type, required, secret, scope, sort_order, default_value) VALUES (?,?,?,?,?,?,?,?)',
|
||||
);
|
||||
@@ -141,7 +165,7 @@ describe('hasRequired applies the same "filled" rule as the save gate', () => {
|
||||
beforeEach(async () => {
|
||||
getDb.current = freshDb();
|
||||
const dbs = new DatabaseService(dbConn);
|
||||
svc = new PluginsService(dbs, await createTestAddonsService(getDb.current as Database.Database, dbs));
|
||||
svc = await makePluginsService(dbs);
|
||||
(getDb.current as import('better-sqlite3').Database)
|
||||
.prepare('INSERT INTO plugin_settings_fields (plugin_id, field_key, input_type, required, secret, scope, sort_order) VALUES (?,?,?,?,?,?,?)')
|
||||
.run('p', 'consent', 'checkbox', 1, 0, 'user', 9);
|
||||
|
||||
@@ -12,8 +12,11 @@ const { testDb } = vi.hoisted(() => {
|
||||
status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', permissions TEXT DEFAULT '[]', granted_permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, updated_at TEXT,
|
||||
author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT,
|
||||
trek_range TEXT, sort_order INTEGER DEFAULT 0, update_hold INTEGER NOT NULL DEFAULT 0);
|
||||
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER, required INTEGER DEFAULT 0, input_type TEXT DEFAULT 'text', default_value TEXT);
|
||||
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT DEFAULT '2026-01-01');`);
|
||||
CREATE TABLE plugin_settings_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER, required INTEGER DEFAULT 0, input_type TEXT DEFAULT 'text', default_value TEXT, sort_order INTEGER DEFAULT 0);
|
||||
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT DEFAULT '2026-01-01');
|
||||
CREATE TABLE plugin_actions (plugin_id TEXT, action_key TEXT, label TEXT, hint TEXT, danger INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0, scope TEXT DEFAULT 'user', PRIMARY KEY (plugin_id, action_key));
|
||||
CREATE TABLE plugin_egress_hosts (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, host TEXT, created_at TEXT DEFAULT (datetime('now')));
|
||||
CREATE TABLE plugin_user_config (plugin_id TEXT, user_id INTEGER, config TEXT DEFAULT '{}', updated_at TEXT DEFAULT (datetime('now')), PRIMARY KEY (plugin_id, user_id));`);
|
||||
return { testDb: db };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: testDb }));
|
||||
@@ -22,6 +25,14 @@ import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
import { sharedTestOrm } from '../../helpers/test-uow';
|
||||
import { Plugins } from '../../../src/db/entities/Plugins.entity';
|
||||
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
|
||||
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
|
||||
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
|
||||
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
|
||||
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
|
||||
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
|
||||
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
|
||||
import { PluginsController } from '../../../src/nest/plugins/plugins.controller';
|
||||
import { PluginsFeedController } from '../../../src/nest/plugins/plugins-feed.controller';
|
||||
@@ -35,6 +46,27 @@ beforeAll(async () => {
|
||||
addonsService = await createTestAddonsService(testDb, new DatabaseService(dbConn));
|
||||
});
|
||||
|
||||
/**
|
||||
* Plan 3j Task 2 — PluginsService's own repository-backed constructor. One
|
||||
* factory, reused by every call site below (`sharedTestOrm` memoises the ORM
|
||||
* per connection, so this is cheap): the six PS1-PS13 repositories, resolved
|
||||
* over the SAME `testDb` handle every raw-SQL fixture line above writes to.
|
||||
*/
|
||||
async function makeService(): Promise<PluginsService> {
|
||||
const orm = await sharedTestOrm(testDb);
|
||||
return new PluginsService(
|
||||
new DatabaseService(dbConn),
|
||||
addonsService,
|
||||
orm.repo(Plugins),
|
||||
orm.repo(PluginEgressHosts),
|
||||
orm.repo(PluginSettingsFields),
|
||||
orm.repo(PluginActions),
|
||||
orm.repo(PluginUserConfig),
|
||||
orm.repo(PluginErrorLog),
|
||||
orm.repo(PluginCapabilityAudit),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testDb.exec('DELETE FROM plugins');
|
||||
testDb.exec('DELETE FROM plugin_settings_fields');
|
||||
@@ -51,7 +83,7 @@ describe('PluginsService.list', () => {
|
||||
.run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0');
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
|
||||
const out = await (await makeService()).list();
|
||||
expect(out.enabled).toBe(true);
|
||||
expect(out.plugins).toHaveLength(1);
|
||||
expect(out.plugins[0]).toMatchObject({ id: 'flight', name: 'Flight', status: 'inactive' });
|
||||
@@ -72,7 +104,7 @@ describe('PluginsService.list', () => {
|
||||
it('reports the switch off and an outgrown plugin as hostIncompatible by default', async () => {
|
||||
process.env.APP_VERSION = '4.1.0';
|
||||
seed();
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
|
||||
const out = await (await makeService()).list();
|
||||
expect(out.ignoreTrekRange).toBe(false);
|
||||
expect(out.plugins[0]).toMatchObject({ dependencyStatus: 'hostIncompatible', trekRangeBypassed: null });
|
||||
});
|
||||
@@ -81,7 +113,7 @@ describe('PluginsService.list', () => {
|
||||
process.env.APP_VERSION = '4.1.0';
|
||||
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
|
||||
seed();
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
|
||||
const out = await (await makeService()).list();
|
||||
expect(out.ignoreTrekRange).toBe(true);
|
||||
expect(out.plugins[0]).toMatchObject({
|
||||
dependencyStatus: 'ok',
|
||||
@@ -93,7 +125,7 @@ describe('PluginsService.list', () => {
|
||||
process.env.APP_VERSION = '3.5.0';
|
||||
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
|
||||
seed();
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
|
||||
const out = await (await makeService()).list();
|
||||
expect(out.plugins[0]).toMatchObject({ dependencyStatus: 'ok', trekRangeBypassed: null });
|
||||
});
|
||||
});
|
||||
@@ -106,7 +138,7 @@ describe('PluginsService.list', () => {
|
||||
.prepare("INSERT INTO plugins (id, name, type, status, version) VALUES ('free','Free','widget','inactive','1.0.0')")
|
||||
.run();
|
||||
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
|
||||
const out = await (await makeService()).list();
|
||||
expect(out.plugins.find((p) => p.id === 'held')).toMatchObject({ updateHold: true });
|
||||
expect(out.plugins.find((p) => p.id === 'free')).toMatchObject({ updateHold: false });
|
||||
});
|
||||
@@ -115,7 +147,7 @@ describe('PluginsService.list', () => {
|
||||
testDb
|
||||
.prepare("INSERT INTO plugins (id, name, type, status, version, update_hold) VALUES ('held','Held','widget','inactive','1.0.0',1)")
|
||||
.run();
|
||||
const svc = new PluginsService(new DatabaseService(dbConn), addonsService);
|
||||
const svc = await makeService();
|
||||
|
||||
expect(await svc.resumeUpdates('held')).toBe(true);
|
||||
expect(testDb.prepare("SELECT update_hold FROM plugins WHERE id='held'").get()).toMatchObject({ update_hold: 0 });
|
||||
@@ -127,14 +159,14 @@ describe('PluginsService.list', () => {
|
||||
.prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)')
|
||||
.run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0');
|
||||
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
|
||||
const out = await (await makeService()).list();
|
||||
expect(out.enabled).toBe(true);
|
||||
expect(out.plugins).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports disabled when the kill switch is off (TREK_PLUGINS_ENABLED=false)', async () => {
|
||||
process.env.TREK_PLUGINS_ENABLED = 'false';
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
|
||||
const out = await (await makeService()).list();
|
||||
expect(out.enabled).toBe(false);
|
||||
expect(out.plugins).toEqual([]);
|
||||
});
|
||||
@@ -154,7 +186,7 @@ describe('PluginsService.list', () => {
|
||||
const key = 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd';
|
||||
insert('signed-one', 'acme/signed-one', key);
|
||||
|
||||
const p = (await new PluginsService(new DatabaseService(dbConn), addonsService).list()).plugins[0];
|
||||
const p = (await (await makeService()).list()).plugins[0];
|
||||
expect(p.signed).toBe(true);
|
||||
// Short head…tail, for eyeballing against what the author reads out over the
|
||||
// phone. NOT a confidentiality measure — the key is public, and the re-trust
|
||||
@@ -164,7 +196,7 @@ describe('PluginsService.list', () => {
|
||||
|
||||
it('reports unsigned for a registry plugin with no pinned key', async () => {
|
||||
insert('plain', 'acme/plain', null);
|
||||
const p = (await new PluginsService(new DatabaseService(dbConn), addonsService).list()).plugins[0];
|
||||
const p = (await (await makeService()).list()).plugins[0];
|
||||
expect(p.signed).toBe(false);
|
||||
expect(p.keyFingerprint).toBeNull();
|
||||
});
|
||||
@@ -172,7 +204,7 @@ describe('PluginsService.list', () => {
|
||||
it('reports unsigned for a sideloaded and a dev-linked plugin (they carry no key)', async () => {
|
||||
insert('uploaded', 'local:upload', null);
|
||||
insert('linked', 'local:link', null);
|
||||
const plugins = (await new PluginsService(new DatabaseService(dbConn), addonsService).list()).plugins;
|
||||
const plugins = (await (await makeService()).list()).plugins;
|
||||
expect(plugins.map((p) => [p.id, p.signed, p.source_repo])).toEqual([
|
||||
['linked', false, 'local:link'],
|
||||
['uploaded', false, 'local:upload'],
|
||||
@@ -186,7 +218,7 @@ describe('PluginsService.list', () => {
|
||||
.prepare('UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?')
|
||||
.run('SIGNATURE_KEY_CHANGED', 'the key changed', '2.0.0', 'blocked');
|
||||
|
||||
const byId = Object.fromEntries((await new PluginsService(new DatabaseService(dbConn), addonsService).list()).plugins.map((p) => [p.id, p]));
|
||||
const byId = Object.fromEntries((await (await makeService()).list()).plugins.map((p) => [p.id, p]));
|
||||
expect(byId.blocked.updateBlock).toEqual({ code: 'SIGNATURE_KEY_CHANGED', detail: 'the key changed', version: '2.0.0' });
|
||||
expect(byId.fine.updateBlock).toBeNull();
|
||||
});
|
||||
@@ -196,7 +228,7 @@ describe('PluginsService.list', () => {
|
||||
// PluginListItem is an interface, so it has no implicit index signature and cannot
|
||||
// be narrowed to a record directly. Widening through unknown is what lets this case
|
||||
// probe for a key the contract deliberately does not declare.
|
||||
const p = (await new PluginsService(new DatabaseService(dbConn), addonsService).list())
|
||||
const p = (await (await makeService()).list())
|
||||
.plugins[0] as unknown as Record<string, unknown>;
|
||||
expect(p.author_pubkey).toBeUndefined();
|
||||
});
|
||||
@@ -323,7 +355,7 @@ describe('PluginsService instance config', () => {
|
||||
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('x','api_key','instance',1)").run();
|
||||
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('x','server','instance',0)").run();
|
||||
|
||||
const svc = new PluginsService(new DatabaseService(dbConn), addonsService);
|
||||
const svc = await makeService();
|
||||
const masked = await svc.updateInstanceConfig('x', { api_key: 'super-secret', server: 'https://h' });
|
||||
// client gets the masked view
|
||||
expect(masked.api_key).toBe('••••••••');
|
||||
@@ -347,7 +379,7 @@ describe('PluginsService instance config', () => {
|
||||
testDb.prepare("INSERT INTO plugins (id, name, status, config) VALUES ('y','Y','inactive','{}')").run();
|
||||
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('y','server','instance',0)").run();
|
||||
|
||||
const svc = new PluginsService(new DatabaseService(dbConn), addonsService);
|
||||
const svc = await makeService();
|
||||
const masked = await svc.updateInstanceConfig('y', { server: 'https://h', smuggled: 'nope' });
|
||||
|
||||
expect(masked.server).toBe('https://h');
|
||||
@@ -357,8 +389,8 @@ describe('PluginsService instance config', () => {
|
||||
});
|
||||
|
||||
it('throws for an unknown plugin', async () => {
|
||||
await expect(new PluginsService(new DatabaseService(dbConn), addonsService).updateInstanceConfig('nope', {})).rejects.toThrow(/not found/);
|
||||
await expect(new PluginsService(new DatabaseService(dbConn), addonsService).getInstanceConfig('nope')).rejects.toThrow(/not found/);
|
||||
await expect((await makeService()).updateInstanceConfig('nope', {})).rejects.toThrow(/not found/);
|
||||
await expect((await makeService()).getInstanceConfig('nope')).rejects.toThrow(/not found/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -366,7 +398,7 @@ describe('PluginsService error log', () => {
|
||||
beforeEach(() => testDb.exec('DELETE FROM plugin_error_log'));
|
||||
it('lists and clears a plugin error log', async () => {
|
||||
testDb.prepare("INSERT INTO plugin_error_log (plugin_id, level, message) VALUES ('p','error','boom')").run();
|
||||
const svc = new PluginsService(new DatabaseService(dbConn), addonsService);
|
||||
const svc = await makeService();
|
||||
expect(await svc.errors('p')).toEqual([{ ts: '2026-01-01', level: 'error', message: 'boom' }]);
|
||||
await svc.clearErrors('p');
|
||||
expect(await svc.errors('p')).toEqual([]);
|
||||
|
||||
@@ -31,6 +31,21 @@ import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
import { Plugins } from '../../../src/db/entities/Plugins.entity';
|
||||
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
|
||||
import { PluginScheduledTasks } from '../../../src/db/entities/PluginScheduledTasks.entity';
|
||||
import { PluginUserErasureQueue } from '../../../src/db/entities/PluginUserErasureQueue.entity';
|
||||
import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity';
|
||||
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
|
||||
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
|
||||
import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity';
|
||||
import { PluginEntityMetadata } from '../../../src/db/entities/PluginEntityMetadata.entity';
|
||||
import { PluginOauthTokens } from '../../../src/db/entities/PluginOauthTokens.entity';
|
||||
import { PluginOauthState } from '../../../src/db/entities/PluginOauthState.entity';
|
||||
import { PluginMetaMigrations } from '../../../src/db/entities/PluginMetaMigrations.entity';
|
||||
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
|
||||
import { Settings } from '../../../src/db/entities/Settings.entity';
|
||||
import { NotificationChannelPreferences } from '../../../src/db/entities/NotificationChannelPreferences.entity';
|
||||
/** The host-side settings reads, over the same connection the test seeded. */
|
||||
const userSettings = () => new PluginUserSettingsService(new DatabaseService(dbConn));
|
||||
|
||||
@@ -140,6 +155,21 @@ describe('a plugin channel label is bounded by the host', () => {
|
||||
new AuditService(t.repo(AuditLog), t.repo(Users)),
|
||||
await createTestAddonsService(dbConn),
|
||||
userSettings(),
|
||||
t.repo(Plugins),
|
||||
t.repo(PluginErrorLog),
|
||||
t.repo(PluginScheduledTasks),
|
||||
t.repo(PluginUserErasureQueue),
|
||||
t.repo(PluginEgressHosts),
|
||||
t.repo(PluginSettingsFields),
|
||||
t.repo(PluginActions),
|
||||
t.repo(PluginUserConfig),
|
||||
t.repo(PluginEntityMetadata),
|
||||
t.repo(PluginOauthTokens),
|
||||
t.repo(PluginOauthState),
|
||||
t.repo(PluginMetaMigrations),
|
||||
t.repo(PluginCapabilityAudit),
|
||||
t.repo(Settings),
|
||||
t.repo(NotificationChannelPreferences),
|
||||
);
|
||||
// Stand the plugin up as a granted, active notificationChannel provider.
|
||||
(rt as unknown as { supervisor: { running: Map<string, unknown> } }).supervisor.running.set('loud', {
|
||||
|
||||
Reference in New Issue
Block a user