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

This commit is contained in:
jubnl
2026-09-22 00:00:53 +02:00
parent e413308227
commit e28dc6de28
68 changed files with 743 additions and 706 deletions
+1 -1
View File
@@ -169,7 +169,7 @@ export class McpRegistry {
): Promise<void> {
let tools: readonly McpDynamicTool[];
try {
tools = source(ctx) ?? [];
tools = (await source(ctx)) ?? [];
} catch (err) {
// A session with no dynamic tools is degraded; a session that throws here
// is a 500 on initialize, because hosts call attach() outside their try.
+1 -1
View File
@@ -220,7 +220,7 @@ export interface McpDynamicTool {
* creation. A source that needs to ask something slow what tools exist should
* answer from state it already holds.
*/
export type McpDynamicToolSource = (ctx: McpContext) => readonly McpDynamicTool[];
export type McpDynamicToolSource = (ctx: McpContext) => readonly McpDynamicTool[] | Promise<readonly McpDynamicTool[]>;
export type McpEntry =
| { kind: 'tool'; methodName: string; options: ToolOptions }
+1 -1
View File
@@ -257,7 +257,7 @@ export class AdminService {
if (!userToDel) return { error: 'User not found', status: 404 };
await this.userCleanup.deleteUserCompletely(userToDel.id);
emitUserDeleted(userToDel.id); // let plugins erase their own per-user data
await emitUserDeleted(userToDel.id); // let plugins erase their own per-user data
return { email: userToDel.email };
}
+1 -1
View File
@@ -621,7 +621,7 @@ export class AuthService {
}
}
await this.userCleanup.deleteUserCompletely(userId);
emitUserDeleted(userId); // let plugins erase their own per-user data
await emitUserDeleted(userId); // let plugins erase their own per-user data
return { success: true };
}
@@ -88,17 +88,17 @@ function sanitizePluginChannels(raw: readonly ExternalChannel[]): ExternalChanne
// setPluginEventSink() in plugin-runtime.service.ts: the runtime pushes a getter
// in at onModuleInit, and we pull from it on every read so a plugin that is
// disabled or uninstalled simply stops appearing.
let pluginChannelSource: (() => ExternalChannel[]) | null = null;
let pluginChannelSource: (() => ExternalChannel[] | Promise<ExternalChannel[]>) | null = null;
export function setPluginChannelSource(source: (() => ExternalChannel[]) | null): void {
export function setPluginChannelSource(source: (() => ExternalChannel[] | Promise<ExternalChannel[]>) | null): void {
pluginChannelSource = source;
}
/** Every channel that currently exists: built-ins plus live, sanitized plugin channels. */
export function listChannels(): ExternalChannel[] {
const plugins = (() => {
export async function listChannels(): Promise<ExternalChannel[]> {
const plugins = await (async () => {
try {
return pluginChannelSource?.() ?? [];
return (await pluginChannelSource?.()) ?? [];
} catch {
// A broken plugin runtime must never take notifications down with it.
return [];
@@ -109,8 +109,8 @@ export function listChannels(): ExternalChannel[] {
return [...builtins.values(), ...sanitizePluginChannels(plugins)];
}
export function getChannel(id: string): ExternalChannel | undefined {
if (isPluginChannelId(id)) return listChannels().find((c) => c.id === id);
export async function getChannel(id: string): Promise<ExternalChannel | undefined> {
if (isPluginChannelId(id)) return (await listChannels()).find((c) => c.id === id);
return builtins.get(id);
}
@@ -135,7 +135,7 @@ export interface ExternalChannel {
*/
isInstanceConfigured?(): boolean;
/** Does this recipient have credentials for this channel? */
isConfiguredFor(userId: number): boolean;
isConfiguredFor(userId: number): boolean | Promise<boolean>;
sendToUser(userId: number, msg: ChannelMessage): Promise<unknown>;
sendGlobal?(msg: ChannelMessage): Promise<unknown>;
test?(userId: number, override?: Record<string, unknown>): Promise<{ success: boolean; error?: string }>;
@@ -45,13 +45,13 @@ export class NotificationPreferencesService {
* Channels implemented for an event. In-app takes everything; external channels
* decide for themselves (today: everything except `synology_session_cleared`).
*/
combosFor(event: NotifEventType): NotifChannel[] {
return [INAPP_CHANNEL, ...listChannels().filter(c => c.supportsEvent(event)).map(c => c.id)];
async combosFor(event: NotifEventType): Promise<NotifChannel[]> {
return [INAPP_CHANNEL, ...(await listChannels()).filter(c => c.supportsEvent(event)).map(c => c.id)];
}
private allCombos(): Record<string, NotifChannel[]> {
private async allCombos(): Promise<Record<string, NotifChannel[]>> {
const out: Record<string, NotifChannel[]> = {};
for (const event of ALL_EVENT_TYPES) out[event] = this.combosFor(event);
for (const event of ALL_EVENT_TYPES) out[event] = await this.combosFor(event);
return out;
}
@@ -68,16 +68,16 @@ export class NotificationPreferencesService {
* into this CSV, and the admin toggle rebuilds it from the three built-in booleans, which
* would silently drop any that were).
*/
getActiveChannels(): NotifChannel[] {
async getActiveChannels(): Promise<NotifChannel[]> {
const raw = this.getAppSetting('notification_channels') || this.getAppSetting('notification_channel') || 'none';
if (raw === 'none') return [];
const builtins = new Set(listChannels().filter(c => c.source === 'builtin').map(c => c.id));
const builtins = new Set((await listChannels()).filter(c => c.source === 'builtin').map(c => c.id));
return raw.split(',').map(c => c.trim()).filter(c => builtins.has(c));
}
/** Is this channel switched on? Plugin channels are on by virtue of being live. */
isChannelActive(channel: ExternalChannel): boolean {
return channel.source === 'plugin' || this.getActiveChannels().includes(channel.id);
async isChannelActive(channel: ExternalChannel): Promise<boolean> {
return channel.source === 'plugin' || (await this.getActiveChannels()).includes(channel.id);
}
// ── Per-user preference checks ─────────────────────────────────────────────
@@ -112,7 +112,7 @@ export class NotificationPreferencesService {
* scope='user' — a column per channel the admin turned on in `notification_channels`.
* scope='admin' — a column per channel that has admin-global credentials.
*/
private describeChannels(userId: number, scope: 'user' | 'admin'): ChannelDescriptor[] {
private async describeChannels(userId: number, scope: 'user' | 'admin'): Promise<ChannelDescriptor[]> {
const out: ChannelDescriptor[] = [this.inAppDescriptor()];
if (scope === 'admin') {
@@ -122,7 +122,7 @@ export class NotificationPreferencesService {
const hasAdminWebhook = !!this.getAppSetting('admin_webhook_url');
const hasAdminNtfy = !!this.getAppSetting('admin_ntfy_topic');
const adminActive: Record<string, boolean> = { email: hasSmtp, webhook: hasAdminWebhook, ntfy: hasAdminNtfy };
for (const channel of listChannels()) {
for (const channel of await listChannels()) {
// Plugin channels are user-scoped only — they never carry admin-global events.
if (channel.source !== 'builtin') continue;
const active = adminActive[channel.id] ?? false;
@@ -138,7 +138,7 @@ export class NotificationPreferencesService {
return out;
}
for (const channel of listChannels()) {
for (const channel of await listChannels()) {
out.push({
id: channel.id,
source: channel.source,
@@ -147,8 +147,8 @@ export class NotificationPreferencesService {
settingsPath: channel.settingsPath,
// A live plugin channel is always a column. `configured` tells the user whether
// they still need to enter credentials — it does not hide the channel from them.
active: this.isChannelActive(channel),
configured: channel.isConfiguredFor(userId),
active: await this.isChannelActive(channel),
configured: await channel.isConfiguredFor(userId),
});
}
return out;
@@ -159,7 +159,7 @@ export class NotificationPreferencesService {
* scope='user' — excludes admin-scoped events (for user settings page)
* scope='admin' — returns only admin-scoped events (for admin notifications tab)
*/
getPreferencesMatrix(userId: number, userRole: string, scope: 'user' | 'admin' = 'user'): PreferencesMatrix {
async getPreferencesMatrix(userId: number, userRole: string, scope: 'user' | 'admin' = 'user'): Promise<PreferencesMatrix> {
const rows = this.db.all<{ event_type: string; channel: string; enabled: number }>(
'SELECT event_type, channel, enabled FROM notification_channel_preferences WHERE user_id = ?', userId,
);
@@ -171,7 +171,7 @@ export class NotificationPreferencesService {
stored[row.event_type]![row.channel] = row.enabled === 1;
}
const implemented_combos = this.allCombos();
const implemented_combos = await this.allCombos();
// Build the full matrix with defaults (true when no row exists)
const preferences: Partial<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>> = {};
@@ -195,7 +195,7 @@ export class NotificationPreferencesService {
return {
preferences,
channels: this.describeChannels(userId, scope),
channels: await this.describeChannels(userId, scope),
event_types,
implemented_combos,
...(scope === 'user' && { defaults: { ntfyServer: this.getAppSetting('admin_ntfy_server') || null } }),
@@ -304,7 +304,7 @@ export class NotificationPreferencesService {
return this.mailer.isSmtpConfigured();
}
isWebhookConfigured(): boolean {
return this.getActiveChannels().includes('webhook');
async isWebhookConfigured(): Promise<boolean> {
return (await this.getActiveChannels()).includes('webhook');
}
}
@@ -264,13 +264,13 @@ export interface NotificationPayload {
* - everything else: the admin enabled the channel, the user didn't opt out of this
* event on it, and the user has credentials for it.
*/
function shouldSendToUser(
async function shouldSendToUser(
channel: ExternalChannel,
event: NotifEventType,
recipientId: number,
activeChannels: string[],
prefs: NotificationPreferencesService,
): boolean {
): Promise<boolean> {
if (!channel.supportsEvent(event)) return false;
if (channel.isInstanceConfigured && !channel.isInstanceConfigured()) return false;
@@ -285,7 +285,7 @@ function shouldSendToUser(
if (!prefs.isEnabledForEvent(recipientId, event, channel.id)) return false;
}
return channel.isConfiguredFor(recipientId);
return await channel.isConfiguredFor(recipientId);
}
/**
@@ -324,16 +324,16 @@ export class NotificationsService {
registerBuiltinChannels({ mailer, webhook, ntfy });
}
getPreferences(userId: number, role: string): PreferencesMatrix {
return this.prefs.getPreferencesMatrix(userId, role, 'user');
async getPreferences(userId: number, role: string): Promise<PreferencesMatrix> {
return await this.prefs.getPreferencesMatrix(userId, role, 'user');
}
/** Send a test notification over any registered channel (built-in or plugin). */
async testChannel(userId: number, channelId: string): Promise<ChannelTestResult> {
const channel = getChannel(channelId);
const channel = await getChannel(channelId);
if (!channel) return { success: false, error: 'Unknown channel' };
if (!channel.test) return { success: false, error: 'This channel does not support test sends' };
if (!channel.isConfiguredFor(userId)) return { success: false, error: 'Channel is not configured for this user' };
if (!(await channel.isConfiguredFor(userId))) return { success: false, error: 'Channel is not configured for this user' };
try {
return await channel.test(userId);
} catch (e) {
@@ -702,8 +702,8 @@ export class NotificationsService {
}
}
const config = configEntry ?? FALLBACK_EVENT_CONFIG;
const activeChannels = this.prefs.getActiveChannels();
const channels = listChannels();
const activeChannels = await this.prefs.getActiveChannels();
const channels = await listChannels();
const appUrl = getAppUrl();
// Build navigate target (used by email/webhook CTA and in-app navigate)
@@ -783,7 +783,12 @@ export class NotificationsService {
// One loop over the registry. The message is rendered once per recipient, in
// their language, and handed to every channel that wants it — so a plugin
// channel never touches i18n.
const deliverable = channels.filter(ch => shouldSendToUser(ch, event, recipientId, activeChannels, this.prefs));
// R1.4: `filter` cannot await, and the credential check behind shouldSendToUser
// is now async (a plugin channel reads the recipient's stored settings).
const deliverable: ExternalChannel[] = [];
for (const ch of channels) {
if (await shouldSendToUser(ch, event, recipientId, activeChannels, this.prefs)) deliverable.push(ch);
}
if (deliverable.length > 0) {
const lang = this.mailer.getUserLanguage(recipientId);
const { title, body } = getEventText(lang, event, params);
+2 -2
View File
@@ -92,10 +92,10 @@ export class PackingRpc {
}
@PluginMethod('packing.listBags', { permission: 'db:write:packing' })
listBags(params: Record<string, unknown>, ctx: PluginRpcContext): unknown[] {
async listBags(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown[]> {
// Note the permission: the envelope really does gate this READ on the write
// grant. The decorator makes the oddity visible instead of burying it.
return this.guards.tripRead(params, ctx, () => this.packing.listBags(String(num(params.tripId, 'tripId'))) as unknown[]);
return await this.guards.tripRead(params, ctx, () => this.packing.listBags(String(num(params.tripId, 'tripId'))) as unknown[]);
}
@PluginMethod('packing.createBag', { permission: 'db:write:packing' })
@@ -62,7 +62,9 @@ export class PluginMcpToolsService implements OnApplicationBootstrap, OnModuleDe
// needs PluginHooks, and PluginHooks injects PluginRuntimeService: owning it
// there would be a cycle. Cleared on destroy, or a torn-down buildApp() leaves
// a live source closing over a dead runtime and bleeds into the next suite.
onApplicationBootstrap(): void {
// `async` with nothing to await: the call-graph gate keys on the `async` modifier
// and this frame reaches the DB through the source it installs.
async onApplicationBootstrap(): Promise<void> {
setPluginMcpToolSource((ctx) => this.mcpTools(ctx));
}
@@ -71,11 +73,11 @@ export class PluginMcpToolsService implements OnApplicationBootstrap, OnModuleDe
}
/**
* Every plugin tool this session may see. Synchronous, and never throws:
* nest-mcp contains a throwing source, but a per-plugin failure here should
* cost that plugin's tools and nothing else.
* Every plugin tool this session may see. Never throws: nest-mcp contains a
* throwing source, but a per-plugin failure here should cost that plugin's
* tools and nothing else.
*/
mcpTools(_ctx: McpContext): McpDynamicTool[] {
async mcpTools(_ctx: McpContext): Promise<McpDynamicTool[]> {
if (!pluginsEnabled()) return [];
const out: McpDynamicTool[] = [];
let dropped = 0;
@@ -83,7 +85,7 @@ export class PluginMcpToolsService implements OnApplicationBootstrap, OnModuleDe
for (const id of this.hooks.providersOf(HOOK)) {
let tools: McpDynamicTool[];
try {
tools = this.toolsOf(id);
tools = await this.toolsOf(id);
} catch {
// One plugin's bad row contributes nothing; the others still advertise.
continue;
@@ -114,8 +116,8 @@ export class PluginMcpToolsService implements OnApplicationBootstrap, OnModuleDe
* on every restart with no version bump. Same two-sided shape as callPlugin's
* exports check.
*/
private toolsOf(pluginId: string): McpDynamicTool[] {
const declared = this.runtime.mcpToolCapabilities(pluginId);
private async toolsOf(pluginId: string): Promise<McpDynamicTool[]> {
const declared = await this.runtime.mcpToolCapabilities(pluginId);
if (!declared.length) return [];
const implemented = new Set(this.runtime.mcpToolsOf(pluginId));
const grants = this.runtime.grantsOf(pluginId);
@@ -120,7 +120,7 @@ export function normalize(
return { pluginId, profile, coordinates, distance, duration, legs, viaPoints };
}
export function declaredProfiles(conn: Database.Database, pluginId: string): string[] {
export async function declaredProfiles(conn: Database.Database, pluginId: string): Promise<string[]> {
try {
const row = conn.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(pluginId) as
| { capabilities?: string }
@@ -56,7 +56,7 @@ export class PluginRoutesController {
// one the manifest declared — re-validated from the DB row like the plugins feed,
// so a hand-edited capabilities blob can't invent profiles.
if (!this.hooks.providersOf('routeProvider').includes(pluginId)) return { route: null };
if (!declaredProfiles(this.dbs.connection, pluginId).includes(profileId)) return { route: null };
if (!(await declaredProfiles(this.dbs.connection, pluginId)).includes(profileId)) return { route: null };
try {
const raw = await this.hooks.route(pluginId, { tripId, dayId, profile: profileId, waypoints }, userId);
+7 -6
View File
@@ -98,7 +98,7 @@ function envInt(name: string, def: number): number {
/** Keep only the newest `MAX_AUDIT_ROWS` rows for a plugin. Called amortised from
* appendAudit; exported for tests. No-op when disabled or under the cap. */
export function pruneAudit(db: AuditDb, pluginId: string, keep = MAX_AUDIT_ROWS): void {
export async function pruneAudit(db: AuditDb, pluginId: string, keep = MAX_AUDIT_ROWS): Promise<void> {
if (keep <= 0) return;
db.prepare(
`DELETE FROM plugin_capability_audit WHERE plugin_id = ? AND id NOT IN
@@ -106,8 +106,9 @@ export function pruneAudit(db: AuditDb, pluginId: string, keep = MAX_AUDIT_ROWS)
).run(pluginId, pluginId, keep);
}
/** Append one entry to the per-plugin hash chain. Synchronous (better-sqlite3). */
export function appendAudit(db: AuditDb, e: AuditEntry): void {
/** Append one entry to the per-plugin hash chain. The statements themselves are
* synchronous (better-sqlite3); the method is async for the ORM seam. */
export async function appendAudit(db: AuditDb, e: AuditEntry): Promise<void> {
const prev =
(db.prepare('SELECT hash FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id DESC LIMIT 1').get(e.pluginId) as
| { hash: string }
@@ -120,7 +121,7 @@ export function appendAudit(db: AuditDb, e: AuditEntry): void {
).run(e.pluginId, e.actingUserId ?? null, e.method, e.resource ?? null, e.code, ts, prev || null, hash);
// Amortised retention: prune roughly every PRUNE_EVERY appends per plugin.
const n = (appendsSincePrune.get(e.pluginId) ?? 0) + 1;
if (n >= PRUNE_EVERY) { appendsSincePrune.set(e.pluginId, 0); pruneAudit(db, e.pluginId); }
if (n >= PRUNE_EVERY) { appendsSincePrune.set(e.pluginId, 0); await pruneAudit(db, e.pluginId); }
else appendsSincePrune.set(e.pluginId, n);
}
@@ -128,7 +129,7 @@ export function appendAudit(db: AuditDb, e: AuditEntry): void {
* "what have plugins done in my name?" view. This is what legitimizes the broad
* read grants: the user, not just the admin, can see every plugin action bound to
* them. Joined with the plugin name for display; capped. */
export function readAuditForUser(db: AuditDb, userId: number, limit = 200): unknown[] {
export async function readAuditForUser(db: AuditDb, userId: number, limit = 200): Promise<unknown[]> {
return db
.prepare(
`SELECT a.ts, a.plugin_id, p.name AS plugin_name, a.method, a.resource, a.code
@@ -139,7 +140,7 @@ export function readAuditForUser(db: AuditDb, userId: number, limit = 200): unkn
}
/** Read the most recent audit rows for a plugin (admin view). */
export function readAudit(db: AuditDb, pluginId: string, limit = 200): unknown[] {
export async function readAudit(db: AuditDb, pluginId: string, limit = 200): Promise<unknown[]> {
return db
.prepare('SELECT ts, acting_user_id, method, resource, code FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id DESC LIMIT ?')
.all(pluginId, limit);
@@ -31,7 +31,7 @@ export class PluginGuards {
* so a plugin cannot read another user's trips by naming their id. A userless
* context (a job, or onLoad) has no acting user and is refused.
*/
tripRead<T>(params: Record<string, unknown>, ctx: PluginRpcContext, read: (userId: number) => T): T {
async tripRead<T>(params: Record<string, unknown>, ctx: PluginRpcContext, read: (userId: number) => T): Promise<T> {
const tripId = num(params.tripId, 'tripId');
if (ctx.actingUserId === undefined) {
throw new ForbiddenResource('trip reads require an authenticated user context');
@@ -44,7 +44,7 @@ export function closePluginDataDb(id: string): void {
// nothing persisted or phoned home.
const budgets = new Map<string, DailyBudget>();
export function budgetFor(id: string, conn: Database.Database): DailyBudget {
export async function budgetFor(id: string, conn: Database.Database): Promise<DailyBudget> {
let b = budgets.get(id);
if (!b) {
const now = Date.now();
@@ -64,6 +64,6 @@ export function budgetFor(id: string, conn: Database.Database): DailyBudget {
}
/** Today's broker usage for one plugin (admin view). Seeds the counter if unseen. */
export function pluginBudgetUsage(id: string, conn: Database.Database): ReturnType<DailyBudget['used']> {
return budgetFor(id, conn).used(Date.now());
export async function pluginBudgetUsage(id: string, conn: Database.Database): Promise<ReturnType<DailyBudget['used']>> {
return (await budgetFor(id, conn)).used(Date.now());
}
@@ -9,7 +9,7 @@ import { getPluginDataDb } from './plugin-host-state';
/** Routes inter-plugin calls/events; supplied by PluginRuntimeService (owns the supervisor). */
export interface PluginCallRouter {
callPlugin(callerId: string, targetId: string, fn: string, args: unknown, actingUserId: number | undefined): Promise<unknown>;
emitPluginEvent(sourceId: string, event: string, payload: unknown): void;
emitPluginEvent(sourceId: string, event: string, payload: unknown): Promise<void>;
}
/**
+3 -3
View File
@@ -46,9 +46,9 @@ export interface HostDeps {
* the dependency edge + the target's `provides` allowlist, forwards the acting user. */
callPlugin(targetId: string, fn: string, args: unknown, actingUserId: number | undefined): Promise<unknown>;
/** Publish an event from this host's plugin to its subscribed dependents. */
emitPluginEvent(event: string, payload: unknown): void;
emitPluginEvent(event: string, payload: unknown): Promise<void>;
/** Optional sink for the capability audit log (host-side, hash-chained). */
audit?(entry: { pluginId: string; actingUserId?: number; method: string; resource: string | null; code: string }): void;
audit?(entry: { pluginId: string; actingUserId?: number; method: string; resource: string | null; code: string }): Promise<void>;
}
type Handler = (params: Record<string, unknown>, actingUserId: number | undefined) => unknown;
@@ -98,7 +98,7 @@ export class PluginRpcHost {
// Audit the core-data / broadcast surface (incl. denials) at the boundary.
if (this.deps.audit && isAuditable(req.method)) {
try {
this.deps.audit({
await this.deps.audit({
pluginId: this.pluginId,
actingUserId,
method: req.method,
@@ -34,7 +34,7 @@ export interface PluginRpcContext {
/** What a plugin may do to its declared dependencies. */
export interface PluginRpcPeers {
call(targetId: string, fn: string, args: unknown, actingUserId: number | undefined): Promise<unknown>;
emit(event: string, payload: unknown): void;
emit(event: string, payload: unknown): Promise<void>;
}
export type PluginRpcHandler = (params: Record<string, unknown>, ctx: PluginRpcContext) => unknown;
+4 -4
View File
@@ -48,8 +48,8 @@ export class DbRpc {
/** Same deal: the router checks the emitter's `emits` allowlist. */
@PluginOpenMethod('events.emit')
emitEvent(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
ctx.plugins.emit(str(params.event, 'event'), params.payload);
async emitEvent(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
await ctx.plugins.emit(str(params.event, 'event'), params.payload);
return { ok: true };
}
@@ -60,8 +60,8 @@ export class DbRpc {
* plugin falls back to ctx.config.
*/
@PluginOpenMethod('settings.get')
getSetting(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async getSetting(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
if (ctx.actingUserId === undefined) return { value: undefined };
return { value: this.settings.readOne(ctx.pluginId, ctx.actingUserId, str(params.key, 'key')) };
return { value: await this.settings.readOne(ctx.pluginId, ctx.actingUserId, str(params.key, 'key')) };
}
}
@@ -45,19 +45,19 @@ export class HostSurfaceRpc {
) {}
@PluginMethod('users.getById', { permission: 'db:read:users' })
getUser(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async getUser(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
// Scoped to people the acting user can actually see (themselves, or someone they
// share a trip with), so a plugin cannot enumerate every account by looping ids.
const id = num(params.id, 'id');
if (ctx.actingUserId === undefined) throw new ForbiddenResource('user reads require an authenticated user context');
if (id !== ctx.actingUserId && !this.sharesATrip(ctx.actingUserId, id)) {
if (id !== ctx.actingUserId && !(await this.sharesATrip(ctx.actingUserId, id))) {
throw new ForbiddenResource(`no access to user ${id}`);
}
return this.db.prepare('SELECT id, username, display_name, avatar FROM users WHERE id = ?').get(id);
}
@PluginMethod('ws.broadcastToTrip', { permission: 'ws:broadcast:trip' })
broadcastToTrip(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async broadcastToTrip(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
// The TARGET is gated like a read: only a trip room the acting user belongs to.
// Namespacing the event type alone does not cross the membership boundary.
const tripId = num(params.tripId, 'tripId');
@@ -105,7 +105,7 @@ export class HostSurfaceRpc {
throw new ForbiddenResource('the acting user is not a member of that trip');
}
const link = this.safeLink(input.link);
if (!budgetFor(ctx.pluginId, this.db.connection).take('notify', Date.now())) {
if (!(await budgetFor(ctx.pluginId, this.db.connection)).take('notify', Date.now())) {
throw new BadParams('daily notification budget exhausted (resets at UTC midnight)');
}
await this.notifications.send({
@@ -126,7 +126,7 @@ export class HostSurfaceRpc {
const prompt = typeof params.prompt === 'string' ? params.prompt : '';
if (prompt.trim() === '') throw new BadParams('prompt is required');
if (prompt.length > AI_TEXT_MAX) throw new BadParams(`prompt exceeds the ${AI_TEXT_MAX}-char cap`);
this.takeAiBudget(ctx);
await this.takeAiBudget(ctx);
const system = typeof params.system === 'string' ? params.system.slice(0, 4000) : undefined;
const results = await this.runModel(config, {
prompt: system || 'You are a helpful assistant. Reply with a JSON object of the form {"text": "..."} whose "text" field holds your answer.',
@@ -150,7 +150,7 @@ export class HostSurfaceRpc {
if (typeof params.jsonSchema !== 'object' || params.jsonSchema === null) {
throw new BadParams('jsonSchema (an object) is required');
}
this.takeAiBudget(ctx);
await this.takeAiBudget(ctx);
const hint = typeof params.prompt === 'string' ? params.prompt.slice(0, 4000) : '';
const results = await this.runModel(config, {
prompt: hint || 'Extract structured data from the text into the given JSON schema.',
@@ -173,7 +173,7 @@ export class HostSurfaceRpc {
}
@PluginMethod('scheduler.set', { permission: 'jobs:run' })
schedulerSet(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async schedulerSet(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const name = str(params.name, 'name');
const dueAt = num(params.dueAt, 'dueAt');
const everyMs = params.everyMs != null ? num(params.everyMs, 'everyMs') : undefined;
@@ -200,7 +200,7 @@ export class HostSurfaceRpc {
}
@PluginMethod('scheduler.cancel', { permission: 'jobs:run' })
schedulerCancel(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
async schedulerCancel(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const r = this.db
.prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?')
.run(ctx.pluginId, str(params.name, 'name'));
@@ -208,7 +208,7 @@ export class HostSurfaceRpc {
}
/** Two users share a trip when both are owner-or-member of the same one. */
private sharesATrip(actingUserId: number, targetUserId: number): boolean {
private async sharesATrip(actingUserId: number, targetUserId: number): Promise<boolean> {
return !!this.db
.prepare(
`SELECT 1 FROM trips t
@@ -245,8 +245,8 @@ export class HostSurfaceRpc {
}
}
private takeAiBudget(ctx: PluginRpcContext): void {
if (!budgetFor(ctx.pluginId, this.db.connection).take('ai', Date.now())) {
private async takeAiBudget(ctx: PluginRpcContext): Promise<void> {
if (!(await budgetFor(ctx.pluginId, this.db.connection)).take('ai', Date.now())) {
throw new BadParams('daily AI budget exhausted (resets at UTC midnight)');
}
}
+2 -2
View File
@@ -129,7 +129,7 @@ export class MetaRpc {
}
const entityId = num(params.entityId, 'entityId');
if (ctx.actingUserId === undefined) throw new ForbiddenResource('metadata requires an authenticated user context');
const tripId = this.entityTrip(entityType, entityId);
const tripId = await this.entityTrip(entityType, entityId);
if (tripId === undefined || !this.db.canAccessTrip(tripId, ctx.actingUserId)) {
throw new ForbiddenResource(`no access to ${entityType} ${entityId}`);
}
@@ -139,7 +139,7 @@ export class MetaRpc {
return { entityType, entityId };
}
private entityTrip(entityType: string, entityId: number): number | undefined {
private async entityTrip(entityType: string, entityId: number): Promise<number | undefined> {
if (entityType === 'trip') {
return (this.db.prepare('SELECT id FROM trips WHERE id = ?').get(entityId) as { id: number } | undefined)?.id;
}
+3 -3
View File
@@ -14,7 +14,7 @@ import { devLinkEnabled } from '../dev-link';
* manifest is invalid or that ships native binaries is skipped (recorded to its
* error log if it already existed).
*/
export function discoverPlugins(db: BetterSqlite3.Database): { discovered: string[]; skipped: string[] } {
export async function discoverPlugins(db: BetterSqlite3.Database): Promise<{ discovered: string[]; skipped: string[] }> {
const root = pluginsCodeRoot();
const discovered: string[] = [];
const skipped: string[] = [];
@@ -50,7 +50,7 @@ export function discoverPlugins(db: BetterSqlite3.Database): { discovered: strin
const manifest = parseManifest(parseJsonText(fs.readFileSync(manifestPath, 'utf8')));
if (manifest.id !== entry.name) throw new Error(`manifest id "${manifest.id}" != directory "${entry.name}"`);
if (scanForNativeBinaries(dir).length) throw new Error('directory contains native binaries');
upsert(db, manifest);
await upsert(db, manifest);
discovered.push(manifest.id);
} catch (e) {
skipped.push(entry.name);
@@ -61,7 +61,7 @@ export function discoverPlugins(db: BetterSqlite3.Database): { discovered: strin
return { discovered, skipped };
}
function upsert(db: BetterSqlite3.Database, m: PluginManifest): void {
async function upsert(db: BetterSqlite3.Database, m: PluginManifest): Promise<void> {
const dependencies = JSON.stringify({ requiredAddons: m.requiredAddons, pluginDependencies: m.pluginDependencies });
const existing = db.prepare('SELECT id FROM plugins WHERE id = ?').get(m.id) as { id: string } | undefined;
if (existing) {
@@ -20,22 +20,22 @@ export class PluginOAuthController {
private readonly dbs: DatabaseService,
) {}
private isActive(id: string): boolean {
private async isActive(id: string): Promise<boolean> {
return !!this.dbs.connection.prepare("SELECT 1 FROM plugins WHERE id = ? AND status = 'active'").get(id);
}
@Get(':id/status')
status(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): { configured: boolean; connected: boolean } {
async status(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): Promise<{ configured: boolean; connected: boolean }> {
const userId = req.user?.id;
if (!pluginsEnabled() || userId == null || !this.isActive(id)) return { configured: false, connected: false };
return this.oauth.status(id, userId);
if (!pluginsEnabled() || userId == null || !(await this.isActive(id))) return { configured: false, connected: false };
return await this.oauth.status(id, userId);
}
@Post(':id/connect')
connect(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): { authorizeUrl: string } {
async connect(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): Promise<{ authorizeUrl: string }> {
const userId = req.user?.id;
if (!pluginsEnabled() || userId == null || !this.isActive(id)) throw new Error('plugin not available');
return { authorizeUrl: this.oauth.startConnect(id, userId, Date.now()) };
if (!pluginsEnabled() || userId == null || !(await this.isActive(id))) throw new Error('plugin not available');
return { authorizeUrl: await this.oauth.startConnect(id, userId, Date.now()) };
}
@Get(':id/callback')
@@ -49,7 +49,7 @@ export class PluginOAuthController {
): Promise<void> {
const userId = req.user?.id;
const back = (status: string) => res.redirect(`/settings?oauth=${encodeURIComponent(id)}:${status}`);
if (!pluginsEnabled() || userId == null || !this.isActive(id)) return back('unavailable');
if (!pluginsEnabled() || userId == null || !(await this.isActive(id))) return back('unavailable');
if (error || !code || !state) return back('denied');
try {
await this.oauth.completeCallback(id, userId, code, state, Date.now());
@@ -60,9 +60,9 @@ export class PluginOAuthController {
}
@Post(':id/disconnect')
disconnect(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): { connected: false } {
async disconnect(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): Promise<{ connected: false }> {
const userId = req.user?.id;
if (userId != null) this.oauth.disconnect(id, userId);
if (userId != null) await this.oauth.disconnect(id, userId);
return { connected: false };
}
}
@@ -74,7 +74,7 @@ export class PluginOAuthService {
/** The plugin's decrypted OAuth provider config from its INSTANCE settings, or null
* when any required piece is missing/blank. */
providerConfig(pluginId: string): OAuthProviderConfig | null {
async providerConfig(pluginId: string): Promise<OAuthProviderConfig | null> {
const row = this.db.prepare('SELECT config FROM plugins WHERE id = ?').get(pluginId) as { config: string } | undefined;
if (!row) return null;
let cfg: Record<string, unknown>;
@@ -85,7 +85,7 @@ export class PluginOAuthService {
}
// A provider plugin ships its endpoints/scopes as manifest defaults; the admin types
// only the client id/secret (secrets — never defaulted).
cfg = applySettingDefaults(cfg, settingDefaults(this.db, pluginId, 'instance'));
cfg = applySettingDefaults(cfg, await settingDefaults(this.db, pluginId, 'instance'));
const authorizeUrl = String(cfg.oauth_authorize_url ?? '').trim();
const tokenUrl = String(cfg.oauth_token_url ?? '').trim();
const clientId = cfg.oauth_client_id ? String(decrypt_api_key(cfg.oauth_client_id)) : '';
@@ -100,15 +100,15 @@ export class PluginOAuthService {
}
/** Whether the acting user has a stored token for this plugin. */
status(pluginId: string, userId: number): { configured: boolean; connected: boolean } {
const configured = this.providerConfig(pluginId) !== null;
async status(pluginId: string, userId: number): Promise<{ configured: boolean; connected: boolean }> {
const configured = (await this.providerConfig(pluginId)) !== null;
const tok = this.db.prepare('SELECT 1 FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ? AND access_token IS NOT NULL').get(pluginId, userId);
return { configured, connected: !!tok };
}
/** Begin the authorize flow: mint PKCE + state, persist them, return the provider URL. */
startConnect(pluginId: string, userId: number, nowMs: number): string {
const cfg = this.providerConfig(pluginId);
async startConnect(pluginId: string, userId: number, nowMs: number): Promise<string> {
const cfg = await this.providerConfig(pluginId);
if (!cfg) throw new Error('OAuth is not configured for this plugin');
const authorize = assertSafeHttps(cfg.authorizeUrl, 'authorize_url');
assertSafeHttps(cfg.tokenUrl, 'token_url'); // fail fast if the token endpoint is unsafe too
@@ -150,7 +150,7 @@ export class PluginOAuthService {
}
this.db.prepare('DELETE FROM plugin_oauth_state WHERE state = ?').run(state); // single-use
const cfg = this.providerConfig(pluginId);
const cfg = await this.providerConfig(pluginId);
if (!cfg) throw new Error('OAuth is not configured for this plugin');
const token = await this.tokenRequest(cfg, {
@@ -159,7 +159,7 @@ export class PluginOAuthService {
redirect_uri: this.redirectUri(pluginId),
code_verifier: row.verifier,
});
this.storeToken(pluginId, userId, token, nowMs);
await this.storeToken(pluginId, userId, token, nowMs);
}
/** A valid access token for the acting user, refreshing it if it is expiring. Null when
@@ -174,7 +174,7 @@ export class PluginOAuthService {
if (notExpiring) return decrypt_api_key(row.access_token) as string;
if (!row.refresh_token) return decrypt_api_key(row.access_token) as string; // no refresh token — hand back what we have
const cfg = this.providerConfig(pluginId);
const cfg = await this.providerConfig(pluginId);
if (!cfg) return null;
const token = await this.tokenRequest(cfg, {
grant_type: 'refresh_token',
@@ -182,11 +182,11 @@ export class PluginOAuthService {
});
// Some providers omit a new refresh_token on refresh — keep the existing one.
if (!token.refresh_token) token.refresh_token = decrypt_api_key(row.refresh_token) as string;
this.storeToken(pluginId, userId, token, nowMs);
await this.storeToken(pluginId, userId, token, nowMs);
return token.access_token ?? null;
}
disconnect(pluginId: string, userId: number): void {
async disconnect(pluginId: string, userId: number): Promise<void> {
this.db.prepare('DELETE FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId);
this.db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId);
}
@@ -221,7 +221,7 @@ export class PluginOAuthService {
};
}
private storeToken(pluginId: string, userId: number, token: { access_token?: string; refresh_token?: string; expires_in?: number; scope?: string }, nowMs: number): void {
private async storeToken(pluginId: string, userId: number, token: { access_token?: string; refresh_token?: string; expires_in?: number; scope?: string }, nowMs: number): Promise<void> {
if (!token.access_token) throw new Error('token endpoint returned no access_token');
const expiresAt = token.expires_in ? nowMs + token.expires_in * 1000 : null;
this.db.prepare(
@@ -21,13 +21,13 @@ export class PluginActivityController {
constructor(private readonly dbs: DatabaseService) {}
@Get()
mine(@Req() req: Request & { user?: { id: number } }, @Query('limit') limitRaw?: string): { activity: unknown[] } {
async mine(@Req() req: Request & { user?: { id: number } }, @Query('limit') limitRaw?: string): Promise<{ activity: unknown[] }> {
if (!pluginsEnabled()) return { activity: [] };
const userId = req.user?.id;
if (userId == null) return { activity: [] };
// Math.floor so a non-integer (e.g. ?limit=2.5) can't reach SQLite's LIMIT and 500;
// Math.floor(NaN) stays NaN so the `|| 200` fallback still applies.
const limit = Math.min(Math.max(Math.floor(Number(limitRaw)) || 200, 1), 500);
return { activity: readAuditForUser(this.dbs.connection, userId, limit) };
return { activity: await readAuditForUser(this.dbs.connection, userId, limit) };
}
}
@@ -39,7 +39,7 @@ export class PluginFrameController {
constructor(private readonly runtime: PluginRuntimeService) {}
@Get('*path')
serve(@Param('pluginId') pluginId: string, @Req() req: Request, @Res() res: Response): void {
async serve(@Param('pluginId') pluginId: string, @Req() req: Request, @Res() res: Response): Promise<void> {
if (!pluginsEnabled() || !this.runtime.isActive(pluginId)) {
res.status(404).send('Plugin not available');
return;
@@ -87,7 +87,7 @@ export class PluginFrameController {
// The sandbox + per-plugin CSP are the isolation boundary here, not CORP:
// these files are the plugin's own public client code.
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Content-Security-Policy', this.frameCsp(pluginId, req.get('host')));
res.setHeader('Content-Security-Policy', await this.frameCsp(pluginId, req.get('host')));
// Explicit { root } + basename, not the absolute path: under the Nest
// ExpressAdapter, res.sendFile(absolutePath) resolves against the rewritten
// req.url and 404s spuriously (same trap as files-download.controller.ts).
@@ -95,7 +95,7 @@ export class PluginFrameController {
}
/** Per-plugin, locked-down CSP for the sandboxed frame document. */
private frameCsp(pluginId: string, host: string | undefined): string {
private async frameCsp(pluginId: string, host: string | undefined): Promise<string> {
// The frame must be able to reach exactly what the CHILD may reach. The child's egress
// guard is the union of the manifest's http:outbound:<host> grants AND the hosts an admin
// added post-install for an operatorEgress plugin (plugin-runtime.service: activate()).
@@ -108,7 +108,7 @@ export class PluginFrameController {
// admin writer's EGRESS_HOST_RE), but never interpolate anything that isn't a clean
// host/wildcard into connect-src — a stray space or `*` would inject an extra CSP source.
const outbound = [
...new Set([...this.runtime.outboundHostsOf(pluginId), ...this.runtime.operatorEgressHosts(pluginId)]),
...new Set([...(await this.runtime.outboundHostsOf(pluginId)), ...(await this.runtime.operatorEgressHosts(pluginId))]),
].filter((h) => /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i.test(h));
// The frame runs at an OPAQUE origin (sandbox without allow-same-origin), so
// 'self' matches nothing and the plugin's own <script src>/<link> files would
@@ -1,6 +1,7 @@
import { Injectable, type OnApplicationBootstrap, type OnModuleDestroy } from '@nestjs/common';
import semver from 'semver';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { pluginsEnabled } from './kill-switch';
import { setPluginEventSink } from '../../plugin-event-sink';
import { setUserDeletedSink } from '../../plugin-user-lifecycle';
@@ -151,7 +152,10 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// Retention: a crash-looping plugin emits a stderr line per restart, so an
// uncapped table grows without bound in the shared trek.db. Keep only the
// most recent LOG_RETENTION rows per plugin (the admin view shows 200).
this.pruneErrorLog(id);
// R1.5: this hook is a child-lifecycle EventEmitter callback the supervisor
// cannot await, so the now-async prune is fired off with its own handler —
// the same "a log line must never crash the host" rule as the catch below.
void this.pruneErrorLog(id).catch(() => { /* retention is best-effort */ });
} catch { /* DB unavailable — a log line must never crash the host */ }
},
});
@@ -179,6 +183,11 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
private readonly userSettings: PluginUserSettingsService,
private readonly registry?: PluginRegistryService,
private readonly hostFactory?: PluginRpcHostFactory,
// LAST, and optional only because TypeScript forbids a required parameter after
// an optional one — Nest always injects it (OrmModule is global). A hand-built
// test instance that exercises a transaction must pass one; setOperatorEgressHosts
// refuses rather than writing outside a transaction (same shape as hostFactory).
private readonly uow?: UnitOfWork,
) {}
private get db() {
@@ -193,7 +202,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// plugin's first RPC after a restart came back PERMISSION_DENIED (pinned by
// tests/integration/plugins/boot-registry-order.test.ts). onApplicationBootstrap
// is guaranteed to run after EVERY module's onModuleInit, registry scan included.
onApplicationBootstrap(): void {
async onApplicationBootstrap(): Promise<void> {
if (!pluginsEnabled()) return;
// If a restore staged plugin trees, swap them into place NOW — before we open any
// plugin DB below. This is where a restored backup's plugin data/code actually
@@ -226,8 +235,8 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// boot must NEVER block app init, even in a context without plugin tables
// (e.g. a slimmed-down test app that only imports AdminModule).
try {
discoverPlugins(this.db);
const installed = this.installedDepRows();
await discoverPlugins(this.db);
const installed = await this.installedDepRows();
const enabledIds = [...installed.values()].filter((r) => r.enabled).map((r) => r.id);
let order: string[];
try {
@@ -254,7 +263,9 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// scheduler is minute-granularity by contract, so 30s precision is plenty and
// cheap. Unref'd so it never holds the process open.
this.schedulerSweep = setInterval(() => {
this.fireDueScheduled();
// R1.5: a timer callback cannot await. Both helpers swallow their own errors
// (each body is wrapped in try/catch), so there is nothing to propagate.
void this.fireDueScheduled();
void this.drainUserErasures();
}, 30_000);
this.schedulerSweep.unref?.();
@@ -264,7 +275,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* ones, delete one-shots. The row is re-armed/deleted BEFORE the fire so a crash
* mid-callback can't double-fire; an inactive plugin's tasks are left untouched so
* they run on the next sweep after it reactivates. Never throws. */
private fireDueScheduled(): void {
private async fireDueScheduled(): Promise<void> {
if (!pluginsEnabled()) return;
try {
// Scope the window to ACTIVE plugins so a backlog of past-due rows belonging to
@@ -293,7 +304,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* try to deliver immediately. Persisted first (INSERT OR IGNORE, idempotent) so the
* erasure survives a restart and reaches a plugin that is offline right now. Never
* throws — a bookkeeping error must not fail the account deletion that triggered it. */
private enqueueUserErasure(userId: number): void {
private async enqueueUserErasure(userId: number): Promise<void> {
try {
const rows = this.db.prepare('SELECT id, permissions FROM plugins').all() as Array<{ id: string; permissions: string | null }>;
const insert = this.db.prepare('INSERT OR IGNORE INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)');
@@ -311,14 +322,14 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
/** Deliver queued erasures to active plugins, dropping each row only once the plugin
* ACKs. Rows for inactive plugins are left for a later sweep / their reactivation.
* Never throws. */
private drainUserErasures(): Promise<void> {
private async drainUserErasures(): Promise<void> {
// Coalesce onto the drain already in flight. Both the 30s sweep and enqueue trigger
// a drain, and a pass awaits per-row delivery (up to the invoke timeout each), so
// running two concurrently would select the SAME rows and deliver an erasure twice.
// A caller that awaits still waits for a full pass (the in-flight one).
if (this.drainInFlight !== null) return this.drainInFlight;
if (this.drainInFlight !== null) return await this.drainInFlight;
this.drainInFlight = this.runDrainOnce().finally(() => { this.drainInFlight = null; });
return this.drainInFlight;
return await this.drainInFlight;
}
private async runDrainOnce(): Promise<void> {
@@ -435,8 +446,8 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
}
/** Re-scan the plugins volume on demand (admin action). */
rescan(): { discovered: string[]; skipped: string[] } {
return discoverPlugins(this.db);
async rescan(): Promise<{ discovered: string[]; skipped: string[] }> {
return await discoverPlugins(this.db);
}
async onModuleDestroy(): Promise<void> {
@@ -462,7 +473,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* update consent dialog), and otherwise throws PluginConsentRequired.
*/
async activate(id: string, consentWiden = false): Promise<void> {
const installed = this.installedDepRows();
const installed = await this.installedDepRows();
// Deps-first order over the installed graph (throws DependencyCycleError on a
// cycle). Missing deps aren't in `installed` so they don't appear here — the
// per-node gate reports those separately.
@@ -483,7 +494,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
}
/** All plugin rows projected to what the dependency helpers reason over. */
private installedDepRows(): Map<string, PluginDepRow> {
private async installedDepRows(): Promise<Map<string, PluginDepRow>> {
const rows = this.db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[];
return new Map(rows.map((r) => [r.id, r]));
}
@@ -574,7 +585,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
this.db.prepare('UPDATE plugins SET granted_permissions = ?, enabled = 1 WHERE id = ?').run(JSON.stringify(declared), id);
// Manifest defaults fill whatever the admin never set, so the child's ctx.config is
// the same effective value the settings form shows (see settings-defaults.ts).
const config = applySettingDefaults(decryptConfig(parseObject(row.config)), settingDefaults(this.db, id, 'instance'));
const config = applySettingDefaults(decryptConfig(parseObject(row.config)), await settingDefaults(this.db, id, 'instance'));
const manifestHosts = declared.filter((p) => p.startsWith(HTTP_OUTBOUND)).map((p) => p.slice(HTTP_OUTBOUND.length));
// Union in the hosts the ADMIN added post-install. A plugin that talks to a
// self-hosted service can't name the operator's hostname in its manifest, so without
@@ -583,12 +594,12 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// still bounds what is possible — and it is always the admin, never an end user,
// who widens it. The egress list is spawn-time only, which is why changing it
// re-spawns the plugin (see setOperatorEgressHosts).
const egress = [...new Set([...manifestHosts, ...this.operatorEgressHosts(id)])];
const egress = [...new Set([...manifestHosts, ...(await this.operatorEgressHosts(id))])];
await this.supervisor.activate(id, new Set(declared), config, egress);
}
/** Hosts an admin added for this plugin (empty unless it declared `operatorEgress`). */
operatorEgressHosts(id: string): string[] {
async operatorEgressHosts(id: string): Promise<string[]> {
try {
return (this.db.prepare('SELECT host FROM plugin_egress_hosts WHERE plugin_id = ? ORDER BY host').all(id) as Array<{ host: string }>)
.map((r) => r.host);
@@ -598,7 +609,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
}
/** Does this plugin's manifest declare that it needs operator-supplied hosts? */
wantsOperatorEgress(id: string): boolean {
async wantsOperatorEgress(id: string): Promise<boolean> {
const row = this.db.prepare('SELECT operator_egress FROM plugins WHERE id = ?').get(id) as { operator_egress: number } | undefined;
return row?.operator_egress === 1;
}
@@ -609,7 +620,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* `init` is deliberately refused, so there is no way to widen a live child's allow-list.
*/
async setOperatorEgressHosts(id: string, hosts: string[]): Promise<string[]> {
if (!this.wantsOperatorEgress(id)) {
if (!(await this.wantsOperatorEgress(id))) {
throw new ForbiddenResource(`plugin ${id} did not declare operatorEgress`);
}
const clean: string[] = [];
@@ -621,11 +632,12 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
if (host === '*' || !EGRESS_HOST_RE.test(host)) throw new ForbiddenResource(`invalid host "${raw}"`);
if (!clean.includes(host)) clean.push(host);
}
this.db.transaction(() => {
if (!this.uow) throw new Error('UnitOfWork not provided — tests that set egress hosts must pass one');
await this.uow.transactional(async () => {
this.db.prepare('DELETE FROM plugin_egress_hosts WHERE plugin_id = ?').run(id);
const ins = this.db.prepare('INSERT OR IGNORE INTO plugin_egress_hosts (plugin_id, host) VALUES (?, ?)');
for (const h of clean) ins.run(id, h);
})();
});
// Re-spawn so a live child actually gets the new allow-list.
if (this.isActive(id)) {
await this.supervisor.disable(id);
@@ -807,7 +819,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// — without a fresh activation + permission consent. deactivate() no-ops on
// a plugin that isn't running.
if (replaced) await this.deactivate(staged.id);
this.registry.commitUpload(staged); // moves code + registers INACTIVE, then clears staging
await this.registry.commitUpload(staged); // moves code + registers INACTIVE, then clears staging
return { id: staged.id, version: staged.version, replaced, trekRangeBypassed: staged.trekRangeBypassed };
} catch (e) {
// A failure before commitUpload leaves staging behind — clean it up.
@@ -850,7 +862,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
removePluginCodeEntry(dest); // drop any prior link — never follows into the author's source
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.symlinkSync(sourceDir, dest, 'junction'); // Windows junction (no elevation); POSIX ignores the type -> dir symlink
discoverPlugins(this.db); // registers/updates the row from the linked manifest, INACTIVE
await discoverPlugins(this.db); // registers/updates the row from the linked manifest, INACTIVE
// Same as a sideload: the plugin has left the registry trust model, so a block that
// described a refused REGISTRY update no longer describes the code that will run.
this.db.prepare(
@@ -859,7 +871,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
status = 'inactive', enabled = 0
WHERE id = ?`,
).run(DEV_LINK_SOURCE, id);
this.watchLinked(id, sourceDir);
await this.watchLinked(id, sourceDir);
return { id, version: manifest.version, replaced, trekRangeBypassed };
}
@@ -880,8 +892,10 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
if (wasActive) await this.activate(id);
}
/** Best-effort fs.watch on a linked plugin's built output that debounces -> reload. */
private watchLinked(id: string, sourceDir: string): void {
/** Best-effort fs.watch on a linked plugin's built output that debounces -> reload.
* `async` with nothing to await: the call-graph gate keys on the `async` modifier and
* this frame reaches the DB through the debounced `reload` below. */
private async watchLinked(id: string, sourceDir: string): Promise<void> {
this.stopWatch(id);
const serverDir = path.join(sourceDir, 'server'); // the loader runs server/index.js
try {
@@ -930,7 +944,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// same reason as the settings fields: these are TREK's config ABOUT the plugin, and
// leaving them means a later plugin that reuses this id silently inherits every
// user's opt-outs and the admin's enablement.
this.retireNotificationChannel(id);
await this.retireNotificationChannel(id);
if (deleteData) {
removePluginData(id);
this.db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id);
@@ -958,7 +972,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
}
/** Declared outbound hosts (from http:outbound:<host> grants) for the frame CSP. */
outboundHostsOf(id: string): string[] {
async outboundHostsOf(id: string): Promise<string[]> {
const row = this.db.prepare('SELECT granted_permissions FROM plugins WHERE id = ?').get(id) as
| { granted_permissions: string }
| undefined;
@@ -1010,14 +1024,14 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* `capabilities.provides` AND implemented, as reported at load). The acting user
* is forwarded, so the target's export runs membership-checked as the caller's user.
*/
callPlugin(callerId: string, targetId: string, fn: string, args: unknown, actingUserId: number | undefined): Promise<unknown> {
async callPlugin(callerId: string, targetId: string, fn: string, args: unknown, actingUserId: number | undefined): Promise<unknown> {
if (!this.supervisor.isActive(targetId)) {
return Promise.reject(new ForbiddenResource(`plugin ${targetId} is not active`));
}
if (!this.dependsOnSatisfied(callerId, targetId)) {
if (!(await this.dependsOnSatisfied(callerId, targetId))) {
return Promise.reject(new ForbiddenResource(`plugin ${callerId} does not declare ${targetId} as a satisfied dependency`));
}
if (!this.capabilityList(targetId, 'provides').includes(fn) || !this.supervisor.exportsOf(targetId).includes(fn)) {
if (!(await this.capabilityList(targetId, 'provides')).includes(fn) || !this.supervisor.exportsOf(targetId).includes(fn)) {
return Promise.reject(new ForbiddenResource(`plugin ${targetId} does not export "${fn}"`));
}
return this.supervisor.invoke(targetId, 'invoke.export', { fn, args }, { actingUserId, timeoutMs: 5000 });
@@ -1028,12 +1042,12 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* to `(source, event)` and (b) declares `source` as a satisfied dependency. The
* source must declare `event` in its manifest `capabilities.emits`. Fire-and-forget.
*/
emitPluginEvent(sourceId: string, event: string, payload: unknown): void {
if (!this.capabilityList(sourceId, 'emits').includes(event)) {
async emitPluginEvent(sourceId: string, event: string, payload: unknown): Promise<void> {
if (!(await this.capabilityList(sourceId, 'emits')).includes(event)) {
throw new ForbiddenResource(`plugin ${sourceId} does not declare event "${event}"`);
}
for (const subscriberId of this.supervisor.subscribersOf(sourceId, event)) {
if (!this.dependsOnSatisfied(subscriberId, sourceId)) continue;
if (!(await this.dependsOnSatisfied(subscriberId, sourceId))) continue;
this.supervisor
.invoke(subscriberId, 'invoke.pluginEvent', { source: sourceId, event, payload }, { actingUserId: undefined, timeoutMs: 5000 })
.catch(() => {
@@ -1044,7 +1058,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
/** True if `caller` declares `target` as a plugin dependency whose range the
* installed target version satisfies. */
private dependsOnSatisfied(callerId: string, targetId: string): boolean {
private async dependsOnSatisfied(callerId: string, targetId: string): Promise<boolean> {
const caller = this.db.prepare('SELECT dependencies FROM plugins WHERE id = ?').get(callerId) as { dependencies: string | null } | undefined;
const target = this.db.prepare('SELECT version FROM plugins WHERE id = ?').get(targetId) as { version: string | null } | undefined;
if (!caller || !target) return false;
@@ -1057,7 +1071,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* Drop every trace of a plugin's notification channel: the per-user event opt-outs
* and the id in the admin's `notification_channels` list.
*/
private retireNotificationChannel(id: string): void {
private async retireNotificationChannel(id: string): Promise<void> {
// Drop the users' per-event opt-outs for this channel. A plugin channel is never
// listed in the admin's `notification_channels` CSV (it is active by virtue of the
// plugin being enabled), so there is nothing to scrub there.
@@ -1079,7 +1093,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* The label, the event set and the configured-check are all answered from the
* manifest and the DB, with no IPC: the child is only ever woken to actually send.
*/
notificationChannels(): ExternalChannel[] {
async notificationChannels(): Promise<ExternalChannel[]> {
if (!pluginsEnabled()) return [];
return this.supervisor.providersOf('notificationChannel').map((id) => {
const row = this.db.prepare('SELECT name, capabilities FROM plugins WHERE id = ?').get(id) as
@@ -1109,12 +1123,12 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// Declared as a contract on PluginHooks.sendNotification, like every other
// hook. It is invoked here rather than through that class because PluginHooks
// injects this service, and going back the other way would close a DI cycle.
sendToUser: (userId: number, msg: ChannelMessage) =>
this.invokeHook(
sendToUser: async (userId: number, msg: ChannelMessage) =>
await this.invokeHook(
id,
'notificationChannel',
'send',
[{ event: msg.event, title: msg.title, body: msg.body, url: msg.url, tripName: msg.tripName }, this.userSettings.readAll(id, userId)],
[{ event: msg.event, title: msg.title, body: msg.body, url: msg.url, tripName: msg.tripName }, await this.userSettings.readAll(id, userId)],
// No acting user: a notification is host-initiated for an arbitrary
// recipient, so the hook gets the recipient's config as an argument
// rather than the right to read anything AS them.
@@ -1123,7 +1137,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
),
test: async (userId: number) => {
try {
await this.invokeHook(id, 'notificationChannel', 'test', [this.userSettings.readAll(id, userId)], undefined, 8000);
await this.invokeHook(id, 'notificationChannel', 'test', [await this.userSettings.readAll(id, userId)], undefined, 8000);
return { success: true };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
@@ -1134,7 +1148,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
}
/** The settings-form action buttons a plugin declared for ONE scope (descriptors, from the DB). */
actionsOf(id: string, scope: PluginActionScope): PluginActionDescriptor[] {
async actionsOf(id: string, scope: PluginActionScope): Promise<PluginActionDescriptor[]> {
try {
return (
this.db
@@ -1154,7 +1168,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* CALLER's route, so a user route can never fire an admin button, nor the reverse.
*/
async invokeAction(id: string, key: string, actingUserId: number, scope: PluginActionScope): Promise<PluginActionResult> {
if (!this.actionsOf(id, scope).some((a) => a.key === key)) {
if (!(await this.actionsOf(id, scope)).some((a) => a.key === key)) {
throw new ForbiddenResource(`plugin ${id} did not declare action "${key}" in scope ${scope}`);
}
const cap = (v: unknown) => stripEmoji(String(v)).slice(0, 200);
@@ -1184,7 +1198,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
*
* Public, unlike capabilityList, because PluginMcpToolsService reads it.
*/
mcpToolCapabilities(id: string): McpToolCapability[] {
async mcpToolCapabilities(id: string): Promise<McpToolCapability[]> {
const row = this.db.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(id) as { capabilities: string } | undefined;
if (!row) return [];
try {
@@ -1198,7 +1212,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
}
}
private capabilityList(id: string, field: 'provides' | 'emits'): string[] {
private async capabilityList(id: string, field: 'provides' | 'emits'): Promise<string[]> {
const row = this.db.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(id) as { capabilities: string } | undefined;
if (!row) return [];
try {
@@ -1212,7 +1226,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
/** Trim a plugin's error log to the most recent LOG_RETENTION rows. Cheap: onLog
* only fires on warn/error, so this never runs on the hot path. */
private pruneErrorLog(pluginId: string): void {
private async pruneErrorLog(pluginId: string): Promise<void> {
this.db.prepare(
`DELETE FROM plugin_error_log WHERE plugin_id = ? AND id NOT IN (
SELECT id FROM plugin_error_log WHERE plugin_id = ? ORDER BY id DESC LIMIT ${LOG_RETENTION}
@@ -28,23 +28,23 @@ export class PluginUserSettingsController {
private readonly dbs: DatabaseService,
) {}
private activeWithUserFields(id: string): boolean {
private async activeWithUserFields(id: string): Promise<boolean> {
const row = this.dbs.connection.prepare("SELECT 1 FROM plugins WHERE id = ? AND status = 'active'").get(id);
return !!row;
}
@Get(':id')
get(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): {
async get(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): Promise<{
fields: unknown[];
config: Record<string, unknown>;
actions: PluginActionDescriptor[];
} {
}> {
const userId = req.user?.id;
if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) return { fields: [], config: {}, actions: [] };
if (!pluginsEnabled() || userId == null || !(await this.activeWithUserFields(id))) return { fields: [], config: {}, actions: [] };
return {
fields: this.plugins.userSettingsFields(id),
config: this.plugins.getUserConfig(id, userId),
actions: this.runtime.actionsOf(id, 'user'),
fields: await this.plugins.userSettingsFields(id),
config: await this.plugins.getUserConfig(id, userId),
actions: await this.runtime.actionsOf(id, 'user'),
};
}
@@ -62,7 +62,7 @@ export class PluginUserSettingsController {
@Req() req: Request & { user?: { id: number } },
): Promise<PluginActionResult> {
const userId = req.user?.id;
if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) {
if (!pluginsEnabled() || userId == null || !(await this.activeWithUserFields(id))) {
throw new HttpException({ error: 'Plugin is not active' }, 404);
}
try {
@@ -74,16 +74,16 @@ export class PluginUserSettingsController {
}
@Post(':id')
update(
async update(
@Param('id') id: string,
@Body() body: PluginUserSettingsUpdateDto,
@Req() req: Request & { user?: { id: number } },
): { config: Record<string, unknown> } {
): Promise<{ config: Record<string, unknown> }> {
const userId = req.user?.id;
if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) return { config: {} };
if (!pluginsEnabled() || userId == null || !(await this.activeWithUserFields(id))) return { config: {} };
const patch = body?.config && typeof body.config === 'object' ? (body.config as Record<string, unknown>) : {};
try {
return { config: this.plugins.updateUserConfig(id, userId, patch) };
return { config: await this.plugins.updateUserConfig(id, userId, patch) };
} catch (e) {
if (e instanceof MissingRequiredSettingError) throw new HttpException({ error: e.message }, 400);
throw e;
@@ -24,15 +24,15 @@ export class PluginUserSettingsService {
}
/** One decrypted value for the acting user, for the runtime's `ctx.settings.get()`. */
readOne(pluginId: string, userId: number, key: string): unknown {
async readOne(pluginId: string, userId: number, key: string): Promise<unknown> {
const isSecret =
(
this.db
.prepare("SELECT secret FROM plugin_settings_fields WHERE plugin_id = ? AND field_key = ? AND scope = 'user'")
.get(pluginId, key) as { secret: number } | undefined
)?.secret === 1;
const value = this.storedFor(pluginId, userId)[key];
if (value == null) return settingDefaults(this.db, pluginId, 'user')[key]; // unset → the manifest default, if any
const value = (await this.storedFor(pluginId, userId))[key];
if (value == null) return (await settingDefaults(this.db, pluginId, 'user'))[key]; // unset → the manifest default, if any
return isSecret ? decrypt_api_key(value as string) : value;
}
@@ -43,15 +43,15 @@ export class PluginUserSettingsService {
* dispatch is host-initiated with no acting user, so `ctx.settings.get()` (which
* resolves against the acting user) would return undefined there.
*/
readAll(pluginId: string, userId: number): Record<string, unknown> {
async readAll(pluginId: string, userId: number): Promise<Record<string, unknown>> {
const fields = this.db
.prepare("SELECT field_key, secret FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user'")
.all(pluginId) as Array<{ field_key: string; secret: number }>;
const stored = this.storedFor(pluginId, userId);
const stored = await this.storedFor(pluginId, userId);
// Null-prototype for the same reason as safeParseConfig: never let a field key write
// through to Object.prototype on the way out to the plugin.
const out: Record<string, unknown> = Object.create(null);
const defaults = settingDefaults(this.db, pluginId, 'user');
const defaults = await settingDefaults(this.db, pluginId, 'user');
for (const field of fields) {
const value = stored[field.field_key];
if (value == null) {
@@ -73,19 +73,19 @@ export class PluginUserSettingsService {
* what decides whether a channel dispatches to the user, and a save the form accepted
* must not leave them "not configured".
*/
hasRequired(pluginId: string, userId: number): boolean {
async hasRequired(pluginId: string, userId: number): Promise<boolean> {
const required = this.db
.prepare(
"SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user' AND required = 1 AND input_type != 'checkbox'",
)
.all(pluginId) as Array<{ field_key: string }>;
if (required.length === 0) return true;
const stored = this.storedFor(pluginId, userId);
const defaults = settingDefaults(this.db, pluginId, 'user');
const stored = await this.storedFor(pluginId, userId);
const defaults = await settingDefaults(this.db, pluginId, 'user');
return required.every((field) => isFilled(stored[field.field_key] ?? defaults[field.field_key]));
}
private storedFor(pluginId: string, userId: number): Record<string, unknown> {
private async storedFor(pluginId: string, userId: number): Promise<Record<string, unknown>> {
const row = this.db
.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?')
.get(pluginId, userId) as { config: string } | undefined;
@@ -33,7 +33,7 @@ export class PluginsFeedController {
constructor(private readonly dbs: DatabaseService) {}
@Get()
list(): { plugins: ActivePlugin[] } {
async list(): Promise<{ plugins: ActivePlugin[] }> {
if (!pluginsEnabled()) return { plugins: [] };
const rows = this.dbs.connection
.prepare("SELECT id, name, type, icon, capabilities, granted_permissions FROM plugins WHERE status = 'active' ORDER BY sort_order, name")
+21 -21
View File
@@ -59,8 +59,8 @@ export class PluginsController {
) {}
@Get()
list() {
return this.plugins.list();
async list() {
return await this.plugins.list();
}
@Get('registry')
@@ -140,11 +140,11 @@ export class PluginsController {
}
@Get(':id/config')
getConfig(@Param('id') id: string): PluginInstanceConfigResponse {
async getConfig(@Param('id') id: string): Promise<PluginInstanceConfigResponse> {
return {
fields: this.plugins.instanceSettingsFields(id),
config: this.plugins.getInstanceConfig(id),
actions: this.runtime.actionsOf(id, 'instance'),
fields: await this.plugins.instanceSettingsFields(id),
config: await this.plugins.getInstanceConfig(id),
actions: await this.runtime.actionsOf(id, 'instance'),
};
}
@@ -162,7 +162,7 @@ export class PluginsController {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
let config: Record<string, unknown>;
try {
config = this.plugins.updateInstanceConfig(id, body || {});
config = await this.plugins.updateInstanceConfig(id, body || {});
} catch (e) {
if (e instanceof MissingRequiredSettingError) throw new HttpException({ error: e.message }, 400);
throw e;
@@ -227,8 +227,8 @@ export class PluginsController {
* is admin-guarded): an end user can never widen a plugin's egress.
*/
@Get(':id/egress-hosts')
egressHosts(@Param('id') id: string) {
return { supported: this.runtime.wantsOperatorEgress(id), hosts: this.runtime.operatorEgressHosts(id) };
async egressHosts(@Param('id') id: string) {
return { supported: await this.runtime.wantsOperatorEgress(id), hosts: await this.runtime.operatorEgressHosts(id) };
}
@Put(':id/egress-hosts')
@@ -324,8 +324,8 @@ export class PluginsController {
/** Release a per-plugin update hold (set by a deliberate non-latest install). */
@Post(':id/resume-updates')
@HttpCode(200)
resumeUpdates(@Param('id') id: string) {
if (!this.plugins.resumeUpdates(id)) throw new HttpException({ error: `plugin ${id} not found` }, 404);
async resumeUpdates(@Param('id') id: string) {
if (!(await this.plugins.resumeUpdates(id))) throw new HttpException({ error: `plugin ${id} not found` }, 404);
return { updateHold: false };
}
@@ -370,18 +370,18 @@ export class PluginsController {
}
@Get(':id/errors')
errors(@Param('id') id: string) {
return { errors: this.plugins.errors(id) };
async errors(@Param('id') id: string) {
return { errors: await this.plugins.errors(id) };
}
@Get(':id/audit')
audit(@Param('id') id: string) {
return { audit: this.plugins.auditLog(id) };
async audit(@Param('id') id: string) {
return { audit: await this.plugins.auditLog(id) };
}
@Get(':id/budget')
budget(@Param('id') id: string) {
return { budget: this.plugins.budget(id) };
async budget(@Param('id') id: string) {
return { budget: await this.plugins.budget(id) };
}
/** GDPR portability: aggregate everything the installed plugins hold about one
@@ -395,15 +395,15 @@ export class PluginsController {
}
@Delete(':id/errors')
clearErrors(@Param('id') id: string) {
this.plugins.clearErrors(id);
async clearErrors(@Param('id') id: string) {
await this.plugins.clearErrors(id);
return { ok: true };
}
@Post('rescan')
@HttpCode(200)
rescan() {
async rescan() {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
return this.runtime.rescan();
return await this.runtime.rescan();
}
}
+31 -31
View File
@@ -128,7 +128,7 @@ export class PluginsService {
}
/** Hosts an admin has added for a plugin (0 unless it declared operatorEgress). */
private egressHostCount(id: string): number {
private async egressHostCount(id: string): Promise<number> {
try {
return (this.db.prepare('SELECT COUNT(*) AS n FROM plugin_egress_hosts WHERE plugin_id = ?').get(id) as { n: number }).n;
} catch {
@@ -136,7 +136,7 @@ export class PluginsService {
}
}
private instanceSettingsCount(id: string): number {
private async instanceSettingsCount(id: string): Promise<number> {
try {
return (
this.db.prepare("SELECT COUNT(*) AS n FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance'").get(id) as { n: number }
@@ -146,7 +146,7 @@ export class PluginsService {
}
}
private instanceActionsCount(id: string): number {
private async instanceActionsCount(id: string): Promise<number> {
try {
return (
this.db.prepare("SELECT COUNT(*) AS n FROM plugin_actions WHERE plugin_id = ? AND scope = 'instance'").get(id) as { n: number }
@@ -200,9 +200,9 @@ export class PluginsService {
plugins.push({
...rest,
operatorEgress: _oe === 1,
egressHostCount: this.egressHostCount(r.id),
instanceSettingsCount: this.instanceSettingsCount(r.id),
instanceActionsCount: this.instanceActionsCount(r.id),
egressHostCount: await this.egressHostCount(r.id),
instanceSettingsCount: await this.instanceSettingsCount(r.id),
instanceActionsCount: await this.instanceActionsCount(r.id),
dependencies: deps,
dependencyStatus,
trekRange: trek_range,
@@ -224,7 +224,7 @@ export class PluginsService {
* Release a per-plugin update hold (set by a deliberate non-latest install).
* Returns whether the plugin row existed — the controller answers 404 otherwise.
*/
resumeUpdates(id: string): boolean {
async resumeUpdates(id: string): Promise<boolean> {
return this.db.prepare('UPDATE plugins SET update_hold = 0 WHERE id = ?').run(id).changes > 0;
}
@@ -234,7 +234,7 @@ export class PluginsService {
* declared as `scope:'instance'` fields are accepted, same as updateUserConfig.
* Returns the config with secrets masked for the client.
*/
updateInstanceConfig(id: string, patch: Record<string, unknown>): Record<string, unknown> {
async updateInstanceConfig(id: string, patch: Record<string, unknown>): Promise<Record<string, unknown>> {
const row = this.db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined;
if (!row) throw new Error(`plugin ${id} not found`);
@@ -264,22 +264,22 @@ export class PluginsService {
config[k] = v;
}
}
this.assertRequiredFilled(id, 'instance', config);
await this.assertRequiredFilled(id, 'instance', config);
this.db.prepare('UPDATE plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(config), id);
return maskSecrets(config, secretKeys);
}
/** The plugin's `scope:'user'` settings fields, in declared order (for the user form). */
userSettingsFields(id: string): PluginSettingsField[] {
return this.settingsFields(id, 'user');
async userSettingsFields(id: string): Promise<PluginSettingsField[]> {
return await this.settingsFields(id, 'user');
}
/** The plugin's `scope:'instance'` settings fields, in declared order (for the ADMIN form). */
instanceSettingsFields(id: string): PluginSettingsField[] {
return this.settingsFields(id, 'instance');
async instanceSettingsFields(id: string): Promise<PluginSettingsField[]> {
return await this.settingsFields(id, 'instance');
}
private settingsFields(id: string, scope: 'user' | 'instance'): PluginSettingsField[] {
private async settingsFields(id: string, scope: 'user' | 'instance'): Promise<PluginSettingsField[]> {
return this.db
.prepare(
`SELECT field_key AS key, label, input_type, placeholder, hint, required, secret, options, default_value
@@ -306,7 +306,7 @@ export class PluginsService {
});
}
private userSecretKeys(id: string): Set<string> {
private async userSecretKeys(id: string): Promise<Set<string>> {
return new Set(
(
this.db
@@ -317,22 +317,22 @@ export class PluginsService {
}
/** A user's own config for a plugin, secrets masked (safe to send to the client). */
getUserConfig(id: string, userId: number): Record<string, unknown> {
async getUserConfig(id: string, userId: number): Promise<Record<string, unknown>> {
const row = this.db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as
| { config: string }
| undefined;
return maskSecrets(safeParse(row?.config ?? '{}'), this.userSecretKeys(id));
return maskSecrets(safeParse(row?.config ?? '{}'), await this.userSecretKeys(id));
}
/** Merge a user's own settings, encrypting secret fields (SECRET_MASK = keep stored
* ciphertext). Only keys declared as `scope:'user'` fields are accepted. Returns masked. */
updateUserConfig(id: string, userId: number, patch: Record<string, unknown>): Record<string, unknown> {
async updateUserConfig(id: string, userId: number, patch: Record<string, unknown>): Promise<Record<string, unknown>> {
const allowed = new Set(
(this.db.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user'").all(id) as Array<{ field_key: string }>).map(
(r) => r.field_key,
),
);
const secretKeys = this.userSecretKeys(id);
const secretKeys = await this.userSecretKeys(id);
const existing = this.db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as
| { config: string }
| undefined;
@@ -346,7 +346,7 @@ export class PluginsService {
config[k] = v;
}
}
this.assertRequiredFilled(id, 'user', config);
await this.assertRequiredFilled(id, 'user', config);
this.db.prepare(
`INSERT INTO plugin_user_config (plugin_id, user_id, config, updated_at) VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(plugin_id, user_id) DO UPDATE SET config = excluded.config, updated_at = excluded.updated_at`,
@@ -356,40 +356,40 @@ export class PluginsService {
/** A user's own config with secrets DECRYPTED — host-only, for runtime `ctx.settings`.
* Never sent to a client; the acting user is resolved host-side. */
getUserConfigDecrypted(id: string, userId: number): Record<string, unknown> {
async getUserConfigDecrypted(id: string, userId: number): Promise<Record<string, unknown>> {
const row = this.db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as
| { config: string }
| undefined;
const config = safeParse(row?.config ?? '{}');
const secretKeys = this.userSecretKeys(id);
const secretKeys = await this.userSecretKeys(id);
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(config)) out[k] = secretKeys.has(k) && v ? decrypt_api_key(v) : v;
return out;
}
/** A plugin's error log, newest first. */
errors(id: string): Array<{ ts: string; level: string; message: string }> {
async errors(id: string): Promise<Array<{ ts: string; level: string; message: string }>> {
return this.db
.prepare('SELECT ts, level, message FROM plugin_error_log WHERE plugin_id = ? ORDER BY ts DESC, id DESC LIMIT 200')
.all(id) as Array<{ ts: string; level: string; message: string }>;
}
clearErrors(id: string): void {
async clearErrors(id: string): Promise<void> {
this.db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id);
}
/** A plugin's hash-chained capability audit log, newest first. */
auditLog(id: string): unknown[] {
return readAudit(this.db, id);
async auditLog(id: string): Promise<unknown[]> {
return await readAudit(this.db, id);
}
/** A plugin's broker budget usage for today (AI + notification counts vs caps). */
budget(id: string): ReturnType<typeof pluginBudgetUsage> {
return pluginBudgetUsage(id, this.db);
async budget(id: string): ReturnType<typeof pluginBudgetUsage> {
return await pluginBudgetUsage(id, this.db);
}
/** Read the instance config with secret fields masked. */
getInstanceConfig(id: string): Record<string, unknown> {
async getInstanceConfig(id: string): Promise<Record<string, unknown>> {
const row = this.db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined;
if (!row) throw new Error(`plugin ${id} not found`);
const secretKeys = new Set(
@@ -408,13 +408,13 @@ export class PluginsService {
* stored secret (non-empty ciphertext) counts as filled. A `checkbox` is exempt — required
* would demand `true`, which is a consent flow, not a settings field.
*/
private assertRequiredFilled(id: string, scope: 'instance' | 'user', config: Record<string, unknown>): void {
private async assertRequiredFilled(id: string, scope: 'instance' | 'user', config: Record<string, unknown>): Promise<void> {
const required = this.db
.prepare(
"SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? AND required = 1 AND input_type != 'checkbox'",
)
.all(id, scope) as Array<{ field_key: string }>;
const defaults = settingDefaults(this.db, id, scope);
const defaults = await settingDefaults(this.db, id, scope);
for (const f of required) {
// The runtime resolves the default too, so it counts as filled here as well.
if (!isFilled(config[f.field_key] ?? defaults[f.field_key])) throw new MissingRequiredSettingError(f.field_key);
@@ -446,9 +446,9 @@ export class PluginRegistryService {
// A refusal is REMEMBERED (the plugin keeps running on its old code, so the
// reason must outlive the toast) and re-thrown untouched.
try {
this.verifySignatureAndTofu(id, bytes, entry, ver, opts?.retrustKey);
await this.verifySignatureAndTofu(id, bytes, entry, ver, opts?.retrustKey);
} catch (e) {
if (e instanceof RegistryError && isSignatureCode(e.code)) setUpdateBlock(this.dbs.connection, id, e.code, e.message, ver.version);
if (e instanceof RegistryError && isSignatureCode(e.code)) await setUpdateBlock(this.dbs.connection, id, e.code, e.message, ver.version);
throw e;
}
@@ -480,7 +480,7 @@ export class PluginRegistryService {
fs.renameSync(pluginRoot, dest);
// 7. register INACTIVE (record provenance)
discoverPlugins(this.db);
await discoverPlugins(this.db);
this.db.prepare('UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?').run(
entry.repo,
ver.commitSha,
@@ -497,7 +497,7 @@ export class PluginRegistryService {
}
// The plugin is now on new code that passed every check — whatever refusal was
// recorded before no longer describes reality.
clearUpdateBlock(this.dbs.connection, id);
await clearUpdateBlock(this.dbs.connection, id);
return { id, version: ver.version, trekRangeBypassed };
} finally {
fs.rmSync(staging, { recursive: true, force: true });
@@ -603,13 +603,13 @@ export class PluginRegistryService {
* the UI flags it and offers no auto-update. The caller MUST have stopped any
* running child of this id first (the code dir is replaced).
*/
commitUpload(staged: { id: string; root: string; stagingDir: string }): void {
async commitUpload(staged: { id: string; root: string; stagingDir: string }): Promise<void> {
try {
const dest = pluginCodeDir(staged.id);
fs.mkdirSync(pluginsCodeRoot(), { recursive: true });
fs.rmSync(dest, { recursive: true, force: true });
fs.renameSync(staged.root, dest);
discoverPlugins(this.db);
await discoverPlugins(this.db);
// Provenance for a sideload, plus a hard INACTIVE floor: discoverPlugins keeps
// an existing row's status, so replacing a plugin that was active must not
// leave the new code marked active — the admin re-activates (and re-consents
@@ -650,13 +650,13 @@ export class PluginRegistryService {
* key, over the artifact bytes. A key an admin blesses must still sign the code it
* ships — a re-trust moves the pin from one VERIFIED key to another verified key.
*/
private verifySignatureAndTofu(
private async verifySignatureAndTofu(
id: string,
bytes: Buffer,
entry: RegistryEntry,
ver: RegistryVersion,
retrustKey?: string,
): void {
): Promise<void> {
const pinned =
(this.db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string } | undefined)
?.author_pubkey ?? null;
+2 -2
View File
@@ -31,11 +31,11 @@ export function parseDefaultValue(raw: unknown): SettingDefault | undefined {
/** The declared defaults for one plugin and scope, keyed by field. Null-prototype so a
* field key can never resolve off Object.prototype (see plugin-config-parse.ts). */
export function settingDefaults(
export async function settingDefaults(
db: BetterSqlite3.Database,
pluginId: string,
scope: 'instance' | 'user',
): Record<string, SettingDefault> {
): Promise<Record<string, SettingDefault>> {
const rows = db
.prepare(
'SELECT field_key, default_value FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? AND secret = 0 AND default_value IS NOT NULL',
+2 -2
View File
@@ -67,7 +67,7 @@ export function keyFingerprint(pubkey: string | null | undefined): string | null
* old code. A blocked update is not a broken runtime, and conflating them would
* make the isolation-health dot lie.
*/
export function setUpdateBlock(conn: Database.Database, id: string, code: SignatureCode, detail: string, version: string | null): void {
export async function setUpdateBlock(conn: Database.Database, id: string, code: SignatureCode, detail: string, version: string | null): Promise<void> {
try {
conn.prepare('UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?').run(
code,
@@ -84,7 +84,7 @@ export function setUpdateBlock(conn: Database.Database, id: string, code: Signat
* activating the plugin at its OLD version resolves nothing, and letting an off/on
* toggle erase the warning is exactly the silent-stops-updating failure this exists
* to prevent. (Uninstall drops the row entirely, so it needs no explicit clear.) */
export function clearUpdateBlock(conn: Database.Database, id: string): void {
export async function clearUpdateBlock(conn: Database.Database, id: string): Promise<void> {
try {
conn.prepare('UPDATE plugins SET update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL WHERE id = ?').run(id);
} catch {
@@ -68,7 +68,7 @@ export class RoadtripPlanService {
private readonly boundaries: DayBoundariesService,
) {}
context(tripId: number, userId: number) {
async context(tripId: number, userId: number) {
if (!this.db.canAccessTrip(tripId, userId)) throw new HttpException({ error: 'Trip not found' }, 404);
const days = this.db.all<StoredDay>(
'SELECT id, day_number, date, title, default_transport_mode FROM days WHERE trip_id = ? ORDER BY day_number',
@@ -89,7 +89,7 @@ export class RoadtripPlanService {
days,
visits,
settings: this.preferences.read(tripId),
profiles: this.router.profiles(),
profiles: await this.router.profiles(),
vias: this.roadtrip.listForTrip(tripId),
tracks: this.roadtrip.tracksForTrip(tripId),
boundaries: this.boundaries.list(tripId),
@@ -97,7 +97,7 @@ export class RoadtripPlanService {
}
async calculate(tripId: number, userId: number, overrides?: RoadtripPreferences) {
const context = this.context(tripId, userId);
const context = await this.context(tripId, userId);
const preferences = { ...context.settings, ...overrides };
const window = dayWindow(
preferences.roadtrip_day_start,
@@ -35,7 +35,7 @@ export class RoadtripPlanningMcp {
when,
})
async context({ tripId }: { tripId: number }, ctx: McpContext) {
return answeringRefusals(() => ok(this.plans.context(tripId, ctx.userId)));
return await answeringRefusals(async () => ok(await this.plans.context(tripId, ctx.userId)));
}
@Tool({
@@ -102,17 +102,16 @@ export class RoadtripRouterService {
private readonly db: DatabaseService,
) {}
profiles(): string[] {
return [
'driving',
'walking',
'cycling',
...(pluginsEnabled()
? this.hooks
.providersOf('routeProvider')
.flatMap((id) => declaredProfiles(this.db.connection, id).map((profile) => `plugin:${id}/${profile}`))
: []),
];
async profiles(): Promise<string[]> {
const plugin: string[] = [];
if (pluginsEnabled()) {
// R1.4: a `flatMap` callback cannot await the now-async profile read, so the
// fan-out runs as an explicit loop — same providers, same order.
for (const id of this.hooks.providersOf('routeProvider')) {
for (const profile of await declaredProfiles(this.db.connection, id)) plugin.push(`plugin:${id}/${profile}`);
}
}
return ['driving', 'walking', 'cycling', ...plugin];
}
private async request(url: string, body?: unknown): Promise<unknown> {
@@ -176,7 +175,7 @@ export class RoadtripRouterService {
points.length > 30 ||
!pluginsEnabled() ||
!this.hooks.providersOf('routeProvider').includes(id) ||
!declaredProfiles(this.db.connection, id).includes(name)
!(await declaredProfiles(this.db.connection, id)).includes(name)
)
throw new Error('Routing plugin is unavailable or has too many waypoints');
const raw = await this.hooks.route(id, { tripId, dayId, profile: name, waypoints: points }, userId);
@@ -269,7 +269,7 @@ export class TripMembersService {
// (trip_members, budget/packing/assignment links) via the ON DELETE foreign keys.
this.db.prepare('DELETE FROM users WHERE id = ? AND is_guest = 1').run(guestUserId);
});
emitUserDeleted(guestUserId); // deliver the erasure to any active plugin now
await emitUserDeleted(guestUserId); // deliver the erasure to any active plugin now
return true;
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ import type { McpContext, McpDynamicTool } from './nest-mcp';
* again on the way out would swallow that diagnostic and report the same
* degraded surface less usefully.
*/
export type PluginMcpToolSource = (ctx: McpContext) => readonly McpDynamicTool[];
export type PluginMcpToolSource = (ctx: McpContext) => readonly McpDynamicTool[] | Promise<readonly McpDynamicTool[]>;
let source: PluginMcpToolSource | null = null;
+4 -4
View File
@@ -6,19 +6,19 @@
// (the plugin side persists the erasure so nothing is lost if the sink is absent
// or the runtime is mid-boot).
let sink: ((userId: number) => void) | null = null;
let sink: ((userId: number) => void | Promise<void>) | null = null;
export function setUserDeletedSink(fn: ((userId: number) => void) | null): void {
export function setUserDeletedSink(fn: ((userId: number) => void | Promise<void>) | null): void {
sink = fn;
}
/** Announce that a TREK account was fully deleted. Called by the core deletion
* paths after deleteUserCompletely so plugins can erase their own per-user data.
* Swallows everything: a plugin bookkeeping error must never fail the deletion. */
export function emitUserDeleted(userId: number): void {
export async function emitUserDeleted(userId: number): Promise<void> {
if (!sink) return;
try {
sink(userId);
await sink(userId);
} catch {
/* the erasure is enqueued transactionally on the plugin side; a sink hiccup
is non-fatal and the next runtime boot reconciles from the queue */
+9 -1
View File
@@ -152,5 +152,13 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
/** A PluginRuntimeService constructed the way Nest would: with a real host factory. */
export async function createPluginRuntime(dbs: DatabaseService, registry?: PluginRegistryService): Promise<PluginRuntimeService> {
return new PluginRuntimeService(dbs, new AuditService(dbs), new AddonsService(dbs), new PluginUserSettingsService(dbs), registry, await createPluginRpcHostFactory(dbs));
return new PluginRuntimeService(
dbs,
new AuditService(dbs),
new AddonsService(dbs),
new PluginUserSettingsService(dbs),
registry,
await createPluginRpcHostFactory(dbs),
await createTestUnitOfWork(dbs.connection),
);
}
@@ -260,7 +260,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
testDb.prepare("INSERT INTO plugins (id, status, enabled, granted_permissions, config) VALUES ('booter','error',1,'[]','{}')").run();
const rt = await createPluginRuntime(new DatabaseService(dbConn));
rt.onApplicationBootstrap(); // fire-and-forget spawn
await rt.onApplicationBootstrap(); // fire-and-forget spawn
for (let i = 0; i < 40 && !rt.isActive('booter'); i++) await new Promise((r) => setTimeout(r, 50));
expect(rt.isActive('booter')).toBe(true);
await rt.deactivate('booter');
@@ -272,7 +272,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
testDb.prepare("INSERT INTO plugins (id, status, enabled, granted_permissions, config) VALUES ('sleeper','inactive',0,'[]','{}')").run();
const rt = await createPluginRuntime(new DatabaseService(dbConn));
rt.onApplicationBootstrap();
await rt.onApplicationBootstrap();
await new Promise((r) => setTimeout(r, 300));
expect(rt.isActive('sleeper')).toBe(false);
});
@@ -280,8 +280,8 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
it('outboundHostsOf extracts declared http:outbound hosts', async () => {
testDb.prepare("INSERT INTO plugins (id, status, granted_permissions, config) VALUES ('net','inactive','[\"db:own\",\"http:outbound:api.x.com\",\"http:outbound:*.y.com\"]','{}')").run();
const rt = await createPluginRuntime(new DatabaseService(dbConn));
expect(rt.outboundHostsOf('net')).toEqual(['api.x.com', '*.y.com']);
expect(rt.outboundHostsOf('missing')).toEqual([]);
expect(await rt.outboundHostsOf('net')).toEqual(['api.x.com', '*.y.com']);
expect(await rt.outboundHostsOf('missing')).toEqual([]);
});
it('uninstall removes the code, DB rows, settings and (with deleteData) data', async () => {
@@ -525,9 +525,9 @@ describe('PluginRuntimeService inter-plugin (exports + events)', () => {
await expect(runtime.callPlugin('consumer', 'lib', 'nope', {}, 7)).rejects.toThrow(/does not export/);
});
it('emitPluginEvent validates the declared event and fans out without throwing', () => {
expect(() => runtime.emitPluginEvent('lib', 'ping', { x: 1 })).not.toThrow();
expect(() => runtime.emitPluginEvent('lib', 'not-declared', {})).toThrow(/does not declare event/);
it('emitPluginEvent validates the declared event and fans out without throwing', async () => {
await expect(runtime.emitPluginEvent('lib', 'ping', { x: 1 })).resolves.toBeUndefined();
await expect(runtime.emitPluginEvent('lib', 'not-declared', {})).rejects.toThrow(/does not declare event/);
});
});
@@ -431,13 +431,13 @@ describe('PluginRegistryService', () => {
});
// ── Sideload (upload your own plugin) ────────────────────────────────────────
it('sideload: stage + commit installs an uploaded plugin INACTIVE as local:upload', () => {
it('sideload: stage + commit installs an uploaded plugin INACTIVE as local:upload', async () => {
const zip = makeArtifact({ id: 'my-upload', name: 'Uploaded', version: '2.0.0', type: 'widget', permissions: ['db:own'] });
const staged = svc.stageUpload(zip);
expect(staged.id).toBe('my-upload');
expect(staged.version).toBe('2.0.0');
svc.commitUpload(staged);
await svc.commitUpload(staged);
const row = testDb.prepare('SELECT status, source_repo, reviewed_at, version FROM plugins WHERE id = ?').get('my-upload') as
{ status: string; source_repo: string | null; reviewed_at: string | null; version: string } | undefined;
@@ -458,11 +458,11 @@ describe('PluginRegistryService', () => {
expect(() => svc.stageUpload(empty)).toThrow(/trek-plugin\.json/);
});
it('sideload: forces INACTIVE even when replacing a plugin that was active', () => {
it('sideload: forces INACTIVE even when replacing a plugin that was active', async () => {
const zip = () => makeArtifact({ id: 'my-upload', name: 'Uploaded', version: '2.0.0', type: 'widget', permissions: ['db:own'] });
svc.commitUpload(svc.stageUpload(zip())); // first install
await svc.commitUpload(svc.stageUpload(zip())); // first install
testDb.prepare("UPDATE plugins SET status = 'active', enabled = 1 WHERE id = 'my-upload'").run(); // admin activated it
svc.commitUpload(svc.stageUpload(zip())); // re-upload replaces the code
await svc.commitUpload(svc.stageUpload(zip())); // re-upload replaces the code
const row = testDb.prepare('SELECT status, enabled FROM plugins WHERE id = ?').get('my-upload') as { status: string; enabled: number };
expect(row.status).toBe('inactive'); // discoverPlugins keeps the old status; commitUpload floors it back to inactive
expect(row.enabled).toBe(0);
@@ -1076,7 +1076,7 @@ describe('an update block does not outlive the registry relationship', () => {
// The admin now uploads the plugin by hand.
const upload = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '9.9.9', type: 'widget', permissions: ['db:own'] });
svc.commitUpload(svc.stageUpload(upload));
await svc.commitUpload(svc.stageUpload(upload));
const row = testDb.prepare("SELECT source_repo, author_pubkey, update_block_code, update_block_version FROM plugins WHERE id='flight-tracker'").get() as Record<string, unknown>;
expect(row.source_repo).toBe('local:upload');
@@ -43,7 +43,7 @@ function makeSupervisor(events: Array<{ topic: string; data: unknown }>, tuning:
const deps: HostDeps = {
data: new PluginDataDb(id),
callPlugin: async () => undefined,
emitPluginEvent: (event, payload) => broadcasts.push({ id, event, payload }),
emitPluginEvent: async (event, payload) => { broadcasts.push({ id, event, payload }); },
};
// Only db.* is exercised from a child here; the rest of the surface has its own
// unit suites and would drag every domain service into this integration test.
@@ -25,24 +25,24 @@ describe('AdminNotificationPreferencesController', () => {
vi.clearAllMocks();
});
it('ADMINPREF-001 GET asks for the admin scope, not the user one', () => {
it('ADMINPREF-001 GET asks for the admin scope, not the user one', async () => {
const { c, prefs } = controller();
expect(c.get(admin)).toEqual({ rows: [{ event: 'trip_reminder' }] });
expect(await c.get(admin)).toEqual({ rows: [{ event: 'trip_reminder' }] });
expect(prefs.getPreferencesMatrix).toHaveBeenCalledWith(1, 'admin', 'admin');
});
it('ADMINPREF-002 PUT persists, then answers with the REFRESHED matrix', () => {
it('ADMINPREF-002 PUT persists, then answers with the REFRESHED matrix', async () => {
const { c, prefs } = controller();
// The admin panel renders straight from this response, so returning the write
// result instead of a fresh read would leave it showing stale rows.
expect(c.set(admin, { trip_reminder: { email: true } } as never)).toEqual({ rows: [{ event: 'trip_reminder' }] });
expect(await c.set(admin, { trip_reminder: { email: true } } as never)).toEqual({ rows: [{ event: 'trip_reminder' }] });
expect(prefs.setAdminPreferences).toHaveBeenCalledWith(1, { trip_reminder: { email: true } });
expect(prefs.getPreferencesMatrix).toHaveBeenCalledWith(1, 'admin', 'admin');
});
it('ADMINPREF-003 the acting user drives the lookup, never a body field', () => {
it('ADMINPREF-003 the acting user drives the lookup, never a body field', async () => {
const { c, prefs } = controller();
c.get({ id: 7, role: 'admin' } as User);
await c.get({ id: 7, role: 'admin' } as User);
expect(prefs.getPreferencesMatrix).toHaveBeenCalledWith(7, 'admin', 'admin');
});
+20 -16
View File
@@ -78,7 +78,11 @@ import type { ExternalChannel } from '../../../src/nest/notifications/notificati
const prefsDbs = new DatabaseService(testDb);
const prefsSvc = new NotificationPreferencesService(prefsDbs, new MailerService(prefsDbs));
const getPreferencesMatrix = prefsSvc.getPreferencesMatrix.bind(prefsSvc);
// An arrow forwarder rather than `.bind(prefsSvc)`: under `strictBindCallApply: false`
// a bound alias is typed `any`, which hides a missing `await` from tsc and the lint
// rules alike (recipe R4).
const getPreferencesMatrix = (...a: Parameters<NotificationPreferencesService['getPreferencesMatrix']>) =>
prefsSvc.getPreferencesMatrix(...a);
void WebhookService; void NtfyService;
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
@@ -113,10 +117,10 @@ const TRIP_INVITE = { event: 'trip_invite', actorId: null, scope: 'user', target
describe('a plugin channel can never override a built-in', () => {
beforeEach(() => { rogueSend.mockClear(); rogueGlobal.mockClear(); });
it('CHOVR-001 — a channel claiming a built-in id is dropped from the registry', () => {
it('CHOVR-001 — a channel claiming a built-in id is dropped from the registry', async () => {
setPluginChannelSource(() => [rogue()]);
expect(listChannels().filter(c => c.id === 'email')).toHaveLength(1);
expect(getChannel('email')!.source).toBe('builtin');
expect((await listChannels()).filter(c => c.id === 'email')).toHaveLength(1);
expect((await getChannel('email'))!.source).toBe('builtin');
});
it('CHOVR-002 — it does NOT ride the user’s email opt-in', async () => {
@@ -131,19 +135,19 @@ describe('a plugin channel can never override a built-in', () => {
expect(sendMailMock).toHaveBeenCalledTimes(1); // the real email channel still delivers
});
it('CHOVR-003 — an un-namespaced id is dropped even when it collides with nothing', () => {
it('CHOVR-003 — an un-namespaced id is dropped even when it collides with nothing', async () => {
setPluginChannelSource(() => [rogue({ id: 'carrier-pigeon', source: 'plugin' })]);
expect(listChannels().map(c => c.id)).not.toContain('carrier-pigeon');
expect((await listChannels()).map(c => c.id)).not.toContain('carrier-pigeon');
});
it('CHOVR-004 — a properly namespaced channel IS admitted', () => {
it('CHOVR-004 — a properly namespaced channel IS admitted', async () => {
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', source: 'plugin' })]);
expect(listChannels().map(c => c.id)).toContain('plugin:gotify');
expect((await listChannels()).map(c => c.id)).toContain('plugin:gotify');
});
it('CHOVR-005 — an admitted plugin channel has its built-in-only privileges stripped', () => {
it('CHOVR-005 — an admitted plugin channel has its built-in-only privileges stripped', async () => {
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify' })]);
const ch = getChannel('plugin:gotify')!;
const ch = (await getChannel('plugin:gotify'))!;
expect(ch.source).toBe('plugin'); // it cannot self-declare as a built-in
expect(ch.bypassesActiveToggleForAdminEvents).toBe(false);
expect(ch.supportsAdminGlobal).toBe(false);
@@ -168,16 +172,16 @@ describe('a plugin channel can never override a built-in', () => {
setNotificationChannels(testDb, 'plugin:gotify');
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify' }), rogue({ id: 'plugin:gotify' })]);
expect(listChannels().filter(c => c.id === 'plugin:gotify')).toHaveLength(1);
expect((await listChannels()).filter(c => c.id === 'plugin:gotify')).toHaveLength(1);
await send({ ...TRIP_INVITE, targetId: user.id });
expect(rogueSend).toHaveBeenCalledTimes(1);
});
it('CHOVR-008 — the preferences matrix never shows a duplicate column', () => {
it('CHOVR-008 — the preferences matrix never shows a duplicate column', async () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'email');
setPluginChannelSource(() => [rogue()]);
const ids = getPreferencesMatrix(user.id, 'user').channels.map(c => c.id);
const ids = (await getPreferencesMatrix(user.id, 'user')).channels.map(c => c.id);
expect(ids.filter(i => i === 'email')).toHaveLength(1);
expect(new Set(ids).size).toBe(ids.length);
});
@@ -190,12 +194,12 @@ describe('a plugin channel can never override a built-in', () => {
describe('a live plugin channel needs no second opt-in', () => {
beforeEach(() => { rogueSend.mockClear(); });
it('CHOVR-011 — it appears in the matrix with NOTHING in notification_channels', () => {
it('CHOVR-011 — it appears in the matrix with NOTHING in notification_channels', async () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'none'); // the admin has enabled no built-in at all
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', source: 'plugin', label: 'Gotify' })]);
const matrix = getPreferencesMatrix(user.id, 'user');
const matrix = await getPreferencesMatrix(user.id, 'user');
const ch = matrix.channels.find(c => c.id === 'plugin:gotify')!;
// Enabling the PLUGIN is the opt-in. There is no UI that can write a `plugin:` id into
// the notification_channels CSV, so requiring one meant the channel could never show.
@@ -227,7 +231,7 @@ describe('a live plugin channel needs no second opt-in', () => {
const { user } = createUser(testDb);
setPluginChannelSource(() => [rogue({ id: 'plugin:gotify', source: 'plugin', isConfiguredFor: () => false })]);
const ch = getPreferencesMatrix(user.id, 'user').channels.find(c => c.id === 'plugin:gotify')!;
const ch = (await getPreferencesMatrix(user.id, 'user')).channels.find(c => c.id === 'plugin:gotify')!;
expect(ch.active).toBe(true); // shown — so they can see it and go configure it
expect(ch.configured).toBe(false); // …but flagged as needing setup
+30 -30
View File
@@ -52,65 +52,65 @@ afterEach(() => {
});
describe('channelRegistry', () => {
it('CHREG-001 — the three built-in external channels are registered; in-app is not', () => {
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
expect(getChannel('inapp')).toBeUndefined();
it('CHREG-001 — the three built-in external channels are registered; in-app is not', async () => {
expect((await listChannels()).map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
expect(await getChannel('inapp')).toBeUndefined();
});
it('CHREG-002 — plugin channels come from the injected source and are namespaced', () => {
it('CHREG-002 — plugin channels come from the injected source and are namespaced', async () => {
setPluginChannelSource(() => [fakeChannel(pluginChannelId('gotify'))]);
expect(listChannels().map(c => c.id)).toContain('plugin:gotify');
expect(getChannel('plugin:gotify')?.source).toBe('plugin');
expect((await listChannels()).map(c => c.id)).toContain('plugin:gotify');
expect((await getChannel('plugin:gotify'))?.source).toBe('plugin');
expect(isPluginChannelId('plugin:gotify')).toBe(true);
expect(isPluginChannelId('email')).toBe(false);
});
it('CHREG-003 — a plugin channel disappears when the runtime stops reporting it', () => {
it('CHREG-003 — a plugin channel disappears when the runtime stops reporting it', async () => {
let live = true;
setPluginChannelSource(() => (live ? [fakeChannel('plugin:gotify')] : []));
expect(getChannel('plugin:gotify')).toBeDefined();
expect(await getChannel('plugin:gotify')).toBeDefined();
live = false;
expect(getChannel('plugin:gotify')).toBeUndefined();
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
expect(await getChannel('plugin:gotify')).toBeUndefined();
expect((await listChannels()).map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
});
it('CHREG-004 — a throwing plugin source cannot take notifications down', () => {
it('CHREG-004 — a throwing plugin source cannot take notifications down', async () => {
setPluginChannelSource(() => {
throw new Error('runtime exploded');
});
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
expect((await listChannels()).map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
});
it('CHREG-005 — a plugin can never claim a built-in id', () => {
it('CHREG-005 — a plugin can never claim a built-in id', async () => {
// The prefix is the whole defence: an id without it is not a plugin channel id,
// and pluginChannelId() is the only way the runtime mints one.
expect(pluginChannelId('email')).toBe('plugin:email');
expect(getChannel('email')?.source).toBe('builtin');
expect((await getChannel('email'))?.source).toBe('builtin');
});
it('CHREG-006 — only email declares the admin-scoped toggle bypass', () => {
expect(getChannel('email')?.bypassesActiveToggleForAdminEvents).toBe(true);
expect(getChannel('webhook')?.bypassesActiveToggleForAdminEvents).toBeUndefined();
expect(getChannel('ntfy')?.bypassesActiveToggleForAdminEvents).toBeUndefined();
it('CHREG-006 — only email declares the admin-scoped toggle bypass', async () => {
expect((await getChannel('email'))?.bypassesActiveToggleForAdminEvents).toBe(true);
expect((await getChannel('webhook'))?.bypassesActiveToggleForAdminEvents).toBeUndefined();
expect((await getChannel('ntfy'))?.bypassesActiveToggleForAdminEvents).toBeUndefined();
});
it('CHREG-007 — webhook and ntfy deliver the admin-global copy; email does not', () => {
expect(getChannel('webhook')?.supportsAdminGlobal).toBe(true);
expect(getChannel('ntfy')?.supportsAdminGlobal).toBe(true);
expect(getChannel('email')?.supportsAdminGlobal).toBeUndefined();
it('CHREG-007 — webhook and ntfy deliver the admin-global copy; email does not', async () => {
expect((await getChannel('webhook'))?.supportsAdminGlobal).toBe(true);
expect((await getChannel('ntfy'))?.supportsAdminGlobal).toBe(true);
expect((await getChannel('email'))?.supportsAdminGlobal).toBeUndefined();
});
it('CHREG-008 — built-ins carry every event except synology_session_cleared', () => {
it('CHREG-008 — built-ins carry every event except synology_session_cleared', async () => {
for (const id of ['email', 'webhook', 'ntfy']) {
expect(getChannel(id)!.supportsEvent('trip_invite')).toBe(true);
expect(getChannel(id)!.supportsEvent('version_available')).toBe(true);
expect(getChannel(id)!.supportsEvent('synology_session_cleared')).toBe(false);
expect((await getChannel(id))!.supportsEvent('trip_invite')).toBe(true);
expect((await getChannel(id))!.supportsEvent('version_available')).toBe(true);
expect((await getChannel(id))!.supportsEvent('synology_session_cleared')).toBe(false);
}
});
it('CHREG-009 — registerChannel replaces an existing id rather than duplicating it', () => {
it('CHREG-009 — registerChannel replaces an existing id rather than duplicating it', async () => {
registerChannel(fakeChannel('email', { source: 'builtin' }));
expect(listChannels().filter(c => c.id === 'email')).toHaveLength(1);
expect((await listChannels()).filter(c => c.id === 'email')).toHaveLength(1);
});
it('CHREG-010 — a channel that rejects is the caller’s problem, not the registry’s', async () => {
@@ -120,8 +120,8 @@ describe('channelRegistry', () => {
},
});
setPluginChannelSource(() => [boom]);
await expect(getChannel('plugin:boom')!.sendToUser(1, MSG)).rejects.toThrow('nope');
await expect((await getChannel('plugin:boom'))!.sendToUser(1, MSG)).rejects.toThrow('nope');
// and the registry is unharmed
expect(listChannels()).toHaveLength(4);
expect(await listChannels()).toHaveLength(4);
});
});
@@ -50,14 +50,18 @@ registerBuiltinChannels({ mailer, webhook: new WebhookService(dbs), ntfy: new Nt
const svc = new NotificationPreferencesService(dbs, mailer);
// Legacy free-function names bound to the service, so the moved cases read as before.
const isEnabledForEvent = svc.isEnabledForEvent.bind(svc);
const getPreferencesMatrix = svc.getPreferencesMatrix.bind(svc);
const setPreferences = svc.setPreferences.bind(svc);
const setAdminPreferences = svc.setAdminPreferences.bind(svc);
const getAdminGlobalPref = svc.getAdminGlobalPref.bind(svc);
const getActiveChannels = svc.getActiveChannels.bind(svc);
const isSmtpConfigured = svc.isSmtpConfigured.bind(svc);
const isWebhookConfigured = svc.isWebhookConfigured.bind(svc);
// Arrow forwarders rather than `.bind(svc)`: under `strictBindCallApply: false` a bound
// alias is typed `any`, which hides a missing `await` from tsc AND from the type-aware
// lint rules (recipe R4).
type Svc = NotificationPreferencesService;
const isEnabledForEvent = (...a: Parameters<Svc['isEnabledForEvent']>) => svc.isEnabledForEvent(...a);
const getPreferencesMatrix = (...a: Parameters<Svc['getPreferencesMatrix']>) => svc.getPreferencesMatrix(...a);
const setPreferences = (...a: Parameters<Svc['setPreferences']>) => svc.setPreferences(...a);
const setAdminPreferences = (...a: Parameters<Svc['setAdminPreferences']>) => svc.setAdminPreferences(...a);
const getAdminGlobalPref = (...a: Parameters<Svc['getAdminGlobalPref']>) => svc.getAdminGlobalPref(...a);
const getActiveChannels = (...a: Parameters<Svc['getActiveChannels']>) => svc.getActiveChannels(...a);
const isSmtpConfigured = (...a: Parameters<Svc['isSmtpConfigured']>) => svc.isSmtpConfigured(...a);
const isWebhookConfigured = (...a: Parameters<Svc['isWebhookConfigured']>) => svc.isWebhookConfigured(...a);
beforeAll(() => {
@@ -103,32 +107,32 @@ describe('isEnabledForEvent', () => {
// ─────────────────────────────────────────────────────────────────────────────
describe('getPreferencesMatrix', () => {
it('NPREF-004 — regular user does not see version_available in event_types', () => {
it('NPREF-004 — regular user does not see version_available in event_types', async () => {
const { user } = createUser(testDb);
const { event_types } = getPreferencesMatrix(user.id, 'user');
const { event_types } = await getPreferencesMatrix(user.id, 'user');
expect(event_types).not.toContain('version_available');
// +1 for plugin_notification: users can mute host-mediated plugin notifications.
expect(event_types.length).toBe(12);
});
it('NPREF-005 — user scope excludes version_available for everyone including admins', () => {
it('NPREF-005 — user scope excludes version_available for everyone including admins', async () => {
const { user } = createAdmin(testDb);
const { event_types } = getPreferencesMatrix(user.id, 'admin', 'user');
const { event_types } = await getPreferencesMatrix(user.id, 'admin', 'user');
expect(event_types).not.toContain('version_available');
expect(event_types.length).toBe(12);
});
it('NPREF-005b — admin scope returns the admin-scoped events', () => {
it('NPREF-005b — admin scope returns the admin-scoped events', async () => {
const { user } = createAdmin(testDb);
const { event_types } = getPreferencesMatrix(user.id, 'admin', 'admin');
const { event_types } = await getPreferencesMatrix(user.id, 'admin', 'admin');
expect(event_types).toContain('version_available');
expect(event_types).toContain('replica_failure');
expect(event_types.length).toBe(2);
});
it('NPREF-006 — returns default true for all preferences when no stored prefs', () => {
it('NPREF-006 — returns default true for all preferences when no stored prefs', async () => {
const { user } = createUser(testDb);
const { preferences } = getPreferencesMatrix(user.id, 'user');
const { preferences } = await getPreferencesMatrix(user.id, 'user');
for (const [, channels] of Object.entries(preferences)) {
for (const [, enabled] of Object.entries(channels as Record<string, boolean>)) {
expect(enabled).toBe(true);
@@ -136,11 +140,11 @@ describe('getPreferencesMatrix', () => {
}
});
it('NPREF-007 — reflects stored disabled preferences in the matrix', () => {
it('NPREF-007 — reflects stored disabled preferences in the matrix', async () => {
const { user } = createUser(testDb);
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
disableNotificationPref(testDb, user.id, 'collab_message', 'webhook');
const { preferences } = getPreferencesMatrix(user.id, 'user');
const { preferences } = await getPreferencesMatrix(user.id, 'user');
expect(preferences['trip_invite']!['email']).toBe(false);
expect(preferences['collab_message']!['webhook']).toBe(false);
// Others unaffected
@@ -148,29 +152,29 @@ describe('getPreferencesMatrix', () => {
expect(preferences['booking_change']!['email']).toBe(true);
});
it('NPREF-008 — the inapp channel is always active', () => {
it('NPREF-008 — the inapp channel is always active', async () => {
const { user } = createUser(testDb);
const { channels } = getPreferencesMatrix(user.id, 'user');
const { channels } = await getPreferencesMatrix(user.id, 'user');
expect(channels.find(c => c.id === 'inapp')?.active).toBe(true);
});
it('NPREF-009 — email is active when email is in notification_channels', () => {
it('NPREF-009 — email is active when email is in notification_channels', async () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'email');
const { channels } = getPreferencesMatrix(user.id, 'user');
const { channels } = await getPreferencesMatrix(user.id, 'user');
expect(channels.find(c => c.id === 'email')?.active).toBe(true);
});
it('NPREF-010 — email is inactive when email is not in notification_channels', () => {
it('NPREF-010 — email is inactive when email is not in notification_channels', async () => {
const { user } = createUser(testDb);
// No notification_channels set → defaults to none
const { channels } = getPreferencesMatrix(user.id, 'user');
const { channels } = await getPreferencesMatrix(user.id, 'user');
expect(channels.find(c => c.id === 'email')?.active).toBe(false);
});
it('NPREF-011 — implemented_combos maps version_available to [inapp, email, webhook, ntfy]', () => {
it('NPREF-011 — implemented_combos maps version_available to [inapp, email, webhook, ntfy]', async () => {
const { user } = createAdmin(testDb);
const { implemented_combos } = getPreferencesMatrix(user.id, 'admin', 'admin');
const { implemented_combos } = await getPreferencesMatrix(user.id, 'admin', 'admin');
expect(implemented_combos['version_available']).toEqual(['inapp', 'email', 'webhook', 'ntfy']);
// All events now support all four channels
expect(implemented_combos['trip_invite']).toContain('inapp');
@@ -231,26 +235,26 @@ describe('setPreferences', () => {
// ─────────────────────────────────────────────────────────────────────────────
describe('getActiveChannels', () => {
it('NPREF-015 — returns [] when notification_channels is none', () => {
it('NPREF-015 — returns [] when notification_channels is none', async () => {
setAppSetting(testDb, 'notification_channels', 'none');
expect(getActiveChannels()).toEqual([]);
expect(await getActiveChannels()).toEqual([]);
});
it('NPREF-016 — returns [email] when notification_channels is email', () => {
it('NPREF-016 — returns [email] when notification_channels is email', async () => {
setAppSetting(testDb, 'notification_channels', 'email');
expect(getActiveChannels()).toEqual(['email']);
expect(await getActiveChannels()).toEqual(['email']);
});
it('NPREF-017 — returns [email, webhook] when notification_channels is email,webhook', () => {
it('NPREF-017 — returns [email, webhook] when notification_channels is email,webhook', async () => {
setAppSetting(testDb, 'notification_channels', 'email,webhook');
expect(getActiveChannels()).toEqual(['email', 'webhook']);
expect(await getActiveChannels()).toEqual(['email', 'webhook']);
});
it('NPREF-018 — falls back to notification_channel (singular) when plural key absent', () => {
it('NPREF-018 — falls back to notification_channel (singular) when plural key absent', async () => {
// Only set the singular key
setAppSetting(testDb, 'notification_channel', 'webhook');
// No notification_channels key
expect(getActiveChannels()).toEqual(['webhook']);
expect(await getActiveChannels()).toEqual(['webhook']);
});
});
@@ -264,9 +268,9 @@ describe('channel availability', () => {
expect(isSmtpConfigured()).toBe(true);
});
it('NPREF-020 — webhook is active when admin has enabled the webhook channel', () => {
it('NPREF-020 — webhook is active when admin has enabled the webhook channel', async () => {
setNotificationChannels(testDb, 'webhook');
expect(getActiveChannels()).toContain('webhook');
expect(await getActiveChannels()).toContain('webhook');
});
it('NPREF-021 — detects SMTP config from env var SMTP_HOST', () => {
@@ -280,10 +284,10 @@ describe('channel availability', () => {
}
});
it('NPREF-022 — an unknown channel id in notification_channels is ignored', () => {
it('NPREF-022 — an unknown channel id in notification_channels is ignored', async () => {
// e.g. a plugin channel left in the CSV after the plugin was uninstalled
setNotificationChannels(testDb, 'webhook,plugin:long-gone');
expect(getActiveChannels()).toEqual(['webhook']);
expect(await getActiveChannels()).toEqual(['webhook']);
});
});
@@ -341,13 +345,13 @@ describe('setAdminPreferences', () => {
// ─────────────────────────────────────────────────────────────────────────────
describe('isWebhookConfigured', () => {
it('NPREF-026 — returns false when webhook is not in active channels', () => {
it('NPREF-026 — returns false when webhook is not in active channels', async () => {
// No notification_channels configured → defaults don't include webhook
expect(isWebhookConfigured()).toBe(false);
expect(await isWebhookConfigured()).toBe(false);
});
it('NPREF-027 — returns true when webhook is in active channels', () => {
it('NPREF-027 — returns true when webhook is in active channels', async () => {
setNotificationChannels(testDb, 'webhook');
expect(isWebhookConfigured()).toBe(true);
expect(await isWebhookConfigured()).toBe(true);
});
});
@@ -65,14 +65,14 @@ beforeEach(() => {
});
describe('a visit end time on the road trip', () => {
it('is read from the visit, and from the place when the visit has none', () => {
it('is read from the visit, and from the place when the visit has none', async () => {
const { user, trip, visits, plans } = setup();
db.prepare("UPDATE day_assignments SET assignment_end_time = '14:00' WHERE id = ?").run(visits[1].id);
db.prepare(
"UPDATE places SET end_time = '18:00' WHERE id = (SELECT place_id FROM day_assignments WHERE id = ?)",
).run(visits[2].id);
const context = plans.context(trip.id, user.id);
const context = await plans.context(trip.id, user.id);
expect(context.visits.map((v) => [v.name, v.end_time])).toEqual([
['Hamburg', null],
@@ -184,6 +184,6 @@ describe('server Roadtrip router', () => {
});
expect((await router.route(7, 8, 9, points, 'plugin:scenic/car', [])).leg.seg.distance).toBe(3000);
expect(hooks.route).toHaveBeenCalledWith('scenic', { tripId: 8, dayId: 9, profile: 'car', waypoints: points }, 7);
expect(router.profiles()).toContain('plugin:scenic/car');
expect(await router.profiles()).toContain('plugin:scenic/car');
});
});
+24 -24
View File
@@ -41,7 +41,7 @@ afterEach(() => {
});
describe('discoverPlugins', () => {
it('registers a new plugin inactive with its settings fields', () => {
it('registers a new plugin inactive with its settings fields', async () => {
writePlugin('flight-tracker', {
name: 'Flight',
type: 'widget',
@@ -52,7 +52,7 @@ describe('discoverPlugins', () => {
{ key: 'oauth', input_type: 'oauth', scope: 'user', oauth: { initPath: '/o/start', callbackPath: '/o/cb' } },
],
});
const res = discoverPlugins(db);
const res = await discoverPlugins(db);
expect(res.discovered).toEqual(['flight-tracker']);
const row = db.prepare("SELECT status, type, permissions FROM plugins WHERE id='flight-tracker'").get() as { status: string; type: string; permissions: string };
@@ -64,12 +64,12 @@ describe('discoverPlugins', () => {
expect(field).toMatchObject({ field_key: 'api_key', secret: 1 });
});
it('persists each action with its scope', () => {
it('persists each action with its scope', async () => {
writePlugin('acts', {
name: 'Acts', type: 'integration', permissions: [],
actions: [{ key: 'ping', label: 'Ping' }, { key: 'purge', label: 'Purge', scope: 'instance', danger: true }],
});
discoverPlugins(db);
await discoverPlugins(db);
const rows = db.prepare("SELECT action_key, scope, danger FROM plugin_actions WHERE plugin_id='acts' ORDER BY sort_order").all();
expect(rows).toEqual([
{ action_key: 'ping', scope: 'user', danger: 0 },
@@ -77,39 +77,39 @@ describe('discoverPlugins', () => {
]);
});
it('keeps an existing plugin status + granted permissions on re-discovery', () => {
it('keeps an existing plugin status + granted permissions on re-discovery', async () => {
db.prepare("INSERT INTO plugins (id, name, type, status, granted_permissions) VALUES ('keep','Keep','page','active','[\"db:own\"]')").run();
writePlugin('keep', { name: 'Keep v2', type: 'page', version: '2.0.0' });
discoverPlugins(db);
await discoverPlugins(db);
const row = db.prepare("SELECT status, version, granted_permissions FROM plugins WHERE id='keep'").get() as { status: string; version: string; granted_permissions: string };
expect(row.status).toBe('active'); // not downgraded
expect(row.version).toBe('2.0.0'); // metadata refreshed
expect(JSON.parse(row.granted_permissions)).toEqual(['db:own']); // grants preserved
});
it('tolerates a UTF-8 BOM in trek-plugin.json (Windows-authored plugins)', () => {
it('tolerates a UTF-8 BOM in trek-plugin.json (Windows-authored plugins)', async () => {
writePlugin('bom-plug', { type: 'integration' });
const mp = path.join(codeRoot, 'bom-plug', 'trek-plugin.json');
fs.writeFileSync(mp, '\uFEFF' + fs.readFileSync(mp, 'utf8'));
expect(discoverPlugins(db).discovered).toEqual(['bom-plug']);
expect((await discoverPlugins(db)).discovered).toEqual(['bom-plug']);
});
it('skips an invalid manifest and logs the reason', () => {
it('skips an invalid manifest and logs the reason', async () => {
writePlugin('bad', { type: 'not-a-type' });
const res = discoverPlugins(db);
const res = await discoverPlugins(db);
expect(res.skipped).toEqual(['bad']);
expect(db.prepare("SELECT COUNT(*) c FROM plugins WHERE id='bad'").get()).toMatchObject({ c: 0 });
expect((db.prepare("SELECT message FROM plugin_error_log WHERE plugin_id='bad'").get() as { message: string }).message).toContain('discovery');
});
it('skips a plugin that ships native binaries', () => {
it('skips a plugin that ships native binaries', async () => {
writePlugin('native', { type: 'integration' }, () => {
fs.writeFileSync(path.join(codeRoot, 'native', 'server', 'addon.node'), '\0');
});
expect(discoverPlugins(db).skipped).toEqual(['native']);
expect((await discoverPlugins(db)).skipped).toEqual(['native']);
});
it('follows a symlinked dev-link plugin only when dev-link mode is on', () => {
it('follows a symlinked dev-link plugin only when dev-link mode is on', async () => {
const srcRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'disc-src-'));
const prev = process.env.TREK_PLUGINS_DEV_LINK;
try {
@@ -121,12 +121,12 @@ describe('discoverPlugins', () => {
// Off (default): a stale dev-link symlink is not discovered or registered.
delete process.env.TREK_PLUGINS_DEV_LINK;
expect(discoverPlugins(db).discovered).toEqual([]);
expect((await discoverPlugins(db)).discovered).toEqual([]);
expect(db.prepare("SELECT status FROM plugins WHERE id='linked'").get()).toBeUndefined();
// On: the dev-link is followed and registered inactive.
process.env.TREK_PLUGINS_DEV_LINK = '1';
expect(discoverPlugins(db).discovered).toEqual(['linked']);
expect((await discoverPlugins(db)).discovered).toEqual(['linked']);
expect(db.prepare("SELECT status FROM plugins WHERE id='linked'").get()).toMatchObject({ status: 'inactive' });
} finally {
if (prev === undefined) delete process.env.TREK_PLUGINS_DEV_LINK; else process.env.TREK_PLUGINS_DEV_LINK = prev;
@@ -134,34 +134,34 @@ describe('discoverPlugins', () => {
}
});
it('is a no-op when the plugins dir is absent', () => {
it('is a no-op when the plugins dir is absent', async () => {
process.env.TREK_PLUGINS_DIR = path.join(codeRoot, 'does-not-exist');
expect(discoverPlugins(db)).toEqual({ discovered: [], skipped: [] });
expect(await discoverPlugins(db)).toEqual({ discovered: [], skipped: [] });
});
describe('the TREK range', () => {
it('persists the range and its lower bound', () => {
it('persists the range and its lower bound', async () => {
writePlugin('ranged', { trek: '>=3.2.0 <4.0.0' });
discoverPlugins(db);
await discoverPlugins(db);
expect(db.prepare("SELECT trek_range, min_trek_version FROM plugins WHERE id='ranged'").get())
.toMatchObject({ trek_range: '>=3.2.0 <4.0.0', min_trek_version: '3.2.0' });
});
it('still registers a plugin that declares NO range — it must not vanish', () => {
it('still registers a plugin that declares NO range — it must not vanish', async () => {
// Discovery is a reconciler, not a gate: it only logs and skips on a throw and never
// touches the plugins row, so refusing here would leave an existing plugin's stale
// enabled=1 row to be spawned by the next boot — invisible AND running. The row is
// registered with a null range and the activation gate refuses it (TREK_VERSION_UNKNOWN).
writePlugin('rangeless', {});
expect(discoverPlugins(db).discovered).toEqual(['rangeless']);
expect((await discoverPlugins(db)).discovered).toEqual(['rangeless']);
expect(db.prepare("SELECT trek_range FROM plugins WHERE id='rangeless'").get()).toMatchObject({ trek_range: null });
});
it('refreshes the range on re-discovery, so a plugin that narrowed its support is caught', () => {
it('refreshes the range on re-discovery, so a plugin that narrowed its support is caught', async () => {
writePlugin('shrink', { trek: '>=3.0.0' });
discoverPlugins(db);
await discoverPlugins(db);
writePlugin('shrink', { trek: '>=3.0.0 <3.1.0' });
discoverPlugins(db);
await discoverPlugins(db);
expect(db.prepare("SELECT trek_range FROM plugins WHERE id='shrink'").get()).toMatchObject({ trek_range: '>=3.0.0 <3.1.0' });
});
});
+2 -2
View File
@@ -103,7 +103,7 @@ const registry = createTestPluginRegistry([
new HostSurfaceRpc(dbs, new RealtimeService(), notifications, llmConfig, oauth, guards),
]);
const factory = new PluginRpcHostFactory(dbs, registry as unknown as PluginRpcRegistryService);
const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} };
const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: async () => {} };
const makeHost = (id: string, ...perms: string[]) => factory.create(id, new Set(perms), stubRouter);
/**
* A response read loosely, on purpose.
@@ -224,7 +224,7 @@ describe('DbRpc — the unconditional three', () => {
calls.push({ callerId, targetId, fn, args, uid });
return { echoed: true };
},
emitPluginEvent: (sourceId, event, payload) => {
emitPluginEvent: async (sourceId, event, payload) => {
emits.push({ sourceId, event, payload });
},
};
@@ -52,18 +52,18 @@ beforeEach(async () => {
describe('operator-supplied egress hosts', () => {
it('OEG-001 — an admin can add hosts to a plugin that declared operatorEgress', async () => {
install('gotify', true);
expect(rt.wantsOperatorEgress('gotify')).toBe(true);
expect(await rt.wantsOperatorEgress('gotify')).toBe(true);
expect(await rt.setOperatorEgressHosts('gotify', ['gotify.mydomain.com'])).toEqual(['gotify.mydomain.com']);
expect(rt.operatorEgressHosts('gotify')).toEqual(['gotify.mydomain.com']);
expect(await rt.operatorEgressHosts('gotify')).toEqual(['gotify.mydomain.com']);
});
it('OEG-002 — a plugin that did NOT declare it can never have hosts added', async () => {
install('sneaky', false);
expect(rt.wantsOperatorEgress('sneaky')).toBe(false);
expect(await rt.wantsOperatorEgress('sneaky')).toBe(false);
// This is the load-bearing check: without it an admin could silently widen egress for
// ANY plugin, and the install-time consent would stop bounding what's possible.
await expect(rt.setOperatorEgressHosts('sneaky', ['evil.example.com'])).rejects.toThrow(/did not declare operatorEgress/);
expect(rt.operatorEgressHosts('sneaky')).toEqual([]);
expect(await rt.operatorEgressHosts('sneaky')).toEqual([]);
});
it('OEG-003 — hosts are validated exactly like manifest egress', async () => {
@@ -86,7 +86,7 @@ describe('operator-supplied egress hosts', () => {
install('gotify', true);
await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']);
await rt.setOperatorEgressHosts('gotify', ['b.example.com']);
expect(rt.operatorEgressHosts('gotify')).toEqual(['b.example.com']);
expect(await rt.operatorEgressHosts('gotify')).toEqual(['b.example.com']);
});
it('OEG-006 — the manifest rejects operatorEgress without an outbound permission', () => {
@@ -113,7 +113,7 @@ describe('operator-supplied egress hosts', () => {
// It ACTIVATES (it may have useful offline features), but the child's allow-list is the
// union of its http:outbound:<host> grants and the admin's hosts — both empty here.
install('gotify', true, ['http:outbound']);
expect(rt.operatorEgressHosts('gotify')).toEqual([]);
expect(await rt.operatorEgressHosts('gotify')).toEqual([]);
expect(makeHostAllow([])('gotify.mydomain.com')).toBe(false);
});
@@ -122,7 +122,7 @@ describe('operator-supplied egress hosts', () => {
await rt.setOperatorEgressHosts('gotify', ['gotify.mydomain.com']);
await rt.uninstall('gotify', false);
// A LATER plugin reusing this id must not silently inherit hosts approved for another.
expect(rt.operatorEgressHosts('gotify')).toEqual([]);
expect(await rt.operatorEgressHosts('gotify')).toEqual([]);
});
});
@@ -132,12 +132,12 @@ describe('settings-page actions (runtime)', () => {
.run(id, key, key, scope);
}
it('ACT-001 — actionsOf returns the declared descriptors of ONE scope', () => {
it('ACT-001 — actionsOf returns the declared descriptors of ONE scope', async () => {
install('p', false);
declareAction('p', 'testConnection');
declareAction('p', 'purge', 'instance');
expect(rt.actionsOf('p', 'user')).toEqual([{ key: 'testConnection', label: 'testConnection', hint: undefined, danger: false, scope: 'user' }]);
expect(rt.actionsOf('p', 'instance')).toEqual([{ key: 'purge', label: 'purge', hint: undefined, danger: false, scope: 'instance' }]);
expect(await rt.actionsOf('p', 'user')).toEqual([{ key: 'testConnection', label: 'testConnection', hint: undefined, danger: false, scope: 'user' }]);
expect(await rt.actionsOf('p', 'instance')).toEqual([{ key: 'purge', label: 'purge', hint: undefined, danger: false, scope: 'instance' }]);
});
it('ACT-002 — invoking an action the plugin never declared is REFUSED', async () => {
+23 -23
View File
@@ -44,9 +44,9 @@ describe('appendAudit hash chain', () => {
let db: ReturnType<typeof makeDb>;
beforeEach(() => { db = makeDb(); });
it('chains each entry off the previous hash (per plugin)', () => {
appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' });
appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:2', code: 'ok' });
it('chains each entry off the previous hash (per plugin)', async () => {
await appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' });
await appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:2', code: 'ok' });
const rows = db.prepare('SELECT prev_hash, hash FROM plugin_capability_audit ORDER BY id').all() as Array<{ prev_hash: string | null; hash: string }>;
expect(rows).toHaveLength(2);
expect(rows[0].prev_hash).toBeNull();
@@ -54,53 +54,53 @@ describe('appendAudit hash chain', () => {
expect(rows[1].hash).not.toBe(rows[0].hash);
});
it('keeps separate chains per plugin', () => {
appendAudit(db, { pluginId: 'a', method: 'trips.getById', resource: 'trip:1', code: 'ok' });
appendAudit(db, { pluginId: 'b', method: 'trips.getById', resource: 'trip:1', code: 'ok' });
it('keeps separate chains per plugin', async () => {
await appendAudit(db, { pluginId: 'a', method: 'trips.getById', resource: 'trip:1', code: 'ok' });
await appendAudit(db, { pluginId: 'b', method: 'trips.getById', resource: 'trip:1', code: 'ok' });
const a = db.prepare("SELECT prev_hash FROM plugin_capability_audit WHERE plugin_id='b'").get() as { prev_hash: string | null };
expect(a.prev_hash).toBeNull(); // b's first entry doesn't chain off a's
});
it('records denials too (code is the error code)', () => {
appendAudit(db, { pluginId: 'p', actingUserId: 99, method: 'trips.getById', resource: 'trip:1', code: 'RESOURCE_FORBIDDEN' });
const row = readAudit(db, 'p')[0] as { code: string; method: string };
it('records denials too (code is the error code)', async () => {
await appendAudit(db, { pluginId: 'p', actingUserId: 99, method: 'trips.getById', resource: 'trip:1', code: 'RESOURCE_FORBIDDEN' });
const row = (await readAudit(db, 'p'))[0] as { code: string; method: string };
expect(row.code).toBe('RESOURCE_FORBIDDEN');
});
it('readAuditForUser returns one user\'s actions across ALL plugins, newest first, with the plugin name', () => {
it('readAuditForUser returns one user\'s actions across ALL plugins, newest first, with the plugin name', async () => {
db.exec("CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT)");
db.prepare("INSERT INTO plugins (id, name) VALUES ('koffi','Koffi'), ('flight','Flight Tracker')").run();
appendAudit(db, { pluginId: 'koffi', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' });
appendAudit(db, { pluginId: 'flight', actingUserId: 42, method: 'reservations.create', resource: 'trip:1', code: 'ok' });
appendAudit(db, { pluginId: 'koffi', actingUserId: 99, method: 'trips.getById', resource: 'trip:2', code: 'ok' }); // another user
const mine = readAuditForUser(db, 42) as Array<{ plugin_id: string; plugin_name: string; method: string }>;
await appendAudit(db, { pluginId: 'koffi', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' });
await appendAudit(db, { pluginId: 'flight', actingUserId: 42, method: 'reservations.create', resource: 'trip:1', code: 'ok' });
await appendAudit(db, { pluginId: 'koffi', actingUserId: 99, method: 'trips.getById', resource: 'trip:2', code: 'ok' }); // another user
const mine = (await readAuditForUser(db, 42)) as Array<{ plugin_id: string; plugin_name: string; method: string }>;
expect(mine).toHaveLength(2); // only user 42's rows, across both plugins
expect(mine[0]).toMatchObject({ plugin_id: 'flight', plugin_name: 'Flight Tracker', method: 'reservations.create' }); // newest first
expect(mine[1]).toMatchObject({ plugin_id: 'koffi', plugin_name: 'Koffi' });
expect(mine.some((r) => r.plugin_id === 'koffi' && r.method === 'trips.getById' && (r as { resource?: string }).resource === 'trip:2')).toBe(false); // never another user's
});
it('readAudit returns newest first with the projected fields only', () => {
appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' });
const rows = readAudit(db, 'p') as Array<Record<string, unknown>>;
it('readAudit returns newest first with the projected fields only', async () => {
await appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' });
const rows = (await readAudit(db, 'p')) as Array<Record<string, unknown>>;
expect(rows[0]).toHaveProperty('method', 'trips.getById');
expect(rows[0]).not.toHaveProperty('prev_hash'); // internal chain fields not exposed
});
it('pruneAudit keeps only the newest N rows per plugin, leaving the retained window chain-consistent', () => {
for (let i = 0; i < 50; i++) appendAudit(db, { pluginId: 'p', actingUserId: 1, method: 'trips.getById', resource: `trip:${i}`, code: 'ok' });
appendAudit(db, { pluginId: 'other', actingUserId: 1, method: 'trips.getById', resource: 'trip:x', code: 'ok' });
pruneAudit(db, 'p', 10);
it('pruneAudit keeps only the newest N rows per plugin, leaving the retained window chain-consistent', async () => {
for (let i = 0; i < 50; i++) await appendAudit(db, { pluginId: 'p', actingUserId: 1, method: 'trips.getById', resource: `trip:${i}`, code: 'ok' });
await appendAudit(db, { pluginId: 'other', actingUserId: 1, method: 'trips.getById', resource: 'trip:x', code: 'ok' });
await pruneAudit(db, 'p', 10);
const rows = db.prepare("SELECT resource, prev_hash, hash FROM plugin_capability_audit WHERE plugin_id = 'p' ORDER BY id ASC").all() as Array<{ resource: string; prev_hash: string | null; hash: string }>;
expect(rows).toHaveLength(10);
expect(rows[rows.length - 1].resource).toBe('trip:49'); // newest kept
// each retained row is still self-consistent: hash === sha256(prev_hash + row-content)
// (proven by re-appending on top — the chain continues from the surviving tip)
appendAudit(db, { pluginId: 'p', actingUserId: 1, method: 'trips.getById', resource: 'trip:new', code: 'ok' });
await appendAudit(db, { pluginId: 'p', actingUserId: 1, method: 'trips.getById', resource: 'trip:new', code: 'ok' });
expect(db.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='p'").get()).toMatchObject({ c: 11 });
// pruning one plugin never touches another's rows
expect(db.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='other'").get()).toMatchObject({ c: 1 });
expect(() => pruneAudit(db, 'p', 0)).not.toThrow(); // 0 = disabled, no-op
await expect(pruneAudit(db, 'p', 0)).resolves.toBeUndefined(); // 0 = disabled, no-op
expect(db.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='p'").get()).toMatchObject({ c: 11 });
});
});
+30 -30
View File
@@ -70,15 +70,15 @@ const req = (p: string, host?: string) => ({ params: { path: p }, get: (h: strin
function runtime(active = true, hosts: string[] = [], operatorHosts: string[] = []): PluginRuntimeService {
return {
isActive: vi.fn(() => active),
outboundHostsOf: vi.fn(() => hosts),
operatorEgressHosts: vi.fn(() => operatorHosts),
outboundHostsOf: vi.fn(async () => hosts),
operatorEgressHosts: vi.fn(async () => operatorHosts),
} as unknown as PluginRuntimeService;
}
describe('PluginFrameController', () => {
it('serves index.html with the opaque-frame CSP + sandbox', () => {
it('serves index.html with the opaque-frame CSP + sandbox', async () => {
const res = fakeRes();
new PluginFrameController(runtime(true, ['api.weather.com'])).serve('widget', req(''), res as never);
await new PluginFrameController(runtime(true, ['api.weather.com'])).serve('widget', req(''), res as never);
expect(res.filePath).toContain(path.join('widget', 'client', 'index.html'));
const csp = res.headers['Content-Security-Policy'];
expect(csp).toContain("default-src 'none'");
@@ -91,53 +91,53 @@ describe('PluginFrameController', () => {
// Under the Nest ExpressAdapter, res.sendFile(absolutePath) resolves against the
// rewritten req.url and 404s spuriously — files must go out root-relative.
it('serves files with an explicit root, not an absolute path', () => {
it('serves files with an explicit root, not an absolute path', async () => {
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
await new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
expect(res.fileRoot).toBe(path.join(codeRoot, 'widget', 'client'));
});
// The frame document runs at an opaque origin, so its own <script src> loads are
// cross-origin fetches; helmet's CORP: same-origin makes the browser drop them
// and a multi-file client dies on boot.
it('marks responses cross-origin loadable for the opaque frame', () => {
it('marks responses cross-origin loadable for the opaque frame', async () => {
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
await new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
expect(res.headers['Cross-Origin-Resource-Policy']).toBe('cross-origin');
});
// An operatorEgress plugin's hosts are supplied by the ADMIN after install, so they are not
// in the manifest grants. activate() unions them into the child's egress guard; the frame
// must match, or a plugin with a UI can call the host from its server but not its iframe.
it('includes admin-supplied operatorEgress hosts in connect-src', () => {
it('includes admin-supplied operatorEgress hosts in connect-src', async () => {
const res = fakeRes();
new PluginFrameController(runtime(true, ['api.weather.com'], ['gotify.home.lan'])).serve('widget', req(''), res as never);
await new PluginFrameController(runtime(true, ['api.weather.com'], ['gotify.home.lan'])).serve('widget', req(''), res as never);
const csp = res.headers['Content-Security-Policy'];
expect(csp).toContain('https://api.weather.com');
expect(csp).toContain('https://gotify.home.lan');
});
it('does not duplicate a host granted in the manifest AND added by the admin', () => {
it('does not duplicate a host granted in the manifest AND added by the admin', async () => {
const res = fakeRes();
new PluginFrameController(runtime(true, ['ntfy.sh'], ['ntfy.sh'])).serve('widget', req(''), res as never);
await new PluginFrameController(runtime(true, ['ntfy.sh'], ['ntfy.sh'])).serve('widget', req(''), res as never);
const csp = res.headers['Content-Security-Policy'];
expect(csp.match(/https:\/\/ntfy\.sh/g)).toHaveLength(1);
});
it('refuses to interpolate a malformed operator host into the CSP', () => {
it('refuses to interpolate a malformed operator host into the CSP', async () => {
const res = fakeRes();
// The admin writer validates hosts, but connect-src is the last line of defence: a token
// with a space or a bare * would widen the whole policy.
new PluginFrameController(runtime(true, [], ['evil.com https://*', '*'])).serve('widget', req(''), res as never);
await new PluginFrameController(runtime(true, [], ['evil.com https://*', '*'])).serve('widget', req(''), res as never);
const csp = res.headers['Content-Security-Policy'];
expect(csp).toContain("connect-src 'self'");
expect(csp).not.toContain('evil.com');
expect(csp).not.toContain('*');
});
it('allows the plugin its own static assets when the Host header is clean', () => {
it('allows the plugin its own static assets when the Host header is clean', async () => {
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req('', 'trek.example.com:8443'), res as never);
await new PluginFrameController(runtime(true)).serve('widget', req('', 'trek.example.com:8443'), res as never);
const csp = res.headers['Content-Security-Policy'];
// Scheme-less on purpose: matches the frame's own http AND https documents.
expect(csp).toContain("script-src 'self' 'unsafe-inline' trek.example.com:8443/plugin-frame/widget/");
@@ -145,54 +145,54 @@ describe('PluginFrameController', () => {
expect(csp).toContain("connect-src 'self' trek.example.com:8443/plugin-frame/widget/");
});
it('drops the own-assets source on a malformed Host header instead of widening the policy', () => {
it('drops the own-assets source on a malformed Host header instead of widening the policy', async () => {
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req('', 'evil.com *'), res as never);
await new PluginFrameController(runtime(true)).serve('widget', req('', 'evil.com *'), res as never);
const csp = res.headers['Content-Security-Policy'];
expect(csp).toContain("script-src 'self' 'unsafe-inline';");
expect(csp).not.toContain('evil.com');
});
it('404 when the plugin is inactive', () => {
it('404 when the plugin is inactive', async () => {
const res = fakeRes();
new PluginFrameController(runtime(false)).serve('widget', req(''), res as never);
await new PluginFrameController(runtime(false)).serve('widget', req(''), res as never);
expect(res.statusCode).toBe(404);
});
it('404 when the runtime is disabled', () => {
it('404 when the runtime is disabled', async () => {
pluginsEnabledMock.mockReturnValueOnce(false);
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
await new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
expect(res.statusCode).toBe(404);
});
it('403 on a path-traversal attempt', () => {
it('403 on a path-traversal attempt', async () => {
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req('../../../etc/passwd'), res as never);
await new PluginFrameController(runtime(true)).serve('widget', req('../../../etc/passwd'), res as never);
expect(res.statusCode).toBe(403);
});
it('404 for a missing file', () => {
it('404 for a missing file', async () => {
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req('missing.js'), res as never);
await new PluginFrameController(runtime(true)).serve('widget', req('missing.js'), res as never);
expect(res.statusCode).toBe(404);
});
// The lexical guard above cannot see through a link, and statSync follows one.
it('403 on a symlink inside client/ that points out of the plugin', () => {
it('403 on a symlink inside client/ that points out of the plugin', async () => {
if (!symlinksAvailable) return;
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req('esc/leak.js'), res as never);
await new PluginFrameController(runtime(true)).serve('widget', req('esc/leak.js'), res as never);
expect(res.statusCode).toBe(403);
expect(res.filePath).toBeUndefined();
});
// Dev-link mode: the plugin directory itself is a link, so the containment
// check has to resolve BOTH sides or local development stops working.
it('still serves a dev-linked plugin whose whole tree is a symlink', () => {
it('still serves a dev-linked plugin whose whole tree is a symlink', async () => {
if (!symlinksAvailable) return;
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('linked', req(''), res as never);
await new PluginFrameController(runtime(true)).serve('linked', req(''), res as never);
expect(res.statusCode).toBe(200);
expect(res.filePath).toContain('index.html');
});
+10 -10
View File
@@ -47,39 +47,39 @@ function build(overrides: { role?: string | undefined; allow?: boolean; addonOn?
}
describe('PluginGuards — tripRead', () => {
it('PGUARD-001 runs the read for a member and hands it the acting user', () => {
it('PGUARD-001 runs the read for a member and hands it the acting user', async () => {
const { guards } = build();
const read = vi.fn((userId: number) => ({ seenBy: userId }));
expect(guards.tripRead({ tripId: 1 }, ctx(42), read)).toEqual({ seenBy: 42 });
expect(await guards.tripRead({ tripId: 1 }, ctx(42), read)).toEqual({ seenBy: 42 });
expect(read).toHaveBeenCalledWith(42);
});
it('PGUARD-002 a userless context is refused before the read runs', () => {
it('PGUARD-002 a userless context is refused before the read runs', async () => {
const { guards } = build();
const read = vi.fn();
expect(() => guards.tripRead({ tripId: 1 }, ctx(undefined), read)).toThrow(
await expect(guards.tripRead({ tripId: 1 }, ctx(undefined), read)).rejects.toThrow(
new ForbiddenResource('trip reads require an authenticated user context'),
);
expect(read).not.toHaveBeenCalled();
});
it('PGUARD-003 a non-member is refused, naming the trip', () => {
it('PGUARD-003 a non-member is refused, naming the trip', async () => {
const { guards } = build();
const read = vi.fn();
expect(() => guards.tripRead({ tripId: 2 }, ctx(42), read)).toThrow(
await expect(guards.tripRead({ tripId: 2 }, ctx(42), read)).rejects.toThrow(
new ForbiddenResource('no access to trip 2'),
);
expect(read).not.toHaveBeenCalled();
});
it('PGUARD-004 a missing tripId is BAD_PARAMS, not a refusal', () => {
it('PGUARD-004 a missing tripId is BAD_PARAMS, not a refusal', async () => {
const { guards } = build();
expect(() => guards.tripRead({}, ctx(42), vi.fn())).toThrow(new BadParams('tripId must be a number'));
await expect(guards.tripRead({}, ctx(42), vi.fn())).rejects.toThrow(new BadParams('tripId must be a number'));
});
it('PGUARD-005 a numeric string tripId is accepted, as the wire contract allows', () => {
it('PGUARD-005 a numeric string tripId is accepted, as the wire contract allows', async () => {
const { guards } = build();
expect(guards.tripRead({ tripId: '1' }, ctx(42), (u) => u)).toBe(42);
expect(await guards.tripRead({ tripId: '1' }, ctx(42), (u) => u)).toBe(42);
});
});
@@ -63,7 +63,7 @@ const svc = () => {
* `parseSettingDefault` in manifest.ts, not just the settingsFields() read path.
*/
let codeRoot: string;
function installFixturePlugin(opts: { settings: Array<Record<string, unknown>> }) {
async function installFixturePlugin(opts: { settings: Array<Record<string, unknown>> }) {
const dir = path.join(codeRoot, 'fixture-id', 'server');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
@@ -71,7 +71,7 @@ function installFixturePlugin(opts: { settings: Array<Record<string, unknown>> }
JSON.stringify({ id: 'fixture-id', name: 'Fixture', version: '1.0.0', type: 'integration', trek: '>=4.0.0 <5.0.0', settings: opts.settings }),
);
fs.writeFileSync(path.join(dir, 'index.js'), 'module.exports={}');
discoverPlugins(testDb);
await discoverPlugins(testDb);
}
beforeAll(() => {
@@ -91,22 +91,22 @@ afterEach(() => {
});
describe('instance settings fields', () => {
it('INS-001 — lists only the instance-scope fields, in declared order, with form metadata', () => {
it('INS-001 — lists only the instance-scope fields, in declared order, with form metadata', async () => {
install('p');
declareField('p', 'apiUrl', 'instance', { required: true, sortOrder: 1 });
declareField('p', 'apiKey', 'instance', { secret: true, sortOrder: 0 });
declareField('p', 'units', 'user', { sortOrder: 2 }); // must never leak into the admin form
const fields = svc().instanceSettingsFields('p');
const fields = await svc().instanceSettingsFields('p');
expect(fields.map((f) => f.key)).toEqual(['apiKey', 'apiUrl']);
expect(fields[0]).toMatchObject({ secret: true, required: false, input_type: 'text' });
expect(fields[1]).toMatchObject({ secret: false, required: true });
});
it('INS-002 — parses select options like the user form does', () => {
it('INS-002 — parses select options like the user form does', async () => {
install('p');
declareField('p', 'mode', 'instance', { options: '["fast","slow"]' });
expect(svc().instanceSettingsFields('p')[0].options).toEqual(['fast', 'slow']);
expect((await svc().instanceSettingsFields('p'))[0].options).toEqual(['fast', 'slow']);
});
it('INS-003 — the admin list carries the instance-field count (gates the menu item)', async () => {
@@ -137,14 +137,14 @@ describe('instance settings fields', () => {
testDb.prepare("DELETE FROM plugin_actions WHERE plugin_id = 'with-action'").run();
});
it('INS-010 — persists a settings-field default and serves it on the fields list', () => {
installFixturePlugin({ settings: [{ key: 'oauth_authorize_url', required: true, default: 'https://auth.openbnb.org/authorize' }] });
const fields = svc().instanceSettingsFields('fixture-id');
it('INS-010 — persists a settings-field default and serves it on the fields list', async () => {
await installFixturePlugin({ settings: [{ key: 'oauth_authorize_url', required: true, default: 'https://auth.openbnb.org/authorize' }] });
const fields = await svc().instanceSettingsFields('fixture-id');
expect(fields[0].default).toBe('https://auth.openbnb.org/authorize');
});
it('INS-013 — drops a default the field cannot take: non-boolean on a checkbox, or not one of the select options', () => {
installFixturePlugin({
it('INS-013 — drops a default the field cannot take: non-boolean on a checkbox, or not one of the select options', async () => {
await installFixturePlugin({
settings: [
{ key: 'on', input_type: 'checkbox', default: 'true' },
{ key: 'mode', input_type: 'select', options: ['fast', 'slow'], default: 'warp' },
@@ -152,60 +152,60 @@ describe('instance settings fields', () => {
{ key: 'on_ok', input_type: 'checkbox', default: true },
],
});
const byKey = Object.fromEntries(svc().instanceSettingsFields('fixture-id').map((f) => [f.key, f.default]));
const byKey = Object.fromEntries((await svc().instanceSettingsFields('fixture-id')).map((f) => [f.key, f.default]));
expect(byKey).toEqual({ on: undefined, mode: undefined, mode_ok: 'a', on_ok: true });
});
it('INS-011 — drops a default on a secret field at parse time', () => {
installFixturePlugin({ settings: [{ key: 'token', secret: true, default: 'leak' }] });
expect(svc().instanceSettingsFields('fixture-id')[0].default).toBeUndefined();
it('INS-011 — drops a default on a secret field at parse time', async () => {
await installFixturePlugin({ settings: [{ key: 'token', secret: true, default: 'leak' }] });
expect((await svc().instanceSettingsFields('fixture-id'))[0].default).toBeUndefined();
});
});
describe('required settings are enforced on save', () => {
it('refuses a save that leaves a required instance field empty', () => {
installFixturePlugin({ settings: [{ key: 'client_id', required: true }] });
expect(() => svc().updateInstanceConfig('fixture-id', { client_id: ' ' })).toThrow(/Missing required setting "client_id"/);
it('refuses a save that leaves a required instance field empty', async () => {
await installFixturePlugin({ settings: [{ key: 'client_id', required: true }] });
await expect(svc().updateInstanceConfig('fixture-id', { client_id: ' ' })).rejects.toThrow(/Missing required setting "client_id"/);
});
it('accepts a partial patch when the required field is already stored', () => {
installFixturePlugin({ settings: [{ key: 'client_id', required: true }, { key: 'note', required: false }] });
it('accepts a partial patch when the required field is already stored', async () => {
await installFixturePlugin({ settings: [{ key: 'client_id', required: true }, { key: 'note', required: false }] });
const s = svc();
s.updateInstanceConfig('fixture-id', { client_id: 'abc' });
expect(() => s.updateInstanceConfig('fixture-id', { note: 'hi' })).not.toThrow();
await s.updateInstanceConfig('fixture-id', { client_id: 'abc' });
await expect(s.updateInstanceConfig('fixture-id', { note: 'hi' })).resolves.toBeDefined();
});
it('accepts a user-scope partial patch when the required user field is already stored', () => {
installFixturePlugin({ settings: [{ key: 'api_key', scope: 'user', required: true }, { key: 'units', scope: 'user' }] });
it('accepts a user-scope partial patch when the required user field is already stored', async () => {
await installFixturePlugin({ settings: [{ key: 'api_key', scope: 'user', required: true }, { key: 'units', scope: 'user' }] });
const s = svc();
s.updateUserConfig('fixture-id', 1, { api_key: 'sk-1' });
expect(() => s.updateUserConfig('fixture-id', 1, { units: 'metric' })).not.toThrow();
await s.updateUserConfig('fixture-id', 1, { api_key: 'sk-1' });
await expect(s.updateUserConfig('fixture-id', 1, { units: 'metric' })).resolves.toBeDefined();
});
it('refuses a user-settings save that leaves a required user field empty', () => {
installFixturePlugin({ settings: [{ key: 'api_key', scope: 'user', required: true }] });
expect(() => svc().updateUserConfig('fixture-id', 1, { api_key: '' })).toThrow(/Missing required setting "api_key"/);
it('refuses a user-settings save that leaves a required user field empty', async () => {
await installFixturePlugin({ settings: [{ key: 'api_key', scope: 'user', required: true }] });
await expect(svc().updateUserConfig('fixture-id', 1, { api_key: '' })).rejects.toThrow(/Missing required setting "api_key"/);
});
it('exempts checkbox fields from required enforcement (consent, not a settings field)', () => {
installFixturePlugin({ settings: [{ key: 'accept_terms', input_type: 'checkbox', required: true }, { key: 'note', required: false }] });
it('exempts checkbox fields from required enforcement (consent, not a settings field)', async () => {
await installFixturePlugin({ settings: [{ key: 'accept_terms', input_type: 'checkbox', required: true }, { key: 'note', required: false }] });
// accept_terms is left entirely unset (never patched, nothing stored) — a
// non-checkbox required field in this state would throw.
expect(() => svc().updateInstanceConfig('fixture-id', { note: 'hi' })).not.toThrow();
await expect(svc().updateInstanceConfig('fixture-id', { note: 'hi' })).resolves.toBeDefined();
});
it('a required field with a manifest default is satisfied by the default', () => {
installFixturePlugin({ settings: [{ key: 'region', required: true, default: 'eu' }, { key: 'note', required: false }] });
it('a required field with a manifest default is satisfied by the default', async () => {
await installFixturePlugin({ settings: [{ key: 'region', required: true, default: 'eu' }, { key: 'note', required: false }] });
// region is never patched and nothing is stored — the runtime will see the default,
// so refusing the save here would contradict what the child actually gets.
expect(() => svc().updateInstanceConfig('fixture-id', { note: 'hi' })).not.toThrow();
await expect(svc().updateInstanceConfig('fixture-id', { note: 'hi' })).resolves.toBeDefined();
});
it('a stored secret (non-empty ciphertext) counts as filled on a later partial patch', () => {
installFixturePlugin({ settings: [{ key: 'api_key', secret: true, required: true }, { key: 'note', required: false }] });
it('a stored secret (non-empty ciphertext) counts as filled on a later partial patch', async () => {
await installFixturePlugin({ settings: [{ key: 'api_key', secret: true, required: true }, { key: 'note', required: false }] });
const s = svc();
s.updateInstanceConfig('fixture-id', { api_key: 'sk-real' });
expect(() => s.updateInstanceConfig('fixture-id', { api_key: '••••••••', note: 'hi' })).not.toThrow();
await s.updateInstanceConfig('fixture-id', { api_key: 'sk-real' });
await expect(s.updateInstanceConfig('fixture-id', { api_key: '••••••••', note: 'hi' })).resolves.toBeDefined();
});
});
@@ -239,12 +239,12 @@ describe('admin config endpoints (controller)', () => {
{ isManaged: () => false } as unknown as RuntimeEnvService,
);
it('INS-006 — GET :id/config returns the fields alongside the (masked) values', () => {
it('INS-006 — GET :id/config returns the fields alongside the (masked) values', async () => {
install('p');
declareField('p', 'apiUrl', 'instance');
testDb.prepare("UPDATE plugins SET config = '{\"apiUrl\":\"https://x.example\"}' WHERE id = 'p'").run();
const out = controllerWith({ actionsOf: () => [] }).getConfig('p');
const out = await controllerWith({ actionsOf: async () => [] }).getConfig('p');
expect(out.config).toEqual({ apiUrl: 'https://x.example' });
expect(out.fields.map((f: Record<string, unknown>) => f.key)).toEqual(['apiUrl']);
});
@@ -292,7 +292,7 @@ describe('admin config endpoints (controller)', () => {
expect(failed?.getStatus()).toBe(503);
// The respawn is a spawn: it must not run while the whole plugin system is off.
expect(respawnIfActive).not.toHaveBeenCalled();
expect(svc().getInstanceConfig('p')).toEqual({});
expect(await svc().getInstanceConfig('p')).toEqual({});
});
it('INS-011: a respawn that fails reports the save that DID happen, and stops claiming the plugin runs', async () => {
@@ -315,7 +315,7 @@ describe('admin config endpoints (controller)', () => {
config: { apiUrl: 'https://y.example' },
error: expect.stringMatching(/Settings saved.*db:read:trips/),
});
expect(svc().getInstanceConfig('p')).toEqual({ apiUrl: 'https://y.example' });
expect(await svc().getInstanceConfig('p')).toEqual({ apiUrl: 'https://y.example' });
// disable() leaves the row enabled, so the admin list would keep showing a plugin
// that no longer has a child. The enable toggle is where the reason is offered.
expect(deactivate).toHaveBeenCalledWith('p');
@@ -324,7 +324,7 @@ describe('admin config endpoints (controller)', () => {
describe('defaults reach the child at spawn', () => {
it('INS-012 — an unset instance field is handed to the child as its manifest default; a stored value wins', async () => {
installFixturePlugin({
await installFixturePlugin({
settings: [
{ key: 'api_url', default: 'https://api.example' },
{ key: 'retries', input_type: 'number', default: 3 },
@@ -332,7 +332,7 @@ describe('defaults reach the child at spawn', () => {
],
});
const s = svc();
s.updateInstanceConfig('fixture-id', { api_url: 'https://mine.example' });
await s.updateInstanceConfig('fixture-id', { api_url: 'https://mine.example' });
const rt = await createPluginRuntime(new DatabaseService(dbConn));
const sup = (rt as unknown as { supervisor: { activate: (...a: unknown[]) => Promise<void> } }).supervisor;
@@ -380,7 +380,7 @@ describe('instance-scope actions (admin)', () => {
declareAction('p', 'purge', 'instance');
declareAction('p', 'testConnection', 'user');
const { c } = await controller();
expect(c.getConfig('p').actions).toEqual([{ key: 'purge', label: 'purge', hint: undefined, danger: false, scope: 'instance' }]);
expect((await c.getConfig('p')).actions).toEqual([{ key: 'purge', label: 'purge', hint: undefined, danger: false, scope: 'instance' }]);
});
it('ACT-ADM-002 — POST runs the action as the clicking admin in the instance scope', async () => {
@@ -61,9 +61,9 @@ afterEach(() => {
});
describe('the advertised surface', () => {
it('MCPTOOLS-001: advertises a declared and implemented tool under the prefixed name', () => {
it('MCPTOOLS-001: advertises a declared and implemented tool under the prefixed name', async () => {
const { svc } = makeService();
const tools = svc.mcpTools(ctx);
const tools = await svc.mcpTools(ctx);
expect(tools).toHaveLength(1);
expect(tools[0].options.name).toBe('plugin_weather_forecast');
@@ -71,32 +71,32 @@ describe('the advertised surface', () => {
expect(tools[0].options.access).toEqual({ group: 'plugins', mode: 'use' });
});
it('MCPTOOLS-002: drops a tool the manifest declares but the build does not implement', () => {
it('MCPTOOLS-002: drops a tool the manifest declares but the build does not implement', async () => {
const { svc } = makeService({ implemented: { weather: [] } });
expect(svc.mcpTools(ctx)).toEqual([]);
expect(await svc.mcpTools(ctx)).toEqual([]);
});
it('MCPTOOLS-003: drops a tool the build reports but the manifest never declared', () => {
it('MCPTOOLS-003: drops a tool the build reports but the manifest never declared', async () => {
// The manifest is signed and re-consented; the loaded report is neither.
const { svc } = makeService({ declared: { weather: [] }, implemented: { weather: ['sneaky'] } });
expect(svc.mcpTools(ctx)).toEqual([]);
expect(await svc.mcpTools(ctx)).toEqual([]);
});
it('MCPTOOLS-004: a plugin without the grant never reaches the surface', () => {
it('MCPTOOLS-004: a plugin without the grant never reaches the surface', async () => {
// providersOf already filters on active AND reported AND granted.
const { svc, hooks } = makeService({ providers: [] });
expect(svc.mcpTools(ctx)).toEqual([]);
expect(await svc.mcpTools(ctx)).toEqual([]);
expect(hooks.providersOf).toHaveBeenCalledWith('mcpToolProvider');
});
it('MCPTOOLS-005: the kill switch empties the surface', () => {
it('MCPTOOLS-005: the kill switch empties the surface', async () => {
pluginsOn = false;
const { svc, hooks } = makeService();
expect(svc.mcpTools(ctx)).toEqual([]);
expect(await svc.mcpTools(ctx)).toEqual([]);
expect(hooks.providersOf).not.toHaveBeenCalled();
});
it('MCPTOOLS-006: one failing plugin costs only its own tools', () => {
it('MCPTOOLS-006: one failing plugin costs only its own tools', async () => {
const { svc } = makeService({
providers: ['broken', 'weather'],
declared: {
@@ -106,20 +106,20 @@ describe('the advertised surface', () => {
implemented: { weather: ['forecast'], broken: ['x'] },
});
const tools = svc.mcpTools(ctx);
const tools = await svc.mcpTools(ctx);
expect(tools.map((t) => t.options.name)).toEqual(['plugin_weather_forecast']);
});
it('MCPTOOLS-007: caps the tools one plugin may contribute', () => {
it('MCPTOOLS-007: caps the tools one plugin may contribute', async () => {
const many = Array.from({ length: 12 }, (_, i) => ({ ...weatherTool, name: `t${i}` }));
const { svc } = makeService({
declared: { weather: many },
implemented: { weather: many.map((t) => t.name) },
});
expect(svc.mcpTools(ctx)).toHaveLength(8);
expect(await svc.mcpTools(ctx)).toHaveLength(8);
});
it('MCPTOOLS-008: caps the whole surface and says so rather than truncating silently', () => {
it('MCPTOOLS-008: caps the whole surface and says so rather than truncating silently', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const eight = Array.from({ length: 8 }, (_, i) => ({ ...weatherTool, name: `t${i}` }));
const providers = ['p1', 'p2', 'p3', 'p4', 'p5'];
@@ -131,22 +131,22 @@ describe('the advertised surface', () => {
}
const { svc } = makeService({ providers, declared, implemented });
expect(svc.mcpTools(ctx)).toHaveLength(32);
expect(await svc.mcpTools(ctx)).toHaveLength(32);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('dropped'));
});
it('MCPTOOLS-009: clamps annotations against the grants the plugin actually holds', () => {
it('MCPTOOLS-009: clamps annotations against the grants the plugin actually holds', async () => {
const claiming = [{ ...weatherTool, annotations: { readOnlyHint: true } }];
const { svc } = makeService({
declared: { weather: claiming },
grants: { weather: ['db:write:trips'] },
});
expect(svc.mcpTools(ctx)[0].options.annotations).toMatchObject({ readOnlyHint: false });
expect((await svc.mcpTools(ctx))[0].options.annotations).toMatchObject({ readOnlyHint: false });
});
it('MCPTOOLS-010: never advertises _meta or an outputSchema', () => {
it('MCPTOOLS-010: never advertises _meta or an outputSchema', async () => {
const { svc } = makeService({ declared: { weather: [{ ...weatherTool, _meta: { x: 1 } }] } });
const options = svc.mcpTools(ctx)[0].options;
const options = (await svc.mcpTools(ctx))[0].options;
expect(options._meta).toBeUndefined();
expect(options.outputSchema).toBeUndefined();
});
@@ -154,7 +154,7 @@ describe('the advertised surface', () => {
describe('invoking a tool', () => {
const invoke = async (svc: PluginMcpToolsService, args: unknown = { city: 'Lisbon' }) => {
const tool = svc.mcpTools(ctx)[0];
const tool = (await svc.mcpTools(ctx))[0];
return (await tool.handler(args, ctx)) as { content: Array<{ text: string }>; isError?: boolean };
};
@@ -191,7 +191,7 @@ describe('invoking a tool', () => {
it('MCPTOOLS-015: refuses once the kill switch goes off mid-session', async () => {
const { svc, callTool } = makeService();
const tool = svc.mcpTools(ctx)[0];
const tool = (await svc.mcpTools(ctx))[0];
pluginsOn = false;
const res = (await tool.handler({}, ctx)) as { isError?: boolean };
expect(res.isError).toBe(true);
@@ -200,29 +200,29 @@ describe('invoking a tool', () => {
});
describe('toMcpTextResult', () => {
it('MCPTOOLS-016: preserves a well-formed result, isError included', () => {
it('MCPTOOLS-016: preserves a well-formed result, isError included', async () => {
const given = { content: [{ type: 'text', text: 'hi' }], isError: true };
expect(toMcpTextResult(given)).toEqual(given);
});
it('MCPTOOLS-017: wraps a bare string', () => {
it('MCPTOOLS-017: wraps a bare string', async () => {
expect(toMcpTextResult('hello')).toEqual({ content: [{ type: 'text', text: 'hello' }] });
});
it('MCPTOOLS-018: survives a value that cannot be serialised', () => {
it('MCPTOOLS-018: survives a value that cannot be serialised', async () => {
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
const res = toMcpTextResult(cyclic) as { isError?: boolean };
expect(res.isError).toBe(true);
});
it('MCPTOOLS-019: caps a flood so a plugin cannot fill the assistant context', () => {
it('MCPTOOLS-019: caps a flood so a plugin cannot fill the assistant context', async () => {
const huge = 'x'.repeat(200_000);
const res = toMcpTextResult(huge);
expect(res.content[0].text.length).toBeLessThanOrEqual(64 * 1024);
});
it('MCPTOOLS-020: the budget covers the WHOLE result, not each block', () => {
it('MCPTOOLS-020: the budget covers the WHOLE result, not each block', async () => {
// Per-block slicing is not a limit: 100 blocks of the maximum size is 100
// times the maximum. This shape produced 6.5M characters before the fix.
const many = { content: Array.from({ length: 100 }, () => ({ type: 'text', text: 'x'.repeat(200_000) })) };
@@ -231,14 +231,14 @@ describe('toMcpTextResult', () => {
expect(total).toBeLessThan(70 * 1024);
});
it('MCPTOOLS-021: caps the number of content blocks and says it truncated', () => {
it('MCPTOOLS-021: caps the number of content blocks and says it truncated', async () => {
const many = { content: Array.from({ length: 500 }, (_, i) => ({ type: 'text', text: `b${i}` })) };
const res = toMcpTextResult(many);
expect(res.content.length).toBeLessThanOrEqual(33);
expect(res.content[res.content.length - 1].text).toContain('truncated');
});
it('MCPTOOLS-022: bounds a huge object without serialising it whole first', () => {
it('MCPTOOLS-022: bounds a huge object without serialising it whole first', async () => {
const fat = { rows: Array.from({ length: 20_000 }, (_, i) => ({ i, blob: 'y'.repeat(500) })) };
const res = toMcpTextResult(fat);
const total = res.content.reduce((n, c) => n + c.text.length, 0);
@@ -26,10 +26,10 @@ function fakeRes() {
}
function ctrl(over: Partial<PluginOAuthService> = {}) {
const svc = {
status: vi.fn(() => ({ configured: true, connected: false })),
startConnect: vi.fn(() => 'https://provider.example/auth?state=s'),
status: vi.fn(async () => ({ configured: true, connected: false })),
startConnect: vi.fn(async () => 'https://provider.example/auth?state=s'),
completeCallback: vi.fn(async () => undefined),
disconnect: vi.fn(),
disconnect: vi.fn(async () => undefined),
...over,
} as unknown as PluginOAuthService;
return { c: new PluginOAuthController(svc, new DatabaseService(dbConn)), svc };
@@ -38,19 +38,19 @@ function ctrl(over: Partial<PluginOAuthService> = {}) {
describe('PluginOAuthController', () => {
beforeEach(() => { pluginsEnabled.mockReturnValue(true); getMock.mockReturnValue({ 1: 1 }); });
it('status is gated: runtime off / no user / inactive → not configured', () => {
expect(ctrl().c.status('p', req(5))).toEqual({ configured: true, connected: false });
it('status is gated: runtime off / no user / inactive → not configured', async () => {
expect(await ctrl().c.status('p', req(5))).toEqual({ configured: true, connected: false });
pluginsEnabled.mockReturnValue(false);
expect(ctrl().c.status('p', req(5))).toEqual({ configured: false, connected: false });
expect(await ctrl().c.status('p', req(5))).toEqual({ configured: false, connected: false });
pluginsEnabled.mockReturnValue(true);
expect(ctrl().c.status('p', req(undefined))).toEqual({ configured: false, connected: false });
expect(await ctrl().c.status('p', req(undefined))).toEqual({ configured: false, connected: false });
getMock.mockReturnValue(undefined as never); // inactive
expect(ctrl().c.status('p', req(5))).toEqual({ configured: false, connected: false });
expect(await ctrl().c.status('p', req(5))).toEqual({ configured: false, connected: false });
});
it('connect returns the authorize URL for a bound user', () => {
it('connect returns the authorize URL for a bound user', async () => {
const { c, svc } = ctrl();
expect(c.connect('p', req(5))).toEqual({ authorizeUrl: 'https://provider.example/auth?state=s' });
expect(await c.connect('p', req(5))).toEqual({ authorizeUrl: 'https://provider.example/auth?state=s' });
expect(svc.startConnect).toHaveBeenCalledWith('p', 5, expect.any(Number));
});
@@ -68,9 +68,9 @@ describe('PluginOAuthController', () => {
expect(denyRes.redirectedTo).toBe('/settings?oauth=p:denied');
});
it('disconnect delegates for a bound user', () => {
it('disconnect delegates for a bound user', async () => {
const { c, svc } = ctrl();
expect(c.disconnect('p', req(5))).toEqual({ connected: false });
expect(await c.disconnect('p', req(5))).toEqual({ connected: false });
expect(svc.disconnect).toHaveBeenCalledWith('p', 5);
});
});
@@ -58,13 +58,13 @@ describe('PluginOAuthService', () => {
let svc: PluginOAuthService;
beforeEach(() => { getDb.current = freshDb(); svc = new PluginOAuthService(new DatabaseService(dbConn)); vi.restoreAllMocks(); dnsState.address = '93.184.216.34'; dnsState.family = 4; });
it('providerConfig returns null unless every piece is present, decrypting the secrets', () => {
expect(svc.providerConfig('p')).toMatchObject({ clientId: 'client-123', clientSecret: 'secret-abc', scopes: 'read write' });
it('providerConfig returns null unless every piece is present, decrypting the secrets', async () => {
expect(await svc.providerConfig('p')).toMatchObject({ clientId: 'client-123', clientSecret: 'secret-abc', scopes: 'read write' });
getDb.current = freshDb({ ...CFG, oauth_client_secret: '' });
expect(new PluginOAuthService(new DatabaseService(dbConn)).providerConfig('p')).toBeNull();
expect(await new PluginOAuthService(new DatabaseService(dbConn)).providerConfig('p')).toBeNull();
});
it('providerConfig falls back to the manifest defaults for the endpoints the admin left unset', () => {
it('providerConfig falls back to the manifest defaults for the endpoints the admin left unset', async () => {
// A provider plugin ships its authorize/token URLs as defaults; the admin only ever
// types the client id/secret. Those two are secrets and can never carry a default.
const { oauth_authorize_url: _a, oauth_token_url: _t, oauth_scopes: _s, ...stored } = CFG;
@@ -75,7 +75,7 @@ describe('PluginOAuthService', () => {
ins.run('oauth_token_url', JSON.stringify('https://provider.example/token'));
ins.run('oauth_scopes', JSON.stringify('read'));
svc = new PluginOAuthService(new DatabaseService(dbConn));
expect(svc.providerConfig('p')).toEqual({
expect(await svc.providerConfig('p')).toEqual({
authorizeUrl: 'https://provider.example/authorize',
tokenUrl: 'https://provider.example/token',
scopes: 'read',
@@ -84,8 +84,8 @@ describe('PluginOAuthService', () => {
});
});
it('startConnect builds a PKCE authorize URL + persists a single fresh state per user', () => {
const url = new URL(svc.startConnect('p', 42, NOW));
it('startConnect builds a PKCE authorize URL + persists a single fresh state per user', async () => {
const url = new URL(await svc.startConnect('p', 42, NOW));
expect(url.origin + url.pathname).toBe('https://provider.example/authorize');
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
expect(url.searchParams.get('client_id')).toBe('client-123');
@@ -96,28 +96,28 @@ describe('PluginOAuthService', () => {
const stored = rows.prepare('SELECT verifier, user_id FROM plugin_oauth_state WHERE state = ?').get(state) as { verifier: string; user_id: number };
expect(stored.user_id).toBe(42);
// a second connect replaces the first (one live state per user)
svc.startConnect('p', 42, NOW);
await svc.startConnect('p', 42, NOW);
expect((rows.prepare('SELECT COUNT(*) c FROM plugin_oauth_state WHERE user_id = 42').get() as { c: number }).c).toBe(1);
});
it('rejects a non-https / loopback / metadata / internal authorize endpoint', () => {
it('rejects a non-https / loopback / metadata / internal authorize endpoint', async () => {
getDb.current = freshDb({ ...CFG, oauth_authorize_url: 'http://provider.example/authorize' });
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/https/);
await expect(new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).rejects.toThrow(/https/);
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://127.0.0.1/token' });
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback|private/);
await expect(new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).rejects.toThrow(/loopback|private/);
// IPv6-literal loopback must not slip past the fast-fail
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://[::1]/token' });
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback/);
await expect(new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).rejects.toThrow(/loopback/);
// cloud-metadata by literal is refused too
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://169.254.169.254/token' });
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback|metadata/);
await expect(new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).rejects.toThrow(/loopback|metadata/);
// an internal name suffix is refused
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://idp.internal/token' });
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/local/);
await expect(new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).rejects.toThrow(/local/);
});
it('completeCallback verifies state (single-use, user-bound, TTL), exchanges the code, encrypts tokens', async () => {
const url = new URL(svc.startConnect('p', 42, NOW));
const url = new URL(await svc.startConnect('p', 42, NOW));
const state = url.searchParams.get('state')!;
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true, json: async () => ({ access_token: 'AT', refresh_token: 'RT', expires_in: 3600, scope: 'read' }),
@@ -134,15 +134,15 @@ describe('PluginOAuthService', () => {
const tok = rows.prepare('SELECT access_token, refresh_token FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = 42').get('p') as { access_token: string; refresh_token: string };
expect(tok.access_token).toBe('enc:AT'); // encrypted at rest
expect(tok.refresh_token).toBe('enc:RT');
expect(svc.status('p', 42)).toMatchObject({ configured: true, connected: true });
expect(await svc.status('p', 42)).toMatchObject({ configured: true, connected: true });
// state is single-use — replaying it fails
await expect(svc.completeCallback('p', 42, 'the-code', state, NOW + 2000)).rejects.toThrow(/state/);
});
it('rejects a foreign or expired state', async () => {
const state = new URL(svc.startConnect('p', 42, NOW)).searchParams.get('state')!;
const state = new URL(await svc.startConnect('p', 42, NOW)).searchParams.get('state')!;
await expect(svc.completeCallback('p', 99, 'code', state, NOW + 1000)).rejects.toThrow(/state/); // wrong user
const state2 = new URL(svc.startConnect('p', 42, NOW)).searchParams.get('state')!;
const state2 = new URL(await svc.startConnect('p', 42, NOW)).searchParams.get('state')!;
await expect(svc.completeCallback('p', 42, 'code', state2, NOW + 20 * 60 * 1000)).rejects.toThrow(/state/); // > 10 min
});
@@ -166,7 +166,7 @@ describe('PluginOAuthService', () => {
});
it('routes the token exchange through the SSRF guard — a token_url resolving to cloud metadata is refused', async () => {
const state = new URL(svc.startConnect('p', 42, NOW)).searchParams.get('state')!;
const state = new URL(await svc.startConnect('p', 42, NOW)).searchParams.get('state')!;
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ access_token: 'AT' }) } as Response);
// The provider's token_url now resolves to the cloud-metadata address.
dnsState.address = '169.254.169.254';
@@ -177,7 +177,7 @@ describe('PluginOAuthService', () => {
it('disconnect drops the user\'s tokens', async () => {
const rows = getDb.current as unknown as InstanceType<typeof Database>;
rows.prepare('INSERT INTO plugin_oauth_tokens (plugin_id, user_id, access_token) VALUES (?,?,?)').run('p', 42, 'enc:X');
svc.disconnect('p', 42);
expect(svc.status('p', 42).connected).toBe(false);
await svc.disconnect('p', 42);
expect((await svc.status('p', 42)).connected).toBe(false);
});
});
@@ -55,7 +55,7 @@ beforeEach(() => {
process.env.TREK_PLUGINS_ENABLED = 'true';
runtime = { update: vi.fn(), retrust: vi.fn() } as unknown as PluginRuntimeService;
registry = { install: vi.fn(), installWithDependencies: vi.fn(), recomputeUpdateHold: vi.fn() } as unknown as PluginRegistryService;
plugins = { resumeUpdates: vi.fn(() => true) } as unknown as PluginsService;
plugins = { resumeUpdates: vi.fn(async () => true) } as unknown as PluginsService;
controller = new PluginsController(plugins, runtime, registry, { isManaged: () => false } as unknown as RuntimeEnvService);
});
@@ -175,7 +175,7 @@ describe('update hold wiring', () => {
});
it('POST :id/resume-updates is a 404 for an unknown plugin', async () => {
vi.mocked(plugins.resumeUpdates).mockReturnValue(false);
vi.mocked(plugins.resumeUpdates).mockResolvedValue(false);
const { status } = await wireFailure(async () => controller.resumeUpdates('ghost'));
expect(status).toBe(404);
@@ -22,43 +22,43 @@ import type { PluginsService } from '../../../src/nest/plugins/plugins.service';
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
function ctrl() {
const svc = {
userSettingsFields: vi.fn(() => [{ key: 'apiKey', secret: true }]),
getUserConfig: vi.fn(() => ({ apiKey: '••••••••' })),
updateUserConfig: vi.fn((_id: string, _uid: number, patch: Record<string, unknown>) => ({ ...patch, apiKey: '••••••••' })),
userSettingsFields: vi.fn(async () => [{ key: 'apiKey', secret: true }]),
getUserConfig: vi.fn(async () => ({ apiKey: '••••••••' })),
updateUserConfig: vi.fn(async (_id: string, _uid: number, patch: Record<string, unknown>) => ({ ...patch, apiKey: '••••••••' })),
} as unknown as PluginsService;
// The controller now also takes the runtime (for settings-page actions).
const runtime = { actionsOf: vi.fn(() => []), invokeAction: vi.fn(async () => ({ ok: true })) } as unknown as PluginRuntimeService;
const runtime = { actionsOf: vi.fn(async () => []), invokeAction: vi.fn(async () => ({ ok: true })) } as unknown as PluginRuntimeService;
return { c: new PluginUserSettingsController(svc, runtime, new DatabaseService(dbConn)), svc, runtime };
}
describe('PluginUserSettingsController', () => {
beforeEach(() => { pluginsEnabled.mockReturnValue(true); getMock.mockReturnValue({ 1: 1 }); });
it('GET returns fields + masked config for a bound user; empty when gated', () => {
it('GET returns fields + masked config for a bound user; empty when gated', async () => {
const { c } = ctrl();
expect(c.get('p', req(5))).toEqual({ fields: [{ key: 'apiKey', secret: true }], config: { apiKey: '••••••••' }, actions: [] });
expect(await c.get('p', req(5))).toEqual({ fields: [{ key: 'apiKey', secret: true }], config: { apiKey: '••••••••' }, actions: [] });
pluginsEnabled.mockReturnValue(false);
expect(c.get('p', req(5))).toEqual({ fields: [], config: {}, actions: [] });
expect(await c.get('p', req(5))).toEqual({ fields: [], config: {}, actions: [] });
pluginsEnabled.mockReturnValue(true);
expect(c.get('p', req(undefined))).toEqual({ fields: [], config: {}, actions: [] });
expect(await c.get('p', req(undefined))).toEqual({ fields: [], config: {}, actions: [] });
getMock.mockReturnValue(undefined as never);
expect(c.get('p', req(5))).toEqual({ fields: [], config: {}, actions: [] });
expect(await c.get('p', req(5))).toEqual({ fields: [], config: {}, actions: [] });
});
it('POST delegates the patch for a bound user; empty when gated', () => {
it('POST delegates the patch for a bound user; empty when gated', async () => {
const { c, svc } = ctrl();
expect(c.update('p', { config: { units: 'metric' } }, req(5))).toEqual({ config: { units: 'metric', apiKey: '••••••••' } });
expect(await c.update('p', { config: { units: 'metric' } }, req(5))).toEqual({ config: { units: 'metric', apiKey: '••••••••' } });
expect(svc.updateUserConfig).toHaveBeenCalledWith('p', 5, { units: 'metric' });
// a non-object body → empty patch (no throw)
c.update('p', {}, req(5));
await c.update('p', {}, req(5));
expect(svc.updateUserConfig).toHaveBeenCalledWith('p', 5, {});
getMock.mockReturnValue(undefined as never);
expect(c.update('p', { config: { units: 'metric' } }, req(5))).toEqual({ config: {} });
expect(await c.update('p', { config: { units: 'metric' } }, req(5))).toEqual({ config: {} });
});
it('GET lists USER-scope actions only, and POST runs one as the caller in the user scope', async () => {
const { c, runtime } = ctrl();
c.get('p', req(5));
await c.get('p', req(5));
expect(runtime.actionsOf).toHaveBeenCalledWith('p', 'user');
expect(await c.runAction('p', 'sync', req(5))).toEqual({ ok: true });
expect(runtime.invokeAction).toHaveBeenCalledWith('p', 'sync', 5, 'user');
@@ -49,44 +49,44 @@ describe('per-user plugin settings', () => {
svc = new PluginsService(dbs, new AddonsService(dbs));
});
it('lists only the user-scope fields, in order', () => {
const fields = svc.userSettingsFields('p');
it('lists only the user-scope fields, in order', async () => {
const fields = await svc.userSettingsFields('p');
expect(fields.map(f => f.key)).toEqual(['apiKey', 'units']); // not the instance field
expect(fields[0]).toMatchObject({ secret: true, required: true });
});
it('encrypts a secret at rest, masks it to the client, stores a plain field verbatim', () => {
const masked = svc.updateUserConfig('p', 42, { apiKey: 'sk-123', units: 'metric' });
it('encrypts a secret at rest, masks it to the client, stores a plain field verbatim', async () => {
const masked = await svc.updateUserConfig('p', 42, { apiKey: 'sk-123', units: 'metric' });
expect(masked.apiKey).toBe('••••••••'); // never echoed
expect(masked.units).toBe('metric');
// decrypted runtime read returns the real value; the stored form is ciphertext
expect(userSettings().readOne('p', 42, 'apiKey')).toBe('sk-123');
expect(svc.getUserConfig('p', 42).apiKey).toBe('••••••••');
expect(await userSettings().readOne('p', 42, 'apiKey')).toBe('sk-123');
expect((await svc.getUserConfig('p', 42)).apiKey).toBe('••••••••');
});
it('an unchanged secret (the mask) keeps the stored ciphertext', () => {
svc.updateUserConfig('p', 42, { apiKey: 'sk-123' });
svc.updateUserConfig('p', 42, { apiKey: '••••••••', units: 'imperial' }); // mask = untouched
expect(userSettings().readOne('p', 42, 'apiKey')).toBe('sk-123'); // still the original
expect(svc.getUserConfig('p', 42).units).toBe('imperial');
it('an unchanged secret (the mask) keeps the stored ciphertext', async () => {
await svc.updateUserConfig('p', 42, { apiKey: 'sk-123' });
await svc.updateUserConfig('p', 42, { apiKey: '••••••••', units: 'imperial' }); // mask = untouched
expect(await userSettings().readOne('p', 42, 'apiKey')).toBe('sk-123'); // still the original
expect((await svc.getUserConfig('p', 42)).units).toBe('imperial');
});
it('ignores keys that are not declared user-scope fields', () => {
it('ignores keys that are not declared user-scope fields', async () => {
// apiKey is required — filled here so the save isn't refused; the assertion is
// about adminOnly/bogus being dropped, not about required enforcement.
svc.updateUserConfig('p', 42, { apiKey: 'sk-123', adminOnly: 'nope', bogus: 'x', units: 'metric' } as Record<string, unknown>);
const cfg = svc.getUserConfig('p', 42);
await svc.updateUserConfig('p', 42, { apiKey: 'sk-123', adminOnly: 'nope', bogus: 'x', units: 'metric' } as Record<string, unknown>);
const cfg = await svc.getUserConfig('p', 42);
expect(cfg.adminOnly).toBeUndefined(); // instance field — not accepted here
expect(cfg.bogus).toBeUndefined();
expect(cfg.units).toBe('metric');
});
it('is per-user — one user cannot see another\'s value', () => {
it('is per-user — one user cannot see another\'s value', async () => {
// apiKey is required — filled here so the save isn't refused; the point of this
// test is that user 99 sees none of user 42's values.
svc.updateUserConfig('p', 42, { apiKey: 'sk-123', units: 'metric' });
expect(svc.getUserConfig('p', 99).units).toBeUndefined();
expect(userSettings().readOne('p', 99, 'apiKey')).toBeUndefined();
await svc.updateUserConfig('p', 42, { apiKey: 'sk-123', units: 'metric' });
expect((await svc.getUserConfig('p', 99)).units).toBeUndefined();
expect(await userSettings().readOne('p', 99, 'apiKey')).toBeUndefined();
});
});
@@ -107,29 +107,29 @@ describe('manifest defaults reach the runtime reads', () => {
ins.run('p', 'endpoint', 'text', 1, 0, 'user', 5, JSON.stringify('https://api.example'));
});
it('readOne falls back to the declared default when nothing is stored', () => {
expect(userSettings().readOne('p', 42, 'region')).toBe('eu');
expect(userSettings().readOne('p', 42, 'retries')).toBe(3); // JSON round-trip keeps the type
expect(userSettings().readOne('p', 42, 'apiKey')).toBeUndefined(); // no default declared
it('readOne falls back to the declared default when nothing is stored', async () => {
expect(await userSettings().readOne('p', 42, 'region')).toBe('eu');
expect(await userSettings().readOne('p', 42, 'retries')).toBe(3); // JSON round-trip keeps the type
expect(await userSettings().readOne('p', 42, 'apiKey')).toBeUndefined(); // no default declared
});
it('a stored value wins over the default', () => {
svc.updateUserConfig('p', 42, { apiKey: 'sk-123', region: 'us' });
expect(userSettings().readOne('p', 42, 'region')).toBe('us');
expect(userSettings().readOne('p', 99, 'region')).toBe('eu'); // another user still gets the default
it('a stored value wins over the default', async () => {
await svc.updateUserConfig('p', 42, { apiKey: 'sk-123', region: 'us' });
expect(await userSettings().readOne('p', 42, 'region')).toBe('us');
expect(await userSettings().readOne('p', 99, 'region')).toBe('eu'); // another user still gets the default
});
it('readAll folds defaults in for the fields the user left unset', () => {
svc.updateUserConfig('p', 42, { apiKey: 'sk-123', region: 'us' });
expect(userSettings().readAll('p', 42)).toMatchObject({ apiKey: 'sk-123', region: 'us', retries: 3, endpoint: 'https://api.example' });
it('readAll folds defaults in for the fields the user left unset', async () => {
await svc.updateUserConfig('p', 42, { apiKey: 'sk-123', region: 'us' });
expect(await userSettings().readAll('p', 42)).toMatchObject({ apiKey: 'sk-123', region: 'us', retries: 3, endpoint: 'https://api.example' });
});
it('hasRequired treats a required field with a default as filled', () => {
it('hasRequired treats a required field with a default as filled', async () => {
// apiKey is required with no default → not configured until stored.
expect(userSettings().hasRequired('p', 42)).toBe(false);
svc.updateUserConfig('p', 42, { apiKey: 'sk-123' });
expect(await userSettings().hasRequired('p', 42)).toBe(false);
await svc.updateUserConfig('p', 42, { apiKey: 'sk-123' });
// endpoint is required too, but its default satisfies it.
expect(userSettings().hasRequired('p', 42)).toBe(true);
expect(await userSettings().hasRequired('p', 42)).toBe(true);
});
});
@@ -147,15 +147,15 @@ describe('hasRequired applies the same "filled" rule as the save gate', () => {
.run('p', 'consent', 'checkbox', 1, 0, 'user', 9);
});
it('exempts a required checkbox (consent, not a settings field)', () => {
svc.updateUserConfig('p', 42, { apiKey: 'sk-123' }); // consent never set
expect(userSettings().hasRequired('p', 42)).toBe(true);
it('exempts a required checkbox (consent, not a settings field)', async () => {
await svc.updateUserConfig('p', 42, { apiKey: 'sk-123' }); // consent never set
expect(await userSettings().hasRequired('p', 42)).toBe(true);
});
it('treats a whitespace-only value as empty', () => {
it('treats a whitespace-only value as empty', async () => {
(getDb.current as import('better-sqlite3').Database)
.prepare("INSERT INTO plugin_user_config (plugin_id, user_id, config, updated_at) VALUES ('p', 42, ?, '')")
.run(JSON.stringify({ apiKey: ' ', consent: true }));
expect(userSettings().hasRequired('p', 42)).toBe(false);
expect(await userSettings().hasRequired('p', 42)).toBe(false);
});
});
@@ -29,23 +29,23 @@ describe('PluginsFeedController', () => {
const c = new PluginsFeedController(new DatabaseService(dbConn));
beforeEach(() => { pluginsEnabled.mockReturnValue(true); rows.value = []; });
it('returns an empty feed when the runtime is disabled', () => {
it('returns an empty feed when the runtime is disabled', async () => {
pluginsEnabled.mockReturnValue(false);
rows.value = [row()];
expect(c.list()).toEqual({ plugins: [] });
expect(await c.list()).toEqual({ plugins: [] });
});
it('serves routeProfiles only alongside the recorded hook:route-provider grant', () => {
it('serves routeProfiles only alongside the recorded hook:route-provider grant', async () => {
rows.value = [
row({ id: 'granted', capabilities: JSON.stringify({ routeProfiles: [{ id: 'ev', label: 'EV' }] }), granted_permissions: JSON.stringify(['hook:route-provider']) }),
row({ id: 'ungranted', capabilities: JSON.stringify({ routeProfiles: [{ id: 'ev', label: 'EV' }] }) }),
];
const { plugins } = c.list();
const { plugins } = await c.list();
expect(plugins.find(p => p.id === 'granted')?.routeProfiles).toEqual([{ id: 'ev', label: 'EV' }]);
expect(plugins.find(p => p.id === 'ungranted')?.routeProfiles).toBeUndefined();
});
it('re-validates hand-edited routeProfiles rows (bad ids dropped, labels capped, max 3)', () => {
it('re-validates hand-edited routeProfiles rows (bad ids dropped, labels capped, max 3)', async () => {
rows.value = [row({
id: 'edited',
capabilities: JSON.stringify({
@@ -57,25 +57,25 @@ describe('PluginsFeedController', () => {
}),
granted_permissions: JSON.stringify(['hook:route-provider']),
})];
const profiles = c.list().plugins[0].routeProfiles!;
const profiles = (await c.list()).plugins[0].routeProfiles!;
expect(profiles.every(p => /^[a-z][a-z0-9-]{0,23}$/.test(p.id))).toBe(true);
expect(profiles.length).toBeLessThanOrEqual(3);
expect(profiles.every(p => p.label.length <= 40)).toBe(true);
});
it('flags geolocation only when the grant is recorded', () => {
it('flags geolocation only when the grant is recorded', async () => {
rows.value = [
row({ id: 'granted', granted_permissions: JSON.stringify(['geolocation:read']) }),
row({ id: 'ungranted' }),
];
const { plugins } = c.list();
const { plugins } = await c.list();
expect(plugins.find(p => p.id === 'granted')?.geolocation).toBe(true);
expect(plugins.find(p => p.id === 'ungranted')?.geolocation).toBeUndefined();
});
it('survives malformed JSON blobs without dropping the plugin', () => {
it('survives malformed JSON blobs without dropping the plugin', async () => {
rows.value = [row({ capabilities: '{not json', granted_permissions: 'also not' })];
const { plugins } = c.list();
const { plugins } = await c.list();
expect(plugins).toHaveLength(1);
expect(plugins[0].routeProfiles).toBeUndefined();
expect(plugins[0].slot).toBe('sidebar');
@@ -102,15 +102,15 @@ describe('PluginsService.list', () => {
expect(out.plugins.find((p) => p.id === 'free')).toMatchObject({ updateHold: false });
});
it('resumeUpdates clears the hold and reports whether the plugin existed', () => {
it('resumeUpdates clears the hold and reports whether the plugin existed', async () => {
testDb
.prepare("INSERT INTO plugins (id, name, type, status, version, update_hold) VALUES ('held','Held','widget','inactive','1.0.0',1)")
.run();
const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn)));
expect(svc.resumeUpdates('held')).toBe(true);
expect(await svc.resumeUpdates('held')).toBe(true);
expect(testDb.prepare("SELECT update_hold FROM plugins WHERE id='held'").get()).toMatchObject({ update_hold: 0 });
expect(svc.resumeUpdates('ghost')).toBe(false);
expect(await svc.resumeUpdates('ghost')).toBe(false);
});
it('reports enabled by default (no kill switch set)', async () => {
@@ -193,67 +193,67 @@ describe('PluginsService.list', () => {
});
});
it('controller delegates to the service', () => {
const svc = { list: vi.fn(() => ({ enabled: false, plugins: [] })) } as unknown as PluginsService;
it('controller delegates to the service', async () => {
const svc = { list: vi.fn(async () => ({ enabled: false, plugins: [] })) } as unknown as PluginsService;
const runtime = {} as unknown as import('../../../src/nest/plugins/plugin-runtime.service').PluginRuntimeService;
const res = new PluginsController(svc, runtime, {} as never, { isManaged: () => false } as unknown as RuntimeEnvService).list();
const res = await new PluginsController(svc, runtime, {} as never, { isManaged: () => false } as unknown as RuntimeEnvService).list();
expect(svc.list).toHaveBeenCalled();
expect(res).toEqual({ enabled: false, plugins: [] });
});
});
describe('PluginsFeedController (client feed)', () => {
it('returns active plugins when enabled, nothing when disabled', () => {
it('returns active plugins when enabled, nothing when disabled', async () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('w','W','widget','Box','active')").run();
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('i','I','integration','Plug','inactive')").run();
const feed = new PluginsFeedController(new DatabaseService(dbConn));
process.env.TREK_PLUGINS_ENABLED = 'true';
const active = feed.list();
const active = await feed.list();
expect(active.plugins).toEqual([{ id: 'w', name: 'W', type: 'widget', icon: 'Box', slot: 'sidebar' }]);
process.env.TREK_PLUGINS_ENABLED = 'false';
expect(feed.list().plugins).toEqual([]);
expect((await feed.list()).plugins).toEqual([]);
});
it('exposes the widget slot from capabilities (hero) and defaults on bad JSON', () => {
it('exposes the widget slot from capabilities (hero) and defaults on bad JSON', async () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('h','H','widget','Box','active','{\"widget\":{\"slot\":\"hero\"}}')").run();
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('b','B','widget','Box','active','not-json')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
const out = new PluginsFeedController(new DatabaseService(dbConn)).list();
const out = await new PluginsFeedController(new DatabaseService(dbConn)).list();
expect(out.plugins.find((p) => p.id === 'h')?.slot).toBe('hero');
expect(out.plugins.find((p) => p.id === 'b')?.slot).toBe('sidebar');
});
it('exposes the day-detail slot (a day-panel widget must not fall back to the dashboard)', () => {
it('exposes the day-detail slot (a day-panel widget must not fall back to the dashboard)', async () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('d','D','widget','Box','active','{\"widget\":{\"slot\":\"day-detail\"}}')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
expect(new PluginsFeedController(new DatabaseService(dbConn)).list().plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail');
expect((await new PluginsFeedController(new DatabaseService(dbConn)).list()).plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail');
});
it('exposes settingsUi only when the capability is exactly true', () => {
it('exposes settingsUi only when the capability is exactly true', async () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('su','S','widget','Box','active','{\"settingsUi\":true}')").run();
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('no','N','widget','Box','active','{\"settingsUi\":\"yes\"}')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
const out = new PluginsFeedController(new DatabaseService(dbConn)).list();
const out = await new PluginsFeedController(new DatabaseService(dbConn)).list();
expect(out.plugins.find((p) => p.id === 'su')?.settingsUi).toBe(true);
expect(out.plugins.find((p) => p.id === 'no')?.settingsUi).toBeUndefined();
});
it('exposes the reservation-detail slot (a booking-card widget must not fall back to the dashboard)', () => {
it('exposes the reservation-detail slot (a booking-card widget must not fall back to the dashboard)', async () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('r','R','widget','Box','active','{\"widget\":{\"slot\":\"reservation-detail\"}}')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
expect(new PluginsFeedController(new DatabaseService(dbConn)).list().plugins.find((p) => p.id === 'r')?.slot).toBe('reservation-detail');
expect((await new PluginsFeedController(new DatabaseService(dbConn)).list()).plugins.find((p) => p.id === 'r')?.slot).toBe('reservation-detail');
});
it('exposes tripPage for trip-page plugins, re-validated against the replaceable-tab whitelist', () => {
it('exposes tripPage for trip-page plugins, re-validated against the replaceable-tab whitelist', async () => {
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('t','T','trip-page','Box','active','{\"tripPage\":{\"replaces\":[\"transports\",\"buchungen\"],\"position\":1}}')").run();
// a hand-edited row trying to hide 'plan' (or junk) is filtered here, not just at install
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('evil','E','trip-page','Box','active','{\"tripPage\":{\"replaces\":[\"plan\",\"nope\"],\"position\":-3}}')").run();
// the capability is meaningless off a trip-page and must not leak onto widgets
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('w2','W2','widget','Box','active','{\"tripPage\":{\"replaces\":[\"transports\"]}}')").run();
process.env.TREK_PLUGINS_ENABLED = 'true';
const out = new PluginsFeedController(new DatabaseService(dbConn)).list();
const out = await new PluginsFeedController(new DatabaseService(dbConn)).list();
expect(out.plugins.find((p) => p.id === 't')?.tripPage).toEqual({ replaces: ['transports', 'buchungen'], position: 1 });
expect(out.plugins.find((p) => p.id === 'evil')?.tripPage).toBeUndefined();
expect(out.plugins.find((p) => p.id === 'w2')?.tripPage).toBeUndefined();
@@ -262,9 +262,9 @@ describe('PluginsFeedController (client feed)', () => {
describe('PluginsController M2 endpoints', () => {
const svc = {
getInstanceConfig: vi.fn(() => ({ a: 1 })),
instanceSettingsFields: vi.fn(() => [{ key: 'a' }]),
updateInstanceConfig: vi.fn(() => ({ a: 2 })),
getInstanceConfig: vi.fn(async () => ({ a: 1 })),
instanceSettingsFields: vi.fn(async () => [{ key: 'a' }]),
updateInstanceConfig: vi.fn(async () => ({ a: 2 })),
} as unknown as PluginsService;
// None of the endpoints below carry the marker, so the ordinary install is the
// whole story here; the refusals have their own tests.
@@ -277,9 +277,9 @@ describe('PluginsController M2 endpoints', () => {
});
it('get/update config delegate to the service (get carries the form fields, update the restart)', async () => {
const rt = { activate: vi.fn(), deactivate: vi.fn(), isActive: vi.fn(), respawnIfActive: vi.fn(async () => false), actionsOf: vi.fn(() => []) } as never;
const rt = { activate: vi.fn(), deactivate: vi.fn(), isActive: vi.fn(), respawnIfActive: vi.fn(async () => false), actionsOf: vi.fn(async () => []) } as never;
const c = new PluginsController(svc, rt, {} as never, envStub);
expect(c.getConfig('x')).toEqual({ fields: [{ key: 'a' }], config: { a: 1 }, actions: [] });
expect(await c.getConfig('x')).toEqual({ fields: [{ key: 'a' }], config: { a: 1 }, actions: [] });
expect(await c.updateConfig('x', { a: 2 })).toEqual({ config: { a: 2 }, restarted: false });
});
@@ -309,13 +309,13 @@ describe('PluginsController M2 endpoints', () => {
});
describe('PluginsService instance config', () => {
it('encrypts secret fields on write and masks them on read; keeps plaintext for non-secrets', () => {
it('encrypts secret fields on write and masks them on read; keeps plaintext for non-secrets', async () => {
testDb.prepare("INSERT INTO plugins (id, name, status, config) VALUES ('x','X','inactive','{}')").run();
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('x','api_key','instance',1)").run();
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('x','server','instance',0)").run();
const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn)));
const masked = svc.updateInstanceConfig('x', { api_key: 'super-secret', server: 'https://h' });
const masked = await svc.updateInstanceConfig('x', { api_key: 'super-secret', server: 'https://h' });
// client gets the masked view
expect(masked.api_key).toBe('••••••••');
expect(masked.server).toBe('https://h');
@@ -327,19 +327,19 @@ describe('PluginsService instance config', () => {
expect(stored.server).toBe('https://h');
// an unchanged mask does not overwrite the stored secret
svc.updateInstanceConfig('x', { api_key: '••••••••' });
await svc.updateInstanceConfig('x', { api_key: '••••••••' });
const still = JSON.parse((testDb.prepare("SELECT config FROM plugins WHERE id='x'").get() as { config: string }).config);
expect(still.api_key).toBe(stored.api_key);
expect(svc.getInstanceConfig('x').api_key).toBe('••••••••');
expect((await svc.getInstanceConfig('x')).api_key).toBe('••••••••');
});
it('drops a key the plugin never declared, like the user-scope sibling does', () => {
it('drops a key the plugin never declared, like the user-scope sibling does', async () => {
testDb.prepare("INSERT INTO plugins (id, name, status, config) VALUES ('y','Y','inactive','{}')").run();
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('y','server','instance',0)").run();
const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn)));
const masked = svc.updateInstanceConfig('y', { server: 'https://h', smuggled: 'nope' });
const masked = await svc.updateInstanceConfig('y', { server: 'https://h', smuggled: 'nope' });
expect(masked.server).toBe('https://h');
expect(masked.smuggled).toBeUndefined();
@@ -347,19 +347,19 @@ describe('PluginsService instance config', () => {
expect(stored).toEqual({ server: 'https://h' });
});
it('throws for an unknown plugin', () => {
expect(() => new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).updateInstanceConfig('nope', {})).toThrow(/not found/);
expect(() => new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).getInstanceConfig('nope')).toThrow(/not found/);
it('throws for an unknown plugin', async () => {
await expect(new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).updateInstanceConfig('nope', {})).rejects.toThrow(/not found/);
await expect(new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).getInstanceConfig('nope')).rejects.toThrow(/not found/);
});
});
describe('PluginsService error log', () => {
beforeEach(() => testDb.exec('DELETE FROM plugin_error_log'));
it('lists and clears a plugin error log', () => {
it('lists and clears a plugin error log', async () => {
testDb.prepare("INSERT INTO plugin_error_log (plugin_id, level, message) VALUES ('p','error','boom')").run();
const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn)));
expect(svc.errors('p')).toEqual([{ ts: '2026-01-01', level: 'error', message: 'boom' }]);
svc.clearErrors('p');
expect(svc.errors('p')).toEqual([]);
expect(await svc.errors('p')).toEqual([{ ts: '2026-01-01', level: 'error', message: 'boom' }]);
await svc.clearErrors('p');
expect(await svc.errors('p')).toEqual([]);
});
});
+1 -1
View File
@@ -121,7 +121,7 @@ describe('PluginRpcHost — capability enforcement', () => {
expect(seen?.data).toBe(deps.data);
await seen?.plugins.call('other', 'sum', [1], 42);
expect(deps.callPlugin).toHaveBeenCalledWith('other', 'sum', [1], 42);
seen?.plugins.emit('ping', { a: 1 });
await seen?.plugins.emit('ping', { a: 1 });
expect(deps.emitPluginEvent).toHaveBeenCalledWith('ping', { a: 1 });
});
@@ -53,7 +53,7 @@ beforeEach(() => {
});
describe('plugin settings are isolated from core and from each other', () => {
it('PSET-001 — a plugin declaring "webhook_url" cannot touch the CORE settings row', () => {
it('PSET-001 — a plugin declaring "webhook_url" cannot touch the CORE settings row', async () => {
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', 'https://core.example.com/real')").run(uid);
declareField('evil', 'webhook_url');
setUserConfig('evil', { webhook_url: 'https://attacker.example.com' });
@@ -62,43 +62,43 @@ describe('plugin settings are isolated from core and from each other', () => {
// its own blob, in its own table. The namespacing is structural, not by key naming.
const core = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(uid) as { value: string };
expect(core.value).toBe('https://core.example.com/real');
expect(userSettings().readAll('evil', uid)).toEqual({ webhook_url: 'https://attacker.example.com' });
expect(await userSettings().readAll('evil', uid)).toEqual({ webhook_url: 'https://attacker.example.com' });
});
it('PSET-002 — plugin A cannot read plugin B’s config, even with the same key name', () => {
it('PSET-002 — plugin A cannot read plugin B’s config, even with the same key name', async () => {
declareField('a', 'token', { secret: true });
declareField('b', 'token', { secret: true });
setUserConfig('b', { token: 'B-SECRET' });
expect(userSettings().readAll('a', uid)).toEqual({});
expect(userSettings().readOne('a', uid, 'token')).toBeUndefined();
expect(userSettings().readOne('b', uid, 'token')).toBe('B-SECRET');
expect(await userSettings().readAll('a', uid)).toEqual({});
expect(await userSettings().readOne('a', uid, 'token')).toBeUndefined();
expect(await userSettings().readOne('b', uid, 'token')).toBe('B-SECRET');
});
it('PSET-003 — a plugin only ever sees its own DECLARED keys', () => {
it('PSET-003 — a plugin only ever sees its own DECLARED keys', async () => {
declareField('p', 'declared');
// An undeclared key that somehow reached the blob is not handed to the plugin.
setUserConfig('p', { declared: 'yes', sneaked: 'no' });
expect(userSettings().readAll('p', uid)).toEqual({ declared: 'yes' });
expect(await userSettings().readAll('p', uid)).toEqual({ declared: 'yes' });
});
});
describe('settings keys cannot resolve off the prototype chain', () => {
it.each(['__proto__', 'constructor', 'prototype'])(
'PSET-004 — a REQUIRED field named "%s" is NOT reported as configured',
(key) => {
async (key) => {
declareField('evil', key, { required: true });
// The user has configured nothing at all.
expect(userSettings().hasRequired('evil', uid)).toBe(false);
expect(userSettings().readAll('evil', uid)).toEqual({});
expect(await userSettings().hasRequired('evil', uid)).toBe(false);
expect(await userSettings().readAll('evil', uid)).toEqual({});
},
);
it('PSET-005 — a genuinely configured required field still reports configured', () => {
it('PSET-005 — a genuinely configured required field still reports configured', async () => {
declareField('good', 'appToken', { required: true, secret: true });
expect(userSettings().hasRequired('good', uid)).toBe(false);
expect(await userSettings().hasRequired('good', uid)).toBe(false);
setUserConfig('good', { appToken: 'T' });
expect(userSettings().hasRequired('good', uid)).toBe(true);
expect(await userSettings().hasRequired('good', uid)).toBe(true);
});
it('PSET-006 — the manifest rejects such a key at install', () => {
@@ -132,7 +132,7 @@ describe('a plugin channel label is bounded by the host', () => {
id: 'loud', status: 'active', hooks: ['notificationChannel'], granted: new Set(['hook:notification-channel']),
});
const [channel] = rt.notificationChannels();
const [channel] = await rt.notificationChannels();
expect(channel.id).toBe('plugin:loud');
expect(channel.label!.length).toBeLessThanOrEqual(40);
expect(channel.label).not.toMatch(/\p{Extended_Pictographic}/u);