mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
Split PluginsModule into four, and gate coverage per domain
PluginsModule was 21 controllers, 24 domain imports and 10 providers in one class, which made it the hub most of the Nest graph ran through: AdminModule imported it for a single cascade-disable call and inherited all of it. It is now PluginsRuntimeModule (supervisor, capability router, hook contracts), PluginContributionsModule (the 14 read-only hook controllers, moved to contributions/), PluginOAuthModule (the credential leaf, moved to oauth/) and a composition root that keeps the install and delivery controllers. AdminModule imports the runtime alone. The three plugin files that reached for the db singleton alongside the injected DatabaseService no longer do. plugin-host-state and signature-status take the connection from the caller, and the three per-user settings functions that were bare exports 'so the RPC host wiring can call them directly' are now PluginUserSettingsService - the host wiring is providers, so it injects them. Coverage moves from one repo-wide 80% to a per-domain ratchet. The single number let a well-covered domain subsidise a thin one: booking-import sits at 50% and integrations at 20%, and the aggregate still cleared the bar, so either could have lost another ten points unnoticed. Each domain now carries its own measured floor, regenerated by scripts/coverage-thresholds.mjs. Two test-side findings on the way: expect(undefined).toContain(SomeClass) PASSES in this vitest version - it only throws for a string needle. Every '<X> is listed in its module providers' guard was written that way, so all twelve of them silently stopped guarding the moment their class moved. They go through expectRegisteredProvider now, which asserts the array shape first. The atlas region test paid for the one-off decompression of the multi-MB admin1 bundle inside its own 5s budget, which is why it failed under load and passed alone. The bundle is warmed in beforeAll instead.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
import { PluginsModule } from '../plugins/plugins.module';
|
||||
import { PluginsRuntimeModule } from '../plugins/plugins-runtime.module';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
@@ -18,7 +18,7 @@ import { PackingModule } from '../packing/packing.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
|
||||
@Module({
|
||||
imports: [PluginsModule, SettingsModule, AuditModule, AddonsModule, AuthModule, NotificationsModule, PackingModule, PermissionsModule],
|
||||
imports: [PluginsRuntimeModule, SettingsModule, AuditModule, AddonsModule, AuthModule, NotificationsModule, PackingModule, PermissionsModule],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
})
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginHooks } from './plugin-hooks.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from '../kill-switch';
|
||||
import { PluginHooks } from '../plugin-hooks.service';
|
||||
import { stripEmoji } from '../text-sanitize';
|
||||
|
||||
/**
|
||||
* GET /api/atlas-layers — country tint layers plugins draw over the Atlas world
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginHooks } from './plugin-hooks.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from '../kill-switch';
|
||||
import { PluginHooks } from '../plugin-hooks.service';
|
||||
import { stripEmoji } from '../text-sanitize';
|
||||
|
||||
/**
|
||||
* GET /api/day-schedule/:tripId — bounded time contributions plugins attach to the
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginHooks } from './plugin-hooks.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from '../kill-switch';
|
||||
import { PluginHooks } from '../plugin-hooks.service';
|
||||
import { stripEmoji } from '../text-sanitize';
|
||||
|
||||
/**
|
||||
* GET /api/day-tints/:tripId — the colours the planner paints into a day card in the
|
||||
+8
-8
@@ -1,13 +1,13 @@
|
||||
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { JourneyDomainService } from '../journey/journey-domain.service';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginHooks } from './plugin-hooks.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { JourneyDomainService } from '../../journey/journey-domain.service';
|
||||
import { AddonsService } from '../../addons/addons.service';
|
||||
import { ADDON_IDS } from '../../../addons';
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from '../kill-switch';
|
||||
import { PluginHooks } from '../plugin-hooks.service';
|
||||
import { stripEmoji } from '../text-sanitize';
|
||||
|
||||
/**
|
||||
* GET /api/journal-entry-rows/:entryId — extra rows for a journal entry,
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginHooks } from './plugin-hooks.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from '../kill-switch';
|
||||
import { PluginHooks } from '../plugin-hooks.service';
|
||||
import { stripEmoji } from '../text-sanitize';
|
||||
|
||||
/**
|
||||
* GET /api/map-layers/:tripId — bounded vector overlays plugins draw on the trip map
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginHooks } from './plugin-hooks.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from '../kill-switch';
|
||||
import { PluginHooks } from '../plugin-hooks.service';
|
||||
import { stripEmoji } from '../text-sanitize';
|
||||
|
||||
/**
|
||||
* GET /api/map-markers/:tripId — bounded markers plugins overlay on the trip map
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginHooks } from './plugin-hooks.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from '../kill-switch';
|
||||
import { PluginHooks } from '../plugin-hooks.service';
|
||||
import { stripEmoji } from '../text-sanitize';
|
||||
|
||||
/**
|
||||
* GET /api/pdf-sections/:tripId — text-only sections plugins append to a trip's
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginHooks } from './plugin-hooks.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from '../kill-switch';
|
||||
import { PluginHooks } from '../plugin-hooks.service';
|
||||
import { stripEmoji } from '../text-sanitize';
|
||||
|
||||
/**
|
||||
* GET /api/place-details/:placeId — extra info for a place, contributed by plugins
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginHooks } from './plugin-hooks.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from '../kill-switch';
|
||||
import { PluginHooks } from '../plugin-hooks.service';
|
||||
import { stripEmoji } from '../text-sanitize';
|
||||
|
||||
/**
|
||||
* Calendar events contributed by plugins that implement the `calendarSource` hook
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PluginsRuntimeModule } from '../plugins-runtime.module';
|
||||
import { AddonsModule } from '../../addons/addons.module';
|
||||
import { JourneyDomainModule } from '../../journey/journey-domain.module';
|
||||
import { PlaceDetailsController } from './place-details.controller';
|
||||
import { TripWarningsController } from './trip-warnings.controller';
|
||||
import { ViewContributionsController } from './view-contributions.controller';
|
||||
import { TripCardContributionsController } from './trip-card-contributions.controller';
|
||||
import { PluginPhotosController } from './plugin-photos.controller';
|
||||
import { PluginCalendarController } from './plugin-calendar.controller';
|
||||
import { MapMarkersController } from './map-markers.controller';
|
||||
import { MapLayersController } from './map-layers.controller';
|
||||
import { PluginRoutesController } from './plugin-routes.controller';
|
||||
import { DayScheduleController } from './day-schedule.controller';
|
||||
import { DayTintsController } from './day-tints.controller';
|
||||
import { PdfSectionsController } from './pdf-sections.controller';
|
||||
import { AtlasLayersController } from './atlas-layers.controller';
|
||||
import { JournalEntryRowsController } from './journal-entry-rows.controller';
|
||||
|
||||
/**
|
||||
* The read-only surface plugins contribute to the app: photos, calendar events,
|
||||
* place details, trip warnings, table columns, map markers and layers, routes, day
|
||||
* schedules and tints, PDF sections, atlas layers, journal entry rows, trip cards.
|
||||
*
|
||||
* Every one of these is the same shape — fan out over `providersOf(hook)`, call the
|
||||
* hook through `PluginHooks`, normalize and cap what comes back, skip a provider that
|
||||
* throws — so they belong together and nowhere else. None of them can install,
|
||||
* activate or configure anything, which is why they are separated from the CRUD
|
||||
* surface in PluginsModule.
|
||||
*/
|
||||
@Module({
|
||||
imports: [PluginsRuntimeModule, AddonsModule, JourneyDomainModule],
|
||||
controllers: [
|
||||
PlaceDetailsController,
|
||||
TripWarningsController,
|
||||
ViewContributionsController,
|
||||
TripCardContributionsController,
|
||||
PluginPhotosController,
|
||||
PluginCalendarController,
|
||||
MapMarkersController,
|
||||
MapLayersController,
|
||||
PluginRoutesController,
|
||||
DayScheduleController,
|
||||
DayTintsController,
|
||||
PdfSectionsController,
|
||||
AtlasLayersController,
|
||||
JournalEntryRowsController,
|
||||
],
|
||||
})
|
||||
export class PluginContributionsModule {}
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginHooks } from './plugin-hooks.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from '../kill-switch';
|
||||
import { PluginHooks } from '../plugin-hooks.service';
|
||||
import { stripEmoji } from '../text-sanitize';
|
||||
|
||||
/**
|
||||
* Photo sources contributed by plugins that implement the `photoProvider` hook
|
||||
+5
-5
@@ -1,11 +1,11 @@
|
||||
import { Body, Controller, Param, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginHooks } from './plugin-hooks.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from '../kill-switch';
|
||||
import { PluginHooks } from '../plugin-hooks.service';
|
||||
import { stripEmoji } from '../text-sanitize';
|
||||
|
||||
/**
|
||||
* POST /api/plugin-routes/:pluginId/:profileId — ask ONE routeProvider plugin to
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginHooks } from './plugin-hooks.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from '../kill-switch';
|
||||
import { PluginHooks } from '../plugin-hooks.service';
|
||||
import { stripEmoji } from '../text-sanitize';
|
||||
|
||||
/**
|
||||
* GET /api/trip-card-contributions?tripIds=1,2,3 — host-rendered badges that plugins
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginHooks } from './plugin-hooks.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from '../kill-switch';
|
||||
import { PluginHooks } from '../plugin-hooks.service';
|
||||
import { stripEmoji } from '../text-sanitize';
|
||||
|
||||
/**
|
||||
* GET /api/trip-warnings/:tripId — validation/warning contributions from plugins
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginHooks } from './plugin-hooks.service';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from '../kill-switch';
|
||||
import { PluginHooks } from '../plugin-hooks.service';
|
||||
import { stripEmoji } from '../text-sanitize';
|
||||
|
||||
/**
|
||||
* GET /api/view-contributions/:view/:tripId — host-rendered columns/actions that
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db } from '../../../db/database';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { PluginDataDb } from './plugin-data.service';
|
||||
import { DailyBudget, DEFAULT_DAILY_BUDGET } from './daily-budget';
|
||||
|
||||
@@ -6,9 +6,14 @@ import { DailyBudget, DEFAULT_DAILY_BUDGET } from './daily-budget';
|
||||
* Process-wide plugin host state, deliberately module-level (NOT a Nest
|
||||
* provider): the data-DB and budget maps must be the single shared instance
|
||||
* across host recreations (disable/re-enable builds a NEW rpc host — see the
|
||||
* `get data()` comment in plugin-host-deps.factory.ts), and pluginBudgetUsage
|
||||
* `get data()` comment in plugin-rpc-host.factory.ts), and pluginBudgetUsage
|
||||
* is read by PluginsService, which the factory itself imports from — folding
|
||||
* this state into the injectable would create a provider cycle for no gain.
|
||||
*
|
||||
* It does NOT reach for the `db` singleton, though: the one read it needs is the
|
||||
* budget seed, so the caller passes its own injected connection in. That keeps the
|
||||
* module-level state (which is the point) without a second route to the database
|
||||
* that no test can substitute.
|
||||
*/
|
||||
|
||||
const dataDbs = new Map<string, PluginDataDb>();
|
||||
@@ -39,12 +44,12 @@ export function closePluginDataDb(id: string): void {
|
||||
// nothing persisted or phoned home.
|
||||
const budgets = new Map<string, DailyBudget>();
|
||||
|
||||
export function budgetFor(id: string): DailyBudget {
|
||||
export function budgetFor(id: string, conn: Database.Database): DailyBudget {
|
||||
let b = budgets.get(id);
|
||||
if (!b) {
|
||||
const now = Date.now();
|
||||
const since = new Date(now).toISOString().slice(0, 10) + 'T00:00:00';
|
||||
const rows = db
|
||||
const rows = conn
|
||||
.prepare("SELECT method, COUNT(*) AS n FROM plugin_capability_audit WHERE plugin_id = ? AND code = 'ok' AND ts >= ? AND method IN ('ai.complete','ai.extract','notify.send') GROUP BY method")
|
||||
.all(id, since) as Array<{ method: string; n: number }>;
|
||||
let ai = 0, notify = 0;
|
||||
@@ -59,6 +64,6 @@ export function budgetFor(id: string): DailyBudget {
|
||||
}
|
||||
|
||||
/** Today's broker usage for one plugin (admin view). Seeds the counter if unseen. */
|
||||
export function pluginBudgetUsage(id: string): ReturnType<DailyBudget['used']> {
|
||||
return budgetFor(id).used(Date.now());
|
||||
export function pluginBudgetUsage(id: string, conn: Database.Database): ReturnType<DailyBudget['used']> {
|
||||
return budgetFor(id, conn).used(Date.now());
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PluginController, PluginMethod, PluginOpenMethod } from '../rpc-kit/decorators';
|
||||
import { asArgs, asTxOps, str } from '../rpc-params';
|
||||
import type { PluginRpcContext } from '../rpc-kit/types';
|
||||
import { readUserSettingDecrypted } from '../../plugins.service';
|
||||
import { PluginUserSettingsService } from '../../plugin-user-settings.service';
|
||||
|
||||
/**
|
||||
* A plugin's OWN sqlite, plus the three methods the router registers unconditionally.
|
||||
@@ -14,6 +14,8 @@ import { readUserSettingDecrypted } from '../../plugins.service';
|
||||
*/
|
||||
@PluginController()
|
||||
export class DbRpc {
|
||||
constructor(private readonly settings: PluginUserSettingsService) {}
|
||||
|
||||
@PluginMethod('db.query', { permission: 'db:own' })
|
||||
query(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
return ctx.data.query(str(params.sql, 'sql'), asArgs(params.args));
|
||||
@@ -60,6 +62,6 @@ export class DbRpc {
|
||||
@PluginOpenMethod('settings.get')
|
||||
getSetting(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
if (ctx.actingUserId === undefined) return { value: undefined };
|
||||
return { value: readUserSettingDecrypted(ctx.pluginId, ctx.actingUserId, str(params.key, 'key')) };
|
||||
return { value: this.settings.readOne(ctx.pluginId, ctx.actingUserId, str(params.key, 'key')) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { RealtimeService } from '../../../realtime/realtime.service';
|
||||
import { NotificationsService } from '../../../notifications/notifications.service';
|
||||
import { LlmConfigResolver } from '../../../llm-parse/llm-config.resolver';
|
||||
import { createLlmClient } from '../../../llm-parse/llm-client.factory';
|
||||
import { PluginOAuthService } from '../../plugin-oauth.service';
|
||||
import { PluginOAuthService } from '../../oauth/plugin-oauth.service';
|
||||
import { stripEmoji } from '../../text-sanitize';
|
||||
|
||||
/** Caps on the persistent scheduler, bounding the abuse surface. */
|
||||
@@ -102,7 +102,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).take('notify', Date.now())) {
|
||||
if (!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({
|
||||
@@ -225,7 +225,7 @@ export class HostSurfaceRpc {
|
||||
}
|
||||
|
||||
private takeAiBudget(ctx: PluginRpcContext): void {
|
||||
if (!budgetFor(ctx.pluginId).take('ai', Date.now())) {
|
||||
if (!budgetFor(ctx.pluginId, this.db.connection).take('ai', Date.now())) {
|
||||
throw new BadParams('daily AI budget exhausted (resets at UTC midnight)');
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
import { Controller, Get, Param, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from '../kill-switch';
|
||||
import { PluginOAuthService } from './plugin-oauth.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
|
||||
/**
|
||||
* Host-brokered outbound OAuth endpoints (#plugins). All are gated by JwtAuthGuard —
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PluginOAuthController } from './plugin-oauth.controller';
|
||||
import { PluginOAuthService } from './plugin-oauth.service';
|
||||
|
||||
/**
|
||||
* Host-brokered outbound OAuth for plugins: the host runs the authorization code
|
||||
* flow and holds the refresh token, and a plugin only ever gets a short-lived access
|
||||
* token for the acting user (`oauth.getToken`, HostSurfaceRpc).
|
||||
*
|
||||
* A leaf on purpose — it owns credentials and nothing else, so it must not depend on
|
||||
* the runtime or the registry. PluginsRuntimeModule imports it for the token read.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [PluginOAuthController],
|
||||
providers: [PluginOAuthService],
|
||||
exports: [PluginOAuthService],
|
||||
})
|
||||
export class PluginOAuthModule {}
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { getAppUrl } from '../../app-config';
|
||||
import { isPrivateIp } from './install/safe-fetch';
|
||||
import { safeFetchLlm } from '../../utils/ssrfGuard';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { encrypt_api_key, decrypt_api_key } from '../../common/crypto/apiKeyCrypto';
|
||||
import { getAppUrl } from '../../../app-config';
|
||||
import { isPrivateIp } from '../install/safe-fetch';
|
||||
import { safeFetchLlm } from '../../../utils/ssrfGuard';
|
||||
|
||||
/**
|
||||
* Host-brokered outbound OAuth (#plugins). A plugin becomes an OAuth *client* of a
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Parse a stored plugin config blob into a NULL-PROTOTYPE object.
|
||||
*
|
||||
* The prototype matters: a settings key of `__proto__` or `constructor` would otherwise
|
||||
* resolve off Object.prototype on read, so `config[key]` comes back truthy for a user who
|
||||
* has configured nothing — and a *required* field with such a name would report as
|
||||
* configured for everyone. The manifest now rejects those keys at install
|
||||
* (SETTING_KEY_RE); this makes it impossible regardless, including for a plugin that was
|
||||
* installed before that check existed.
|
||||
*
|
||||
* Its own file because both PluginsService (the admin view) and
|
||||
* PluginUserSettingsService (the host-side reads) parse the same blobs, and a second
|
||||
* copy of this is exactly the kind of thing that drifts.
|
||||
*/
|
||||
export function safeParseConfig(json: string): Record<string, unknown> {
|
||||
const empty = () => Object.create(null) as Record<string, unknown>;
|
||||
try {
|
||||
const parsed = JSON.parse(json || '{}');
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return empty();
|
||||
const out = empty();
|
||||
// JSON.parse creates `__proto__` as an OWN property (it never invokes the setter), so
|
||||
// copy own keys across onto the null-prototype object rather than trusting the parse.
|
||||
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) out[key] = value;
|
||||
return out;
|
||||
} catch {
|
||||
return empty();
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { setPluginEventSink } from '../../plugin-event-sink';
|
||||
import { setUserDeletedSink } from '../../plugin-user-lifecycle';
|
||||
import { setPluginChannelSource, pluginChannelId } from '../notifications/channel-registry';
|
||||
import type { ChannelMessage, ExternalChannel } from '../notifications/notification-events';
|
||||
import { readUserSettingsDecrypted, hasRequiredUserSettings } from './plugins.service';
|
||||
import { PluginUserSettingsService } from './plugin-user-settings.service';
|
||||
import { PLUGIN_CHANNEL_EVENTS } from './install/manifest';
|
||||
import { stripEmoji } from './text-sanitize';
|
||||
import { applyStagedPluginTrees, setStagedRestoreApplier } from './plugin-backup';
|
||||
@@ -168,6 +168,10 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly dbs: DatabaseService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly addons: AddonsService,
|
||||
// Required, and therefore ahead of the two optionals: the notification-channel
|
||||
// registry reads a recipient's own settings on every dispatch, so an absent one
|
||||
// would be a TypeError at send time rather than a missing-provider error at boot.
|
||||
private readonly userSettings: PluginUserSettingsService,
|
||||
private readonly registry?: PluginRegistryService,
|
||||
private readonly hostFactory?: PluginRpcHostFactory,
|
||||
) {}
|
||||
@@ -1047,7 +1051,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
// Admin-scoped events never reach a plugin channel — PLUGIN_CHANNEL_EVENTS
|
||||
// excludes them, and a manifest can only narrow that set, never widen it.
|
||||
supportsEvent: (event: string) => allowed.has(event),
|
||||
isConfiguredFor: (userId: number) => hasRequiredUserSettings(id, userId),
|
||||
isConfiguredFor: (userId: number) => this.userSettings.hasRequired(id, userId),
|
||||
// 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.
|
||||
@@ -1056,7 +1060,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
id,
|
||||
'notificationChannel',
|
||||
'send',
|
||||
[{ event: msg.event, title: msg.title, body: msg.body, url: msg.url, tripName: msg.tripName }, readUserSettingsDecrypted(id, userId)],
|
||||
[{ event: msg.event, title: msg.title, body: msg.body, url: msg.url, tripName: msg.tripName }, 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.
|
||||
@@ -1065,7 +1069,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
),
|
||||
test: async (userId: number) => {
|
||||
try {
|
||||
await this.invokeHook(id, 'notificationChannel', 'test', [readUserSettingsDecrypted(id, userId)], undefined, 8000);
|
||||
await this.invokeHook(id, 'notificationChannel', 'test', [this.userSettings.readAll(id, userId)], undefined, 8000);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { safeParseConfig } from './plugin-config-parse';
|
||||
|
||||
/**
|
||||
* A plugin's per-user settings, decrypted host-side.
|
||||
*
|
||||
* These three reads used to be bare module-level functions in plugins.service.ts that
|
||||
* pulled the `db` singleton, with the comment "standalone (no Nest DI) so the RPC host
|
||||
* wiring can call it directly". That reason expired: the RPC host wiring is a set of
|
||||
* providers now, so they get injected like everything else, and there is no second
|
||||
* route to the database that a test cannot substitute.
|
||||
*
|
||||
* Nothing here is safe to send to a client — every method returns plaintext secrets.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PluginUserSettingsService {
|
||||
constructor(private readonly dbs: DatabaseService) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
/** One decrypted value for the acting user, for the runtime's `ctx.settings.get()`. */
|
||||
readOne(pluginId: string, userId: number, key: string): 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 undefined;
|
||||
return isSecret ? decrypt_api_key(value as string) : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* ALL of a plugin's per-user settings for one user, decrypted.
|
||||
*
|
||||
* This is how a notification-channel hook reaches the recipient's credentials: that
|
||||
* 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> {
|
||||
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);
|
||||
// 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);
|
||||
for (const field of fields) {
|
||||
const value = stored[field.field_key];
|
||||
if (value == null) continue;
|
||||
out[field.field_key] = field.secret === 1 ? decrypt_api_key(value as string) : value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Has this user filled in every `required`, `scope:'user'` field the plugin declares?
|
||||
* A plugin with no required user fields is configured for everyone (an instance-wide
|
||||
* channel, e.g. a shared workspace webhook).
|
||||
*/
|
||||
hasRequired(pluginId: string, userId: number): boolean {
|
||||
const required = this.db
|
||||
.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user' AND required = 1")
|
||||
.all(pluginId) as Array<{ field_key: string }>;
|
||||
if (required.length === 0) return true;
|
||||
const stored = this.storedFor(pluginId, userId);
|
||||
return required.every((field) => {
|
||||
const value = stored[field.field_key];
|
||||
return value != null && String(value) !== '';
|
||||
});
|
||||
}
|
||||
|
||||
private storedFor(pluginId: string, userId: number): 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;
|
||||
return safeParseConfig(row?.config ?? '{}');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DiscoveryModule } from '@nestjs/core';
|
||||
import { PluginsService } from './plugins.service';
|
||||
import { PluginUserSettingsService } from './plugin-user-settings.service';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { PluginHooks } from './plugin-hooks.service';
|
||||
import { PluginRegistryService } from './registry/registry.service';
|
||||
import { PluginRpcHostFactory } from './host/plugin-rpc-host.factory';
|
||||
import { PluginRpcRegistryService } from './host/rpc-kit/registry.service';
|
||||
import { PluginGuardsModule } from './host/plugin-guards.module';
|
||||
import { PluginOAuthModule } from './oauth/plugin-oauth.module';
|
||||
import { DbRpc } from './host/rpc/db.rpc';
|
||||
import { MetaRpc } from './host/rpc/meta.rpc';
|
||||
import { HostSurfaceRpc } from './host/rpc/host-surface.rpc';
|
||||
import { WeatherModule } from '../weather/weather.module';
|
||||
import { TagsModule } from '../tags/tags.module';
|
||||
import { CategoriesModule } from '../categories/categories.module';
|
||||
import { BudgetModule } from '../budget/budget.module';
|
||||
import { ReservationsModule } from '../reservations/reservations.module';
|
||||
import { TodoModule } from '../todo/todo.module';
|
||||
import { PackingModule } from '../packing/packing.module';
|
||||
import { DaysModule } from '../days/days.module';
|
||||
import { AssignmentsModule } from '../assignments/assignments.module';
|
||||
import { LlmParseModule } from '../llm-parse/llm-parse.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { CollabModule } from '../collab/collab.module';
|
||||
import { VacayModule } from '../vacay/vacay.module';
|
||||
import { TripsModule } from '../trips/trips.module';
|
||||
import { PlacesModule } from '../places/places.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { CollectionsModule } from '../collections/collections.module';
|
||||
import { AtlasModule } from '../atlas/atlas.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
|
||||
import { JourneyDomainModule } from '../journey/journey-domain.module';
|
||||
|
||||
/**
|
||||
* The plugin execution half (#plugins, M1/M2): the process supervisor, the
|
||||
* capability router that every child talks to, and the hook contracts the host calls
|
||||
* back through.
|
||||
*
|
||||
* The domain import list has to be COMPLETE, not just "whatever this module's own
|
||||
* code needs". Every `@PluginController()` provider in the container contributes part
|
||||
* of the 113-method wire surface, and PluginRpcRegistryService validates total
|
||||
* coverage at boot — so a domain reachable only from AppModule would make this module
|
||||
* fail to start in any test app that assembles a subset. WeatherModule is in the list
|
||||
* for exactly that reason and for nothing else.
|
||||
*
|
||||
* Split out of PluginsModule so a consumer that needs the runtime alone — AdminModule
|
||||
* cascade-disabling plugins whose addon was just turned off — does not also pull in
|
||||
* 20 HTTP controllers it never calls.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
// What lets PluginRpcRegistryService find the @PluginController providers at boot.
|
||||
DiscoveryModule,
|
||||
// A leaf that hands the resource gates to the domain modules. It must not be this
|
||||
// module: the domains would then have to import this one back and close a cycle.
|
||||
PluginGuardsModule,
|
||||
PluginOAuthModule,
|
||||
WeatherModule, TagsModule, CategoriesModule, BudgetModule, ReservationsModule,
|
||||
TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule,
|
||||
FilesModule, CollabModule, VacayModule, TripsModule, PlacesModule,
|
||||
PermissionsModule, AuditModule, AddonsModule, CollectionsModule, AtlasModule,
|
||||
NotificationsModule, TripMembershipModule, JourneyDomainModule,
|
||||
],
|
||||
providers: [
|
||||
PluginsService,
|
||||
PluginUserSettingsService,
|
||||
PluginRuntimeService,
|
||||
PluginRegistryService,
|
||||
PluginRpcHostFactory,
|
||||
PluginRpcRegistryService,
|
||||
// Owns no wire method; declares and performs all 18 host-to-plugin hook calls.
|
||||
PluginHooks,
|
||||
// The wire surface that belongs to no domain: the plugin's own sqlite, its
|
||||
// namespaced entity metadata, and the host-mediated calls (user lookup,
|
||||
// broadcasts, notifications, LLM, OAuth, scheduler).
|
||||
DbRpc,
|
||||
MetaRpc,
|
||||
HostSurfaceRpc,
|
||||
],
|
||||
exports: [PluginRuntimeService, PluginRegistryService, PluginsService, PluginUserSettingsService, PluginHooks],
|
||||
})
|
||||
export class PluginsRuntimeModule {}
|
||||
@@ -1,88 +1,44 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DiscoveryModule } from '@nestjs/core';
|
||||
import { JourneyDomainModule } from '../journey/journey-domain.module';
|
||||
import { PluginsController } from './plugins.controller';
|
||||
import { PluginsFeedController } from './plugins-feed.controller';
|
||||
import { PluginsProxyController } from './plugins-proxy.controller';
|
||||
import { PluginFrameController } from './plugin-frame.controller';
|
||||
import { PlaceDetailsController } from './place-details.controller';
|
||||
import { TripWarningsController } from './trip-warnings.controller';
|
||||
import { ViewContributionsController } from './view-contributions.controller';
|
||||
import { TripCardContributionsController } from './trip-card-contributions.controller';
|
||||
import { PluginPhotosController } from './plugin-photos.controller';
|
||||
import { PluginCalendarController } from './plugin-calendar.controller';
|
||||
import { MapMarkersController } from './map-markers.controller';
|
||||
import { MapLayersController } from './map-layers.controller';
|
||||
import { PluginRoutesController } from './plugin-routes.controller';
|
||||
import { DayScheduleController } from './day-schedule.controller';
|
||||
import { DayTintsController } from './day-tints.controller';
|
||||
import { PluginActivityController } from './plugin-activity.controller';
|
||||
import { PdfSectionsController } from './pdf-sections.controller';
|
||||
import { AtlasLayersController } from './atlas-layers.controller';
|
||||
import { JournalEntryRowsController } from './journal-entry-rows.controller';
|
||||
import { PluginUserSettingsController } from './plugin-user-settings.controller';
|
||||
import { PluginOAuthController } from './plugin-oauth.controller';
|
||||
import { PluginOAuthService } from './plugin-oauth.service';
|
||||
import { PluginsService } from './plugins.service';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { PluginHooks } from './plugin-hooks.service';
|
||||
import { PluginRegistryService } from './registry/registry.service';
|
||||
import { PluginRpcHostFactory } from './host/plugin-rpc-host.factory';
|
||||
import { PluginRpcRegistryService } from './host/rpc-kit/registry.service';
|
||||
import { PluginGuardsModule } from './host/plugin-guards.module';
|
||||
import { DbRpc } from './host/rpc/db.rpc';
|
||||
import { MetaRpc } from './host/rpc/meta.rpc';
|
||||
import { HostSurfaceRpc } from './host/rpc/host-surface.rpc';
|
||||
import { WeatherModule } from '../weather/weather.module';
|
||||
import { TagsModule } from '../tags/tags.module';
|
||||
import { CategoriesModule } from '../categories/categories.module';
|
||||
import { BudgetModule } from '../budget/budget.module';
|
||||
import { ReservationsModule } from '../reservations/reservations.module';
|
||||
import { TodoModule } from '../todo/todo.module';
|
||||
import { PackingModule } from '../packing/packing.module';
|
||||
import { DaysModule } from '../days/days.module';
|
||||
import { AssignmentsModule } from '../assignments/assignments.module';
|
||||
import { LlmParseModule } from '../llm-parse/llm-parse.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { CollabModule } from '../collab/collab.module';
|
||||
import { VacayModule } from '../vacay/vacay.module';
|
||||
import { TripsModule } from '../trips/trips.module';
|
||||
import { PlacesModule } from '../places/places.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { CollectionsModule } from '../collections/collections.module';
|
||||
import { AtlasModule } from '../atlas/atlas.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
|
||||
import { PluginsRuntimeModule } from './plugins-runtime.module';
|
||||
import { PluginOAuthModule } from './oauth/plugin-oauth.module';
|
||||
import { PluginContributionsModule } from './contributions/plugin-contributions.module';
|
||||
|
||||
/**
|
||||
* Plugin system (#plugins). M0 read side + M2 isolated runtime + M3 frontend:
|
||||
* the runtime service owns the process supervisor and boots active plugins on
|
||||
* startup; the proxy forwards /api/plugins/:id/* to the child; the feed lists
|
||||
* active plugins for the client; the frame controller serves sandboxed page/
|
||||
* widget assets at /plugin-frame/:id/*.
|
||||
* Plugin system (#plugins), composition root.
|
||||
*
|
||||
* It used to be one module with 21 controllers, 24 domain imports and 10 providers,
|
||||
* which made it the hub almost the whole Nest graph ran through — AdminModule
|
||||
* imported it for one method and inherited all of that. It is now three pieces plus
|
||||
* this one:
|
||||
*
|
||||
* - `PluginsRuntimeModule` — supervisor, capability router, hook contracts. The half
|
||||
* with the domain imports, and the only half AdminModule needs.
|
||||
* - `PluginContributionsModule` — the 14 read-only hook controllers.
|
||||
* - `PluginOAuthModule` — the outbound-OAuth leaf.
|
||||
*
|
||||
* What is left here is the CRUD and delivery surface: install/activate/configure, the
|
||||
* feed the client reads, the proxy to a child's HTTP routes, and the sandboxed frame
|
||||
* that serves page and widget assets.
|
||||
*/
|
||||
@Module({
|
||||
// Every domain module whose @PluginController providers own part of the plugin wire
|
||||
// surface. The list has to be COMPLETE, not just "whatever this module's own code
|
||||
// needs": PluginRpcRegistryService validates total coverage at boot, so a domain
|
||||
// reachable only via AppModule would make PluginsModule fail to start in any test
|
||||
// app that assembles a subset (WeatherModule is here for exactly that reason, and
|
||||
// for nothing else). Importing PluginsModule therefore pulls in the whole surface.
|
||||
//
|
||||
// DatabaseModule is @Global, so not listed. DiscoveryModule is what lets
|
||||
// PluginRpcRegistryService find those providers at boot. PluginGuardsModule is a leaf
|
||||
// that hands the resource gates to the domain modules; it must not be this module,
|
||||
// because the domains would then have to import PluginsModule back and close a cycle.
|
||||
imports: [DiscoveryModule, PluginGuardsModule, WeatherModule, TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, TripsModule, PermissionsModule, AuditModule, AddonsModule, PlacesModule, CollectionsModule, AtlasModule, NotificationsModule, TripMembershipModule, JourneyDomainModule],
|
||||
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController, ViewContributionsController, TripCardContributionsController, PluginPhotosController, PluginCalendarController, MapMarkersController, MapLayersController, PluginRoutesController, DayScheduleController, DayTintsController, PdfSectionsController, AtlasLayersController, JournalEntryRowsController, PluginUserSettingsController, PluginOAuthController, PluginActivityController],
|
||||
// DbRpc, MetaRpc and HostSurfaceRpc are the plugin surface that belongs to no
|
||||
// domain: the plugin's own sqlite, its namespaced entity metadata, and the
|
||||
// host-mediated calls (user lookup, broadcasts, notifications, LLM, OAuth, scheduler).
|
||||
providers: [PluginsService, PluginRuntimeService, PluginRegistryService, PluginOAuthService, PluginRpcHostFactory, PluginRpcRegistryService, PluginHooks, DbRpc, MetaRpc, HostSurfaceRpc],
|
||||
// Exported so the admin addon-toggle handler can cascade-disable plugins whose
|
||||
// required addon was just turned off (#plugins dependencies).
|
||||
exports: [PluginRuntimeService],
|
||||
imports: [PluginsRuntimeModule, PluginOAuthModule, PluginContributionsModule],
|
||||
controllers: [
|
||||
PluginsController,
|
||||
PluginsFeedController,
|
||||
PluginsProxyController,
|
||||
PluginFrameController,
|
||||
PluginUserSettingsController,
|
||||
PluginActivityController,
|
||||
],
|
||||
// Re-exported so AppModule keeps working unchanged. A consumer that only needs the
|
||||
// runtime imports PluginsRuntimeModule directly instead — that is the point of the
|
||||
// split.
|
||||
exports: [PluginsRuntimeModule],
|
||||
})
|
||||
export class PluginsModule {}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { devLinkEnabled } from './dev-link';
|
||||
@@ -7,6 +6,7 @@ import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyC
|
||||
import { readAudit } from './host/plugin-audit';
|
||||
import { keyFingerprint } from './signature-status';
|
||||
import { pluginBudgetUsage } from './host/plugin-host-state';
|
||||
import { safeParseConfig as safeParse } from './plugin-config-parse';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, type PluginDepRow, type PluginDependencies, type VersionMismatch } from './dependencies';
|
||||
import { hostSatisfies, hostVersion } from './install/host-compat';
|
||||
@@ -295,7 +295,7 @@ export class PluginsService {
|
||||
|
||||
/** A plugin's broker budget usage for today (AI + notification counts vs caps). */
|
||||
budget(id: string): ReturnType<typeof pluginBudgetUsage> {
|
||||
return pluginBudgetUsage(id);
|
||||
return pluginBudgetUsage(id, this.db);
|
||||
}
|
||||
|
||||
/** Read the instance config with secret fields masked. */
|
||||
@@ -313,30 +313,6 @@ export class PluginsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a stored plugin config blob into a NULL-PROTOTYPE object.
|
||||
*
|
||||
* The prototype matters: a settings key of `__proto__` or `constructor` would otherwise
|
||||
* resolve off Object.prototype on read, so `config[key]` comes back truthy for a user who
|
||||
* has configured nothing — and a *required* field with such a name would report as
|
||||
* configured for everyone. The manifest now rejects those keys at install
|
||||
* (SETTING_KEY_RE); this makes it impossible regardless, including for a plugin that was
|
||||
* installed before that check existed.
|
||||
*/
|
||||
function safeParse(json: string): Record<string, unknown> {
|
||||
const empty = () => Object.create(null) as Record<string, unknown>;
|
||||
try {
|
||||
const v = JSON.parse(json || '{}');
|
||||
if (!v || typeof v !== 'object' || Array.isArray(v)) return empty();
|
||||
const out = empty();
|
||||
// JSON.parse creates `__proto__` as an OWN property (it never invokes the setter), so
|
||||
// copy own keys across onto the null-prototype object rather than trusting the parse.
|
||||
for (const [k, val] of Object.entries(v as Record<string, unknown>)) out[k] = val;
|
||||
return out;
|
||||
} catch {
|
||||
return empty();
|
||||
}
|
||||
}
|
||||
function safeArray(json: string): unknown[] | undefined {
|
||||
try {
|
||||
const v = JSON.parse(json);
|
||||
@@ -346,62 +322,6 @@ function safeArray(json: string): unknown[] | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/** Host-only: one decrypted per-user setting for a plugin (for runtime `ctx.settings`).
|
||||
* Standalone (no Nest DI) so the RPC host wiring can call it directly. */
|
||||
export function readUserSettingDecrypted(pluginId: string, userId: number, key: string): unknown {
|
||||
const isSecret =
|
||||
(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 row = db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(pluginId, userId) as
|
||||
| { config: string }
|
||||
| undefined;
|
||||
const v = safeParse(row?.config ?? '{}')[key];
|
||||
if (v == null) return undefined;
|
||||
return isSecret ? decrypt_api_key(v) : v;
|
||||
}
|
||||
/** Host-only: ALL of a plugin's per-user settings for one user, decrypted.
|
||||
* This is how a notification-channel hook reaches the recipient's credentials —
|
||||
* that dispatch is host-initiated with no acting user, so `ctx.settings.get()`
|
||||
* (which resolves against the acting user) would return undefined there. Never
|
||||
* send this to a client. */
|
||||
export function readUserSettingsDecrypted(pluginId: string, userId: number): Record<string, unknown> {
|
||||
const fields = 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 row = db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(pluginId, userId) as
|
||||
| { config: string }
|
||||
| undefined;
|
||||
const stored = safeParse(row?.config ?? '{}');
|
||||
// Null-prototype for the same reason as safeParse: 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);
|
||||
for (const f of fields) {
|
||||
const v = stored[f.field_key];
|
||||
if (v == null) continue;
|
||||
out[f.field_key] = f.secret === 1 ? decrypt_api_key(v) : v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Has this user filled in every `required`, `scope:'user'` field the plugin declares?
|
||||
* A plugin with no required user fields is configured for everyone (an instance-wide
|
||||
* channel, e.g. a shared workspace webhook). */
|
||||
export function hasRequiredUserSettings(pluginId: string, userId: number): boolean {
|
||||
const required = db
|
||||
.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user' AND required = 1")
|
||||
.all(pluginId) as Array<{ field_key: string }>;
|
||||
if (required.length === 0) return true;
|
||||
const row = db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(pluginId, userId) as
|
||||
| { config: string }
|
||||
| undefined;
|
||||
const stored = safeParse(row?.config ?? '{}');
|
||||
return required.every(f => {
|
||||
const v = stored[f.field_key];
|
||||
return v != null && String(v) !== '';
|
||||
});
|
||||
}
|
||||
|
||||
function maskSecrets(config: Record<string, unknown>, secretKeys: Set<string>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(config)) {
|
||||
|
||||
@@ -414,7 +414,7 @@ export class PluginRegistryService {
|
||||
try {
|
||||
this.verifySignatureAndTofu(id, bytes, entry, ver, opts?.retrustKey);
|
||||
} catch (e) {
|
||||
if (e instanceof RegistryError && isSignatureCode(e.code)) setUpdateBlock(id, e.code, e.message, ver.version);
|
||||
if (e instanceof RegistryError && isSignatureCode(e.code)) setUpdateBlock(this.dbs.connection, id, e.code, e.message, ver.version);
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -463,7 +463,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(id);
|
||||
clearUpdateBlock(this.dbs.connection, id);
|
||||
return { id, version: ver.version };
|
||||
} finally {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db } from '../../db/database';
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
/**
|
||||
* Signature/trust status shared between the registry installer, the read-side
|
||||
@@ -67,9 +67,9 @@ 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(id: string, code: SignatureCode, detail: string, version: string | null): void {
|
||||
export function setUpdateBlock(conn: Database.Database, id: string, code: SignatureCode, detail: string, version: string | null): void {
|
||||
try {
|
||||
db.prepare('UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?').run(
|
||||
conn.prepare('UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?').run(
|
||||
code,
|
||||
detail,
|
||||
version,
|
||||
@@ -84,9 +84,9 @@ export function setUpdateBlock(id: string, code: SignatureCode, detail: string,
|
||||
* 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(id: string): void {
|
||||
export function clearUpdateBlock(conn: Database.Database, id: string): void {
|
||||
try {
|
||||
db.prepare('UPDATE plugins SET update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL WHERE id = ?').run(id);
|
||||
conn.prepare('UPDATE plugins SET update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL WHERE id = ?').run(id);
|
||||
} catch {
|
||||
// See setUpdateBlock.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user