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