diff --git a/server/scripts/coverage-thresholds.mjs b/server/scripts/coverage-thresholds.mjs new file mode 100644 index 000000000..cd5953fbb --- /dev/null +++ b/server/scripts/coverage-thresholds.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +/** + * Prints the per-domain `thresholds` block for vitest.config.ts from the last + * coverage run, at each domain's measured value minus one point of slack. + * + * npm run test:coverage # writes coverage/coverage-summary.json + * node scripts/coverage-thresholds.mjs + * + * The gate is a RATCHET: raise an entry when you improve a domain, never lower one + * to make a build pass. Use this when you have genuinely raised coverage and want the + * new floor written down, not to re-baseline a regression away. + * + * It reads coverage-summary.json rather than the text reporter on purpose. The text + * reporter prints one row per DIRECTORY, so a domain with subdirectories (plugins, + * memories, auth) reads several points higher there than it actually is, and a + * threshold copied off it would be wrong in the loose direction. + */ +import fs from 'node:fs'; +import path from 'node:path'; + +const summaryPath = path.join(process.cwd(), 'coverage', 'coverage-summary.json'); +if (!fs.existsSync(summaryPath)) { + console.error(`no ${summaryPath} — run "npm run test:coverage" first`); + process.exit(1); +} + +const METRICS = [ + ['statements', 'statements'], + ['branches', 'branches'], + ['functions', 'functions'], + ['lines', 'lines'], +]; + +const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); +const domains = new Map(); +for (const [file, entry] of Object.entries(summary)) { + if (file === 'total') continue; + const match = file.split('\\').join('/').match(/\/src\/nest\/([a-z0-9-]+)\//); + if (!match) continue; + const totals = domains.get(match[1]) ?? new Map(METRICS.map(([k]) => [k, { covered: 0, total: 0 }])); + for (const [key, metric] of METRICS) { + totals.get(key).covered += entry[metric].covered; + totals.get(key).total += entry[metric].total; + } + domains.set(match[1], totals); +} + +// A domain with no branches at all reads as 100%, which is the right floor for it. +const floorOf = ({ covered, total }) => (total === 0 ? 100 : Math.max(0, Math.floor((100 * covered) / total) - 1)); + +for (const domain of [...domains.keys()].sort()) { + const totals = domains.get(domain); + const parts = METRICS.map(([key]) => `${key}: ${floorOf(totals.get(key))}`).join(', '); + console.log(` 'src/nest/${domain}/**/*.ts': { ${parts} },`); +} diff --git a/server/src/nest/admin/admin.module.ts b/server/src/nest/admin/admin.module.ts index 95d90efb7..01b83a5ca 100644 --- a/server/src/nest/admin/admin.module.ts +++ b/server/src/nest/admin/admin.module.ts @@ -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], }) diff --git a/server/src/nest/plugins/atlas-layers.controller.ts b/server/src/nest/plugins/contributions/atlas-layers.controller.ts similarity index 94% rename from server/src/nest/plugins/atlas-layers.controller.ts rename to server/src/nest/plugins/contributions/atlas-layers.controller.ts index 9088cbf7e..b3d693474 100644 --- a/server/src/nest/plugins/atlas-layers.controller.ts +++ b/server/src/nest/plugins/contributions/atlas-layers.controller.ts @@ -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 diff --git a/server/src/nest/plugins/day-schedule.controller.ts b/server/src/nest/plugins/contributions/day-schedule.controller.ts similarity index 94% rename from server/src/nest/plugins/day-schedule.controller.ts rename to server/src/nest/plugins/contributions/day-schedule.controller.ts index 81d9198c3..bd7319375 100644 --- a/server/src/nest/plugins/day-schedule.controller.ts +++ b/server/src/nest/plugins/contributions/day-schedule.controller.ts @@ -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 diff --git a/server/src/nest/plugins/day-tints.controller.ts b/server/src/nest/plugins/contributions/day-tints.controller.ts similarity index 97% rename from server/src/nest/plugins/day-tints.controller.ts rename to server/src/nest/plugins/contributions/day-tints.controller.ts index e656926f7..b6c76652f 100644 --- a/server/src/nest/plugins/day-tints.controller.ts +++ b/server/src/nest/plugins/contributions/day-tints.controller.ts @@ -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 diff --git a/server/src/nest/plugins/journal-entry-rows.controller.ts b/server/src/nest/plugins/contributions/journal-entry-rows.controller.ts similarity index 89% rename from server/src/nest/plugins/journal-entry-rows.controller.ts rename to server/src/nest/plugins/contributions/journal-entry-rows.controller.ts index 9c009aeaf..a2c565da9 100644 --- a/server/src/nest/plugins/journal-entry-rows.controller.ts +++ b/server/src/nest/plugins/contributions/journal-entry-rows.controller.ts @@ -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, diff --git a/server/src/nest/plugins/map-layers.controller.ts b/server/src/nest/plugins/contributions/map-layers.controller.ts similarity index 96% rename from server/src/nest/plugins/map-layers.controller.ts rename to server/src/nest/plugins/contributions/map-layers.controller.ts index c72d53585..53bf030c9 100644 --- a/server/src/nest/plugins/map-layers.controller.ts +++ b/server/src/nest/plugins/contributions/map-layers.controller.ts @@ -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 diff --git a/server/src/nest/plugins/map-markers.controller.ts b/server/src/nest/plugins/contributions/map-markers.controller.ts similarity index 93% rename from server/src/nest/plugins/map-markers.controller.ts rename to server/src/nest/plugins/contributions/map-markers.controller.ts index 5f4ed6229..2dcee2ae8 100644 --- a/server/src/nest/plugins/map-markers.controller.ts +++ b/server/src/nest/plugins/contributions/map-markers.controller.ts @@ -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 diff --git a/server/src/nest/plugins/pdf-sections.controller.ts b/server/src/nest/plugins/contributions/pdf-sections.controller.ts similarity index 93% rename from server/src/nest/plugins/pdf-sections.controller.ts rename to server/src/nest/plugins/contributions/pdf-sections.controller.ts index 6cf6b0a6b..72181e2be 100644 --- a/server/src/nest/plugins/pdf-sections.controller.ts +++ b/server/src/nest/plugins/contributions/pdf-sections.controller.ts @@ -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 diff --git a/server/src/nest/plugins/place-details.controller.ts b/server/src/nest/plugins/contributions/place-details.controller.ts similarity index 92% rename from server/src/nest/plugins/place-details.controller.ts rename to server/src/nest/plugins/contributions/place-details.controller.ts index 7ffd2cfe5..a042ab1cc 100644 --- a/server/src/nest/plugins/place-details.controller.ts +++ b/server/src/nest/plugins/contributions/place-details.controller.ts @@ -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 diff --git a/server/src/nest/plugins/plugin-calendar.controller.ts b/server/src/nest/plugins/contributions/plugin-calendar.controller.ts similarity index 94% rename from server/src/nest/plugins/plugin-calendar.controller.ts rename to server/src/nest/plugins/contributions/plugin-calendar.controller.ts index 5236ec761..e988f19b7 100644 --- a/server/src/nest/plugins/plugin-calendar.controller.ts +++ b/server/src/nest/plugins/contributions/plugin-calendar.controller.ts @@ -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 diff --git a/server/src/nest/plugins/contributions/plugin-contributions.module.ts b/server/src/nest/plugins/contributions/plugin-contributions.module.ts new file mode 100644 index 000000000..f080fe28a --- /dev/null +++ b/server/src/nest/plugins/contributions/plugin-contributions.module.ts @@ -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 {} diff --git a/server/src/nest/plugins/plugin-photos.controller.ts b/server/src/nest/plugins/contributions/plugin-photos.controller.ts similarity index 95% rename from server/src/nest/plugins/plugin-photos.controller.ts rename to server/src/nest/plugins/contributions/plugin-photos.controller.ts index 22fd7becf..5d0d04162 100644 --- a/server/src/nest/plugins/plugin-photos.controller.ts +++ b/server/src/nest/plugins/contributions/plugin-photos.controller.ts @@ -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 diff --git a/server/src/nest/plugins/plugin-routes.controller.ts b/server/src/nest/plugins/contributions/plugin-routes.controller.ts similarity index 96% rename from server/src/nest/plugins/plugin-routes.controller.ts rename to server/src/nest/plugins/contributions/plugin-routes.controller.ts index e6f0065f6..352b145c9 100644 --- a/server/src/nest/plugins/plugin-routes.controller.ts +++ b/server/src/nest/plugins/contributions/plugin-routes.controller.ts @@ -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 diff --git a/server/src/nest/plugins/trip-card-contributions.controller.ts b/server/src/nest/plugins/contributions/trip-card-contributions.controller.ts similarity index 95% rename from server/src/nest/plugins/trip-card-contributions.controller.ts rename to server/src/nest/plugins/contributions/trip-card-contributions.controller.ts index 5f40ab06a..e876df908 100644 --- a/server/src/nest/plugins/trip-card-contributions.controller.ts +++ b/server/src/nest/plugins/contributions/trip-card-contributions.controller.ts @@ -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 diff --git a/server/src/nest/plugins/trip-warnings.controller.ts b/server/src/nest/plugins/contributions/trip-warnings.controller.ts similarity index 90% rename from server/src/nest/plugins/trip-warnings.controller.ts rename to server/src/nest/plugins/contributions/trip-warnings.controller.ts index 62422a835..90ea875ab 100644 --- a/server/src/nest/plugins/trip-warnings.controller.ts +++ b/server/src/nest/plugins/contributions/trip-warnings.controller.ts @@ -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 diff --git a/server/src/nest/plugins/view-contributions.controller.ts b/server/src/nest/plugins/contributions/view-contributions.controller.ts similarity index 95% rename from server/src/nest/plugins/view-contributions.controller.ts rename to server/src/nest/plugins/contributions/view-contributions.controller.ts index f15e19b13..ea22d7aca 100644 --- a/server/src/nest/plugins/view-contributions.controller.ts +++ b/server/src/nest/plugins/contributions/view-contributions.controller.ts @@ -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 diff --git a/server/src/nest/plugins/host/plugin-host-state.ts b/server/src/nest/plugins/host/plugin-host-state.ts index 31cfba019..6d7f118c9 100644 --- a/server/src/nest/plugins/host/plugin-host-state.ts +++ b/server/src/nest/plugins/host/plugin-host-state.ts @@ -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(); @@ -39,12 +44,12 @@ export function closePluginDataDb(id: string): void { // nothing persisted or phoned home. const budgets = new Map(); -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 { - return budgetFor(id).used(Date.now()); +export function pluginBudgetUsage(id: string, conn: Database.Database): ReturnType { + return budgetFor(id, conn).used(Date.now()); } diff --git a/server/src/nest/plugins/host/rpc/db.rpc.ts b/server/src/nest/plugins/host/rpc/db.rpc.ts index 3c88c7f67..e9c74d432 100644 --- a/server/src/nest/plugins/host/rpc/db.rpc.ts +++ b/server/src/nest/plugins/host/rpc/db.rpc.ts @@ -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, 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, 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')) }; } } diff --git a/server/src/nest/plugins/host/rpc/host-surface.rpc.ts b/server/src/nest/plugins/host/rpc/host-surface.rpc.ts index 3aba4c71e..42c7bd642 100644 --- a/server/src/nest/plugins/host/rpc/host-surface.rpc.ts +++ b/server/src/nest/plugins/host/rpc/host-surface.rpc.ts @@ -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)'); } } diff --git a/server/src/nest/plugins/plugin-oauth.controller.ts b/server/src/nest/plugins/oauth/plugin-oauth.controller.ts similarity index 94% rename from server/src/nest/plugins/plugin-oauth.controller.ts rename to server/src/nest/plugins/oauth/plugin-oauth.controller.ts index 5ebaa13f1..462350fc5 100644 --- a/server/src/nest/plugins/plugin-oauth.controller.ts +++ b/server/src/nest/plugins/oauth/plugin-oauth.controller.ts @@ -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 — diff --git a/server/src/nest/plugins/oauth/plugin-oauth.module.ts b/server/src/nest/plugins/oauth/plugin-oauth.module.ts new file mode 100644 index 000000000..ee2ceb69c --- /dev/null +++ b/server/src/nest/plugins/oauth/plugin-oauth.module.ts @@ -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 {} diff --git a/server/src/nest/plugins/plugin-oauth.service.ts b/server/src/nest/plugins/oauth/plugin-oauth.service.ts similarity index 97% rename from server/src/nest/plugins/plugin-oauth.service.ts rename to server/src/nest/plugins/oauth/plugin-oauth.service.ts index 3bca14715..2d2716e6a 100644 --- a/server/src/nest/plugins/plugin-oauth.service.ts +++ b/server/src/nest/plugins/oauth/plugin-oauth.service.ts @@ -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 diff --git a/server/src/nest/plugins/plugin-config-parse.ts b/server/src/nest/plugins/plugin-config-parse.ts new file mode 100644 index 000000000..92f6390ba --- /dev/null +++ b/server/src/nest/plugins/plugin-config-parse.ts @@ -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 { + const empty = () => Object.create(null) as Record; + 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)) out[key] = value; + return out; + } catch { + return empty(); + } +} diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index a6940caeb..49c77708f 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -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) }; diff --git a/server/src/nest/plugins/plugin-user-settings.service.ts b/server/src/nest/plugins/plugin-user-settings.service.ts new file mode 100644 index 000000000..e46482db4 --- /dev/null +++ b/server/src/nest/plugins/plugin-user-settings.service.ts @@ -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 { + 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 = 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 { + 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 ?? '{}'); + } +} diff --git a/server/src/nest/plugins/plugins-runtime.module.ts b/server/src/nest/plugins/plugins-runtime.module.ts new file mode 100644 index 000000000..80324bb12 --- /dev/null +++ b/server/src/nest/plugins/plugins-runtime.module.ts @@ -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 {} diff --git a/server/src/nest/plugins/plugins.module.ts b/server/src/nest/plugins/plugins.module.ts index 17d00f36c..029338534 100644 --- a/server/src/nest/plugins/plugins.module.ts +++ b/server/src/nest/plugins/plugins.module.ts @@ -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 {} diff --git a/server/src/nest/plugins/plugins.service.ts b/server/src/nest/plugins/plugins.service.ts index b19e355b7..d939988f8 100644 --- a/server/src/nest/plugins/plugins.service.ts +++ b/server/src/nest/plugins/plugins.service.ts @@ -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 { - 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 { - const empty = () => Object.create(null) as Record; - 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)) 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 { - 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 = 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, secretKeys: Set): Record { const out: Record = {}; for (const [k, v] of Object.entries(config)) { diff --git a/server/src/nest/plugins/registry/registry.service.ts b/server/src/nest/plugins/registry/registry.service.ts index 0592f7dc5..8243d5a18 100644 --- a/server/src/nest/plugins/registry/registry.service.ts +++ b/server/src/nest/plugins/registry/registry.service.ts @@ -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 }); diff --git a/server/src/nest/plugins/signature-status.ts b/server/src/nest/plugins/signature-status.ts index 23e862da9..d8c5035fa 100644 --- a/server/src/nest/plugins/signature-status.ts +++ b/server/src/nest/plugins/signature-status.ts @@ -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. } diff --git a/server/tests/helpers/module-providers.ts b/server/tests/helpers/module-providers.ts new file mode 100644 index 000000000..5950af556 --- /dev/null +++ b/server/tests/helpers/module-providers.ts @@ -0,0 +1,21 @@ +import { expect } from 'vitest'; + +/** + * Asserts that `moduleClass` lists `provider` in its `providers: []`. + * + * This is the one failure the compiler cannot see: a `@PluginController()` or + * `@McpController()` class that no module registers is invisible to discovery, and + * every call to it comes back PERMISSION_DENIED with no other symptom. + * + * It is a helper rather than an inline `expect(...).toContain(...)` because that form + * is unsound here. `Reflect.getMetadata('providers', M)` is `undefined` for a module + * declared without a `providers` key, and `expect(undefined).toContain(SomeClass)` + * PASSES in this vitest version (it only throws for a string needle). Every guard + * written that way silently stopped guarding the moment its class moved to another + * module. Asserting the array shape first is what makes the check real. + */ +export function expectRegisteredProvider(moduleClass: object, provider: object): void { + const providers = Reflect.getMetadata('providers', moduleClass) as unknown[] | undefined; + expect(Array.isArray(providers)).toBe(true); + expect(providers).toEqual(expect.arrayContaining([provider])); +} diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index d7a98480b..5548b123f 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -1,5 +1,6 @@ import { DatabaseService } from '../../src/nest/database/database.service'; import { PluginRuntimeService } from '../../src/nest/plugins/plugin-runtime.service'; +import { PluginUserSettingsService } from '../../src/nest/plugins/plugin-user-settings.service'; import type { PluginRegistryService } from '../../src/nest/plugins/registry/registry.service'; import { PluginRpcHostFactory } from '../../src/nest/plugins/host/plugin-rpc-host.factory'; import { PluginRpcRegistryService } from '../../src/nest/plugins/host/rpc-kit/registry.service'; @@ -9,7 +10,7 @@ import { DbRpc } from '../../src/nest/plugins/host/rpc/db.rpc'; import { MetaRpc } from '../../src/nest/plugins/host/rpc/meta.rpc'; import { HostSurfaceRpc } from '../../src/nest/plugins/host/rpc/host-surface.rpc'; import { PluginHooks } from '../../src/nest/plugins/plugin-hooks.service'; -import { PluginOAuthService } from '../../src/nest/plugins/plugin-oauth.service'; +import { PluginOAuthService } from '../../src/nest/plugins/oauth/plugin-oauth.service'; import { BudgetService } from '../../src/nest/budget/budget.service'; import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.service'; import { ReservationsService } from '../../src/nest/reservations/reservations.service'; @@ -118,7 +119,7 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF new VacayRpc(vacay, guards), new JournalRpc(journey, guards), new CollectionsRpc(collections, guards), - new DbRpc(), + new DbRpc(new PluginUserSettingsService(dbs)), new MetaRpc(dbs, guards), new HostSurfaceRpc(dbs, realtime, notifications, llmConfig, oauth, guards), new PluginHooks(undefined as never), @@ -128,5 +129,5 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF /** A PluginRuntimeService constructed the way Nest would: with a real host factory. */ export function createPluginRuntime(dbs: DatabaseService, registry?: PluginRegistryService): PluginRuntimeService { - return new PluginRuntimeService(dbs, new AuditService(dbs), new AddonsService(dbs), registry, createPluginRpcHostFactory(dbs)); + return new PluginRuntimeService(dbs, new AuditService(dbs), new AddonsService(dbs), new PluginUserSettingsService(dbs), registry, createPluginRpcHostFactory(dbs)); } diff --git a/server/tests/helpers/rpc-host-deps.ts b/server/tests/helpers/rpc-host-deps.ts index ceb8ee74f..b18d5379e 100644 --- a/server/tests/helpers/rpc-host-deps.ts +++ b/server/tests/helpers/rpc-host-deps.ts @@ -80,7 +80,7 @@ export function allRpcControllers(): object[] { new VacayRpc(anyService(), anyService()), new JournalRpc(anyService(), anyService()), new CollectionsRpc(anyService(), anyService()), - new DbRpc(), + new DbRpc(anyService()), new MetaRpc(anyService(), anyService()), new HostSurfaceRpc(anyService(), anyService(), anyService(), anyService(), anyService(), anyService()), // Owns no wire method, but declares all 18 hook contracts, which requireTotalCoverage checks too. diff --git a/server/tests/unit/budget/costs.rpc.test.ts b/server/tests/unit/budget/costs.rpc.test.ts index 3c88be986..43927ecca 100644 --- a/server/tests/unit/budget/costs.rpc.test.ts +++ b/server/tests/unit/budget/costs.rpc.test.ts @@ -6,6 +6,7 @@ * which refusal a caller sees. */ import { describe, it, expect, vi } from 'vitest'; +import { expectRegisteredProvider } from '../../helpers/module-providers'; import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host'; import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing'; import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service'; @@ -146,6 +147,6 @@ describe('CostsRpc writes', () => { }); it('COSTS-RPC-012 the class is listed in its module providers', () => { - expect(Reflect.getMetadata('providers', BudgetModule) as unknown[]).toContain(CostsRpc); + expectRegisteredProvider(BudgetModule, CostsRpc); }); }); diff --git a/server/tests/unit/days/day-notes.rpc.test.ts b/server/tests/unit/days/day-notes.rpc.test.ts index 7ba6fca7b..50d8bec24 100644 --- a/server/tests/unit/days/day-notes.rpc.test.ts +++ b/server/tests/unit/days/day-notes.rpc.test.ts @@ -5,6 +5,7 @@ * no room for. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { expectRegisteredProvider } from '../../helpers/module-providers'; import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host'; import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing'; import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service'; @@ -114,6 +115,6 @@ describe('DayNotesRpc through the router', () => { }); it('DAYNOTES-RPC-009 the class is listed in its module providers', () => { - expect(Reflect.getMetadata('providers', DaysModule) as unknown[]).toContain(DayNotesRpc); + expectRegisteredProvider(DaysModule, DayNotesRpc); }); }); diff --git a/server/tests/unit/days/days-itinerary.rpc.test.ts b/server/tests/unit/days/days-itinerary.rpc.test.ts index 6a1da64ab..920696b1c 100644 --- a/server/tests/unit/days/days-itinerary.rpc.test.ts +++ b/server/tests/unit/days/days-itinerary.rpc.test.ts @@ -6,6 +6,7 @@ * permission of its own, and both halves scope their rows to the trip before writing. */ import { describe, it, expect, vi } from 'vitest'; +import { expectRegisteredProvider } from '../../helpers/module-providers'; import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host'; import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing'; import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service'; @@ -84,7 +85,7 @@ describe('DaysRpc', () => { }); it('DAYS-RPC-004 the class is listed in its module providers', () => { - expect(Reflect.getMetadata('providers', DaysModule) as unknown[]).toContain(DaysRpc); + expectRegisteredProvider(DaysModule, DaysRpc); }); }); @@ -140,6 +141,6 @@ describe('ItineraryRpc', () => { }); it('ITIN-RPC-008 the class is listed in its module providers', () => { - expect(Reflect.getMetadata('providers', AssignmentsModule) as unknown[]).toContain(ItineraryRpc); + expectRegisteredProvider(AssignmentsModule, ItineraryRpc); }); }); diff --git a/server/tests/unit/files/files.rpc.test.ts b/server/tests/unit/files/files.rpc.test.ts index 10b96e89a..1eec851d1 100644 --- a/server/tests/unit/files/files.rpc.test.ts +++ b/server/tests/unit/files/files.rpc.test.ts @@ -7,6 +7,7 @@ * its own permission rather than one shared domain action. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { expectRegisteredProvider } from '../../helpers/module-providers'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -253,6 +254,6 @@ describe('FilesRpc writes', () => { }); it('FILES-RPC-017 the class is listed in its module providers', () => { - expect(Reflect.getMetadata('providers', FilesModule) as unknown[]).toContain(FilesRpc); + expectRegisteredProvider(FilesModule, FilesRpc); }); }); diff --git a/server/tests/unit/nest/atlas.service.test.ts b/server/tests/unit/nest/atlas.service.test.ts index 6b079f058..a54bbc857 100644 --- a/server/tests/unit/nest/atlas.service.test.ts +++ b/server/tests/unit/nest/atlas.service.test.ts @@ -487,6 +487,15 @@ describe('reverseGeocodeCountry', () => { // These read the committed geoBoundaries bundle (server/assets/atlas/admin1.geojson.gz), // so they double as a guard that the bundle ships current sub-national data (#1119). describe('getRegionGeo', () => { + // The first call streams the multi-MB admin1.geojson.gz through the brace-depth + // splitter and caches the result for the process. That build alone takes ~2.5s on a + // quiet machine and more when every vitest worker is busy, so whichever test called + // getRegionGeo first used to pay for it and blow the 5s default. Warm the store here + // with a budget that fits the build; the tests below then measure only the lookup. + beforeAll(async () => { + await getRegionGeo(['ZZ']); + }, 60_000); + it('ATLAS-SVC-017: returns an empty FeatureCollection for a country with no admin-1 features', async () => { const result = await getRegionGeo(['ZZ']); expect(result).toEqual({ type: 'FeatureCollection', features: [] }); diff --git a/server/tests/unit/packing/packing.rpc.test.ts b/server/tests/unit/packing/packing.rpc.test.ts index f141a62d0..16f8f69b8 100644 --- a/server/tests/unit/packing/packing.rpc.test.ts +++ b/server/tests/unit/packing/packing.rpc.test.ts @@ -7,6 +7,7 @@ * instead of three times over. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { expectRegisteredProvider } from '../../helpers/module-providers'; import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host'; import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing'; import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service'; @@ -153,7 +154,7 @@ describe('PackingRpc through the router', () => { }); it('PACKING-RPC-008 the class is listed in its module providers', () => { - expect(Reflect.getMetadata('providers', PackingModule) as unknown[]).toContain(PackingRpc); + expectRegisteredProvider(PackingModule, PackingRpc); }); }); diff --git a/server/tests/unit/places/places.rpc.test.ts b/server/tests/unit/places/places.rpc.test.ts index 5f2e2a7a0..d8fce8ccf 100644 --- a/server/tests/unit/places/places.rpc.test.ts +++ b/server/tests/unit/places/places.rpc.test.ts @@ -7,6 +7,7 @@ * at all. */ import { describe, it, expect, vi } from 'vitest'; +import { expectRegisteredProvider } from '../../helpers/module-providers'; import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host'; import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing'; import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service'; @@ -131,6 +132,6 @@ describe('PlacesRpc', () => { }); it('PLACES-RPC-010 the class is listed in its module providers', () => { - expect(Reflect.getMetadata('providers', PlacesModule) as unknown[]).toContain(PlacesRpc); + expectRegisteredProvider(PlacesModule, PlacesRpc); }); }); diff --git a/server/tests/unit/plugins/atlas-layers.controller.test.ts b/server/tests/unit/plugins/atlas-layers.controller.test.ts index d42a41872..78ec76f66 100644 --- a/server/tests/unit/plugins/atlas-layers.controller.test.ts +++ b/server/tests/unit/plugins/atlas-layers.controller.test.ts @@ -14,7 +14,7 @@ const { pluginsEnabled } = vi.hoisted(() => ({ vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: () => undefined }) }, canAccessTrip: vi.fn() })); vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); -import { AtlasLayersController } from '../../../src/nest/plugins/atlas-layers.controller'; +import { AtlasLayersController } from '../../../src/nest/plugins/contributions/atlas-layers.controller'; import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/server/tests/unit/plugins/day-schedule.controller.test.ts b/server/tests/unit/plugins/day-schedule.controller.test.ts index 323e82641..50bb19877 100644 --- a/server/tests/unit/plugins/day-schedule.controller.test.ts +++ b/server/tests/unit/plugins/day-schedule.controller.test.ts @@ -20,7 +20,7 @@ import { db as dbConn } from '../../../src/db/database'; import { DatabaseService } from '../../../src/nest/database/database.service'; vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); -import { DayScheduleController } from '../../../src/nest/plugins/day-schedule.controller'; +import { DayScheduleController } from '../../../src/nest/plugins/contributions/day-schedule.controller'; import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/server/tests/unit/plugins/day-tints.controller.test.ts b/server/tests/unit/plugins/day-tints.controller.test.ts index 0bb50603d..629db624b 100644 --- a/server/tests/unit/plugins/day-tints.controller.test.ts +++ b/server/tests/unit/plugins/day-tints.controller.test.ts @@ -20,7 +20,7 @@ import { db as dbConn } from '../../../src/db/database'; import { DatabaseService } from '../../../src/nest/database/database.service'; vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); -import { DayTintsController } from '../../../src/nest/plugins/day-tints.controller'; +import { DayTintsController } from '../../../src/nest/plugins/contributions/day-tints.controller'; import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/server/tests/unit/plugins/host-rpc.test.ts b/server/tests/unit/plugins/host-rpc.test.ts index 0cbca4777..b332916ff 100644 --- a/server/tests/unit/plugins/host-rpc.test.ts +++ b/server/tests/unit/plugins/host-rpc.test.ts @@ -53,17 +53,12 @@ const { llmExtract } = vi.hoisted(() => ({ llmExtract: vi.fn(async (input: { text?: string }) => [{ text: `answer:${input.text ?? ''}` }]), })); vi.mock('../../../src/nest/llm-parse/llm-client.factory', () => ({ createLlmClient: vi.fn(() => ({ extract: llmExtract })) })); -// readUserSettingDecrypted is a bare function export, not an injected service, so it -// stays a path mock. -vi.mock('../../../src/nest/plugins/plugins.service', () => ({ - readUserSettingDecrypted: vi.fn((_pid: string, uid: number, key: string) => (uid === 5 && key === 'apiKey' ? 'k-5' : undefined)), -})); - import { PluginRpcHostFactory, type PluginCallRouter } from '../../../src/nest/plugins/host/plugin-rpc-host.factory'; import { PluginRpcRegistryService } from '../../../src/nest/plugins/host/rpc-kit/registry.service'; import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing'; import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service'; import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc'; +import type { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service'; import { MetaRpc } from '../../../src/nest/plugins/host/rpc/meta.rpc'; import { HostSurfaceRpc } from '../../../src/nest/plugins/host/rpc/host-surface.rpc'; import { getPluginDataDb, closePluginDataDb } from '../../../src/nest/plugins/host/plugin-host-state'; @@ -74,7 +69,7 @@ import type { PermissionsService } from '../../../src/nest/permissions/permissio import type { AddonsService } from '../../../src/nest/addons/addons.service'; import type { NotificationsService } from '../../../src/nest/notifications/notifications.service'; import type { LlmConfigResolver } from '../../../src/nest/llm-parse/llm-config.resolver'; -import type { PluginOAuthService } from '../../../src/nest/plugins/plugin-oauth.service'; +import type { PluginOAuthService } from '../../../src/nest/plugins/oauth/plugin-oauth.service'; import type { RpcError, RpcResponse } from '../../../src/nest/plugins/protocol/envelope'; const checkPermission = vi.fn(() => true as boolean); @@ -91,10 +86,16 @@ const oauth = { }, } as unknown as PluginOAuthService; +// The per-user settings reads are stubbed rather than seeded: this suite is about the +// RPC surface, and plugin-user-settings.test.ts already covers the encryption round-trip. +const userSettings = { + readOne: vi.fn((_pid: string, uid: number, key: string) => (uid === 5 && key === 'apiKey' ? 'k-5' : undefined)), +} as unknown as PluginUserSettingsService; + const dbs = new DatabaseService(mockDb); const guards = new PluginGuards(dbs, permissions, addons); const registry = createTestPluginRegistry([ - new DbRpc(), + new DbRpc(userSettings), new MetaRpc(dbs, guards), new HostSurfaceRpc(dbs, new RealtimeService(), notifications, llmConfig, oauth, guards), ]); diff --git a/server/tests/unit/plugins/journal-entry-rows.controller.test.ts b/server/tests/unit/plugins/journal-entry-rows.controller.test.ts index 9e64ca00e..b0f945ab4 100644 --- a/server/tests/unit/plugins/journal-entry-rows.controller.test.ts +++ b/server/tests/unit/plugins/journal-entry-rows.controller.test.ts @@ -22,7 +22,7 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; import type { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); -import { JournalEntryRowsController } from '../../../src/nest/plugins/journal-entry-rows.controller'; +import { JournalEntryRowsController } from '../../../src/nest/plugins/contributions/journal-entry-rows.controller'; import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; import type { AddonsService } from '../../../src/nest/addons/addons.service'; diff --git a/server/tests/unit/plugins/map-layers.controller.test.ts b/server/tests/unit/plugins/map-layers.controller.test.ts index 4bcac3682..0043877b0 100644 --- a/server/tests/unit/plugins/map-layers.controller.test.ts +++ b/server/tests/unit/plugins/map-layers.controller.test.ts @@ -14,7 +14,7 @@ const { canAccessTrip, pluginsEnabled } = vi.hoisted(() => ({ vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: () => undefined }) }, canAccessTrip })); vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); -import { MapLayersController } from '../../../src/nest/plugins/map-layers.controller'; +import { MapLayersController } from '../../../src/nest/plugins/contributions/map-layers.controller'; import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; import type { DatabaseService } from '../../../src/nest/database/database.service'; diff --git a/server/tests/unit/plugins/map-markers.controller.test.ts b/server/tests/unit/plugins/map-markers.controller.test.ts index f01a7bacd..ba549e8ed 100644 --- a/server/tests/unit/plugins/map-markers.controller.test.ts +++ b/server/tests/unit/plugins/map-markers.controller.test.ts @@ -13,7 +13,7 @@ const { canAccessTrip, pluginsEnabled } = vi.hoisted(() => ({ vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: () => undefined }) }, canAccessTrip })); vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); -import { MapMarkersController } from '../../../src/nest/plugins/map-markers.controller'; +import { MapMarkersController } from '../../../src/nest/plugins/contributions/map-markers.controller'; import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; import type { DatabaseService } from '../../../src/nest/database/database.service'; diff --git a/server/tests/unit/plugins/pdf-sections.controller.test.ts b/server/tests/unit/plugins/pdf-sections.controller.test.ts index 34de54f28..637f77076 100644 --- a/server/tests/unit/plugins/pdf-sections.controller.test.ts +++ b/server/tests/unit/plugins/pdf-sections.controller.test.ts @@ -14,7 +14,7 @@ const { canAccessTrip, pluginsEnabled } = vi.hoisted(() => ({ vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: () => undefined }) }, canAccessTrip })); vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); -import { PdfSectionsController } from '../../../src/nest/plugins/pdf-sections.controller'; +import { PdfSectionsController } from '../../../src/nest/plugins/contributions/pdf-sections.controller'; import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; import type { DatabaseService } from '../../../src/nest/database/database.service'; diff --git a/server/tests/unit/plugins/place-details.controller.test.ts b/server/tests/unit/plugins/place-details.controller.test.ts index a5d9fb27e..cc3e1084f 100644 --- a/server/tests/unit/plugins/place-details.controller.test.ts +++ b/server/tests/unit/plugins/place-details.controller.test.ts @@ -13,7 +13,7 @@ import { db as dbConn } from '../../../src/db/database'; import { DatabaseService } from '../../../src/nest/database/database.service'; vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); -import { PlaceDetailsController } from '../../../src/nest/plugins/place-details.controller'; +import { PlaceDetailsController } from '../../../src/nest/plugins/contributions/place-details.controller'; import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/server/tests/unit/plugins/plugin-hooks.test.ts b/server/tests/unit/plugins/plugin-hooks.test.ts index 15cd8603d..af034129d 100644 --- a/server/tests/unit/plugins/plugin-hooks.test.ts +++ b/server/tests/unit/plugins/plugin-hooks.test.ts @@ -9,8 +9,9 @@ * These tests pin all three, plus the argument shape each hook is handed. */ import { describe, it, expect, vi } from 'vitest'; +import { expectRegisteredProvider } from '../../helpers/module-providers'; import { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; -import { PluginsModule } from '../../../src/nest/plugins/plugins.module'; +import { PluginsRuntimeModule } from '../../../src/nest/plugins/plugins-runtime.module'; import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing'; import { HOOK_PERMISSION } from '../../../src/nest/plugins/protocol/envelope'; import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; @@ -137,7 +138,6 @@ describe('PluginHooks contracts', () => { // A decorated class no module registers is invisible to discovery, which would // now fail boot on "no host-side consumer" rather than fail quietly. Cheapest // possible guard against getting there. - const providers = Reflect.getMetadata('providers', PluginsModule) as unknown[] | undefined; - expect(providers).toContain(PluginHooks); + expectRegisteredProvider(PluginsRuntimeModule, PluginHooks); }); }); diff --git a/server/tests/unit/plugins/plugin-oauth.controller.test.ts b/server/tests/unit/plugins/plugin-oauth.controller.test.ts index a1b15f428..1fafbc6ba 100644 --- a/server/tests/unit/plugins/plugin-oauth.controller.test.ts +++ b/server/tests/unit/plugins/plugin-oauth.controller.test.ts @@ -15,8 +15,8 @@ vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: getMoc import { db as dbConn } from '../../../src/db/database'; import { DatabaseService } from '../../../src/nest/database/database.service'; -import { PluginOAuthController } from '../../../src/nest/plugins/plugin-oauth.controller'; -import type { PluginOAuthService } from '../../../src/nest/plugins/plugin-oauth.service'; +import { PluginOAuthController } from '../../../src/nest/plugins/oauth/plugin-oauth.controller'; +import type { PluginOAuthService } from '../../../src/nest/plugins/oauth/plugin-oauth.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any; diff --git a/server/tests/unit/plugins/plugin-oauth.service.test.ts b/server/tests/unit/plugins/plugin-oauth.service.test.ts index d7f814459..ad0decd2e 100644 --- a/server/tests/unit/plugins/plugin-oauth.service.test.ts +++ b/server/tests/unit/plugins/plugin-oauth.service.test.ts @@ -30,7 +30,7 @@ vi.mock('node:dns/promises', () => { }); import Database from 'better-sqlite3'; -import { PluginOAuthService } from '../../../src/nest/plugins/plugin-oauth.service'; +import { PluginOAuthService } from '../../../src/nest/plugins/oauth/plugin-oauth.service'; const CFG = { oauth_authorize_url: 'https://provider.example/authorize', diff --git a/server/tests/unit/plugins/plugin-photos-calendar.controller.test.ts b/server/tests/unit/plugins/plugin-photos-calendar.controller.test.ts index aca789e61..b6fadb303 100644 --- a/server/tests/unit/plugins/plugin-photos-calendar.controller.test.ts +++ b/server/tests/unit/plugins/plugin-photos-calendar.controller.test.ts @@ -9,8 +9,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const { pluginsEnabled } = vi.hoisted(() => ({ pluginsEnabled: vi.fn(() => true) })); vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); -import { PluginPhotosController } from '../../../src/nest/plugins/plugin-photos.controller'; -import { PluginCalendarController } from '../../../src/nest/plugins/plugin-calendar.controller'; +import { PluginPhotosController } from '../../../src/nest/plugins/contributions/plugin-photos.controller'; +import { PluginCalendarController } from '../../../src/nest/plugins/contributions/plugin-calendar.controller'; import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/server/tests/unit/plugins/plugin-routes.controller.test.ts b/server/tests/unit/plugins/plugin-routes.controller.test.ts index 463f19ec9..ed4bae552 100644 --- a/server/tests/unit/plugins/plugin-routes.controller.test.ts +++ b/server/tests/unit/plugins/plugin-routes.controller.test.ts @@ -20,7 +20,7 @@ import { db as dbConn } from '../../../src/db/database'; import { DatabaseService } from '../../../src/nest/database/database.service'; vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); -import { PluginRoutesController } from '../../../src/nest/plugins/plugin-routes.controller'; +import { PluginRoutesController } from '../../../src/nest/plugins/contributions/plugin-routes.controller'; import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/server/tests/unit/plugins/plugin-user-settings.test.ts b/server/tests/unit/plugins/plugin-user-settings.test.ts index e1e43732c..797b50b69 100644 --- a/server/tests/unit/plugins/plugin-user-settings.test.ts +++ b/server/tests/unit/plugins/plugin-user-settings.test.ts @@ -19,7 +19,10 @@ import { db as dbConn } from '../../../src/db/database'; import { DatabaseService } from '../../../src/nest/database/database.service'; import Database from 'better-sqlite3'; -import { PluginsService, readUserSettingDecrypted } from '../../../src/nest/plugins/plugins.service'; +import { PluginsService } from '../../../src/nest/plugins/plugins.service'; +import { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service'; +/** The host-side settings reads, over the same connection the test seeded. */ +const userSettings = () => new PluginUserSettingsService(new DatabaseService(dbConn)); function freshDb() { const d = new Database(':memory:'); @@ -50,14 +53,14 @@ describe('per-user plugin settings', () => { expect(masked.apiKey).toBe('••••••••'); // never echoed expect(masked.units).toBe('metric'); // decrypted runtime read returns the real value; the stored form is ciphertext - expect(readUserSettingDecrypted('p', 42, 'apiKey')).toBe('sk-123'); + expect(userSettings().readOne('p', 42, 'apiKey')).toBe('sk-123'); expect(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(readUserSettingDecrypted('p', 42, 'apiKey')).toBe('sk-123'); // still the original + expect(userSettings().readOne('p', 42, 'apiKey')).toBe('sk-123'); // still the original expect(svc.getUserConfig('p', 42).units).toBe('imperial'); }); @@ -72,6 +75,6 @@ describe('per-user plugin settings', () => { it('is per-user — one user cannot see another\'s value', () => { svc.updateUserConfig('p', 42, { units: 'metric' }); expect(svc.getUserConfig('p', 99).units).toBeUndefined(); - expect(readUserSettingDecrypted('p', 99, 'apiKey')).toBeUndefined(); + expect(userSettings().readOne('p', 99, 'apiKey')).toBeUndefined(); }); }); diff --git a/server/tests/unit/plugins/settings-isolation.test.ts b/server/tests/unit/plugins/settings-isolation.test.ts index e276dff1f..d0e09c178 100644 --- a/server/tests/unit/plugins/settings-isolation.test.ts +++ b/server/tests/unit/plugins/settings-isolation.test.ts @@ -26,8 +26,10 @@ vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_K import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { createUser } from '../../helpers/factories'; -import { readUserSettingsDecrypted, hasRequiredUserSettings, readUserSettingDecrypted } from '../../../src/nest/plugins/plugins.service'; +import { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service'; import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest'; +/** The host-side settings reads, over the same connection the test seeded. */ +const userSettings = () => new PluginUserSettingsService(new DatabaseService(dbConn)); let uid: number; @@ -60,7 +62,7 @@ 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(readUserSettingsDecrypted('evil', uid)).toEqual({ webhook_url: 'https://attacker.example.com' }); + expect(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', () => { @@ -68,16 +70,16 @@ describe('plugin settings are isolated from core and from each other', () => { declareField('b', 'token', { secret: true }); setUserConfig('b', { token: 'B-SECRET' }); - expect(readUserSettingsDecrypted('a', uid)).toEqual({}); - expect(readUserSettingDecrypted('a', uid, 'token')).toBeUndefined(); - expect(readUserSettingDecrypted('b', uid, 'token')).toBe('B-SECRET'); + expect(userSettings().readAll('a', uid)).toEqual({}); + expect(userSettings().readOne('a', uid, 'token')).toBeUndefined(); + expect(userSettings().readOne('b', uid, 'token')).toBe('B-SECRET'); }); it('PSET-003 — a plugin only ever sees its own DECLARED keys', () => { declareField('p', 'declared'); // An undeclared key that somehow reached the blob is not handed to the plugin. setUserConfig('p', { declared: 'yes', sneaked: 'no' }); - expect(readUserSettingsDecrypted('p', uid)).toEqual({ declared: 'yes' }); + expect(userSettings().readAll('p', uid)).toEqual({ declared: 'yes' }); }); }); @@ -87,16 +89,16 @@ describe('settings keys cannot resolve off the prototype chain', () => { (key) => { declareField('evil', key, { required: true }); // The user has configured nothing at all. - expect(hasRequiredUserSettings('evil', uid)).toBe(false); - expect(readUserSettingsDecrypted('evil', uid)).toEqual({}); + expect(userSettings().hasRequired('evil', uid)).toBe(false); + expect(userSettings().readAll('evil', uid)).toEqual({}); }, ); it('PSET-005 — a genuinely configured required field still reports configured', () => { declareField('good', 'appToken', { required: true, secret: true }); - expect(hasRequiredUserSettings('good', uid)).toBe(false); + expect(userSettings().hasRequired('good', uid)).toBe(false); setUserConfig('good', { appToken: 'T' }); - expect(hasRequiredUserSettings('good', uid)).toBe(true); + expect(userSettings().hasRequired('good', uid)).toBe(true); }); it('PSET-006 — the manifest rejects such a key at install', () => { @@ -124,7 +126,7 @@ describe('a plugin channel label is bounded by the host', () => { VALUES ('loud', 'Loud', 'active', 1, '1.0.0', '[]', '[]', ?, '{}')`, ).run(JSON.stringify({ notificationChannel: { title: '🎉'.repeat(5) + 'A'.repeat(500) } })); - const rt = new PluginRuntimeService(new DatabaseService(dbConn), new AuditService(new DatabaseService(dbConn)), new AddonsService(new DatabaseService(dbConn))); + const rt = new PluginRuntimeService(new DatabaseService(dbConn), new AuditService(new DatabaseService(dbConn)), new AddonsService(new DatabaseService(dbConn)), userSettings()); // Stand the plugin up as a granted, active notificationChannel provider. (rt as unknown as { supervisor: { running: Map } }).supervisor.running.set('loud', { id: 'loud', status: 'active', hooks: ['notificationChannel'], granted: new Set(['hook:notification-channel']), diff --git a/server/tests/unit/plugins/tenant-free.rpc.test.ts b/server/tests/unit/plugins/tenant-free.rpc.test.ts index b3c24dc59..9633e2bfa 100644 --- a/server/tests/unit/plugins/tenant-free.rpc.test.ts +++ b/server/tests/unit/plugins/tenant-free.rpc.test.ts @@ -6,6 +6,7 @@ * with NO acting user. Everything else in the plugin surface refuses that. */ import { describe, it, expect, vi } from 'vitest'; +import { expectRegisteredProvider } from '../../helpers/module-providers'; import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host'; import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing'; import { CategoriesRpc } from '../../../src/nest/categories/categories.rpc'; @@ -75,8 +76,8 @@ describe('tenant-free plugin methods', () => { }); it('TENANTFREE-007 each class is listed in its module providers', () => { - expect(Reflect.getMetadata('providers', CategoriesModule) as unknown[]).toContain(CategoriesRpc); - expect(Reflect.getMetadata('providers', WeatherModule) as unknown[]).toContain(WeatherRpc); - expect(Reflect.getMetadata('providers', BudgetModule) as unknown[]).toContain(ExchangeRatesRpc); + expectRegisteredProvider(CategoriesModule, CategoriesRpc); + expectRegisteredProvider(WeatherModule, WeatherRpc); + expectRegisteredProvider(BudgetModule, ExchangeRatesRpc); }); }); diff --git a/server/tests/unit/plugins/trip-card-contributions.controller.test.ts b/server/tests/unit/plugins/trip-card-contributions.controller.test.ts index f4f3eb91d..9ba4c510b 100644 --- a/server/tests/unit/plugins/trip-card-contributions.controller.test.ts +++ b/server/tests/unit/plugins/trip-card-contributions.controller.test.ts @@ -15,7 +15,7 @@ const { canAccessTrip, pluginsEnabled } = vi.hoisted(() => ({ vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: () => undefined }) }, canAccessTrip })); vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); -import { TripCardContributionsController } from '../../../src/nest/plugins/trip-card-contributions.controller'; +import { TripCardContributionsController } from '../../../src/nest/plugins/contributions/trip-card-contributions.controller'; import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; import type { DatabaseService } from '../../../src/nest/database/database.service'; diff --git a/server/tests/unit/plugins/trip-warnings.controller.test.ts b/server/tests/unit/plugins/trip-warnings.controller.test.ts index f09445aed..6d2a11c9d 100644 --- a/server/tests/unit/plugins/trip-warnings.controller.test.ts +++ b/server/tests/unit/plugins/trip-warnings.controller.test.ts @@ -7,7 +7,7 @@ const { canAccessTrip, pluginsEnabled } = vi.hoisted(() => ({ vi.mock('../../../src/db/database', () => ({ db: {}, canAccessTrip })); vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); -import { TripWarningsController } from '../../../src/nest/plugins/trip-warnings.controller'; +import { TripWarningsController } from '../../../src/nest/plugins/contributions/trip-warnings.controller'; import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; import type { DatabaseService } from '../../../src/nest/database/database.service'; diff --git a/server/tests/unit/plugins/view-contributions.controller.test.ts b/server/tests/unit/plugins/view-contributions.controller.test.ts index 60f468535..1711999a6 100644 --- a/server/tests/unit/plugins/view-contributions.controller.test.ts +++ b/server/tests/unit/plugins/view-contributions.controller.test.ts @@ -14,7 +14,7 @@ const { canAccessTrip, pluginsEnabled } = vi.hoisted(() => ({ vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: () => undefined }) }, canAccessTrip })); vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); -import { ViewContributionsController } from '../../../src/nest/plugins/view-contributions.controller'; +import { ViewContributionsController } from '../../../src/nest/plugins/contributions/view-contributions.controller'; import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; import type { DatabaseService } from '../../../src/nest/database/database.service'; diff --git a/server/tests/unit/reservations/bookings.rpc.test.ts b/server/tests/unit/reservations/bookings.rpc.test.ts index a98ac4dbb..56e015923 100644 --- a/server/tests/unit/reservations/bookings.rpc.test.ts +++ b/server/tests/unit/reservations/bookings.rpc.test.ts @@ -9,6 +9,7 @@ * relies on, so they are asserted by event name and order. */ import { describe, it, expect, vi } from 'vitest'; +import { expectRegisteredProvider } from '../../helpers/module-providers'; import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host'; import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing'; import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service'; @@ -170,7 +171,7 @@ describe('ReservationsRpc', () => { }); it('BOOK-RPC-009 the class is listed in its module providers', () => { - expect(Reflect.getMetadata('providers', ReservationsModule) as unknown[]).toContain(ReservationsRpc); + expectRegisteredProvider(ReservationsModule, ReservationsRpc); }); }); @@ -242,6 +243,6 @@ describe('AccommodationsRpc', () => { }); it('BOOK-RPC-016 the class is listed in its module providers', () => { - expect(Reflect.getMetadata('providers', DaysModule) as unknown[]).toContain(AccommodationsRpc); + expectRegisteredProvider(DaysModule, AccommodationsRpc); }); }); diff --git a/server/tests/unit/tags/tags.rpc.test.ts b/server/tests/unit/tags/tags.rpc.test.ts index 477893850..d5d393831 100644 --- a/server/tests/unit/tags/tags.rpc.test.ts +++ b/server/tests/unit/tags/tags.rpc.test.ts @@ -8,6 +8,7 @@ * particular the ownership re-check on the two writes. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { expectRegisteredProvider } from '../../helpers/module-providers'; import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host'; import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing'; import { BadParams, ForbiddenResource } from '../../../src/nest/plugins/host/rpc-errors'; @@ -116,8 +117,7 @@ describe('TagsRpc is reachable at all', () => { // The one failure the compiler cannot see: a decorated class that no module // registers is invisible to discovery, and every tags.* call then comes back // PERMISSION_DENIED with no other symptom. Cheapest possible guard against it. - const providers = Reflect.getMetadata('providers', TagsModule) as unknown[] | undefined; - expect(providers).toContain(TagsRpc); + expectRegisteredProvider(TagsModule, TagsRpc); }); }); diff --git a/server/tests/unit/todo/todo.rpc.test.ts b/server/tests/unit/todo/todo.rpc.test.ts index 2a549be56..35e709205 100644 --- a/server/tests/unit/todo/todo.rpc.test.ts +++ b/server/tests/unit/todo/todo.rpc.test.ts @@ -4,6 +4,7 @@ * easy to lose in a move and is asserted here explicitly. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { expectRegisteredProvider } from '../../helpers/module-providers'; import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host'; import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing'; import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service'; @@ -109,6 +110,6 @@ describe('TodoRpc through the router', () => { }); it('TODO-RPC-010 the class is listed in its module providers', () => { - expect(Reflect.getMetadata('providers', TodoModule) as unknown[]).toContain(TodoRpc); + expectRegisteredProvider(TodoModule, TodoRpc); }); }); diff --git a/server/tests/unit/trips/trips.rpc.test.ts b/server/tests/unit/trips/trips.rpc.test.ts index 8f5504c22..c3ebb0160 100644 --- a/server/tests/unit/trips/trips.rpc.test.ts +++ b/server/tests/unit/trips/trips.rpc.test.ts @@ -8,6 +8,7 @@ * ACCESS, which is why it sits behind its own permission rather than any other write. */ import { describe, it, expect, vi } from 'vitest'; +import { expectRegisteredProvider } from '../../helpers/module-providers'; import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host'; import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing'; import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service'; @@ -203,6 +204,6 @@ describe('TripsRpc membership', () => { }); it('TRIPS-RPC-019 the class is listed in its module providers', () => { - expect(Reflect.getMetadata('providers', TripsModule) as unknown[]).toContain(TripsRpc); + expectRegisteredProvider(TripsModule, TripsRpc); }); }); diff --git a/server/vitest.config.ts b/server/vitest.config.ts index 8ca662568..4e86b1fac 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -29,7 +29,10 @@ export default defineConfig({ // though every test passes). `istanbul` instruments the source directly, so // coverage is measured independently of the transform pipeline. provider: 'istanbul', - reporter: ['lcov', 'text'], + // json-summary is what the per-domain ratchet below is derived from: the text + // reporter prints one row per DIRECTORY, not a recursive total, so reading the + // thresholds off it silently understates any domain with subdirectories. + reporter: ['lcov', 'text', 'json-summary'], reportsDirectory: './coverage', include: ['src/**/*.ts'], // The plugin child bootstrap runs in a forked subprocess via tsx, so the @@ -37,10 +40,74 @@ export default defineConfig({ // the supervisor integration test instead. Everything else in the plugin // module runs in-process and is unit-tested. exclude: ['src/nest/plugins/runtime/plugin-host-entry.ts'], - // Coverage gate scoped to the new NestJS code only — the legacy codebase - // is intentionally ungated. Raised to the DoD's >=80% bar once the first - // module (weather) landed; ratchet further as more modules are migrated. + // Coverage gate scoped to the new NestJS code only — the legacy codebase is + // intentionally ungated. + // + // The bar is PER DOMAIN and works as a ratchet: each entry is that domain's + // measured coverage at the time it was set, minus one point of slack. Regenerate + // with `node scripts/coverage-thresholds.mjs` after a run that RAISED coverage. A single + // repo-wide 80% let a well-covered domain subsidise a thin one, so a domain could + // lose ten points and the build stayed green. Now it cannot: the only way past a + // number here is to raise it, which makes the ratchet visible in the diff. + // + // Raise an entry when you improve a domain. Never lower one to make a build pass — + // that is the whole point of the mechanism. + // + // A file matched by a glob below is EXCLUDED from the catch-all, so the last entry + // only covers what sits directly under src/nest (app.module.ts and the like). thresholds: { + 'src/nest/addons/**/*.ts': { statements: 99, branches: 87, functions: 99, lines: 99 }, + 'src/nest/admin/**/*.ts': { statements: 86, branches: 70, functions: 89, lines: 88 }, + 'src/nest/airports/**/*.ts': { statements: 68, branches: 60, functions: 93, lines: 73 }, + 'src/nest/app-config/**/*.ts': { statements: 79, branches: 100, functions: 65, lines: 71 }, + 'src/nest/assignments/**/*.ts': { statements: 90, branches: 81, functions: 97, lines: 95 }, + 'src/nest/atlas/**/*.ts': { statements: 91, branches: 78, functions: 96, lines: 93 }, + 'src/nest/audit/**/*.ts': { statements: 92, branches: 79, functions: 99, lines: 96 }, + 'src/nest/auth/**/*.ts': { statements: 93, branches: 84, functions: 95, lines: 96 }, + 'src/nest/backup/**/*.ts': { statements: 97, branches: 91, functions: 99, lines: 97 }, + 'src/nest/booking-import/**/*.ts': { statements: 49, branches: 28, functions: 58, lines: 52 }, + 'src/nest/budget/**/*.ts': { statements: 91, branches: 77, functions: 97, lines: 95 }, + 'src/nest/categories/**/*.ts': { statements: 99, branches: 80, functions: 99, lines: 99 }, + 'src/nest/collab/**/*.ts': { statements: 93, branches: 86, functions: 96, lines: 98 }, + 'src/nest/collections/**/*.ts': { statements: 83, branches: 70, functions: 91, lines: 92 }, + 'src/nest/common/**/*.ts': { statements: 95, branches: 89, functions: 99, lines: 97 }, + 'src/nest/config/**/*.ts': { statements: 99, branches: 100, functions: 99, lines: 99 }, + 'src/nest/database/**/*.ts': { statements: 99, branches: 100, functions: 99, lines: 99 }, + 'src/nest/days/**/*.ts': { statements: 93, branches: 82, functions: 98, lines: 97 }, + 'src/nest/feeds/**/*.ts': { statements: 91, branches: 83, functions: 83, lines: 91 }, + 'src/nest/files/**/*.ts': { statements: 97, branches: 95, functions: 99, lines: 98 }, + 'src/nest/health/**/*.ts': { statements: 99, branches: 65, functions: 99, lines: 99 }, + 'src/nest/help/**/*.ts': { statements: 81, branches: 70, functions: 99, lines: 86 }, + 'src/nest/integrations/**/*.ts': { statements: 19, branches: 0, functions: 10, lines: 19 }, + 'src/nest/journey/**/*.ts': { statements: 90, branches: 84, functions: 83, lines: 91 }, + 'src/nest/llm-parse/**/*.ts': { statements: 91, branches: 85, functions: 85, lines: 94 }, + 'src/nest/maps/**/*.ts': { statements: 92, branches: 85, functions: 95, lines: 95 }, + 'src/nest/memories/**/*.ts': { statements: 88, branches: 80, functions: 95, lines: 90 }, + 'src/nest/notifications/**/*.ts': { statements: 83, branches: 71, functions: 84, lines: 86 }, + 'src/nest/oauth/**/*.ts': { statements: 96, branches: 95, functions: 97, lines: 97 }, + 'src/nest/oidc/**/*.ts': { statements: 88, branches: 83, functions: 91, lines: 92 }, + 'src/nest/packing/**/*.ts': { statements: 92, branches: 83, functions: 98, lines: 97 }, + 'src/nest/permissions/**/*.ts': { statements: 97, branches: 90, functions: 99, lines: 97 }, + 'src/nest/photos/**/*.ts': { statements: 95, branches: 92, functions: 89, lines: 95 }, + 'src/nest/place-photos/**/*.ts': { statements: 79, branches: 70, functions: 72, lines: 80 }, + 'src/nest/places/**/*.ts': { statements: 90, branches: 80, functions: 96, lines: 94 }, + 'src/nest/platform/**/*.ts': { statements: 99, branches: 99, functions: 99, lines: 99 }, + 'src/nest/plugins/**/*.ts': { statements: 86, branches: 81, functions: 78, lines: 89 }, + 'src/nest/query-helpers/**/*.ts': { statements: 90, branches: 75, functions: 99, lines: 92 }, + 'src/nest/realtime/**/*.ts': { statements: 99, branches: 100, functions: 99, lines: 99 }, + 'src/nest/reservations/**/*.ts': { statements: 93, branches: 83, functions: 96, lines: 96 }, + 'src/nest/settings/**/*.ts': { statements: 85, branches: 68, functions: 99, lines: 87 }, + 'src/nest/share/**/*.ts': { statements: 97, branches: 87, functions: 99, lines: 99 }, + 'src/nest/system-notices/**/*.ts': { statements: 99, branches: 99, functions: 99, lines: 99 }, + 'src/nest/tags/**/*.ts': { statements: 97, branches: 89, functions: 99, lines: 99 }, + 'src/nest/todo/**/*.ts': { statements: 91, branches: 83, functions: 99, lines: 99 }, + 'src/nest/transit/**/*.ts': { statements: 92, branches: 83, functions: 97, lines: 94 }, + 'src/nest/trip-invite/**/*.ts': { statements: 91, branches: 93, functions: 93, lines: 89 }, + 'src/nest/trip-membership/**/*.ts': { statements: 99, branches: 86, functions: 99, lines: 99 }, + 'src/nest/trips/**/*.ts': { statements: 92, branches: 82, functions: 91, lines: 94 }, + 'src/nest/unsplash/**/*.ts': { statements: 97, branches: 82, functions: 99, lines: 96 }, + 'src/nest/vacay/**/*.ts': { statements: 82, branches: 66, functions: 89, lines: 86 }, + 'src/nest/weather/**/*.ts': { statements: 93, branches: 78, functions: 91, lines: 97 }, 'src/nest/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 }, }, },