diff --git a/server/src/nest-mcp/registry.ts b/server/src/nest-mcp/registry.ts index 61fff8d0e..5717d9d60 100644 --- a/server/src/nest-mcp/registry.ts +++ b/server/src/nest-mcp/registry.ts @@ -169,7 +169,7 @@ export class McpRegistry { ): Promise { 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. diff --git a/server/src/nest-mcp/types.ts b/server/src/nest-mcp/types.ts index c4ef9f7eb..2f09d08b4 100644 --- a/server/src/nest-mcp/types.ts +++ b/server/src/nest-mcp/types.ts @@ -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; export type McpEntry = | { kind: 'tool'; methodName: string; options: ToolOptions } diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index 0e23d1391..d66977e2c 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -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 }; } diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index 8d4de3678..d6fc43afc 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -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 }; } diff --git a/server/src/nest/notifications/channel-registry.ts b/server/src/nest/notifications/channel-registry.ts index 42577778e..9673567c1 100644 --- a/server/src/nest/notifications/channel-registry.ts +++ b/server/src/nest/notifications/channel-registry.ts @@ -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) | null = null; -export function setPluginChannelSource(source: (() => ExternalChannel[]) | null): void { +export function setPluginChannelSource(source: (() => ExternalChannel[] | Promise) | 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 { + 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 { + if (isPluginChannelId(id)) return (await listChannels()).find((c) => c.id === id); return builtins.get(id); } diff --git a/server/src/nest/notifications/notification-events.ts b/server/src/nest/notifications/notification-events.ts index 5b542efc4..362e098cc 100644 --- a/server/src/nest/notifications/notification-events.ts +++ b/server/src/nest/notifications/notification-events.ts @@ -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; sendToUser(userId: number, msg: ChannelMessage): Promise; sendGlobal?(msg: ChannelMessage): Promise; test?(userId: number, override?: Record): Promise<{ success: boolean; error?: string }>; diff --git a/server/src/nest/notifications/notification-preferences.service.ts b/server/src/nest/notifications/notification-preferences.service.ts index 5f627aedb..3b5bc135d 100644 --- a/server/src/nest/notifications/notification-preferences.service.ts +++ b/server/src/nest/notifications/notification-preferences.service.ts @@ -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 { + return [INAPP_CHANNEL, ...(await listChannels()).filter(c => c.supportsEvent(event)).map(c => c.id)]; } - private allCombos(): Record { + private async allCombos(): Promise> { const out: Record = {}; - 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 { 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 { + 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 { 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 = { 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 { 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>>> = {}; @@ -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 { + return (await this.getActiveChannels()).includes('webhook'); } } diff --git a/server/src/nest/notifications/notifications.service.ts b/server/src/nest/notifications/notifications.service.ts index f5923d080..1b51e5ef0 100644 --- a/server/src/nest/notifications/notifications.service.ts +++ b/server/src/nest/notifications/notifications.service.ts @@ -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 { 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 { + 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 { - 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); diff --git a/server/src/nest/packing/packing.rpc.ts b/server/src/nest/packing/packing.rpc.ts index 4c3ae0b7b..ea28ab89e 100644 --- a/server/src/nest/packing/packing.rpc.ts +++ b/server/src/nest/packing/packing.rpc.ts @@ -92,10 +92,10 @@ export class PackingRpc { } @PluginMethod('packing.listBags', { permission: 'db:write:packing' }) - listBags(params: Record, ctx: PluginRpcContext): unknown[] { + async listBags(params: Record, ctx: PluginRpcContext): Promise { // 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' }) diff --git a/server/src/nest/plugins/contributions/plugin-mcp-tools.service.ts b/server/src/nest/plugins/contributions/plugin-mcp-tools.service.ts index 3538fdd43..338ca05ca 100644 --- a/server/src/nest/plugins/contributions/plugin-mcp-tools.service.ts +++ b/server/src/nest/plugins/contributions/plugin-mcp-tools.service.ts @@ -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 { 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 { 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 { + 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); diff --git a/server/src/nest/plugins/contributions/plugin-route-normalize.ts b/server/src/nest/plugins/contributions/plugin-route-normalize.ts index 9963e551e..c02c2846d 100644 --- a/server/src/nest/plugins/contributions/plugin-route-normalize.ts +++ b/server/src/nest/plugins/contributions/plugin-route-normalize.ts @@ -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 { try { const row = conn.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(pluginId) as | { capabilities?: string } diff --git a/server/src/nest/plugins/contributions/plugin-routes.controller.ts b/server/src/nest/plugins/contributions/plugin-routes.controller.ts index fd8f07699..25e31dba1 100644 --- a/server/src/nest/plugins/contributions/plugin-routes.controller.ts +++ b/server/src/nest/plugins/contributions/plugin-routes.controller.ts @@ -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); diff --git a/server/src/nest/plugins/host/plugin-audit.ts b/server/src/nest/plugins/host/plugin-audit.ts index af993e5d3..5b886eff7 100644 --- a/server/src/nest/plugins/host/plugin-audit.ts +++ b/server/src/nest/plugins/host/plugin-audit.ts @@ -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 { 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 { 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 { 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 { 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); diff --git a/server/src/nest/plugins/host/plugin-guards.service.ts b/server/src/nest/plugins/host/plugin-guards.service.ts index 56fdd04c1..e55a57b9d 100644 --- a/server/src/nest/plugins/host/plugin-guards.service.ts +++ b/server/src/nest/plugins/host/plugin-guards.service.ts @@ -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(params: Record, ctx: PluginRpcContext, read: (userId: number) => T): T { + async tripRead(params: Record, ctx: PluginRpcContext, read: (userId: number) => T): Promise { const tripId = num(params.tripId, 'tripId'); if (ctx.actingUserId === undefined) { throw new ForbiddenResource('trip reads require an authenticated user context'); diff --git a/server/src/nest/plugins/host/plugin-host-state.ts b/server/src/nest/plugins/host/plugin-host-state.ts index 6d7f118c9..2176e1604 100644 --- a/server/src/nest/plugins/host/plugin-host-state.ts +++ b/server/src/nest/plugins/host/plugin-host-state.ts @@ -44,7 +44,7 @@ export function closePluginDataDb(id: string): void { // nothing persisted or phoned home. const budgets = new Map(); -export function budgetFor(id: string, conn: Database.Database): DailyBudget { +export async function budgetFor(id: string, conn: Database.Database): Promise { 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 { - return budgetFor(id, conn).used(Date.now()); +export async function pluginBudgetUsage(id: string, conn: Database.Database): Promise> { + return (await budgetFor(id, conn)).used(Date.now()); } diff --git a/server/src/nest/plugins/host/plugin-rpc-host.factory.ts b/server/src/nest/plugins/host/plugin-rpc-host.factory.ts index 7c9cfe262..3a582265a 100644 --- a/server/src/nest/plugins/host/plugin-rpc-host.factory.ts +++ b/server/src/nest/plugins/host/plugin-rpc-host.factory.ts @@ -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; - emitPluginEvent(sourceId: string, event: string, payload: unknown): void; + emitPluginEvent(sourceId: string, event: string, payload: unknown): Promise; } /** diff --git a/server/src/nest/plugins/host/rpc-host.ts b/server/src/nest/plugins/host/rpc-host.ts index df5ce33eb..575ab57f3 100644 --- a/server/src/nest/plugins/host/rpc-host.ts +++ b/server/src/nest/plugins/host/rpc-host.ts @@ -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; /** Publish an event from this host's plugin to its subscribed dependents. */ - emitPluginEvent(event: string, payload: unknown): void; + emitPluginEvent(event: string, payload: unknown): Promise; /** 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; } type Handler = (params: Record, 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, diff --git a/server/src/nest/plugins/host/rpc-kit/types.ts b/server/src/nest/plugins/host/rpc-kit/types.ts index c84c15a8a..faa36095c 100644 --- a/server/src/nest/plugins/host/rpc-kit/types.ts +++ b/server/src/nest/plugins/host/rpc-kit/types.ts @@ -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; - emit(event: string, payload: unknown): void; + emit(event: string, payload: unknown): Promise; } export type PluginRpcHandler = (params: Record, ctx: PluginRpcContext) => unknown; diff --git a/server/src/nest/plugins/host/rpc/db.rpc.ts b/server/src/nest/plugins/host/rpc/db.rpc.ts index e9c74d432..ffc3d7a3a 100644 --- a/server/src/nest/plugins/host/rpc/db.rpc.ts +++ b/server/src/nest/plugins/host/rpc/db.rpc.ts @@ -48,8 +48,8 @@ export class DbRpc { /** Same deal: the router checks the emitter's `emits` allowlist. */ @PluginOpenMethod('events.emit') - emitEvent(params: Record, ctx: PluginRpcContext): unknown { - ctx.plugins.emit(str(params.event, 'event'), params.payload); + async emitEvent(params: Record, ctx: PluginRpcContext): Promise { + 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, ctx: PluginRpcContext): unknown { + async getSetting(params: Record, ctx: PluginRpcContext): Promise { 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')) }; } } diff --git a/server/src/nest/plugins/host/rpc/host-surface.rpc.ts b/server/src/nest/plugins/host/rpc/host-surface.rpc.ts index b0f836625..f4c0eebc6 100644 --- a/server/src/nest/plugins/host/rpc/host-surface.rpc.ts +++ b/server/src/nest/plugins/host/rpc/host-surface.rpc.ts @@ -45,19 +45,19 @@ export class HostSurfaceRpc { ) {} @PluginMethod('users.getById', { permission: 'db:read:users' }) - getUser(params: Record, ctx: PluginRpcContext): unknown { + async getUser(params: Record, ctx: PluginRpcContext): Promise { // 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, ctx: PluginRpcContext): unknown { + async broadcastToTrip(params: Record, ctx: PluginRpcContext): Promise { // 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, ctx: PluginRpcContext): unknown { + async schedulerSet(params: Record, ctx: PluginRpcContext): Promise { 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, ctx: PluginRpcContext): unknown { + async schedulerCancel(params: Record, ctx: PluginRpcContext): Promise { 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 { 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 { + if (!(await budgetFor(ctx.pluginId, this.db.connection)).take('ai', Date.now())) { throw new BadParams('daily AI budget exhausted (resets at UTC midnight)'); } } diff --git a/server/src/nest/plugins/host/rpc/meta.rpc.ts b/server/src/nest/plugins/host/rpc/meta.rpc.ts index 3de10d6f1..43969eb14 100644 --- a/server/src/nest/plugins/host/rpc/meta.rpc.ts +++ b/server/src/nest/plugins/host/rpc/meta.rpc.ts @@ -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 { if (entityType === 'trip') { return (this.db.prepare('SELECT id FROM trips WHERE id = ?').get(entityId) as { id: number } | undefined)?.id; } diff --git a/server/src/nest/plugins/install/discovery.ts b/server/src/nest/plugins/install/discovery.ts index 2019f39e8..c6219afec 100644 --- a/server/src/nest/plugins/install/discovery.ts +++ b/server/src/nest/plugins/install/discovery.ts @@ -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 { 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) { diff --git a/server/src/nest/plugins/oauth/plugin-oauth.controller.ts b/server/src/nest/plugins/oauth/plugin-oauth.controller.ts index 462350fc5..ba7d0d033 100644 --- a/server/src/nest/plugins/oauth/plugin-oauth.controller.ts +++ b/server/src/nest/plugins/oauth/plugin-oauth.controller.ts @@ -20,22 +20,22 @@ export class PluginOAuthController { private readonly dbs: DatabaseService, ) {} - private isActive(id: string): boolean { + private async isActive(id: string): Promise { 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 { 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 }; } } diff --git a/server/src/nest/plugins/oauth/plugin-oauth.service.ts b/server/src/nest/plugins/oauth/plugin-oauth.service.ts index 3a19bc91f..71f147b53 100644 --- a/server/src/nest/plugins/oauth/plugin-oauth.service.ts +++ b/server/src/nest/plugins/oauth/plugin-oauth.service.ts @@ -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 { const row = this.db.prepare('SELECT config FROM plugins WHERE id = ?').get(pluginId) as { config: string } | undefined; if (!row) return null; let cfg: Record; @@ -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 { + 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 { 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 { 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( diff --git a/server/src/nest/plugins/plugin-activity.controller.ts b/server/src/nest/plugins/plugin-activity.controller.ts index c9737448e..146076773 100644 --- a/server/src/nest/plugins/plugin-activity.controller.ts +++ b/server/src/nest/plugins/plugin-activity.controller.ts @@ -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) }; } } diff --git a/server/src/nest/plugins/plugin-frame.controller.ts b/server/src/nest/plugins/plugin-frame.controller.ts index 0545cd0bb..5dbbbeb11 100644 --- a/server/src/nest/plugins/plugin-frame.controller.ts +++ b/server/src/nest/plugins/plugin-frame.controller.ts @@ -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 { 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 { // 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: 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