refactor(plugins): async DB-touching methods; transactions through UnitOfWork

This commit is contained in:
jubnl
2026-09-22 00:00:53 +02:00
parent e413308227
commit e28dc6de28
68 changed files with 743 additions and 706 deletions
+1 -1
View File
@@ -169,7 +169,7 @@ export class McpRegistry {
): Promise<void> {
let tools: readonly McpDynamicTool[];
try {
tools = source(ctx) ?? [];
tools = (await source(ctx)) ?? [];
} catch (err) {
// A session with no dynamic tools is degraded; a session that throws here
// is a 500 on initialize, because hosts call attach() outside their try.
+1 -1
View File
@@ -220,7 +220,7 @@ export interface McpDynamicTool {
* creation. A source that needs to ask something slow what tools exist should
* answer from state it already holds.
*/
export type McpDynamicToolSource = (ctx: McpContext) => readonly McpDynamicTool[];
export type McpDynamicToolSource = (ctx: McpContext) => readonly McpDynamicTool[] | Promise<readonly McpDynamicTool[]>;
export type McpEntry =
| { kind: 'tool'; methodName: string; options: ToolOptions }
+1 -1
View File
@@ -257,7 +257,7 @@ export class AdminService {
if (!userToDel) return { error: 'User not found', status: 404 };
await this.userCleanup.deleteUserCompletely(userToDel.id);
emitUserDeleted(userToDel.id); // let plugins erase their own per-user data
await emitUserDeleted(userToDel.id); // let plugins erase their own per-user data
return { email: userToDel.email };
}
+1 -1
View File
@@ -621,7 +621,7 @@ export class AuthService {
}
}
await this.userCleanup.deleteUserCompletely(userId);
emitUserDeleted(userId); // let plugins erase their own per-user data
await emitUserDeleted(userId); // let plugins erase their own per-user data
return { success: true };
}
@@ -88,17 +88,17 @@ function sanitizePluginChannels(raw: readonly ExternalChannel[]): ExternalChanne
// setPluginEventSink() in plugin-runtime.service.ts: the runtime pushes a getter
// in at onModuleInit, and we pull from it on every read so a plugin that is
// disabled or uninstalled simply stops appearing.
let pluginChannelSource: (() => ExternalChannel[]) | null = null;
let pluginChannelSource: (() => ExternalChannel[] | Promise<ExternalChannel[]>) | null = null;
export function setPluginChannelSource(source: (() => ExternalChannel[]) | null): void {
export function setPluginChannelSource(source: (() => ExternalChannel[] | Promise<ExternalChannel[]>) | null): void {
pluginChannelSource = source;
}
/** Every channel that currently exists: built-ins plus live, sanitized plugin channels. */
export function listChannels(): ExternalChannel[] {
const plugins = (() => {
export async function listChannels(): Promise<ExternalChannel[]> {
const plugins = await (async () => {
try {
return pluginChannelSource?.() ?? [];
return (await pluginChannelSource?.()) ?? [];
} catch {
// A broken plugin runtime must never take notifications down with it.
return [];
@@ -109,8 +109,8 @@ export function listChannels(): ExternalChannel[] {
return [...builtins.values(), ...sanitizePluginChannels(plugins)];
}
export function getChannel(id: string): ExternalChannel | undefined {
if (isPluginChannelId(id)) return listChannels().find((c) => c.id === id);
export async function getChannel(id: string): Promise<ExternalChannel | undefined> {
if (isPluginChannelId(id)) return (await listChannels()).find((c) => c.id === id);
return builtins.get(id);
}
@@ -135,7 +135,7 @@ export interface ExternalChannel {
*/
isInstanceConfigured?(): boolean;
/** Does this recipient have credentials for this channel? */
isConfiguredFor(userId: number): boolean;
isConfiguredFor(userId: number): boolean | Promise<boolean>;
sendToUser(userId: number, msg: ChannelMessage): Promise<unknown>;
sendGlobal?(msg: ChannelMessage): Promise<unknown>;
test?(userId: number, override?: Record<string, unknown>): Promise<{ success: boolean; error?: string }>;
@@ -45,13 +45,13 @@ export class NotificationPreferencesService {
* Channels implemented for an event. In-app takes everything; external channels
* decide for themselves (today: everything except `synology_session_cleared`).
*/
combosFor(event: NotifEventType): NotifChannel[] {
return [INAPP_CHANNEL, ...listChannels().filter(c => c.supportsEvent(event)).map(c => c.id)];
async combosFor(event: NotifEventType): Promise<NotifChannel[]> {
return [INAPP_CHANNEL, ...(await listChannels()).filter(c => c.supportsEvent(event)).map(c => c.id)];
}
private allCombos(): Record<string, NotifChannel[]> {
private async allCombos(): Promise<Record<string, NotifChannel[]>> {
const out: Record<string, NotifChannel[]> = {};
for (const event of ALL_EVENT_TYPES) out[event] = this.combosFor(event);
for (const event of ALL_EVENT_TYPES) out[event] = await this.combosFor(event);
return out;
}
@@ -68,16 +68,16 @@ export class NotificationPreferencesService {
* into this CSV, and the admin toggle rebuilds it from the three built-in booleans, which
* would silently drop any that were).
*/
getActiveChannels(): NotifChannel[] {
async getActiveChannels(): Promise<NotifChannel[]> {
const raw = this.getAppSetting('notification_channels') || this.getAppSetting('notification_channel') || 'none';
if (raw === 'none') return [];
const builtins = new Set(listChannels().filter(c => c.source === 'builtin').map(c => c.id));
const builtins = new Set((await listChannels()).filter(c => c.source === 'builtin').map(c => c.id));
return raw.split(',').map(c => c.trim()).filter(c => builtins.has(c));
}
/** Is this channel switched on? Plugin channels are on by virtue of being live. */
isChannelActive(channel: ExternalChannel): boolean {
return channel.source === 'plugin' || this.getActiveChannels().includes(channel.id);
async isChannelActive(channel: ExternalChannel): Promise<boolean> {
return channel.source === 'plugin' || (await this.getActiveChannels()).includes(channel.id);
}
// ── Per-user preference checks ─────────────────────────────────────────────
@@ -112,7 +112,7 @@ export class NotificationPreferencesService {
* scope='user' — a column per channel the admin turned on in `notification_channels`.
* scope='admin' — a column per channel that has admin-global credentials.
*/
private describeChannels(userId: number, scope: 'user' | 'admin'): ChannelDescriptor[] {
private async describeChannels(userId: number, scope: 'user' | 'admin'): Promise<ChannelDescriptor[]> {
const out: ChannelDescriptor[] = [this.inAppDescriptor()];
if (scope === 'admin') {
@@ -122,7 +122,7 @@ export class NotificationPreferencesService {
const hasAdminWebhook = !!this.getAppSetting('admin_webhook_url');
const hasAdminNtfy = !!this.getAppSetting('admin_ntfy_topic');
const adminActive: Record<string, boolean> = { email: hasSmtp, webhook: hasAdminWebhook, ntfy: hasAdminNtfy };
for (const channel of listChannels()) {
for (const channel of await listChannels()) {
// Plugin channels are user-scoped only — they never carry admin-global events.
if (channel.source !== 'builtin') continue;
const active = adminActive[channel.id] ?? false;
@@ -138,7 +138,7 @@ export class NotificationPreferencesService {
return out;
}
for (const channel of listChannels()) {
for (const channel of await listChannels()) {
out.push({
id: channel.id,
source: channel.source,
@@ -147,8 +147,8 @@ export class NotificationPreferencesService {
settingsPath: channel.settingsPath,
// A live plugin channel is always a column. `configured` tells the user whether
// they still need to enter credentials — it does not hide the channel from them.
active: this.isChannelActive(channel),
configured: channel.isConfiguredFor(userId),
active: await this.isChannelActive(channel),
configured: await channel.isConfiguredFor(userId),
});
}
return out;
@@ -159,7 +159,7 @@ export class NotificationPreferencesService {
* scope='user' — excludes admin-scoped events (for user settings page)
* scope='admin' — returns only admin-scoped events (for admin notifications tab)
*/
getPreferencesMatrix(userId: number, userRole: string, scope: 'user' | 'admin' = 'user'): PreferencesMatrix {
async getPreferencesMatrix(userId: number, userRole: string, scope: 'user' | 'admin' = 'user'): Promise<PreferencesMatrix> {
const rows = this.db.all<{ event_type: string; channel: string; enabled: number }>(
'SELECT event_type, channel, enabled FROM notification_channel_preferences WHERE user_id = ?', userId,
);
@@ -171,7 +171,7 @@ export class NotificationPreferencesService {
stored[row.event_type]![row.channel] = row.enabled === 1;
}
const implemented_combos = this.allCombos();
const implemented_combos = await this.allCombos();
// Build the full matrix with defaults (true when no row exists)
const preferences: Partial<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>> = {};
@@ -195,7 +195,7 @@ export class NotificationPreferencesService {
return {
preferences,
channels: this.describeChannels(userId, scope),
channels: await this.describeChannels(userId, scope),
event_types,
implemented_combos,
...(scope === 'user' && { defaults: { ntfyServer: this.getAppSetting('admin_ntfy_server') || null } }),
@@ -304,7 +304,7 @@ export class NotificationPreferencesService {
return this.mailer.isSmtpConfigured();
}
isWebhookConfigured(): boolean {
return this.getActiveChannels().includes('webhook');
async isWebhookConfigured(): Promise<boolean> {
return (await this.getActiveChannels()).includes('webhook');
}
}
@@ -264,13 +264,13 @@ export interface NotificationPayload {
* - everything else: the admin enabled the channel, the user didn't opt out of this
* event on it, and the user has credentials for it.
*/
function shouldSendToUser(
async function shouldSendToUser(
channel: ExternalChannel,
event: NotifEventType,
recipientId: number,
activeChannels: string[],
prefs: NotificationPreferencesService,
): boolean {
): Promise<boolean> {
if (!channel.supportsEvent(event)) return false;
if (channel.isInstanceConfigured && !channel.isInstanceConfigured()) return false;
@@ -285,7 +285,7 @@ function shouldSendToUser(
if (!prefs.isEnabledForEvent(recipientId, event, channel.id)) return false;
}
return channel.isConfiguredFor(recipientId);
return await channel.isConfiguredFor(recipientId);
}
/**
@@ -324,16 +324,16 @@ export class NotificationsService {
registerBuiltinChannels({ mailer, webhook, ntfy });
}
getPreferences(userId: number, role: string): PreferencesMatrix {
return this.prefs.getPreferencesMatrix(userId, role, 'user');
async getPreferences(userId: number, role: string): Promise<PreferencesMatrix> {
return await this.prefs.getPreferencesMatrix(userId, role, 'user');
}
/** Send a test notification over any registered channel (built-in or plugin). */
async testChannel(userId: number, channelId: string): Promise<ChannelTestResult> {
const channel = getChannel(channelId);
const channel = await getChannel(channelId);
if (!channel) return { success: false, error: 'Unknown channel' };
if (!channel.test) return { success: false, error: 'This channel does not support test sends' };
if (!channel.isConfiguredFor(userId)) return { success: false, error: 'Channel is not configured for this user' };
if (!(await channel.isConfiguredFor(userId))) return { success: false, error: 'Channel is not configured for this user' };
try {
return await channel.test(userId);
} catch (e) {
@@ -702,8 +702,8 @@ export class NotificationsService {
}
}
const config = configEntry ?? FALLBACK_EVENT_CONFIG;
const activeChannels = this.prefs.getActiveChannels();
const channels = listChannels();
const activeChannels = await this.prefs.getActiveChannels();
const channels = await listChannels();
const appUrl = getAppUrl();
// Build navigate target (used by email/webhook CTA and in-app navigate)
@@ -783,7 +783,12 @@ export class NotificationsService {
// One loop over the registry. The message is rendered once per recipient, in
// their language, and handed to every channel that wants it — so a plugin
// channel never touches i18n.
const deliverable = channels.filter(ch => shouldSendToUser(ch, event, recipientId, activeChannels, this.prefs));
// R1.4: `filter` cannot await, and the credential check behind shouldSendToUser
// is now async (a plugin channel reads the recipient's stored settings).
const deliverable: ExternalChannel[] = [];
for (const ch of channels) {
if (await shouldSendToUser(ch, event, recipientId, activeChannels, this.prefs)) deliverable.push(ch);
}
if (deliverable.length > 0) {
const lang = this.mailer.getUserLanguage(recipientId);
const { title, body } = getEventText(lang, event, params);
+2 -2
View File
@@ -92,10 +92,10 @@ export class PackingRpc {
}
@PluginMethod('packing.listBags', { permission: 'db:write:packing' })
listBags(params: Record<string, unknown>, ctx: PluginRpcContext): unknown[] {
async listBags(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown[]> {
// Note the permission: the envelope really does gate this READ on the write
// grant. The decorator makes the oddity visible instead of burying it.
return this.guards.tripRead(params, ctx, () => this.packing.listBags(String(num(params.tripId, 'tripId'))) as unknown[]);
return await this.guards.tripRead(params, ctx, () => this.packing.listBags(String(num(params.tripId, 'tripId'))) as unknown[]);
}
@PluginMethod('packing.createBag', { permission: 'db:write:packing' })
@@ -62,7 +62,9 @@ export class PluginMcpToolsService implements OnApplicationBootstrap, OnModuleDe
// needs PluginHooks, and PluginHooks injects PluginRuntimeService: owning it
// there would be a cycle. Cleared on destroy, or a torn-down buildApp() leaves
// a live source closing over a dead runtime and bleeds into the next suite.
onApplicationBootstrap(): void {
// `async` with nothing to await: the call-graph gate keys on the `async` modifier
// and this frame reaches the DB through the source it installs.
async onApplicationBootstrap(): Promise<void> {
setPluginMcpToolSource((ctx) => this.mcpTools(ctx));
}
@@ -71,11 +73,11 @@ export class PluginMcpToolsService implements OnApplicationBootstrap, OnModuleDe
}
/**
* Every plugin tool this session may see. Synchronous, and never throws:
* nest-mcp contains a throwing source, but a per-plugin failure here should
* cost that plugin's tools and nothing else.
* Every plugin tool this session may see. Never throws: nest-mcp contains a
* throwing source, but a per-plugin failure here should cost that plugin's
* tools and nothing else.
*/
mcpTools(_ctx: McpContext): McpDynamicTool[] {
async mcpTools(_ctx: McpContext): Promise<McpDynamicTool[]> {
if (!pluginsEnabled()) return [];
const out: McpDynamicTool[] = [];
let dropped = 0;
@@ -83,7 +85,7 @@ export class PluginMcpToolsService implements OnApplicationBootstrap, OnModuleDe
for (const id of this.hooks.providersOf(HOOK)) {
let tools: McpDynamicTool[];
try {
tools = this.toolsOf(id);
tools = await this.toolsOf(id);
} catch {
// One plugin's bad row contributes nothing; the others still advertise.
continue;
@@ -114,8 +116,8 @@ export class PluginMcpToolsService implements OnApplicationBootstrap, OnModuleDe
* on every restart with no version bump. Same two-sided shape as callPlugin's
* exports check.
*/
private toolsOf(pluginId: string): McpDynamicTool[] {
const declared = this.runtime.mcpToolCapabilities(pluginId);
private async toolsOf(pluginId: string): Promise<McpDynamicTool[]> {
const declared = await this.runtime.mcpToolCapabilities(pluginId);
if (!declared.length) return [];
const implemented = new Set(this.runtime.mcpToolsOf(pluginId));
const grants = this.runtime.grantsOf(pluginId);
@@ -120,7 +120,7 @@ export function normalize(
return { pluginId, profile, coordinates, distance, duration, legs, viaPoints };
}
export function declaredProfiles(conn: Database.Database, pluginId: string): string[] {
export async function declaredProfiles(conn: Database.Database, pluginId: string): Promise<string[]> {
try {
const row = conn.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(pluginId) as
| { capabilities?: string }
@@ -56,7 +56,7 @@ export class PluginRoutesController {
// one the manifest declared — re-validated from the DB row like the plugins feed,
// so a hand-edited capabilities blob can't invent profiles.
if (!this.hooks.providersOf('routeProvider').includes(pluginId)) return { route: null };
if (!declaredProfiles(this.dbs.connection, pluginId).includes(profileId)) return { route: null };
if (!(await declaredProfiles(this.dbs.connection, pluginId)).includes(profileId)) return { route: null };
try {
const raw = await this.hooks.route(pluginId, { tripId, dayId, profile: profileId, waypoints }, userId);
+7 -6
View File
@@ -98,7 +98,7 @@ function envInt(name: string, def: number): number {
/** Keep only the newest `MAX_AUDIT_ROWS` rows for a plugin. Called amortised from
* appendAudit; exported for tests. No-op when disabled or under the cap. */
export function pruneAudit(db: AuditDb, pluginId: string, keep = MAX_AUDIT_ROWS): void {
export async function pruneAudit(db: AuditDb, pluginId: string, keep = MAX_AUDIT_ROWS): Promise<void> {
if (keep <= 0) return;
db.prepare(
`DELETE FROM plugin_capability_audit WHERE plugin_id = ? AND id NOT IN
@@ -106,8 +106,9 @@ export function pruneAudit(db: AuditDb, pluginId: string, keep = MAX_AUDIT_ROWS)
).run(pluginId, pluginId, keep);
}
/** Append one entry to the per-plugin hash chain. Synchronous (better-sqlite3). */
export function appendAudit(db: AuditDb, e: AuditEntry): void {
/** Append one entry to the per-plugin hash chain. The statements themselves are
* synchronous (better-sqlite3); the method is async for the ORM seam. */
export async function appendAudit(db: AuditDb, e: AuditEntry): Promise<void> {
const prev =
(db.prepare('SELECT hash FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id DESC LIMIT 1').get(e.pluginId) as
| { hash: string }
@@ -120,7 +121,7 @@ export function appendAudit(db: AuditDb, e: AuditEntry): void {
).run(e.pluginId, e.actingUserId ?? null, e.method, e.resource ?? null, e.code, ts, prev || null, hash);
// Amortised retention: prune roughly every PRUNE_EVERY appends per plugin.
const n = (appendsSincePrune.get(e.pluginId) ?? 0) + 1;
if (n >= PRUNE_EVERY) { appendsSincePrune.set(e.pluginId, 0); pruneAudit(db, e.pluginId); }
if (n >= PRUNE_EVERY) { appendsSincePrune.set(e.pluginId, 0); await pruneAudit(db, e.pluginId); }
else appendsSincePrune.set(e.pluginId, n);
}
@@ -128,7 +129,7 @@ export function appendAudit(db: AuditDb, e: AuditEntry): void {
* "what have plugins done in my name?" view. This is what legitimizes the broad
* read grants: the user, not just the admin, can see every plugin action bound to
* them. Joined with the plugin name for display; capped. */
export function readAuditForUser(db: AuditDb, userId: number, limit = 200): unknown[] {
export async function readAuditForUser(db: AuditDb, userId: number, limit = 200): Promise<unknown[]> {
return db
.prepare(
`SELECT a.ts, a.plugin_id, p.name AS plugin_name, a.method, a.resource, a.code
@@ -139,7 +140,7 @@ export function readAuditForUser(db: AuditDb, userId: number, limit = 200): unkn
}
/** Read the most recent audit rows for a plugin (admin view). */
export function readAudit(db: AuditDb, pluginId: string, limit = 200): unknown[] {
export async function readAudit(db: AuditDb, pluginId: string, limit = 200): Promise<unknown[]> {
return db
.prepare('SELECT ts, acting_user_id, method, resource, code FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id DESC LIMIT ?')
.all(pluginId, limit);
@@ -31,7 +31,7 @@ export class PluginGuards {
* so a plugin cannot read another user's trips by naming their id. A userless
* context (a job, or onLoad) has no acting user and is refused.
*/
tripRead<T>(params: Record<string, unknown>, ctx: PluginRpcContext, read: (userId: number) => T): T {
async tripRead<T>(params: Record<string, unknown>, ctx: PluginRpcContext, read: (userId: number) => T): Promise<T> {
const tripId = num(params.tripId, 'tripId');
if (ctx.actingUserId === undefined) {
throw new ForbiddenResource('trip reads require an authenticated user context');
@@ -44,7 +44,7 @@ export function closePluginDataDb(id: string): void {
// nothing persisted or phoned home.
const budgets = new Map<string, DailyBudget>();
export function budgetFor(id: string, conn: Database.Database): DailyBudget {
export async function budgetFor(id: string, conn: Database.Database): Promise<DailyBudget> {
let b = budgets.get(id);
if (!b) {
const now = Date.now();
@@ -64,6 +64,6 @@ export function budgetFor(id: string, conn: Database.Database): DailyBudget {
}
/** Today's broker usage for one plugin (admin view). Seeds the counter if unseen. */
export function pluginBudgetUsage(id: string, conn: Database.Database): ReturnType<DailyBudget['used']> {
return budgetFor(id, conn).used(Date.now());
export async function pluginBudgetUsage(id: string, conn: Database.Database): Promise<ReturnType<DailyBudget['used']>> {
return (await budgetFor(id, conn)).used(Date.now());
}
@@ -9,7 +9,7 @@ import { getPluginDataDb } from './plugin-host-state';
/** Routes inter-plugin calls/events; supplied by PluginRuntimeService (owns the supervisor). */
export interface PluginCallRouter {
callPlugin(callerId: string, targetId: string, fn: string, args: unknown, actingUserId: number | undefined): Promise<unknown>;
emitPluginEvent(sourceId: string, event: string, payload: unknown): void;
emitPluginEvent(sourceId: string, event: string, payload: unknown): Promise<void>;
}
/**
+3 -3
View File
@@ -46,9 +46,9 @@ export interface HostDeps {
* the dependency edge + the target's `provides` allowlist, forwards the acting user. */
callPlugin(targetId: string, fn: string, args: unknown, actingUserId: number | undefined): Promise<unknown>;
/** Publish an event from this host's plugin to its subscribed dependents. */
emitPluginEvent(event: string, payload: unknown): void;
emitPluginEvent(event: string, payload: unknown): Promise<void>;
/** Optional sink for the capability audit log (host-side, hash-chained). */
audit?(entry: { pluginId: string; actingUserId?: number; method: string; resource: string | null; code: string }): void;
audit?(entry: { pluginId: string; actingUserId?: number; method: string; resource: string | null; code: string }): Promise<void>;
}
type Handler = (params: Record<string, unknown>, actingUserId: number | undefined) => unknown;
@@ -98,7 +98,7 @@ export class PluginRpcHost {
// Audit the core-data / broadcast surface (incl. denials) at the boundary.
if (this.deps.audit && isAuditable(req.method)) {
try {
this.deps.audit({
await this.deps.audit({
pluginId: this.pluginId,
actingUserId,
method: req.method,
@@ -34,7 +34,7 @@ export interface PluginRpcContext {
/** What a plugin may do to its declared dependencies. */
export interface PluginRpcPeers {
call(targetId: string, fn: string, args: unknown, actingUserId: number | undefined): Promise<unknown>;
emit(event: string, payload: unknown): void;
emit(event: string, payload: unknown): Promise<void>;
}
export type PluginRpcHandler = (params: Record<string, unknown>, ctx: PluginRpcContext) => unknown;
+4 -4
View File
@@ -48,8 +48,8 @@ export class DbRpc {
/** Same deal: the router checks the emitter's `emits` allowlist. */
@PluginOpenMethod('events.emit')
emitEvent(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
ctx.plugins.emit(str(params.event, 'event'), params.payload);
async emitEvent(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
await ctx.plugins.emit(str(params.event, 'event'), params.payload);
return { ok: true };
}
@@ -60,8 +60,8 @@ export class DbRpc {
* plugin falls back to ctx.config.
*/
@PluginOpenMethod('settings.get')
getSetting(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async getSetting(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
if (ctx.actingUserId === undefined) return { value: undefined };
return { value: this.settings.readOne(ctx.pluginId, ctx.actingUserId, str(params.key, 'key')) };
return { value: await this.settings.readOne(ctx.pluginId, ctx.actingUserId, str(params.key, 'key')) };
}
}
@@ -45,19 +45,19 @@ export class HostSurfaceRpc {
) {}
@PluginMethod('users.getById', { permission: 'db:read:users' })
getUser(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async getUser(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
// Scoped to people the acting user can actually see (themselves, or someone they
// share a trip with), so a plugin cannot enumerate every account by looping ids.
const id = num(params.id, 'id');
if (ctx.actingUserId === undefined) throw new ForbiddenResource('user reads require an authenticated user context');
if (id !== ctx.actingUserId && !this.sharesATrip(ctx.actingUserId, id)) {
if (id !== ctx.actingUserId && !(await this.sharesATrip(ctx.actingUserId, id))) {
throw new ForbiddenResource(`no access to user ${id}`);
}
return this.db.prepare('SELECT id, username, display_name, avatar FROM users WHERE id = ?').get(id);
}
@PluginMethod('ws.broadcastToTrip', { permission: 'ws:broadcast:trip' })
broadcastToTrip(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async broadcastToTrip(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
// The TARGET is gated like a read: only a trip room the acting user belongs to.
// Namespacing the event type alone does not cross the membership boundary.
const tripId = num(params.tripId, 'tripId');
@@ -105,7 +105,7 @@ export class HostSurfaceRpc {
throw new ForbiddenResource('the acting user is not a member of that trip');
}
const link = this.safeLink(input.link);
if (!budgetFor(ctx.pluginId, this.db.connection).take('notify', Date.now())) {
if (!(await budgetFor(ctx.pluginId, this.db.connection)).take('notify', Date.now())) {
throw new BadParams('daily notification budget exhausted (resets at UTC midnight)');
}
await this.notifications.send({
@@ -126,7 +126,7 @@ export class HostSurfaceRpc {
const prompt = typeof params.prompt === 'string' ? params.prompt : '';
if (prompt.trim() === '') throw new BadParams('prompt is required');
if (prompt.length > AI_TEXT_MAX) throw new BadParams(`prompt exceeds the ${AI_TEXT_MAX}-char cap`);
this.takeAiBudget(ctx);
await this.takeAiBudget(ctx);
const system = typeof params.system === 'string' ? params.system.slice(0, 4000) : undefined;
const results = await this.runModel(config, {
prompt: system || 'You are a helpful assistant. Reply with a JSON object of the form {"text": "..."} whose "text" field holds your answer.',
@@ -150,7 +150,7 @@ export class HostSurfaceRpc {
if (typeof params.jsonSchema !== 'object' || params.jsonSchema === null) {
throw new BadParams('jsonSchema (an object) is required');
}
this.takeAiBudget(ctx);
await this.takeAiBudget(ctx);
const hint = typeof params.prompt === 'string' ? params.prompt.slice(0, 4000) : '';
const results = await this.runModel(config, {
prompt: hint || 'Extract structured data from the text into the given JSON schema.',
@@ -173,7 +173,7 @@ export class HostSurfaceRpc {
}
@PluginMethod('scheduler.set', { permission: 'jobs:run' })
schedulerSet(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async schedulerSet(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const name = str(params.name, 'name');
const dueAt = num(params.dueAt, 'dueAt');
const everyMs = params.everyMs != null ? num(params.everyMs, 'everyMs') : undefined;
@@ -200,7 +200,7 @@ export class HostSurfaceRpc {
}
@PluginMethod('scheduler.cancel', { permission: 'jobs:run' })
schedulerCancel(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async schedulerCancel(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const r = this.db
.prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?')
.run(ctx.pluginId, str(params.name, 'name'));
@@ -208,7 +208,7 @@ export class HostSurfaceRpc {
}
/** Two users share a trip when both are owner-or-member of the same one. */
private sharesATrip(actingUserId: number, targetUserId: number): boolean {
private async sharesATrip(actingUserId: number, targetUserId: number): Promise<boolean> {
return !!this.db
.prepare(
`SELECT 1 FROM trips t
@@ -245,8 +245,8 @@ export class HostSurfaceRpc {
}
}
private takeAiBudget(ctx: PluginRpcContext): void {
if (!budgetFor(ctx.pluginId, this.db.connection).take('ai', Date.now())) {
private async takeAiBudget(ctx: PluginRpcContext): Promise<void> {
if (!(await budgetFor(ctx.pluginId, this.db.connection)).take('ai', Date.now())) {
throw new BadParams('daily AI budget exhausted (resets at UTC midnight)');
}
}
+2 -2
View File
@@ -129,7 +129,7 @@ export class MetaRpc {
}
const entityId = num(params.entityId, 'entityId');
if (ctx.actingUserId === undefined) throw new ForbiddenResource('metadata requires an authenticated user context');
const tripId = this.entityTrip(entityType, entityId);
const tripId = await this.entityTrip(entityType, entityId);
if (tripId === undefined || !this.db.canAccessTrip(tripId, ctx.actingUserId)) {
throw new ForbiddenResource(`no access to ${entityType} ${entityId}`);
}
@@ -139,7 +139,7 @@ export class MetaRpc {
return { entityType, entityId };
}
private entityTrip(entityType: string, entityId: number): number | undefined {
private async entityTrip(entityType: string, entityId: number): Promise<number | undefined> {
if (entityType === 'trip') {
return (this.db.prepare('SELECT id FROM trips WHERE id = ?').get(entityId) as { id: number } | undefined)?.id;
}
+3 -3
View File
@@ -14,7 +14,7 @@ import { devLinkEnabled } from '../dev-link';
* manifest is invalid or that ships native binaries is skipped (recorded to its
* error log if it already existed).
*/
export function discoverPlugins(db: BetterSqlite3.Database): { discovered: string[]; skipped: string[] } {
export async function discoverPlugins(db: BetterSqlite3.Database): Promise<{ discovered: string[]; skipped: string[] }> {
const root = pluginsCodeRoot();
const discovered: string[] = [];
const skipped: string[] = [];
@@ -50,7 +50,7 @@ export function discoverPlugins(db: BetterSqlite3.Database): { discovered: strin
const manifest = parseManifest(parseJsonText(fs.readFileSync(manifestPath, 'utf8')));
if (manifest.id !== entry.name) throw new Error(`manifest id "${manifest.id}" != directory "${entry.name}"`);
if (scanForNativeBinaries(dir).length) throw new Error('directory contains native binaries');
upsert(db, manifest);
await upsert(db, manifest);
discovered.push(manifest.id);
} catch (e) {
skipped.push(entry.name);
@@ -61,7 +61,7 @@ export function discoverPlugins(db: BetterSqlite3.Database): { discovered: strin
return { discovered, skipped };
}
function upsert(db: BetterSqlite3.Database, m: PluginManifest): void {
async function upsert(db: BetterSqlite3.Database, m: PluginManifest): Promise<void> {
const dependencies = JSON.stringify({ requiredAddons: m.requiredAddons, pluginDependencies: m.pluginDependencies });
const existing = db.prepare('SELECT id FROM plugins WHERE id = ?').get(m.id) as { id: string } | undefined;
if (existing) {
@@ -20,22 +20,22 @@ export class PluginOAuthController {
private readonly dbs: DatabaseService,
) {}
private isActive(id: string): boolean {
private async isActive(id: string): Promise<boolean> {
return !!this.dbs.connection.prepare("SELECT 1 FROM plugins WHERE id = ? AND status = 'active'").get(id);
}
@Get(':id/status')
status(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): { configured: boolean; connected: boolean } {
async status(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): Promise<{ configured: boolean; connected: boolean }> {
const userId = req.user?.id;
if (!pluginsEnabled() || userId == null || !this.isActive(id)) return { configured: false, connected: false };
return this.oauth.status(id, userId);
if (!pluginsEnabled() || userId == null || !(await this.isActive(id))) return { configured: false, connected: false };
return await this.oauth.status(id, userId);
}
@Post(':id/connect')
connect(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): { authorizeUrl: string } {
async connect(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): Promise<{ authorizeUrl: string }> {
const userId = req.user?.id;
if (!pluginsEnabled() || userId == null || !this.isActive(id)) throw new Error('plugin not available');
return { authorizeUrl: this.oauth.startConnect(id, userId, Date.now()) };
if (!pluginsEnabled() || userId == null || !(await this.isActive(id))) throw new Error('plugin not available');
return { authorizeUrl: await this.oauth.startConnect(id, userId, Date.now()) };
}
@Get(':id/callback')
@@ -49,7 +49,7 @@ export class PluginOAuthController {
): Promise<void> {
const userId = req.user?.id;
const back = (status: string) => res.redirect(`/settings?oauth=${encodeURIComponent(id)}:${status}`);
if (!pluginsEnabled() || userId == null || !this.isActive(id)) return back('unavailable');
if (!pluginsEnabled() || userId == null || !(await this.isActive(id))) return back('unavailable');
if (error || !code || !state) return back('denied');
try {
await this.oauth.completeCallback(id, userId, code, state, Date.now());
@@ -60,9 +60,9 @@ export class PluginOAuthController {
}
@Post(':id/disconnect')
disconnect(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): { connected: false } {
async disconnect(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): Promise<{ connected: false }> {
const userId = req.user?.id;
if (userId != null) this.oauth.disconnect(id, userId);
if (userId != null) await this.oauth.disconnect(id, userId);
return { connected: false };
}
}
@@ -74,7 +74,7 @@ export class PluginOAuthService {
/** The plugin's decrypted OAuth provider config from its INSTANCE settings, or null
* when any required piece is missing/blank. */
providerConfig(pluginId: string): OAuthProviderConfig | null {
async providerConfig(pluginId: string): Promise<OAuthProviderConfig | null> {
const row = this.db.prepare('SELECT config FROM plugins WHERE id = ?').get(pluginId) as { config: string } | undefined;
if (!row) return null;
let cfg: Record<string, unknown>;
@@ -85,7 +85,7 @@ export class PluginOAuthService {
}
// A provider plugin ships its endpoints/scopes as manifest defaults; the admin types
// only the client id/secret (secrets — never defaulted).
cfg = applySettingDefaults(cfg, settingDefaults(this.db, pluginId, 'instance'));
cfg = applySettingDefaults(cfg, await settingDefaults(this.db, pluginId, 'instance'));
const authorizeUrl = String(cfg.oauth_authorize_url ?? '').trim();
const tokenUrl = String(cfg.oauth_token_url ?? '').trim();
const clientId = cfg.oauth_client_id ? String(decrypt_api_key(cfg.oauth_client_id)) : '';
@@ -100,15 +100,15 @@ export class PluginOAuthService {
}
/** Whether the acting user has a stored token for this plugin. */
status(pluginId: string, userId: number): { configured: boolean; connected: boolean } {
const configured = this.providerConfig(pluginId) !== null;
async status(pluginId: string, userId: number): Promise<{ configured: boolean; connected: boolean }> {
const configured = (await this.providerConfig(pluginId)) !== null;
const tok = this.db.prepare('SELECT 1 FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ? AND access_token IS NOT NULL').get(pluginId, userId);
return { configured, connected: !!tok };
}
/** Begin the authorize flow: mint PKCE + state, persist them, return the provider URL. */
startConnect(pluginId: string, userId: number, nowMs: number): string {
const cfg = this.providerConfig(pluginId);
async startConnect(pluginId: string, userId: number, nowMs: number): Promise<string> {
const cfg = await this.providerConfig(pluginId);
if (!cfg) throw new Error('OAuth is not configured for this plugin');
const authorize = assertSafeHttps(cfg.authorizeUrl, 'authorize_url');
assertSafeHttps(cfg.tokenUrl, 'token_url'); // fail fast if the token endpoint is unsafe too
@@ -150,7 +150,7 @@ export class PluginOAuthService {
}
this.db.prepare('DELETE FROM plugin_oauth_state WHERE state = ?').run(state); // single-use
const cfg = this.providerConfig(pluginId);
const cfg = await this.providerConfig(pluginId);
if (!cfg) throw new Error('OAuth is not configured for this plugin');
const token = await this.tokenRequest(cfg, {
@@ -159,7 +159,7 @@ export class PluginOAuthService {
redirect_uri: this.redirectUri(pluginId),
code_verifier: row.verifier,
});
this.storeToken(pluginId, userId, token, nowMs);
await this.storeToken(pluginId, userId, token, nowMs);
}
/** A valid access token for the acting user, refreshing it if it is expiring. Null when
@@ -174,7 +174,7 @@ export class PluginOAuthService {
if (notExpiring) return decrypt_api_key(row.access_token) as string;
if (!row.refresh_token) return decrypt_api_key(row.access_token) as string; // no refresh token — hand back what we have
const cfg = this.providerConfig(pluginId);
const cfg = await this.providerConfig(pluginId);
if (!cfg) return null;
const token = await this.tokenRequest(cfg, {
grant_type: 'refresh_token',
@@ -182,11 +182,11 @@ export class PluginOAuthService {
});
// Some providers omit a new refresh_token on refresh — keep the existing one.
if (!token.refresh_token) token.refresh_token = decrypt_api_key(row.refresh_token) as string;
this.storeToken(pluginId, userId, token, nowMs);
await this.storeToken(pluginId, userId, token, nowMs);
return token.access_token ?? null;
}
disconnect(pluginId: string, userId: number): void {
async disconnect(pluginId: string, userId: number): Promise<void> {
this.db.prepare('DELETE FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId);
this.db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId);
}
@@ -221,7 +221,7 @@ export class PluginOAuthService {
};
}
private storeToken(pluginId: string, userId: number, token: { access_token?: string; refresh_token?: string; expires_in?: number; scope?: string }, nowMs: number): void {
private async storeToken(pluginId: string, userId: number, token: { access_token?: string; refresh_token?: string; expires_in?: number; scope?: string }, nowMs: number): Promise<void> {
if (!token.access_token) throw new Error('token endpoint returned no access_token');
const expiresAt = token.expires_in ? nowMs + token.expires_in * 1000 : null;
this.db.prepare(
@@ -21,13 +21,13 @@ export class PluginActivityController {
constructor(private readonly dbs: DatabaseService) {}
@Get()
mine(@Req() req: Request & { user?: { id: number } }, @Query('limit') limitRaw?: string): { activity: unknown[] } {
async mine(@Req() req: Request & { user?: { id: number } }, @Query('limit') limitRaw?: string): Promise<{ activity: unknown[] }> {
if (!pluginsEnabled()) return { activity: [] };
const userId = req.user?.id;
if (userId == null) return { activity: [] };
// Math.floor so a non-integer (e.g. ?limit=2.5) can't reach SQLite's LIMIT and 500;
// Math.floor(NaN) stays NaN so the `|| 200` fallback still applies.
const limit = Math.min(Math.max(Math.floor(Number(limitRaw)) || 200, 1), 500);
return { activity: readAuditForUser(this.dbs.connection, userId, limit) };
return { activity: await readAuditForUser(this.dbs.connection, userId, limit) };
}
}
@@ -39,7 +39,7 @@ export class PluginFrameController {
constructor(private readonly runtime: PluginRuntimeService) {}
@Get('*path')
serve(@Param('pluginId') pluginId: string, @Req() req: Request, @Res() res: Response): void {
async serve(@Param('pluginId') pluginId: string, @Req() req: Request, @Res() res: Response): Promise<void> {
if (!pluginsEnabled() || !this.runtime.isActive(pluginId)) {
res.status(404).send('Plugin not available');
return;
@@ -87,7 +87,7 @@ export class PluginFrameController {
// The sandbox + per-plugin CSP are the isolation boundary here, not CORP:
// these files are the plugin's own public client code.
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Content-Security-Policy', this.frameCsp(pluginId, req.get('host')));
res.setHeader('Content-Security-Policy', await this.frameCsp(pluginId, req.get('host')));
// Explicit { root } + basename, not the absolute path: under the Nest
// ExpressAdapter, res.sendFile(absolutePath) resolves against the rewritten
// req.url and 404s spuriously (same trap as files-download.controller.ts).
@@ -95,7 +95,7 @@ export class PluginFrameController {
}
/** Per-plugin, locked-down CSP for the sandboxed frame document. */
private frameCsp(pluginId: string, host: string | undefined): string {
private async frameCsp(pluginId: string, host: string | undefined): Promise<string> {
// The frame must be able to reach exactly what the CHILD may reach. The child's egress
// guard is the union of the manifest's http:outbound:<host> grants AND the hosts an admin
// added post-install for an operatorEgress plugin (plugin-runtime.service: activate()).
@@ -108,7 +108,7 @@ export class PluginFrameController {
// admin writer's EGRESS_HOST_RE), but never interpolate anything that isn't a clean
// host/wildcard into connect-src — a stray space or `*` would inject an extra CSP source.
const outbound = [
...new Set([...this.runtime.outboundHostsOf(pluginId), ...this.runtime.operatorEgressHosts(pluginId)]),
...new Set([...(await this.runtime.outboundHostsOf(pluginId)), ...(await this.runtime.operatorEgressHosts(pluginId))]),
].filter((h) => /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i.test(h));
// The frame runs at an OPAQUE origin (sandbox without allow-same-origin), so
// 'self' matches nothing and the plugin's own <script src>/<link> files would
@@ -1,6 +1,7 @@
import { Injectable, type OnApplicationBootstrap, type OnModuleDestroy } from '@nestjs/common';
import semver from 'semver';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { pluginsEnabled } from './kill-switch';
import { setPluginEventSink } from '../../plugin-event-sink';
import { setUserDeletedSink } from '../../plugin-user-lifecycle';
@@ -151,7 +152,10 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// 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).
this.pruneErrorLog(id);
// 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 */ }
},
});
@@ -179,6 +183,11 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
private readonly userSettings: PluginUserSettingsService,
private readonly registry?: PluginRegistryService,
private readonly hostFactory?: PluginRpcHostFactory,
// LAST, and optional only because TypeScript forbids a required parameter after
// an optional one — Nest always injects it (OrmModule is global). A hand-built
// test instance that exercises a transaction must pass one; setOperatorEgressHosts
// refuses rather than writing outside a transaction (same shape as hostFactory).
private readonly uow?: UnitOfWork,
) {}
private get db() {
@@ -193,7 +202,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// plugin's first RPC after a restart came back PERMISSION_DENIED (pinned by
// tests/integration/plugins/boot-registry-order.test.ts). onApplicationBootstrap
// is guaranteed to run after EVERY module's onModuleInit, registry scan included.
onApplicationBootstrap(): void {
async onApplicationBootstrap(): Promise<void> {
if (!pluginsEnabled()) return;
// If a restore staged plugin trees, swap them into place NOW — before we open any
// plugin DB below. This is where a restored backup's plugin data/code actually
@@ -226,8 +235,8 @@ 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 {
discoverPlugins(this.db);
const installed = this.installedDepRows();
await discoverPlugins(this.db);
const installed = await this.installedDepRows();
const enabledIds = [...installed.values()].filter((r) => r.enabled).map((r) => r.id);
let order: string[];
try {
@@ -254,7 +263,9 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// scheduler is minute-granularity by contract, so 30s precision is plenty and
// cheap. Unref'd so it never holds the process open.
this.schedulerSweep = setInterval(() => {
this.fireDueScheduled();
// R1.5: a timer callback cannot await. Both helpers swallow their own errors
// (each body is wrapped in try/catch), so there is nothing to propagate.
void this.fireDueScheduled();
void this.drainUserErasures();
}, 30_000);
this.schedulerSweep.unref?.();
@@ -264,7 +275,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* ones, delete one-shots. The row is re-armed/deleted BEFORE the fire so a crash
* mid-callback can't double-fire; an inactive plugin's tasks are left untouched so
* they run on the next sweep after it reactivates. Never throws. */
private fireDueScheduled(): void {
private async fireDueScheduled(): Promise<void> {
if (!pluginsEnabled()) return;
try {
// Scope the window to ACTIVE plugins so a backlog of past-due rows belonging to
@@ -293,7 +304,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* try to deliver immediately. Persisted first (INSERT OR IGNORE, idempotent) so the
* erasure survives a restart and reaches a plugin that is offline right now. Never
* throws — a bookkeeping error must not fail the account deletion that triggered it. */
private enqueueUserErasure(userId: number): void {
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 (?, ?)');
@@ -311,14 +322,14 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
/** Deliver queued erasures to active plugins, dropping each row only once the plugin
* ACKs. Rows for inactive plugins are left for a later sweep / their reactivation.
* Never throws. */
private drainUserErasures(): Promise<void> {
private async drainUserErasures(): Promise<void> {
// Coalesce onto the drain already in flight. Both the 30s sweep and enqueue trigger
// a drain, and a pass awaits per-row delivery (up to the invoke timeout each), so
// running two concurrently would select the SAME rows and deliver an erasure twice.
// A caller that awaits still waits for a full pass (the in-flight one).
if (this.drainInFlight !== null) return this.drainInFlight;
if (this.drainInFlight !== null) return await this.drainInFlight;
this.drainInFlight = this.runDrainOnce().finally(() => { this.drainInFlight = null; });
return this.drainInFlight;
return await this.drainInFlight;
}
private async runDrainOnce(): Promise<void> {
@@ -435,8 +446,8 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
}
/** Re-scan the plugins volume on demand (admin action). */
rescan(): { discovered: string[]; skipped: string[] } {
return discoverPlugins(this.db);
async rescan(): Promise<{ discovered: string[]; skipped: string[] }> {
return await discoverPlugins(this.db);
}
async onModuleDestroy(): Promise<void> {
@@ -462,7 +473,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* update consent dialog), and otherwise throws PluginConsentRequired.
*/
async activate(id: string, consentWiden = false): Promise<void> {
const installed = this.installedDepRows();
const installed = await this.installedDepRows();
// Deps-first order over the installed graph (throws DependencyCycleError on a
// cycle). Missing deps aren't in `installed` so they don't appear here — the
// per-node gate reports those separately.
@@ -483,7 +494,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
}
/** All plugin rows projected to what the dependency helpers reason over. */
private installedDepRows(): Map<string, PluginDepRow> {
private async installedDepRows(): Promise<Map<string, PluginDepRow>> {
const rows = this.db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[];
return new Map(rows.map((r) => [r.id, r]));
}
@@ -574,7 +585,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
this.db.prepare('UPDATE plugins SET granted_permissions = ?, enabled = 1 WHERE id = ?').run(JSON.stringify(declared), id);
// 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)), settingDefaults(this.db, id, 'instance'));
const config = applySettingDefaults(decryptConfig(parseObject(row.config)), await settingDefaults(this.db, id, 'instance'));
const manifestHosts = declared.filter((p) => p.startsWith(HTTP_OUTBOUND)).map((p) => p.slice(HTTP_OUTBOUND.length));
// Union in the hosts the ADMIN added post-install. A plugin that talks to a
// self-hosted service can't name the operator's hostname in its manifest, so without
@@ -583,12 +594,12 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// still bounds what is possible — and it is always the admin, never an end user,
// who widens it. The egress list is spawn-time only, which is why changing it
// re-spawns the plugin (see setOperatorEgressHosts).
const egress = [...new Set([...manifestHosts, ...this.operatorEgressHosts(id)])];
const egress = [...new Set([...manifestHosts, ...(await this.operatorEgressHosts(id))])];
await this.supervisor.activate(id, new Set(declared), config, egress);
}
/** Hosts an admin added for this plugin (empty unless it declared `operatorEgress`). */
operatorEgressHosts(id: string): string[] {
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);
@@ -598,7 +609,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
}
/** Does this plugin's manifest declare that it needs operator-supplied hosts? */
wantsOperatorEgress(id: string): boolean {
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;
}
@@ -609,7 +620,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* `init` is deliberately refused, so there is no way to widen a live child's allow-list.
*/
async setOperatorEgressHosts(id: string, hosts: string[]): Promise<string[]> {
if (!this.wantsOperatorEgress(id)) {
if (!(await this.wantsOperatorEgress(id))) {
throw new ForbiddenResource(`plugin ${id} did not declare operatorEgress`);
}
const clean: string[] = [];
@@ -621,11 +632,12 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
if (host === '*' || !EGRESS_HOST_RE.test(host)) throw new ForbiddenResource(`invalid host "${raw}"`);
if (!clean.includes(host)) clean.push(host);
}
this.db.transaction(() => {
if (!this.uow) throw new Error('UnitOfWork not provided — tests that set egress hosts must pass one');
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);
})();
});
// Re-spawn so a live child actually gets the new allow-list.
if (this.isActive(id)) {
await this.supervisor.disable(id);
@@ -807,7 +819,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// — without a fresh activation + permission consent. deactivate() no-ops on
// a plugin that isn't running.
if (replaced) await this.deactivate(staged.id);
this.registry.commitUpload(staged); // moves code + registers INACTIVE, then clears staging
await this.registry.commitUpload(staged); // moves code + registers INACTIVE, then clears staging
return { id: staged.id, version: staged.version, replaced, trekRangeBypassed: staged.trekRangeBypassed };
} catch (e) {
// A failure before commitUpload leaves staging behind — clean it up.
@@ -850,7 +862,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
removePluginCodeEntry(dest); // drop any prior link — never follows into the author's source
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.symlinkSync(sourceDir, dest, 'junction'); // Windows junction (no elevation); POSIX ignores the type -> dir symlink
discoverPlugins(this.db); // registers/updates the row from the linked manifest, INACTIVE
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(
@@ -859,7 +871,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
status = 'inactive', enabled = 0
WHERE id = ?`,
).run(DEV_LINK_SOURCE, id);
this.watchLinked(id, sourceDir);
await this.watchLinked(id, sourceDir);
return { id, version: manifest.version, replaced, trekRangeBypassed };
}
@@ -880,8 +892,10 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
if (wasActive) await this.activate(id);
}
/** Best-effort fs.watch on a linked plugin's built output that debounces -> reload. */
private watchLinked(id: string, sourceDir: string): void {
/** Best-effort fs.watch on a linked plugin's built output that debounces -> reload.
* `async` with nothing to await: the call-graph gate keys on the `async` modifier and
* this frame reaches the DB through the debounced `reload` below. */
private async watchLinked(id: string, sourceDir: string): Promise<void> {
this.stopWatch(id);
const serverDir = path.join(sourceDir, 'server'); // the loader runs server/index.js
try {
@@ -930,7 +944,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// 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
// user's opt-outs and the admin's enablement.
this.retireNotificationChannel(id);
await this.retireNotificationChannel(id);
if (deleteData) {
removePluginData(id);
this.db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id);
@@ -958,7 +972,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
}
/** Declared outbound hosts (from http:outbound:<host> grants) for the frame CSP. */
outboundHostsOf(id: string): string[] {
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;
@@ -1010,14 +1024,14 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* `capabilities.provides` AND implemented, as reported at load). The acting user
* is forwarded, so the target's export runs membership-checked as the caller's user.
*/
callPlugin(callerId: string, targetId: string, fn: string, args: unknown, actingUserId: number | undefined): Promise<unknown> {
async callPlugin(callerId: string, targetId: string, fn: string, args: unknown, actingUserId: number | undefined): Promise<unknown> {
if (!this.supervisor.isActive(targetId)) {
return Promise.reject(new ForbiddenResource(`plugin ${targetId} is not active`));
}
if (!this.dependsOnSatisfied(callerId, targetId)) {
if (!(await this.dependsOnSatisfied(callerId, targetId))) {
return Promise.reject(new ForbiddenResource(`plugin ${callerId} does not declare ${targetId} as a satisfied dependency`));
}
if (!this.capabilityList(targetId, 'provides').includes(fn) || !this.supervisor.exportsOf(targetId).includes(fn)) {
if (!(await this.capabilityList(targetId, 'provides')).includes(fn) || !this.supervisor.exportsOf(targetId).includes(fn)) {
return Promise.reject(new ForbiddenResource(`plugin ${targetId} does not export "${fn}"`));
}
return this.supervisor.invoke(targetId, 'invoke.export', { fn, args }, { actingUserId, timeoutMs: 5000 });
@@ -1028,12 +1042,12 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* to `(source, event)` and (b) declares `source` as a satisfied dependency. The
* source must declare `event` in its manifest `capabilities.emits`. Fire-and-forget.
*/
emitPluginEvent(sourceId: string, event: string, payload: unknown): void {
if (!this.capabilityList(sourceId, 'emits').includes(event)) {
async emitPluginEvent(sourceId: string, event: string, payload: unknown): Promise<void> {
if (!(await this.capabilityList(sourceId, 'emits')).includes(event)) {
throw new ForbiddenResource(`plugin ${sourceId} does not declare event "${event}"`);
}
for (const subscriberId of this.supervisor.subscribersOf(sourceId, event)) {
if (!this.dependsOnSatisfied(subscriberId, sourceId)) continue;
if (!(await this.dependsOnSatisfied(subscriberId, sourceId))) continue;
this.supervisor
.invoke(subscriberId, 'invoke.pluginEvent', { source: sourceId, event, payload }, { actingUserId: undefined, timeoutMs: 5000 })
.catch(() => {
@@ -1044,7 +1058,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
/** True if `caller` declares `target` as a plugin dependency whose range the
* installed target version satisfies. */
private dependsOnSatisfied(callerId: string, targetId: string): boolean {
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;
if (!caller || !target) return false;
@@ -1057,7 +1071,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* Drop every trace of a plugin's notification channel: the per-user event opt-outs
* and the id in the admin's `notification_channels` list.
*/
private retireNotificationChannel(id: string): void {
private async retireNotificationChannel(id: string): Promise<void> {
// Drop the users' per-event opt-outs for this channel. A plugin channel is never
// listed in the admin's `notification_channels` CSV (it is active by virtue of the
// plugin being enabled), so there is nothing to scrub there.
@@ -1079,7 +1093,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* The label, the event set and the configured-check are all answered from the
* manifest and the DB, with no IPC: the child is only ever woken to actually send.
*/
notificationChannels(): ExternalChannel[] {
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
@@ -1109,12 +1123,12 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// Declared as a contract on PluginHooks.sendNotification, like every other
// hook. It is invoked here rather than through that class because PluginHooks
// injects this service, and going back the other way would close a DI cycle.
sendToUser: (userId: number, msg: ChannelMessage) =>
this.invokeHook(
sendToUser: async (userId: number, msg: ChannelMessage) =>
await this.invokeHook(
id,
'notificationChannel',
'send',
[{ event: msg.event, title: msg.title, body: msg.body, url: msg.url, tripName: msg.tripName }, this.userSettings.readAll(id, userId)],
[{ event: msg.event, title: msg.title, body: msg.body, url: msg.url, tripName: msg.tripName }, await this.userSettings.readAll(id, userId)],
// No acting user: a notification is host-initiated for an arbitrary
// recipient, so the hook gets the recipient's config as an argument
// rather than the right to read anything AS them.
@@ -1123,7 +1137,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
),
test: async (userId: number) => {
try {
await this.invokeHook(id, 'notificationChannel', 'test', [this.userSettings.readAll(id, userId)], undefined, 8000);
await this.invokeHook(id, 'notificationChannel', 'test', [await this.userSettings.readAll(id, userId)], undefined, 8000);
return { success: true };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
@@ -1134,7 +1148,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
}
/** The settings-form action buttons a plugin declared for ONE scope (descriptors, from the DB). */
actionsOf(id: string, scope: PluginActionScope): PluginActionDescriptor[] {
async actionsOf(id: string, scope: PluginActionScope): Promise<PluginActionDescriptor[]> {
try {
return (
this.db
@@ -1154,7 +1168,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* CALLER's route, so a user route can never fire an admin button, nor the reverse.
*/
async invokeAction(id: string, key: string, actingUserId: number, scope: PluginActionScope): Promise<PluginActionResult> {
if (!this.actionsOf(id, scope).some((a) => a.key === key)) {
if (!(await this.actionsOf(id, scope)).some((a) => a.key === key)) {
throw new ForbiddenResource(`plugin ${id} did not declare action "${key}" in scope ${scope}`);
}
const cap = (v: unknown) => stripEmoji(String(v)).slice(0, 200);
@@ -1184,7 +1198,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
*
* Public, unlike capabilityList, because PluginMcpToolsService reads it.
*/
mcpToolCapabilities(id: string): McpToolCapability[] {
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 [];
try {
@@ -1198,7 +1212,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
}
}
private capabilityList(id: string, field: 'provides' | 'emits'): string[] {
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 [];
try {
@@ -1212,7 +1226,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 pruneErrorLog(pluginId: string): void {
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}
@@ -28,23 +28,23 @@ export class PluginUserSettingsController {
private readonly dbs: DatabaseService,
) {}
private activeWithUserFields(id: string): boolean {
private async activeWithUserFields(id: string): Promise<boolean> {
const row = this.dbs.connection.prepare("SELECT 1 FROM plugins WHERE id = ? AND status = 'active'").get(id);
return !!row;
}
@Get(':id')
get(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): {
async get(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): Promise<{
fields: unknown[];
config: Record<string, unknown>;
actions: PluginActionDescriptor[];
} {
}> {
const userId = req.user?.id;
if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) return { fields: [], config: {}, actions: [] };
if (!pluginsEnabled() || userId == null || !(await this.activeWithUserFields(id))) return { fields: [], config: {}, actions: [] };
return {
fields: this.plugins.userSettingsFields(id),
config: this.plugins.getUserConfig(id, userId),
actions: this.runtime.actionsOf(id, 'user'),
fields: await this.plugins.userSettingsFields(id),
config: await this.plugins.getUserConfig(id, userId),
actions: await this.runtime.actionsOf(id, 'user'),
};
}
@@ -62,7 +62,7 @@ export class PluginUserSettingsController {
@Req() req: Request & { user?: { id: number } },
): Promise<PluginActionResult> {
const userId = req.user?.id;
if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) {
if (!pluginsEnabled() || userId == null || !(await this.activeWithUserFields(id))) {
throw new HttpException({ error: 'Plugin is not active' }, 404);
}
try {
@@ -74,16 +74,16 @@ export class PluginUserSettingsController {
}
@Post(':id')
update(
async update(
@Param('id') id: string,
@Body() body: PluginUserSettingsUpdateDto,
@Req() req: Request & { user?: { id: number } },
): { config: Record<string, unknown> } {
): Promise<{ config: Record<string, unknown> }> {
const userId = req.user?.id;
if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) return { config: {} };
if (!pluginsEnabled() || userId == null || !(await this.activeWithUserFields(id))) return { config: {} };
const patch = body?.config && typeof body.config === 'object' ? (body.config as Record<string, unknown>) : {};
try {
return { config: this.plugins.updateUserConfig(id, userId, patch) };
return { config: await this.plugins.updateUserConfig(id, userId, patch) };
} catch (e) {
if (e instanceof MissingRequiredSettingError) throw new HttpException({ error: e.message }, 400);
throw e;
@@ -24,15 +24,15 @@ export class PluginUserSettingsService {
}
/** One decrypted value for the acting user, for the runtime's `ctx.settings.get()`. */
readOne(pluginId: string, userId: number, key: string): unknown {
async readOne(pluginId: string, userId: number, key: string): Promise<unknown> {
const isSecret =
(
this.db
.prepare("SELECT secret FROM plugin_settings_fields WHERE plugin_id = ? AND field_key = ? AND scope = 'user'")
.get(pluginId, key) as { secret: number } | undefined
)?.secret === 1;
const value = this.storedFor(pluginId, userId)[key];
if (value == null) return settingDefaults(this.db, pluginId, 'user')[key]; // unset → the manifest default, if any
const value = (await this.storedFor(pluginId, userId))[key];
if (value == null) return (await settingDefaults(this.db, pluginId, 'user'))[key]; // unset → the manifest default, if any
return isSecret ? decrypt_api_key(value as string) : value;
}
@@ -43,15 +43,15 @@ export class PluginUserSettingsService {
* dispatch is host-initiated with no acting user, so `ctx.settings.get()` (which
* resolves against the acting user) would return undefined there.
*/
readAll(pluginId: string, userId: number): Record<string, unknown> {
async readAll(pluginId: string, userId: number): Promise<Record<string, unknown>> {
const fields = this.db
.prepare("SELECT field_key, secret FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user'")
.all(pluginId) as Array<{ field_key: string; secret: number }>;
const stored = this.storedFor(pluginId, userId);
const stored = await this.storedFor(pluginId, userId);
// Null-prototype for the same reason as safeParseConfig: never let a field key write
// through to Object.prototype on the way out to the plugin.
const out: Record<string, unknown> = Object.create(null);
const defaults = settingDefaults(this.db, pluginId, 'user');
const defaults = await settingDefaults(this.db, pluginId, 'user');
for (const field of fields) {
const value = stored[field.field_key];
if (value == null) {
@@ -73,19 +73,19 @@ export class PluginUserSettingsService {
* what decides whether a channel dispatches to the user, and a save the form accepted
* must not leave them "not configured".
*/
hasRequired(pluginId: string, userId: number): boolean {
async hasRequired(pluginId: string, userId: number): Promise<boolean> {
const required = this.db
.prepare(
"SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user' AND required = 1 AND input_type != 'checkbox'",
)
.all(pluginId) as Array<{ field_key: string }>;
if (required.length === 0) return true;
const stored = this.storedFor(pluginId, userId);
const defaults = settingDefaults(this.db, pluginId, 'user');
const stored = await this.storedFor(pluginId, userId);
const defaults = await settingDefaults(this.db, pluginId, 'user');
return required.every((field) => isFilled(stored[field.field_key] ?? defaults[field.field_key]));
}
private storedFor(pluginId: string, userId: number): Record<string, unknown> {
private async storedFor(pluginId: 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(pluginId, userId) as { config: string } | undefined;
@@ -33,7 +33,7 @@ export class PluginsFeedController {
constructor(private readonly dbs: DatabaseService) {}
@Get()
list(): { plugins: ActivePlugin[] } {
async list(): Promise<{ plugins: ActivePlugin[] }> {
if (!pluginsEnabled()) return { plugins: [] };
const rows = this.dbs.connection
.prepare("SELECT id, name, type, icon, capabilities, granted_permissions FROM plugins WHERE status = 'active' ORDER BY sort_order, name")
+21 -21
View File
@@ -59,8 +59,8 @@ export class PluginsController {
) {}
@Get()
list() {
return this.plugins.list();
async list() {
return await this.plugins.list();
}
@Get('registry')
@@ -140,11 +140,11 @@ export class PluginsController {
}
@Get(':id/config')
getConfig(@Param('id') id: string): PluginInstanceConfigResponse {
async getConfig(@Param('id') id: string): Promise<PluginInstanceConfigResponse> {
return {
fields: this.plugins.instanceSettingsFields(id),
config: this.plugins.getInstanceConfig(id),
actions: this.runtime.actionsOf(id, 'instance'),
fields: await this.plugins.instanceSettingsFields(id),
config: await this.plugins.getInstanceConfig(id),
actions: await this.runtime.actionsOf(id, 'instance'),
};
}
@@ -162,7 +162,7 @@ export class PluginsController {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
let config: Record<string, unknown>;
try {
config = this.plugins.updateInstanceConfig(id, body || {});
config = await this.plugins.updateInstanceConfig(id, body || {});
} catch (e) {
if (e instanceof MissingRequiredSettingError) throw new HttpException({ error: e.message }, 400);
throw e;
@@ -227,8 +227,8 @@ export class PluginsController {
* is admin-guarded): an end user can never widen a plugin's egress.
*/
@Get(':id/egress-hosts')
egressHosts(@Param('id') id: string) {
return { supported: this.runtime.wantsOperatorEgress(id), hosts: this.runtime.operatorEgressHosts(id) };
async egressHosts(@Param('id') id: string) {
return { supported: await this.runtime.wantsOperatorEgress(id), hosts: await this.runtime.operatorEgressHosts(id) };
}
@Put(':id/egress-hosts')
@@ -324,8 +324,8 @@ export class PluginsController {
/** Release a per-plugin update hold (set by a deliberate non-latest install). */
@Post(':id/resume-updates')
@HttpCode(200)
resumeUpdates(@Param('id') id: string) {
if (!this.plugins.resumeUpdates(id)) throw new HttpException({ error: `plugin ${id} not found` }, 404);
async resumeUpdates(@Param('id') id: string) {
if (!(await this.plugins.resumeUpdates(id))) throw new HttpException({ error: `plugin ${id} not found` }, 404);
return { updateHold: false };
}
@@ -370,18 +370,18 @@ export class PluginsController {
}
@Get(':id/errors')
errors(@Param('id') id: string) {
return { errors: this.plugins.errors(id) };
async errors(@Param('id') id: string) {
return { errors: await this.plugins.errors(id) };
}
@Get(':id/audit')
audit(@Param('id') id: string) {
return { audit: this.plugins.auditLog(id) };
async audit(@Param('id') id: string) {
return { audit: await this.plugins.auditLog(id) };
}
@Get(':id/budget')
budget(@Param('id') id: string) {
return { budget: this.plugins.budget(id) };
async budget(@Param('id') id: string) {
return { budget: await this.plugins.budget(id) };
}
/** GDPR portability: aggregate everything the installed plugins hold about one
@@ -395,15 +395,15 @@ export class PluginsController {
}
@Delete(':id/errors')
clearErrors(@Param('id') id: string) {
this.plugins.clearErrors(id);
async clearErrors(@Param('id') id: string) {
await this.plugins.clearErrors(id);
return { ok: true };
}
@Post('rescan')
@HttpCode(200)
rescan() {
async rescan() {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
return this.runtime.rescan();
return await this.runtime.rescan();
}
}
+31 -31
View File
@@ -128,7 +128,7 @@ export class PluginsService {
}
/** Hosts an admin has added for a plugin (0 unless it declared operatorEgress). */
private egressHostCount(id: string): number {
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;
} catch {
@@ -136,7 +136,7 @@ export class PluginsService {
}
}
private instanceSettingsCount(id: string): number {
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 }
@@ -146,7 +146,7 @@ export class PluginsService {
}
}
private instanceActionsCount(id: string): number {
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 }
@@ -200,9 +200,9 @@ export class PluginsService {
plugins.push({
...rest,
operatorEgress: _oe === 1,
egressHostCount: this.egressHostCount(r.id),
instanceSettingsCount: this.instanceSettingsCount(r.id),
instanceActionsCount: this.instanceActionsCount(r.id),
egressHostCount: await this.egressHostCount(r.id),
instanceSettingsCount: await this.instanceSettingsCount(r.id),
instanceActionsCount: await this.instanceActionsCount(r.id),
dependencies: deps,
dependencyStatus,
trekRange: trek_range,
@@ -224,7 +224,7 @@ export class PluginsService {
* Release a per-plugin update hold (set by a deliberate non-latest install).
* Returns whether the plugin row existed — the controller answers 404 otherwise.
*/
resumeUpdates(id: string): boolean {
async resumeUpdates(id: string): Promise<boolean> {
return this.db.prepare('UPDATE plugins SET update_hold = 0 WHERE id = ?').run(id).changes > 0;
}
@@ -234,7 +234,7 @@ export class PluginsService {
* declared as `scope:'instance'` fields are accepted, same as updateUserConfig.
* Returns the config with secrets masked for the client.
*/
updateInstanceConfig(id: string, patch: Record<string, unknown>): Record<string, unknown> {
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`);
@@ -264,22 +264,22 @@ export class PluginsService {
config[k] = v;
}
}
this.assertRequiredFilled(id, 'instance', config);
await this.assertRequiredFilled(id, 'instance', config);
this.db.prepare('UPDATE plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(config), id);
return maskSecrets(config, secretKeys);
}
/** The plugin's `scope:'user'` settings fields, in declared order (for the user form). */
userSettingsFields(id: string): PluginSettingsField[] {
return this.settingsFields(id, 'user');
async userSettingsFields(id: string): Promise<PluginSettingsField[]> {
return await this.settingsFields(id, 'user');
}
/** The plugin's `scope:'instance'` settings fields, in declared order (for the ADMIN form). */
instanceSettingsFields(id: string): PluginSettingsField[] {
return this.settingsFields(id, 'instance');
async instanceSettingsFields(id: string): Promise<PluginSettingsField[]> {
return await this.settingsFields(id, 'instance');
}
private settingsFields(id: string, scope: 'user' | 'instance'): PluginSettingsField[] {
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
@@ -306,7 +306,7 @@ export class PluginsService {
});
}
private userSecretKeys(id: string): Set<string> {
private async userSecretKeys(id: string): Promise<Set<string>> {
return new Set(
(
this.db
@@ -317,22 +317,22 @@ export class PluginsService {
}
/** A user's own config for a plugin, secrets masked (safe to send to the client). */
getUserConfig(id: string, userId: number): Record<string, unknown> {
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 ?? '{}'), this.userSecretKeys(id));
return maskSecrets(safeParse(row?.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. */
updateUserConfig(id: string, userId: number, patch: Record<string, unknown>): Record<string, unknown> {
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 secretKeys = this.userSecretKeys(id);
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;
@@ -346,7 +346,7 @@ export class PluginsService {
config[k] = v;
}
}
this.assertRequiredFilled(id, 'user', config);
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`,
@@ -356,40 +356,40 @@ export class PluginsService {
/** 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. */
getUserConfigDecrypted(id: string, userId: number): Record<string, unknown> {
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 secretKeys = this.userSecretKeys(id);
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;
return out;
}
/** A plugin's error log, newest first. */
errors(id: string): Array<{ ts: string; level: string; message: string }> {
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 }>;
}
clearErrors(id: string): void {
async clearErrors(id: string): Promise<void> {
this.db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id);
}
/** A plugin's hash-chained capability audit log, newest first. */
auditLog(id: string): unknown[] {
return readAudit(this.db, id);
async auditLog(id: string): Promise<unknown[]> {
return await readAudit(this.db, id);
}
/** A plugin's broker budget usage for today (AI + notification counts vs caps). */
budget(id: string): ReturnType<typeof pluginBudgetUsage> {
return pluginBudgetUsage(id, this.db);
async budget(id: string): ReturnType<typeof pluginBudgetUsage> {
return await pluginBudgetUsage(id, this.db);
}
/** Read the instance config with secret fields masked. */
getInstanceConfig(id: string): Record<string, unknown> {
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(
@@ -408,13 +408,13 @@ export class PluginsService {
* stored secret (non-empty ciphertext) counts as filled. A `checkbox` is exempt — required
* would demand `true`, which is a consent flow, not a settings field.
*/
private assertRequiredFilled(id: string, scope: 'instance' | 'user', config: Record<string, unknown>): void {
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 defaults = settingDefaults(this.db, id, scope);
const defaults = await settingDefaults(this.db, id, scope);
for (const f 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);
@@ -446,9 +446,9 @@ export class PluginRegistryService {
// A refusal is REMEMBERED (the plugin keeps running on its old code, so the
// reason must outlive the toast) and re-thrown untouched.
try {
this.verifySignatureAndTofu(id, bytes, entry, ver, opts?.retrustKey);
await this.verifySignatureAndTofu(id, bytes, entry, ver, opts?.retrustKey);
} catch (e) {
if (e instanceof RegistryError && isSignatureCode(e.code)) setUpdateBlock(this.dbs.connection, id, e.code, e.message, ver.version);
if (e instanceof RegistryError && isSignatureCode(e.code)) await setUpdateBlock(this.dbs.connection, id, e.code, e.message, ver.version);
throw e;
}
@@ -480,7 +480,7 @@ export class PluginRegistryService {
fs.renameSync(pluginRoot, dest);
// 7. register INACTIVE (record provenance)
discoverPlugins(this.db);
await discoverPlugins(this.db);
this.db.prepare('UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?').run(
entry.repo,
ver.commitSha,
@@ -497,7 +497,7 @@ export class PluginRegistryService {
}
// The plugin is now on new code that passed every check — whatever refusal was
// recorded before no longer describes reality.
clearUpdateBlock(this.dbs.connection, id);
await clearUpdateBlock(this.dbs.connection, id);
return { id, version: ver.version, trekRangeBypassed };
} finally {
fs.rmSync(staging, { recursive: true, force: true });
@@ -603,13 +603,13 @@ export class PluginRegistryService {
* the UI flags it and offers no auto-update. The caller MUST have stopped any
* running child of this id first (the code dir is replaced).
*/
commitUpload(staged: { id: string; root: string; stagingDir: string }): void {
async commitUpload(staged: { id: string; root: string; stagingDir: string }): Promise<void> {
try {
const dest = pluginCodeDir(staged.id);
fs.mkdirSync(pluginsCodeRoot(), { recursive: true });
fs.rmSync(dest, { recursive: true, force: true });
fs.renameSync(staged.root, dest);
discoverPlugins(this.db);
await discoverPlugins(this.db);
// Provenance for a sideload, plus a hard INACTIVE floor: discoverPlugins keeps
// an existing row's status, so replacing a plugin that was active must not
// leave the new code marked active — the admin re-activates (and re-consents
@@ -650,13 +650,13 @@ export class PluginRegistryService {
* key, over the artifact bytes. A key an admin blesses must still sign the code it
* ships — a re-trust moves the pin from one VERIFIED key to another verified key.
*/
private verifySignatureAndTofu(
private async verifySignatureAndTofu(
id: string,
bytes: Buffer,
entry: RegistryEntry,
ver: RegistryVersion,
retrustKey?: string,
): void {
): Promise<void> {
const pinned =
(this.db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string } | undefined)
?.author_pubkey ?? null;
+2 -2
View File
@@ -31,11 +31,11 @@ export function parseDefaultValue(raw: unknown): SettingDefault | undefined {
/** The declared defaults for one plugin and scope, keyed by field. Null-prototype so a
* field key can never resolve off Object.prototype (see plugin-config-parse.ts). */
export function settingDefaults(
export async function settingDefaults(
db: BetterSqlite3.Database,
pluginId: string,
scope: 'instance' | 'user',
): Record<string, SettingDefault> {
): Promise<Record<string, SettingDefault>> {
const rows = db
.prepare(
'SELECT field_key, default_value FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? AND secret = 0 AND default_value IS NOT NULL',
+2 -2
View File
@@ -67,7 +67,7 @@ export function keyFingerprint(pubkey: string | null | undefined): string | null
* old code. A blocked update is not a broken runtime, and conflating them would
* make the isolation-health dot lie.
*/
export function setUpdateBlock(conn: Database.Database, id: string, code: SignatureCode, detail: string, version: string | null): void {
export async function setUpdateBlock(conn: Database.Database, id: string, code: SignatureCode, detail: string, version: string | null): Promise<void> {
try {
conn.prepare('UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?').run(
code,
@@ -84,7 +84,7 @@ export function setUpdateBlock(conn: Database.Database, id: string, code: Signat
* activating the plugin at its OLD version resolves nothing, and letting an off/on
* toggle erase the warning is exactly the silent-stops-updating failure this exists
* to prevent. (Uninstall drops the row entirely, so it needs no explicit clear.) */
export function clearUpdateBlock(conn: Database.Database, id: string): void {
export async function clearUpdateBlock(conn: Database.Database, id: string): Promise<void> {
try {
conn.prepare('UPDATE plugins SET update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL WHERE id = ?').run(id);
} catch {
@@ -68,7 +68,7 @@ export class RoadtripPlanService {
private readonly boundaries: DayBoundariesService,
) {}
context(tripId: number, userId: number) {
async context(tripId: number, userId: number) {
if (!this.db.canAccessTrip(tripId, userId)) throw new HttpException({ error: 'Trip not found' }, 404);
const days = this.db.all<StoredDay>(
'SELECT id, day_number, date, title, default_transport_mode FROM days WHERE trip_id = ? ORDER BY day_number',
@@ -89,7 +89,7 @@ export class RoadtripPlanService {
days,
visits,
settings: this.preferences.read(tripId),
profiles: this.router.profiles(),
profiles: await this.router.profiles(),
vias: this.roadtrip.listForTrip(tripId),
tracks: this.roadtrip.tracksForTrip(tripId),
boundaries: this.boundaries.list(tripId),
@@ -97,7 +97,7 @@ export class RoadtripPlanService {
}
async calculate(tripId: number, userId: number, overrides?: RoadtripPreferences) {
const context = this.context(tripId, userId);
const context = await this.context(tripId, userId);
const preferences = { ...context.settings, ...overrides };
const window = dayWindow(
preferences.roadtrip_day_start,
@@ -35,7 +35,7 @@ export class RoadtripPlanningMcp {
when,
})
async context({ tripId }: { tripId: number }, ctx: McpContext) {
return answeringRefusals(() => ok(this.plans.context(tripId, ctx.userId)));
return await answeringRefusals(async () => ok(await this.plans.context(tripId, ctx.userId)));
}
@Tool({
@@ -102,17 +102,16 @@ export class RoadtripRouterService {
private readonly db: DatabaseService,
) {}
profiles(): string[] {
return [
'driving',
'walking',
'cycling',
...(pluginsEnabled()
? this.hooks
.providersOf('routeProvider')
.flatMap((id) => declaredProfiles(this.db.connection, id).map((profile) => `plugin:${id}/${profile}`))
: []),
];
async profiles(): Promise<string[]> {
const plugin: string[] = [];
if (pluginsEnabled()) {
// R1.4: a `flatMap` callback cannot await the now-async profile read, so the
// fan-out runs as an explicit loop — same providers, same order.
for (const id of this.hooks.providersOf('routeProvider')) {
for (const profile of await declaredProfiles(this.db.connection, id)) plugin.push(`plugin:${id}/${profile}`);
}
}
return ['driving', 'walking', 'cycling', ...plugin];
}
private async request(url: string, body?: unknown): Promise<unknown> {
@@ -176,7 +175,7 @@ export class RoadtripRouterService {
points.length > 30 ||
!pluginsEnabled() ||
!this.hooks.providersOf('routeProvider').includes(id) ||
!declaredProfiles(this.db.connection, id).includes(name)
!(await declaredProfiles(this.db.connection, id)).includes(name)
)
throw new Error('Routing plugin is unavailable or has too many waypoints');
const raw = await this.hooks.route(id, { tripId, dayId, profile: name, waypoints: points }, userId);
@@ -269,7 +269,7 @@ export class TripMembersService {
// (trip_members, budget/packing/assignment links) via the ON DELETE foreign keys.
this.db.prepare('DELETE FROM users WHERE id = ? AND is_guest = 1').run(guestUserId);
});
emitUserDeleted(guestUserId); // deliver the erasure to any active plugin now
await emitUserDeleted(guestUserId); // deliver the erasure to any active plugin now
return true;
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ import type { McpContext, McpDynamicTool } from './nest-mcp';
* again on the way out would swallow that diagnostic and report the same
* degraded surface less usefully.
*/
export type PluginMcpToolSource = (ctx: McpContext) => readonly McpDynamicTool[];
export type PluginMcpToolSource = (ctx: McpContext) => readonly McpDynamicTool[] | Promise<readonly McpDynamicTool[]>;
let source: PluginMcpToolSource | null = null;
+4 -4
View File
@@ -6,19 +6,19 @@
// (the plugin side persists the erasure so nothing is lost if the sink is absent
// or the runtime is mid-boot).
let sink: ((userId: number) => void) | null = null;
let sink: ((userId: number) => void | Promise<void>) | null = null;
export function setUserDeletedSink(fn: ((userId: number) => void) | null): void {
export function setUserDeletedSink(fn: ((userId: number) => void | Promise<void>) | null): void {
sink = fn;
}
/** Announce that a TREK account was fully deleted. Called by the core deletion
* paths after deleteUserCompletely so plugins can erase their own per-user data.
* Swallows everything: a plugin bookkeeping error must never fail the deletion. */
export function emitUserDeleted(userId: number): void {
export async function emitUserDeleted(userId: number): Promise<void> {
if (!sink) return;
try {
sink(userId);
await sink(userId);
} catch {
/* the erasure is enqueued transactionally on the plugin side; a sink hiccup
is non-fatal and the next runtime boot reconciles from the queue */