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