mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
refactor(plugins): async DB-touching methods; transactions through UnitOfWork
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user