From f72aba0c9162b5f709c1064be9970d30af725af2 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 9 Aug 2026 17:15:53 +0200 Subject: [PATCH] Finish the plugin RPC decorator rollout The last 20 wire methods move onto the decorators: db.* and the three unconditional methods (DbRpc), meta.* (MetaRpc) and the host-mediated surface of users/broadcasts/notify/ai/oauth/scheduler (HostSurfaceRpc). That closes the ledger at 113/113, so the legacy has() blocks in rpc-host.ts are gone and the router is down to what was never domain-specific: build the granted subset of the dispatch map, dispatch, audit, map errors. PluginHostDepsFactory was the 26-argument wiring sheet behind those blocks. With the handlers in the domains it has two dependencies left, so it is now PluginRpcHostFactory: the plugin's own sqlite handle, the inter-plugin router bound with this plugin as the caller, and the audit sink. The 18 host-to-plugin hook calls get the same treatment. They used to spell out the fn name and the timeout at each controller, which meant nothing checked that a hook:* grant on the consent screen had a consumer behind it, that the fn matched what the SDK documents, or that two call sites agreed on the budget. PluginHooks declares all 18 with @PluginHook and performs them, and the controllers call it by name. With both halves complete, requireTotalCoverage goes on: a KNOWN_METHOD without a handler, or a hook without a consumer, now fails app boot instead of surfacing as a PERMISSION_DENIED nobody can explain. --- server/src/nest/README.md | 10 + .../nest/assignments/assignments.bridge.ts | 2 +- .../nest/assignments/assignments.module.ts | 2 +- server/src/nest/atlas/atlas.module.ts | 2 +- server/src/nest/budget/budget.bridge.ts | 2 +- server/src/nest/budget/budget.module.ts | 2 +- .../src/nest/categories/categories.module.ts | 2 +- .../src/nest/categories/categories.service.ts | 2 +- server/src/nest/collab/collab.module.ts | 2 +- server/src/nest/days/days.bridge.ts | 2 +- server/src/nest/days/days.module.ts | 2 +- server/src/nest/llm-parse/llm-parse.module.ts | 2 +- .../notifications/notifications.module.ts | 2 +- .../notifications/notifications.service.ts | 2 +- server/src/nest/packing/packing.bridge.ts | 2 +- server/src/nest/packing/packing.module.ts | 2 +- server/src/nest/places/places.module.ts | 2 +- server/src/nest/places/places.service.ts | 2 +- .../nest/plugins/atlas-layers.controller.ts | 8 +- .../nest/plugins/day-schedule.controller.ts | 8 +- .../src/nest/plugins/day-tints.controller.ts | 8 +- .../plugins/host/plugin-guards.service.ts | 2 +- .../plugins/host/plugin-host-deps.factory.ts | 384 ------------ .../plugins/host/plugin-rpc-host.factory.ts | 59 ++ server/src/nest/plugins/host/rpc-host.ts | 471 +------------- .../src/nest/plugins/host/rpc-kit/README.md | 7 +- .../plugins/host/rpc-kit/registry.service.ts | 26 +- server/src/nest/plugins/host/rpc-kit/types.ts | 16 + server/src/nest/plugins/host/rpc/db.rpc.ts | 65 ++ .../nest/plugins/host/rpc/host-surface.rpc.ts | 250 ++++++++ server/src/nest/plugins/host/rpc/meta.rpc.ts | 150 +++++ .../plugins/journal-entry-rows.controller.ts | 8 +- .../src/nest/plugins/map-layers.controller.ts | 8 +- .../nest/plugins/map-markers.controller.ts | 8 +- .../nest/plugins/pdf-sections.controller.ts | 8 +- .../nest/plugins/place-details.controller.ts | 8 +- .../plugins/plugin-calendar.controller.ts | 10 +- .../src/nest/plugins/plugin-hooks.service.ts | 149 +++++ .../nest/plugins/plugin-photos.controller.ts | 14 +- .../nest/plugins/plugin-routes.controller.ts | 15 +- .../nest/plugins/plugin-runtime.service.ts | 15 +- server/src/nest/plugins/plugins.module.ts | 22 +- .../trip-card-contributions.controller.ts | 8 +- .../nest/plugins/trip-warnings.controller.ts | 8 +- .../plugins/view-contributions.controller.ts | 8 +- .../nest/reservations/reservations.bridge.ts | 2 +- .../nest/reservations/reservations.module.ts | 2 +- .../nest/reservations/reservations.service.ts | 2 +- server/src/nest/tags/tags.module.ts | 2 +- server/src/nest/tags/tags.service.ts | 2 +- server/src/nest/vacay/vacay.module.ts | 2 +- server/tests/helpers/plugin-host.ts | 110 +++- server/tests/helpers/rpc-host-deps.ts | 169 +---- .../integration/plugins/supervisor.test.ts | 12 +- .../plugins/atlas-layers.controller.test.ts | 8 +- .../plugins/day-schedule.controller.test.ts | 8 +- .../unit/plugins/day-tints.controller.test.ts | 8 +- server/tests/unit/plugins/host-rpc.test.ts | 549 ++++++++++++++++ .../journal-entry-rows.controller.test.ts | 8 +- .../plugins/map-layers.controller.test.ts | 6 +- .../plugins/map-markers.controller.test.ts | 6 +- .../plugins/pdf-sections.controller.test.ts | 8 +- .../plugins/place-details.controller.test.ts | 22 +- .../tests/unit/plugins/plugin-hooks.test.ts | 143 +++++ .../plugins/plugin-host-deps.factory.test.ts | 588 ------------------ .../plugin-photos-calendar.controller.test.ts | 13 +- .../plugins/plugin-routes.controller.test.ts | 14 +- .../tests/unit/plugins/rpc-coverage.test.ts | 151 ++--- .../unit/plugins/rpc-host-inv-strip.test.ts | 10 +- server/tests/unit/plugins/rpc-host.test.ts | 412 +++++------- .../plugins/rpc-kit/registry.service.test.ts | 24 +- ...trip-card-contributions.controller.test.ts | 8 +- .../plugins/trip-warnings.controller.test.ts | 22 +- .../view-contributions.controller.test.ts | 8 +- 74 files changed, 1942 insertions(+), 2164 deletions(-) delete mode 100644 server/src/nest/plugins/host/plugin-host-deps.factory.ts create mode 100644 server/src/nest/plugins/host/plugin-rpc-host.factory.ts create mode 100644 server/src/nest/plugins/host/rpc/db.rpc.ts create mode 100644 server/src/nest/plugins/host/rpc/host-surface.rpc.ts create mode 100644 server/src/nest/plugins/host/rpc/meta.rpc.ts create mode 100644 server/src/nest/plugins/plugin-hooks.service.ts create mode 100644 server/tests/unit/plugins/host-rpc.test.ts create mode 100644 server/tests/unit/plugins/plugin-hooks.test.ts delete mode 100644 server/tests/unit/plugins/plugin-host-deps.factory.test.ts diff --git a/server/src/nest/README.md b/server/src/nest/README.md index 4758d0a20..9122f3307 100644 --- a/server/src/nest/README.md +++ b/server/src/nest/README.md @@ -38,6 +38,16 @@ remains as the platform underneath `@nestjs/platform-express`. (`services/tripAccess.ts` and the dead `middleware/tripAccess.ts` are gone); `queryHelpers` and `tripMembership` are providers; the scheduler takes its dependencies from the container. +- **Plugin RPC surface (BE-7, complete):** all 113 wire methods are + `@PluginMethod` / `@PluginOpenMethod` handlers on `@PluginController()` providers + in their own domains (`/.rpc.ts`; the three that belong to no + domain live in `plugins/host/rpc/`), and all 18 host-to-plugin hook contracts are + `@PluginHook` declarations on `PluginHooks`. `PluginRpcHost` no longer holds a + handler: it builds the granted subset of the dispatch map from the registry, + dispatches, audits and maps errors. `PluginHostDepsFactory` — the 26-argument + wiring sheet named throughout the log below — is now + `PluginRpcHostFactory` and injects two things. See + `plugins/host/rpc-kit/README.md`. - **Guards (BE-Phase 2, in progress):** the JWT verify lives in `auth/`, and `middleware/auth.ts`, `validate.ts` and `idempotency.ts` are deleted; the three addon guards are one `AddonGuard` + `@RequireAddon`; the demo-mode block is one diff --git a/server/src/nest/assignments/assignments.bridge.ts b/server/src/nest/assignments/assignments.bridge.ts index 150e624fb..82bd53247 100644 --- a/server/src/nest/assignments/assignments.bridge.ts +++ b/server/src/nest/assignments/assignments.bridge.ts @@ -12,7 +12,7 @@ import { TrekPhotosRepository } from '../photos/trek-photos.repository'; * the Nest container (the legacy places and reservations MCP registrars in * src/mcp/tools/places.ts and src/mcp/tools/reservations.ts; the assignment * MCP tools moved to the DI-discovered assignments.mcp.ts, and the plugin RPC - * host injects AssignmentsService via PluginHostDepsFactory). Exports only the + * surface, ItineraryRpc, injects AssignmentsService directly). Exports only the * legacy services/assignmentService names still consumed outside the * container, 1:1, so repointing a consumer is an import-path-only diff. Inside * the container, inject AssignmentsService instead. Delete this file when diff --git a/server/src/nest/assignments/assignments.module.ts b/server/src/nest/assignments/assignments.module.ts index e56ef4b42..fe63078c0 100644 --- a/server/src/nest/assignments/assignments.module.ts +++ b/server/src/nest/assignments/assignments.module.ts @@ -15,7 +15,7 @@ import { AuthModule } from '../auth/auth.module'; * Assignments domain (S7 — Phase 2 trip sub-domain). The day-assignments mount * sits under the /api/trips/:tripId/days prefix (S6); the per-assignment ops use * the /api/trips/:tripId/assignments prefix. AssignmentsService is exported for - * the plugin RPC host (PluginHostDepsFactory injects it). + * the plugin RPC surface (ItineraryRpc injects it). */ @Module({ // DaysModule: AssignmentsMcp injects DaysService for the target-day checks. diff --git a/server/src/nest/atlas/atlas.module.ts b/server/src/nest/atlas/atlas.module.ts index 271a15788..9b13ade00 100644 --- a/server/src/nest/atlas/atlas.module.ts +++ b/server/src/nest/atlas/atlas.module.ts @@ -7,7 +7,7 @@ import { AtlasMcp } from './atlas.mcp'; /** * Atlas addon domain (L7 leaf module). Registered in AppModule. Exports - * AtlasService for the plugin RPC host (PluginHostDepsFactory injects it). + * AtlasService for the plugin RPC surface (AtlasRpc injects it). * AtlasMcp is a provider (not a controller) — the nest-mcp registry discovers * it after app.init(). */ diff --git a/server/src/nest/budget/budget.bridge.ts b/server/src/nest/budget/budget.bridge.ts index d603050b4..6a906f995 100644 --- a/server/src/nest/budget/budget.bridge.ts +++ b/server/src/nest/budget/budget.bridge.ts @@ -11,7 +11,7 @@ import { ExchangeRatesService } from './exchange-rates.service'; * because BudgetModule imports AuthModule for BudgetMcp's demo guard, so * AuthModule cannot import BudgetModule back — see the note there. (The budget MCP * tools and resources moved to the DI-discovered budget.mcp.ts, the plugin - * RPC host injects BudgetService via PluginHostDepsFactory, and the trip + * surface, CostsRpc, injects BudgetService directly, and the trip * domain folded into the DI-native TripsService, which injects BudgetService — * its bridge exports were pruned with it). Exports only the legacy * services/budgetService names still consumed outside the container, 1:1, so diff --git a/server/src/nest/budget/budget.module.ts b/server/src/nest/budget/budget.module.ts index 1a5fc539d..f330be0e1 100644 --- a/server/src/nest/budget/budget.module.ts +++ b/server/src/nest/budget/budget.module.ts @@ -16,7 +16,7 @@ import { AuthModule } from '../auth/auth.module'; imports: [PermissionsModule, AuthModule, RealtimeModule, PluginGuardsModule], controllers: [BudgetController], providers: [BudgetService, ExchangeRatesService, BudgetMcp, ExchangeRatesRpc, CostsRpc], - // For in-container consumers (PluginHostDepsFactory, TripsService, + // For in-container consumers (CostsRpc, TripsService, // ReservationsService, BookingImportService). exports: [BudgetService, ExchangeRatesService], }) diff --git a/server/src/nest/categories/categories.module.ts b/server/src/nest/categories/categories.module.ts index 4386e5766..b43c5d516 100644 --- a/server/src/nest/categories/categories.module.ts +++ b/server/src/nest/categories/categories.module.ts @@ -8,7 +8,7 @@ import { CategoriesRpc } from './categories.rpc'; @Module({ controllers: [CategoriesController], providers: [CategoriesService, CategoriesMcp, CategoriesRpc], - // For in-container consumers (PluginHostDepsFactory). + // For in-container consumers (CategoriesRpc). exports: [CategoriesService], }) export class CategoriesModule {} diff --git a/server/src/nest/categories/categories.service.ts b/server/src/nest/categories/categories.service.ts index 9daa45bfc..ac280d529 100644 --- a/server/src/nest/categories/categories.service.ts +++ b/server/src/nest/categories/categories.service.ts @@ -6,7 +6,7 @@ import { DatabaseService } from '../database/database.service'; * Categories domain service — owns the category SQL (moved 1:1 from the legacy * services/categoryService.ts: identical statements, the #6366f1/📍 defaults * and the COALESCE update semantics). All consumers are in-container now — the - * plugin RPC host injects this class via PluginHostDepsFactory. + * plugin RPC surface injects this class into CategoriesRpc. */ @Injectable() export class CategoriesService { diff --git a/server/src/nest/collab/collab.module.ts b/server/src/nest/collab/collab.module.ts index 3e753e414..7420f8e42 100644 --- a/server/src/nest/collab/collab.module.ts +++ b/server/src/nest/collab/collab.module.ts @@ -12,7 +12,7 @@ import { AuthModule } from '../auth/auth.module'; imports: [PermissionsModule, AuthModule, RealtimeModule, PluginGuardsModule], controllers: [CollabController], providers: [CollabService, CollabMcp, CollabRpc], - // For in-container consumers (PluginHostDepsFactory). + // For in-container consumers (CollabRpc). exports: [CollabService], }) export class CollabModule {} diff --git a/server/src/nest/days/days.bridge.ts b/server/src/nest/days/days.bridge.ts index 856b8d9d3..afe9abae1 100644 --- a/server/src/nest/days/days.bridge.ts +++ b/server/src/nest/days/days.bridge.ts @@ -11,7 +11,7 @@ import { QueryHelpersService } from '../query-helpers/query-helpers.service'; * the day MCP tools and resources moved to the DI-discovered days.mcp.ts, * the transit tools to the DI-discovered transit.mcp.ts (which injects * DaysService), the plugin RPC host injects DaysService via - * PluginHostDepsFactory, and the trip domain folded into the DI-native + * DaysRpc / AccommodationsRpc, and the trip domain folded into the DI-native * TripsService, which injects DaysService — its bridge exports were pruned * with it). Exports only the legacy services/dayService names still consumed * outside the container, 1:1, so repointing a consumer is an import-path-only diff --git a/server/src/nest/days/days.module.ts b/server/src/nest/days/days.module.ts index 330ec9554..5bdb9b4a0 100644 --- a/server/src/nest/days/days.module.ts +++ b/server/src/nest/days/days.module.ts @@ -20,7 +20,7 @@ import { AuthModule } from '../auth/auth.module'; * /api/trips/:tripId/days covers both the days mount and the nested * /days/:dayId/notes mount. DaysMcp/DayNotesMcp carry the * decorator-registered MCP tools + resources. DaysService and DayNotesService - * are exported for in-container consumers (PluginHostDepsFactory, + * are exported for in-container consumers (DaysRpc, DayNotesRpc, * AccommodationsService, TripsService, the assignments/reservations MCP * controllers). */ diff --git a/server/src/nest/llm-parse/llm-parse.module.ts b/server/src/nest/llm-parse/llm-parse.module.ts index e6092cadf..36f119cf1 100644 --- a/server/src/nest/llm-parse/llm-parse.module.ts +++ b/server/src/nest/llm-parse/llm-parse.module.ts @@ -8,7 +8,7 @@ import { AddonsModule } from '../addons/addons.module'; /** * Provides the LLM booking-import fallback; imported by BookingImportModule. - * Exports LlmConfigResolver for the plugin host wiring (PluginHostDepsFactory). + * Exports LlmConfigResolver for the plugin host surface (HostSurfaceRpc). */ @Module({ imports: [SettingsModule, AddonsModule], diff --git a/server/src/nest/notifications/notifications.module.ts b/server/src/nest/notifications/notifications.module.ts index 230fa199b..4dffa21ed 100644 --- a/server/src/nest/notifications/notifications.module.ts +++ b/server/src/nest/notifications/notifications.module.ts @@ -14,7 +14,7 @@ import { AuthModule } from '../auth/auth.module'; * mail without AuthModule and NotificationsModule importing each other. * NotificationsService and NotificationPreferencesService are exported for * in-container consumers (AdminController's dev test send and preferences tab, - * the plugin RPC host's PluginHostDepsFactory). */ + * the plugin RPC surface, HostSurfaceRpc). */ @Module({ imports: [AuthModule, MailerModule], controllers: [NotificationsController], diff --git a/server/src/nest/notifications/notifications.service.ts b/server/src/nest/notifications/notifications.service.ts index 7175406a4..fd431532b 100644 --- a/server/src/nest/notifications/notifications.service.ts +++ b/server/src/nest/notifications/notifications.service.ts @@ -299,7 +299,7 @@ function shouldSendToUser( * Non-Nest consumers (scheduler, legacy adminService/memories, the lazy * fire-and-forget sends in migrated Nest services) go through * `notifications.bridge.ts`; the plugin RPC host injects this service via - * `PluginHostDepsFactory`. + * `HostSurfaceRpc`. */ @Injectable() export class NotificationsService { diff --git a/server/src/nest/packing/packing.bridge.ts b/server/src/nest/packing/packing.bridge.ts index b309550ba..6f38029e5 100644 --- a/server/src/nest/packing/packing.bridge.ts +++ b/server/src/nest/packing/packing.bridge.ts @@ -9,7 +9,7 @@ import { PermissionsService } from '../permissions/permissions.service'; * Nest container (the legacy packing-list prompt registrar in * src/mcp/tools/prompts.ts; the packing MCP tools and resources moved to the * DI-discovered packing.mcp.ts, the plugin RPC host injects PackingService - * via PluginHostDepsFactory, and the trip domain folded into the DI-native + * via PackingRpc, and the trip domain folded into the DI-native * TripsService, which injects PackingService). Exports only the legacy * services/packingService names still consumed outside the container, 1:1, so * repointing a consumer is an import-path-only diff. Inside the container, diff --git a/server/src/nest/packing/packing.module.ts b/server/src/nest/packing/packing.module.ts index 324a677ab..c4f2e38b3 100644 --- a/server/src/nest/packing/packing.module.ts +++ b/server/src/nest/packing/packing.module.ts @@ -10,7 +10,7 @@ import { AuthModule } from '../auth/auth.module'; /** Packing domain (S2 — Phase 2 trip sub-domain). Registered in AppModule. * Exports PackingService for in-container consumers (TripsService bundle, - * PluginHostDepsFactory). */ + * PackingRpc). */ @Module({ imports: [PermissionsModule, AuthModule, RealtimeModule, PluginGuardsModule], controllers: [PackingController], diff --git a/server/src/nest/places/places.module.ts b/server/src/nest/places/places.module.ts index 36c790ec0..df2c0f759 100644 --- a/server/src/nest/places/places.module.ts +++ b/server/src/nest/places/places.module.ts @@ -19,7 +19,7 @@ import { AuthModule } from '../auth/auth.module'; * Tags for the joined projections, and on MapsModule for places.mcp.ts's * search_place tool. Exports PlacesService for the in-container consumers — * TripsService's trip summary, DaysMcp's place-accommodation creation, - * BookingImportService and the plugin RPC host's PluginHostDepsFactory. There + * BookingImportService and the plugin RPC surface, PlacesRpc. There * is no places.bridge.ts: nothing outside the container consumes this domain. */ @Module({ diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index 48aff2aba..113bd49b2 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -96,7 +96,7 @@ export interface PlaceUpdateInput { * places.helpers.ts. Nothing outside the Nest container consumes this domain * any more, so there is no places.bridge.ts: the MCP surface is the * DI-discovered places.mcp.ts, and TripsService / DaysMcp / - * BookingImportService / PluginHostDepsFactory all inject this class. + * BookingImportService / PlacesRpc all inject this class. */ @Injectable() export class PlacesService { diff --git a/server/src/nest/plugins/atlas-layers.controller.ts b/server/src/nest/plugins/atlas-layers.controller.ts index 0166cf70d..9088cbf7e 100644 --- a/server/src/nest/plugins/atlas-layers.controller.ts +++ b/server/src/nest/plugins/atlas-layers.controller.ts @@ -2,7 +2,7 @@ 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 { PluginRuntimeService } from './plugin-runtime.service'; +import { PluginHooks } from './plugin-hooks.service'; import { stripEmoji } from './text-sanitize'; /** @@ -78,7 +78,7 @@ function normalize(pluginId: string, raw: unknown): AtlasLayer[] { @Controller('api/atlas-layers') @UseGuards(JwtAuthGuard) export class AtlasLayersController { - constructor(private readonly runtime: PluginRuntimeService) {} + constructor(private readonly hooks: PluginHooks) {} @Get() async get(@Req() req: Request & { user?: { id: number } }): Promise<{ layers: AtlasLayer[] }> { @@ -86,11 +86,11 @@ export class AtlasLayersController { const userId = req.user?.id; if (userId == null) return { layers: [] }; - const ids = this.runtime.providersOf('atlasLayerProvider'); + const ids = this.hooks.providersOf('atlasLayerProvider'); const perProvider = await Promise.all( ids.map(async (id): Promise => { try { - const raw = await this.runtime.invokeHook(id, 'atlasLayerProvider', 'getLayers', [], userId, 5000); + const raw = await this.hooks.atlasLayers(id, userId); return normalize(id, raw); } catch { return []; // a slow / failing provider contributes nothing diff --git a/server/src/nest/plugins/day-schedule.controller.ts b/server/src/nest/plugins/day-schedule.controller.ts index 85e85f9dc..81d9198c3 100644 --- a/server/src/nest/plugins/day-schedule.controller.ts +++ b/server/src/nest/plugins/day-schedule.controller.ts @@ -3,7 +3,7 @@ import type { Request } from 'express'; import { DatabaseService } from '../database/database.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { pluginsEnabled } from './kill-switch'; -import { PluginRuntimeService } from './plugin-runtime.service'; +import { PluginHooks } from './plugin-hooks.service'; import { stripEmoji } from './text-sanitize'; /** @@ -79,7 +79,7 @@ function normalize(pluginId: string, tripDayIds: ReadonlySet, raw: unkno @UseGuards(JwtAuthGuard) export class DayScheduleController { constructor( - private readonly runtime: PluginRuntimeService, + private readonly hooks: PluginHooks, private readonly dbs: DatabaseService, ) {} @@ -93,7 +93,7 @@ export class DayScheduleController { const userId = req.user?.id; if (!Number.isFinite(tripId) || userId == null || !this.dbs.canAccessTrip(tripId, userId)) return { items: [] }; - const ids = this.runtime.providersOf('dayScheduleProvider'); + const ids = this.hooks.providersOf('dayScheduleProvider'); if (ids.length === 0) return { items: [] }; const dayRows = this.dbs.connection.prepare('SELECT id FROM days WHERE trip_id = ?').all(tripId) as Array<{ id: number }>; const tripDayIds: ReadonlySet = new Set(dayRows.map((d) => d.id)); @@ -101,7 +101,7 @@ export class DayScheduleController { const perProvider = await Promise.all( ids.map(async (id): Promise => { try { - const raw = await this.runtime.invokeHook(id, 'dayScheduleProvider', 'getSchedule', [tripId], userId, 5000); + const raw = await this.hooks.daySchedule(id, tripId, userId); return normalize(id, tripDayIds, raw); } catch { return []; // a slow / failing provider contributes nothing diff --git a/server/src/nest/plugins/day-tints.controller.ts b/server/src/nest/plugins/day-tints.controller.ts index ef1a51d3d..e656926f7 100644 --- a/server/src/nest/plugins/day-tints.controller.ts +++ b/server/src/nest/plugins/day-tints.controller.ts @@ -3,7 +3,7 @@ import type { Request } from 'express'; import { DatabaseService } from '../database/database.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { pluginsEnabled } from './kill-switch'; -import { PluginRuntimeService } from './plugin-runtime.service'; +import { PluginHooks } from './plugin-hooks.service'; import { stripEmoji } from './text-sanitize'; /** @@ -156,7 +156,7 @@ function normalize(pluginId: string, tripDayIds: ReadonlySet, raw: unkno @UseGuards(JwtAuthGuard) export class DayTintsController { constructor( - private readonly runtime: PluginRuntimeService, + private readonly hooks: PluginHooks, private readonly dbs: DatabaseService, ) {} @@ -170,7 +170,7 @@ export class DayTintsController { const userId = req.user?.id; if (!Number.isFinite(tripId) || userId == null || !this.dbs.canAccessTrip(tripId, userId)) return { tints: [] }; - const ids = this.runtime.providersOf('dayTintProvider'); + const ids = this.hooks.providersOf('dayTintProvider'); if (ids.length === 0) return { tints: [] }; const dayRows = this.dbs.connection.prepare('SELECT id FROM days WHERE trip_id = ?').all(tripId) as Array<{ id: number }>; const tripDayIds: ReadonlySet = new Set(dayRows.map((d) => d.id)); @@ -178,7 +178,7 @@ export class DayTintsController { const perProvider = await Promise.all( ids.map(async (id): Promise => { try { - const raw = await this.runtime.invokeHook(id, 'dayTintProvider', 'getDayTints', [tripId], userId, 5000); + const raw = await this.hooks.dayTints(id, tripId, userId); return normalize(id, tripDayIds, raw); } catch { return []; // a slow / failing provider contributes nothing diff --git a/server/src/nest/plugins/host/plugin-guards.service.ts b/server/src/nest/plugins/host/plugin-guards.service.ts index fe6cd016d..5dcab0bd6 100644 --- a/server/src/nest/plugins/host/plugin-guards.service.ts +++ b/server/src/nest/plugins/host/plugin-guards.service.ts @@ -8,7 +8,7 @@ import type { PluginRpcContext } from './rpc-kit/types'; /** * The resource gates every plugin RPC handler needs, lifted out of PluginRpcHost's - * private methods and PluginHostDepsFactory's canEditTripAs / requireAddon so that + * private methods and the host factory's canEditTripAs / requireAddon so that * decorated *.rpc.ts handlers get them by injection instead of through a closure. * * Every message string is character-identical to what the legacy router produced. diff --git a/server/src/nest/plugins/host/plugin-host-deps.factory.ts b/server/src/nest/plugins/host/plugin-host-deps.factory.ts deleted file mode 100644 index 0265a3554..000000000 --- a/server/src/nest/plugins/host/plugin-host-deps.factory.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { readEnv } from '../../../app-config'; -import { RealtimeService } from '../../realtime/realtime.service'; -import { BLOCKED_EXTENSIONS, filesDir } from '../../files/files.constants'; -import { TripMembershipService } from '../../trip-membership/trip-membership.service'; -import { NotificationsService } from '../../notifications/notifications.service'; -import { createLlmClient } from '../../llm-parse/llm-client.factory'; -import { readUserSettingDecrypted } from '../plugins.service'; -import { PluginOAuthService } from '../plugin-oauth.service'; -import fsMod from 'node:fs'; -import pathMod from 'node:path'; -import { randomUUID } from 'node:crypto'; -import { PermissionsService } from '../../permissions/permissions.service'; -import { TripsService, NotFoundError, ValidationError } from '../../trips/trips.service'; -import { PlacesService, type PlaceCreateInput, type PlaceUpdateInput } from '../../places/places.service'; -import { AddonsService } from '../../addons/addons.service'; -import { isDemoEmail } from '../../common/demo'; -import { ADDON_IDS } from '../../../addons'; -import { JourneyDomainService } from '../../journey/journey-domain.service'; -import { AtlasService } from '../../atlas/atlas.service'; -import { CollectionsService } from '../../collections/collections.service'; -import { BudgetService } from '../../budget/budget.service'; -import { ExchangeRatesService } from '../../budget/exchange-rates.service'; -import { ReservationsService } from '../../reservations/reservations.service'; -import { TagsService } from '../../tags/tags.service'; -import { CategoriesService } from '../../categories/categories.service'; -import { TodoService } from '../../todo/todo.service'; -import { PackingService } from '../../packing/packing.service'; -import { DayNotesService } from '../../days/day-notes.service'; -import { DaysService } from '../../days/days.service'; -import { AssignmentsService } from '../../assignments/assignments.service'; -import { LlmConfigResolver } from '../../llm-parse/llm-config.resolver'; -import { DatabaseService } from '../../database/database.service'; -import { FilesService } from '../../files/files.service'; -import { CollabService } from '../../collab/collab.service'; -import { VacayService } from '../../vacay/vacay.service'; -import { PluginRpcHost, ForbiddenResource, BadParams } from './rpc-host'; -import { PluginRpcRegistryService } from './rpc-kit/registry.service'; -import { appendAudit } from './plugin-audit'; -import { getPluginDataDb, budgetFor } from './plugin-host-state'; - -// collectionsService self-gates per-collection role by THROWING status-tagged errors -// (assertAccess 404, assertCanEdit 403, validation 400). Map them onto the RPC error -// classes so a plugin sees RESOURCE_FORBIDDEN / BAD_PARAMS instead of HOST_ERROR. -function mapCollectionError(fn: () => T): T { - try { - return fn(); - } catch (e) { - const status = (e as { status?: number })?.status; - const msg = e instanceof Error ? e.message : 'collection error'; - if (status === 403 || status === 404) throw new ForbiddenResource(msg); - if (status === 400 || status === 409) throw new BadParams(msg); - throw e; - } -} - -// Journey mirroring is best-effort on every other path that triggers it -// (places.service wraps the same hooks, assignments.service wraps the reconcile), -// so a broken journey link can never turn a successful write into an RPC error. -function mirrorJourneys(run: () => void): void { - try { run(); } catch { /* non-fatal */ } -} - -// The #858 privacy-scoped packing broadcasts used to be copied here, with a comment -// asking for the copy to be kept in lockstep with packing.controller.ts and -// packing.service.ts by hand. They now live once, in PackingService, and both the -// REST controller and PackingRpc call them there. - -// Quotas for plugin entity metadata (db:meta) — a cheap disk-DoS guard on the -// shared trek.db volume. Generous for real use, small enough to bound abuse. -const META_VALUE_MAX = 64 * 1024; // serialized JSON bytes per value -const META_KEY_MAX = 256; // key string length (the key is attacker-controlled too) -const META_KEYS_MAX = 100; // keys per (plugin, entity) - -/** Routes inter-plugin calls/events; supplied by PluginRuntimeService (owns the supervisor). */ -export interface PluginCallRouter { - callPlugin(callerId: string, targetId: string, fn: string, args: unknown, actingUserId: number | undefined): Promise; - emitPluginEvent(sourceId: string, event: string, payload: unknown): void; -} - -/** - * Wires a plugin's capability host to the REAL privileged modules (#plugins, - * M1). This is the ONLY plugin file that touches the realtime broadcast facade - * — it runs in the host (parent), never in the child. Broadcasts are - * force-namespaced to `plugin:{id}:{event}` so a plugin can't forge a core event. - * - * DI-native domain services (budget, exchange-rates, reservations, tags, - * categories, todo, packing, day-notes, assignments, oauth, files, collab, - * vacay, trips, places, collections, atlas, notifications, the LLM config - * resolver) and the DatabaseService (all inline SQL + the trip-access helper) are - * constructor-injected; legacy `services/*` domains stay plain function - * imports until their own migration lands (DI-MIGRATION.md), at which point - * each swaps one import for one injected service. - */ -@Injectable() -export class PluginHostDepsFactory { - constructor( - private readonly budget: BudgetService, - private readonly reservations: ReservationsService, - private readonly tags: TagsService, - private readonly categories: CategoriesService, - private readonly todos: TodoService, - private readonly packing: PackingService, - private readonly oauth: PluginOAuthService, - private readonly dayNotes: DayNotesService, - private readonly assignments: AssignmentsService, - private readonly llmConfig: LlmConfigResolver, - private readonly db: DatabaseService, - private readonly files: FilesService, - private readonly collab: CollabService, - private readonly vacay: VacayService, - private readonly days: DaysService, - private readonly permissions: PermissionsService, - private readonly exchangeRates: ExchangeRatesService, - private readonly addons: AddonsService, - private readonly realtime: RealtimeService, - private readonly trips: TripsService, - private readonly places: PlacesService, - private readonly collections: CollectionsService, - private readonly atlas: AtlasService, - private readonly notifications: NotificationsService, - private readonly membership: TripMembershipService, - private readonly journey: JourneyDomainService, - // Appended last and OPTIONAL so the hand-wired 26-arg construction in - // plugin-host-deps.factory.test.ts keeps compiling. Handed to every host it - // builds, which is how decorated *.rpc.ts handlers reach the dispatch map. - private readonly rpcRegistry?: PluginRpcRegistryService, - ) {} - - /** - * The trip-access + role gate used by every planner write, mirroring the app's - * per-domain `canEdit` (canAccessTrip + checkPermission for the entity's *_edit - * action). Returns false — never throws — so the caller maps it to a clean - * RESOURCE_FORBIDDEN. - */ - private canEditTripAs(action: string, tripId: number, userId: number): boolean { - const trip = this.db.canAccessTrip(tripId, userId); - if (!trip) return false; - const u = this.db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined; - if (!u) return false; - return this.permissions.checkPermission(action, u.role ?? 'user', trip.user_id, userId, trip.user_id !== userId); - } - - // The booking notification the REST controller sends after a create/update/delete - // is fire-and-forget, so it never blocks the plugin write. - private notifyBooking(actingUserId: number, tripId: number, booking: string, type: string): void { - this.reservations.notifyBookingChange(tripId, actingUserId, booking, type); - } - - create(id: string, granted: ReadonlySet, router: PluginCallRouter): PluginRpcHost { - // A subsystem read is refused when its addon is off — parity with the app, where a - // disabled addon means there is simply nothing to read (same shape as the Costs gate). - const requireAddon = (addonId: string, noun: string): void => { - if (!this.addons.isAddonEnabled(addonId)) throw new ForbiddenResource(`the ${noun} addon is disabled`); - }; - return new PluginRpcHost(id, granted, { - // Resolve the data handle lazily on every access rather than capturing it once. - // disable()/uninstall() drop the entry from `running` BEFORE awaiting the kill grace - // and calling dispose(), so a re-enable in that window builds a NEW host. A captured - // handle would let the OLD host's dispose() close the DB out from under the NEW one - // (every db.* call then throws 'database connection is not open'). Resolving per call - // means the new host always uses the current, open handle — getPluginDataDb recreates - // one the moment a stale dispose closes it. Safe because db.* is synchronous, so no - // call is ever mid-flight when a dispose from another tick closes the handle. - get data() { return getPluginDataDb(id); }, - db: this.db.connection, - canAccessTrip: (tripId, userId) => this.db.canAccessTrip(tripId, userId), - // The router binds this host's plugin id as the caller/source. - callPlugin: (targetId, fn, args, actingUserId) => router.callPlugin(id, targetId, fn, args, actingUserId), - emitPluginEvent: (event, payload) => router.emitPluginEvent(id, event, payload), - // Two users "share a trip" when both are owner-or-member of the same trip. - canSeeUser: (actingUserId, targetUserId) => - !!this.db - .prepare( - `SELECT 1 FROM trips t - LEFT JOIN trip_members m1 ON m1.trip_id = t.id AND m1.user_id = ? - LEFT JOIN trip_members m2 ON m2.trip_id = t.id AND m2.user_id = ? - WHERE (t.user_id = ? OR m1.user_id IS NOT NULL) - AND (t.user_id = ? OR m2.user_id IS NOT NULL) - LIMIT 1`, - ) - .get(actingUserId, targetUserId, actingUserId, targetUserId), - broadcastToTrip: (tripId, event, payload) => this.realtime.broadcast(tripId, `plugin:${id}:${event}`, payload), - broadcastToUser: (userId, payload) => this.realtime.broadcastToUser(userId, { type: `plugin:${id}`, ...payload }), - audit: (entry) => appendAudit(this.db.connection, entry), - // --- Costs (budget items) --- - // Same gate as a REST/MCP budget mutation: the acting user must have trip - // access AND the 'budget_edit' permission for their global role. - // --- Read scopes (packing/files). Membership is checked by the host (tripRead); - // these just delegate to the same services the REST paths use. --- - // A file's bytes as base64, size-capped BEFORE the read so a 500MB video can't - // be pulled (~667MB base64) through the IPC pipe. 10MB matches the plugin upload - // cap; trashed files (deleted_at set) are refused like the download path. - // --- Files write. Bytes arrive as bounded base64; the extension is validated - // against the central blocklist BEFORE anything touches disk, and link targets - // must live on the same trip (findForeignLinkTarget). Same events as the app. --- - // --- Collab reads (collab addon; membership checked by the host). Same hydrated - // shapes as the REST GETs, so a collab plugin can finally read what it writes. --- - // --- Collab content (collab addon). The services validate + self-report errors. --- - // --- Member add (member_manage). Grants trip access — the target must exist; - // the acting user is recorded as the inviter. Owner/duplicate adds are no-ops. --- - // --- Host-mediated notifications. Recipient resolution + channel fan-out + - // per-user preferences are all owned by NotificationsService.send; the plugin - // supplies only the target (host-scoped by the router) + plain text. actorId is - // null (no user sender), so the message body carries the plugin's content. --- - canAccessTripForNotify: (tripId, userId) => !!this.db.canAccessTrip(tripId, userId), - sendPluginNotification: async (_pluginId, input) => { - if (!budgetFor(id).take('notify', Date.now())) throw new BadParams('daily notification budget exhausted (resets at UTC midnight)'); - await this.notifications.send({ - event: 'plugin_notification', - actorId: null, - params: { title: input.title, body: input.body, ...(input.link ? { link: input.link } : {}) }, - scope: input.scope, - targetId: input.targetId, - inApp: input.link ? { navigateTarget: input.link } : undefined, - }); - return { sent: true }; - }, - // --- Host-mediated LLM. The host holds the credential (LlmConfigResolver, encrypted - // apiKey) and runs the call under the acting user's provider config; caps + the - // provider-availability check live at the router. Reuses the extraction client: - // complete() wraps it with a {text} schema; extract() passes the plugin's schema. --- - aiConfigured: (userId) => this.llmConfig.resolve(userId) !== null, - aiComplete: async (userId, prompt, system) => { - const config = this.llmConfig.resolve(userId); - if (!config) throw new BadParams('no AI provider is configured for this user'); - if (!budgetFor(id).take('ai', Date.now())) throw new BadParams('daily AI budget exhausted (resets at UTC midnight)'); - const results = await createLlmClient(config).extract({ - prompt: system || 'You are a helpful assistant. Reply with a JSON object of the form {"text": "..."} whose "text" field holds your answer.', - jsonSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] }, - model: config.model, baseUrl: config.baseUrl, apiKey: config.apiKey, text: prompt, - }); - const first = results[0] as { text?: unknown } | undefined; - return { text: typeof first?.text === 'string' ? first.text : '' }; - }, - aiExtract: async (userId, text, jsonSchema, prompt) => { - const config = this.llmConfig.resolve(userId); - if (!config) throw new BadParams('no AI provider is configured for this user'); - if (!budgetFor(id).take('ai', Date.now())) throw new BadParams('daily AI budget exhausted (resets at UTC midnight)'); - const results = await createLlmClient(config).extract({ - prompt: prompt || 'Extract structured data from the text into the given JSON schema.', - jsonSchema, model: config.model, baseUrl: config.baseUrl, apiKey: config.apiKey, text, - }); - return { results }; - }, - // The acting user's own decrypted value for one of this plugin's user-scope settings. - getUserSetting: (pluginId, userId, key) => readUserSettingDecrypted(pluginId, userId, key), - // A short-lived OAuth access token for the acting user (host-brokered; refreshes). - getOAuthToken: (pluginId, userId) => this.oauth.getAccessToken(pluginId, userId, Date.now()), - // Persistent scheduler (jobs:run). Caps bound the abuse surface: a plugin can't - // hoard timers, name-bomb, ship a huge payload, or busy-loop a recurring task. - schedulerSet: (name, dueAt, everyMs, payload) => { - const SCHED_MAX = 100; // entries per plugin - const NAME_MAX = 128; - const PAYLOAD_MAX = 8 * 1024; // 8 KB JSON - const EVERY_MIN = 60_000; // 1 min floor for recurring - const DUE_MAX = Date.now() + 366 * 24 * 60 * 60 * 1000; // <= ~1 year out - if (!name || name.length > NAME_MAX) throw new BadParams(`scheduler name is required (max ${NAME_MAX} chars)`); - if (!Number.isFinite(dueAt) || dueAt > DUE_MAX) throw new BadParams('scheduler dueAt out of range'); - if (everyMs !== undefined && (!Number.isFinite(everyMs) || everyMs < EVERY_MIN)) throw new BadParams(`recurring interval must be >= ${EVERY_MIN} ms`); - const json = JSON.stringify(payload ?? null); - if (json.length > PAYLOAD_MAX) throw new BadParams(`scheduler payload too large (max ${PAYLOAD_MAX} bytes)`); - const existing = this.db.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?').get(id, name) as { id: number } | undefined; - if (!existing) { - const n = (this.db.prepare('SELECT COUNT(*) AS c FROM plugin_scheduled_tasks WHERE plugin_id = ?').get(id) as { c: number }).c; - if (n >= SCHED_MAX) throw new BadParams(`too many scheduled tasks (max ${SCHED_MAX})`); - } - // Upsert by (plugin, name): re-scheduling the same name replaces it. - this.db.prepare( - `INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, payload, every_ms) VALUES (?, ?, ?, ?, ?) - ON CONFLICT (plugin_id, name) DO UPDATE SET due_at = excluded.due_at, payload = excluded.payload, every_ms = excluded.every_ms`, - ).run(id, name, Math.max(dueAt, Date.now()), json, everyMs ?? null); - return { scheduled: true }; - }, - schedulerCancel: (name) => { - const r = this.db.prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?').run(id, name); - return { cancelled: r.changes > 0 }; - }, - // Cross-trip: every accessible trip's budget items (membership predicate is - // baked into listTrips). Reuses the hydrated list so members/payers come too. - // Reuses BudgetService.create (frozen FX + members/payers), then broadcasts - // the same 'budget:created' event the controller emits so the web app updates - // live. No X-Socket-Id — a plugin has no originating socket. - // Reuses BudgetService.update (re-frozen FX on a currency change), then - // broadcasts the same 'budget:updated' event the REST controller emits. A - // missing item is a clean RESOURCE_FORBIDDEN (parity with updatePlace). - // Reuses BudgetService.remove, then broadcasts 'budget:deleted' with the - // numeric id — same payload the REST controller sends. - // --- Places (place_edit). Delegate to the same PlacesService the REST/MCP paths - // use, then broadcast the same events so open web sessions update live, and run - // the journey hooks places.controller runs on the same three writes: a journey - // linked to the trip carries a skeleton entry per day-assigned place, and - // without the hooks it keeps the old title/location — or a dead entry — until - // somebody reloads it. --- - // Consumed by the db:meta gate; the trip writes themselves have moved. - canEditTrip: (tripId, userId) => this.canEditTripAs('trip_edit', tripId, userId), - canEditReservations: (tripId, userId) => this.canEditTripAs('reservation_edit', tripId, userId), - canEditPlaces: (tripId, userId) => this.canEditTripAs('place_edit', tripId, userId), - // --- Days (day_edit). getDay scopes the row to the trip before any write. --- - canEditDays: (tripId, userId) => this.canEditTripAs('day_edit', tripId, userId), - // --- Itinerary (day_edit). Both the day AND the place must belong to the trip, - // so a plugin can't cross-link another trip's rows (AssignmentsService doesn't - // self-check this — the controllers do, so we reproduce it here). --- - // --- Trip creation (trip_create; owner = acting user). No broadcast — a new trip - // is only visible to its owner, who refetches (same as the REST POST). --- - // --- Exchange rates: the same cached upstream feed the budget uses (tenant-free). --- - // --- Trip (trip_edit). Only the schema-writable fields reach updateTrip; its - // NotFound/Validation errors are mapped to clean RPC codes. --- - // --- Cross-trip reads. The membership predicate is baked into listTrips, so a - // plugin only ever sees the acting user's own trips/reservations (no raw - // cross-tenant SELECT). Reuses the same hydrated services as the REST paths. --- - // --- Trip-scoped hydrated reads (membership already checked by tripRead). Same - // services as the REST GETs, so plugins see the exact planner shapes. --- - // --- Accommodations (lodging blocks, day_edit). Delegates to DaysService so the - // partner hotel reservation, the metadata sync and the delete cascade behave - // exactly like the accommodations REST controller, cascade broadcasts included. --- - // --- User-scoped addon reads (the acting user's own data across all trips). Each - // reuses the same service the addon's REST/MCP path uses; the addon-enabled gate - // mirrors the app (a disabled addon has nothing to read). --- - // --- Collections write. The service self-gates per-collection role (assertAccess/ - // assertCanEdit throw status-tagged errors) — map those to the RPC error codes. --- - // --- Atlas write: plain uid-scoped rows, no broadcasts in the service. --- - // --- Vacay write: the plan is the ACTING USER's active plan (resolved host-side); - // the service broadcasts to plan users itself. --- - // --- Journal write: journeyService.canEdit self-gates each call (owner/contributor). --- - // Day notes are core (no addon) and trip-scoped; membership is enforced by the host. - // --- Day notes write (day_edit). The day must belong to the trip; broadcasts the - // same dayNote:* events the REST controller emits so open sessions update live. --- - // --- Reservations (bookings, reservation_edit). Delegates to ReservationsService - // so the accommodation/budget-sync/notification/broadcast side effects match the - // web app EXACTLY. socketId is undefined — a plugin has no originating socket. --- - // --- Plugin metadata (db:meta). A per-plugin namespaced key/value store keyed - // to a core entity; the plugin only ever sees rows tagged with its own id. --- - metaEntityTrip: (entityType, entityId) => { - if (entityType === 'trip') { - return (this.db.prepare('SELECT id FROM trips WHERE id = ?').get(entityId) as { id: number } | undefined)?.id; - } - // Each of these tables has a NOT NULL trip_id, so the metadata gate resolves to - // the owning trip and reuses the standard canAccessTrip / *_edit checks. - const table = entityType === 'place' ? 'places' - : entityType === 'day' ? 'days' - : entityType === 'reservation' ? 'reservations' - : 'day_accommodations'; // accommodation - return (this.db.prepare(`SELECT trip_id FROM ${table} WHERE id = ?`).get(entityId) as { trip_id: number } | undefined)?.trip_id; - }, - metaGet: (entityType, entityId, key) => { - const row = this.db.prepare('SELECT value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?') - .get(id, entityType, entityId, key) as { value: string } | undefined; - if (!row) return null; - try { return JSON.parse(row.value); } catch { return null; } - }, - metaSet: (entityType, entityId, key, value) => { - if (key.length > META_KEY_MAX) throw new BadParams(`metadata key too long (>${META_KEY_MAX} chars)`); - const json = JSON.stringify(value ?? null); - if (json.length > META_VALUE_MAX) throw new BadParams(`metadata value too large (>${META_VALUE_MAX} bytes)`); - const exists = this.db.prepare('SELECT 1 FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?') - .get(id, entityType, entityId, key); - if (!exists) { - const { n } = this.db.prepare('SELECT COUNT(*) AS n FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=?') - .get(id, entityType, entityId) as { n: number }; - if (n >= META_KEYS_MAX) throw new BadParams(`too many metadata keys on this ${entityType} (max ${META_KEYS_MAX})`); - } - this.db.prepare(`INSERT INTO plugin_entity_metadata (plugin_id, entity_type, entity_id, key, value, updated_at) - VALUES (?, ?, ?, ?, ?, datetime('now')) - ON CONFLICT(plugin_id, entity_type, entity_id, key) - DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`) - .run(id, entityType, entityId, key, json); - return { key, value: value ?? null }; - }, - metaList: (entityType, entityId) => { - const list = this.db.prepare('SELECT key, value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? ORDER BY key') - .all(id, entityType, entityId) as Array<{ key: string; value: string }>; - const out: Record = {}; - for (const r of list) { try { out[r.key] = JSON.parse(r.value); } catch { out[r.key] = null; } } - return out; - }, - metaDelete: (entityType, entityId, key) => { - const res = this.db.prepare('DELETE FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?') - .run(id, entityType, entityId, key); - return { deleted: res.changes > 0 }; - }, - }, this.rpcRegistry); - } -} diff --git a/server/src/nest/plugins/host/plugin-rpc-host.factory.ts b/server/src/nest/plugins/host/plugin-rpc-host.factory.ts new file mode 100644 index 000000000..7c9cfe262 --- /dev/null +++ b/server/src/nest/plugins/host/plugin-rpc-host.factory.ts @@ -0,0 +1,59 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DatabaseService } from '../../database/database.service'; +import { PluginRpcHost } from './rpc-host'; +import type { PluginRpcRegistry } from './rpc-kit/registry'; +import { PluginRpcRegistryService } from './rpc-kit/registry.service'; +import { appendAudit } from './plugin-audit'; +import { getPluginDataDb } from './plugin-host-state'; + +/** Routes inter-plugin calls/events; supplied by PluginRuntimeService (owns the supervisor). */ +export interface PluginCallRouter { + callPlugin(callerId: string, targetId: string, fn: string, args: unknown, actingUserId: number | undefined): Promise; + emitPluginEvent(sourceId: string, event: string, payload: unknown): void; +} + +/** + * Builds a plugin's capability host (#plugins, M1). + * + * This used to be a 900-line wiring sheet: every privileged domain service was + * injected here and handed to the router as a closure. The domains now own their own + * plugin surface — one `@PluginController()` provider per domain, discovered at boot + * by PluginRpcRegistryService — so all that is left is the three things that are + * genuinely per-plugin and belong to nobody else: the plugin's own sqlite handle, the + * inter-plugin router bound with this plugin as the caller, and the audit sink. + */ +@Injectable() +export class PluginRpcHostFactory { + constructor( + private readonly db: DatabaseService, + // Injected by its concrete token, held as the base class: a no-Nest test can then + // hand in a createTestPluginRegistry() built from the instances it cares about. + @Inject(PluginRpcRegistryService) private readonly registry: PluginRpcRegistry, + ) {} + + create(id: string, granted: ReadonlySet, router: PluginCallRouter): PluginRpcHost { + return new PluginRpcHost( + id, + granted, + { + // Resolve the data handle lazily on every access rather than capturing it once. + // disable()/uninstall() drop the entry from `running` BEFORE awaiting the kill + // grace and calling dispose(), so a re-enable in that window builds a NEW host. + // A captured handle would let the OLD host's dispose() close the DB out from + // under the NEW one (every db.* call then throws 'database connection is not + // open'). Resolving per call means the new host always uses the current, open + // handle — getPluginDataDb recreates one the moment a stale dispose closes it. + // Safe because db.* is synchronous, so no call is ever mid-flight when a dispose + // from another tick closes the handle. + get data() { + return getPluginDataDb(id); + }, + // The router binds this host's plugin id as the caller/source. + callPlugin: (targetId, fn, args, actingUserId) => router.callPlugin(id, targetId, fn, args, actingUserId), + emitPluginEvent: (event, payload) => router.emitPluginEvent(id, event, payload), + audit: (entry) => appendAudit(this.db.connection, entry), + }, + this.registry, + ); + } +} diff --git a/server/src/nest/plugins/host/rpc-host.ts b/server/src/nest/plugins/host/rpc-host.ts index c688a6dfb..c685b4ebd 100644 --- a/server/src/nest/plugins/host/rpc-host.ts +++ b/server/src/nest/plugins/host/rpc-host.ts @@ -1,16 +1,3 @@ -import { - budgetCreateItemRequestSchema, type BudgetCreateItemRequest, - budgetUpdateItemRequestSchema, type BudgetUpdateItemRequest, - placeCreateRequestSchema, placeUpdateRequestSchema, - dayCreateRequestSchema, dayUpdateRequestSchema, - tripUpdateRequestSchema, tripCreateRequestSchema, - reservationCreateRequestSchema, reservationUpdateRequestSchema, - reservationEndpointsInputSchema, - accommodationCreateRequestSchema, accommodationUpdateRequestSchema, - packingCreateItemRequestSchema, packingUpdateItemRequestSchema, - collectionCreateRequestSchema, collectionUpdateRequestSchema, - collectionSavePlaceRequestSchema, collectionCopyToTripRequestSchema, -} from '@trek/shared'; import { KNOWN_METHODS, type KnownMethod, @@ -19,10 +6,8 @@ import { type RpcResponse, } from '../protocol/envelope'; import type { PluginDataDb } from './plugin-data.service'; -import { stripEmoji } from '../text-sanitize'; import { auditResource, isAuditable } from './plugin-audit'; import { BadParams, ForbiddenResource } from './rpc-errors'; -import { asArgs, asPayload, asTxOps, num, str } from './rpc-params'; import type { PluginRpcRegistry } from './rpc-kit/registry'; // Both used to be declared here. They now live in rpc-errors.ts so decorated @@ -38,145 +23,35 @@ export { BadParams, ForbiddenResource }; * plugin cannot "call it anyway" — there is no shared object, only messages, and * the host is the sole holder of the trek.db handle and the broadcast fns. * + * The handlers themselves no longer live here. Every one of the 113 wire methods is + * a `@PluginMethod` / `@PluginOpenMethod` on a `@PluginController()` provider in its + * own domain, and the registry binds the granted subset into the map below. What is + * left is the part that was never domain-specific: build the map, dispatch into it, + * audit the call, and map a thrown error onto a wire code. + * * Runs in the HOST (parent) process. */ -interface CoreDb { - prepare(sql: string): { - all(...args: unknown[]): unknown[]; - get(...args: unknown[]): unknown; - }; -} - +/** What the router needs that is neither a domain service nor a permission check. */ export interface HostDeps { - /** The plugin's own sqlite (db:own). */ + /** + * The plugin's own sqlite (db:own). A GETTER on the factory side, resolved per + * access: disable()/re-enable builds a NEW host while the old one's dispose() may + * still be closing the handle, so a captured one leaves the new host throwing + * 'database connection is not open'. + */ data: PluginDataDb; - /** Read-only handle to the core trek.db, used ONLY through the typed readers here. */ - db: CoreDb; - /** Returns the trip row if the user may access it, else undefined. */ - canAccessTrip(tripId: number, userId: number): unknown; - /** True if the target user is the acting user or co-members a trip with them. */ - canSeeUser(actingUserId: number, targetUserId: number): boolean; - /** Namespaced trip broadcast (host forces the plugin:{id}:{event} event type). */ - broadcastToTrip(tripId: number, eventType: string, payload: Record): void; - /** Namespaced per-user broadcast. */ - broadcastToUser(userId: number, payload: Record): void; - // --- Host-mediated notifications (recipient resolution + channel fan-out owned - // by the host; the plugin supplies only target + plain text). --- - canAccessTripForNotify(tripId: number, userId: number): boolean; - sendPluginNotification(pluginId: string, input: { title: string; body: string; link?: string; scope: 'user' | 'trip'; targetId: number }): Promise; - // --- Host-mediated LLM (the host owns the credential; output is DATA the plugin - // must still push through the gated write methods). --- - aiConfigured(userId: number): boolean; - aiComplete(userId: number, prompt: string, system: string | undefined): Promise; - aiExtract(userId: number, text: string, jsonSchema: object, prompt: string | undefined): Promise; - /** The acting user's own value for one of this plugin's `scope:'user'` settings (decrypted). */ - getUserSetting(pluginId: string, userId: number, key: string): unknown; - /** A short-lived OAuth access token for the acting user (host-brokered; refreshes if - * expiring). Null when the user hasn't connected. The plugin never sees the refresh token. */ - getOAuthToken(pluginId: string, userId: number): Promise; - /** Schedule (upsert by name) a userless future callback; everyMs set = recurring. Caps enforced here. */ - schedulerSet(name: string, dueAt: number, everyMs: number | undefined, payload: unknown): { scheduled: boolean }; - /** Cancel a scheduled callback by name. */ - schedulerCancel(name: string): { cancelled: boolean }; - /** Optional sink for the capability audit log (host-side, hash-chained). */ - audit?(entry: { pluginId: string; actingUserId?: number; method: string; resource: string | null; code: string }): void; /** Call an export on another plugin (this host's plugin is the caller). Authorizes * the dependency edge + the target's `provides` allowlist, forwards the acting user. */ callPlugin(targetId: string, fn: string, args: unknown, actingUserId: number | undefined): Promise; /** Publish an event from this host's plugin to its subscribed dependents. */ emitPluginEvent(event: string, payload: unknown): void; - /** True when the Costs (budget) addon is enabled — gates all costs.* methods. */ - /** True if the acting user may create costs on the trip (the 'budget_edit' permission). */ - /** A trip's packing items visible to `userId` (#858 private-item filter), for `packing.list`. */ - /** A trip's files (trash excluded), for `files.list`. */ - /** One trip file's bytes as base64 (size-capped), for `files.getContent`. Throws if it's not on the trip or exceeds the cap. */ - // --- Files write (the app's file_upload / file_edit / file_delete permissions) --- - /** Store a bounded base64 payload as a trip file (extension + size validated); broadcasts file:created. */ - /** Link an existing trip file to a reservation/place/assignment on the SAME trip. */ - /** Update a file's description/links (same-trip targets enforced); broadcasts file:updated. */ - /** Move a trip file to the trash; broadcasts file:deleted. */ - // --- Collab reads (membership + collab addon; no separate right, like the REST GETs) --- - // --- Collab content (the app's collab_edit permission; collab addon gated) --- - // --- Member add (the DISTINCT member_manage permission — never bundled) --- - /** The acting user's own journals (journey addon must be enabled). */ - /** The entries of one of the acting user's journeys (journey addon; access-checked). */ - /** The acting user's visited countries + regions (atlas addon must be enabled). */ - /** The acting user's bucket-list items (atlas addon must be enabled). */ - /** The acting user's vacation plan data (vacay addon must be enabled). */ - /** The acting user's saved-place collections (collections addon must be enabled). */ - /** One of the acting user's collections by id (collections addon must be enabled). */ - // --- Collections write (the service enforces per-collection role itself) --- - // --- Atlas write (all rows are the acting user's own; atlas addon gated) --- - // --- Vacay write (plan resolved from the acting user; vacay addon gated) --- - // --- Journal write (journeyService.canEdit self-gates; journey addon gated) --- - /** Create/delete a JOURNAL itself (owned by the acting user) — lets an importer - * bootstrap the journal it then fills, and clean it up. */ - /** A trip day's notes (trip-scoped), for `daynotes.list`. */ - /** Create a day note (the day must be on the trip); broadcasts dayNote:created. */ - /** Update a day note (scoped to the day+trip); broadcasts dayNote:updated. */ - /** Delete a day note (scoped to the day+trip); broadcasts dayNote:deleted. */ - /** All budget items of one trip, hydrated with members/payers. */ - /** All budget items across every trip the acting user can access. */ - /** Create a budget item on a trip (and broadcast); returns the created item. */ - /** Update a budget item on a trip (and broadcast); returns the updated item. */ - /** Delete a budget item from a trip (and broadcast); returns { deleted: true }. */ - // --- Places (the 'place_edit' permission) --- - /** Still needed by the db:meta gate, which has not moved yet. */ - canEditTrip(tripId: number, userId: number): boolean; - /** Also still needed by the db:meta gate. */ - canEditReservations(tripId: number, userId: number): boolean; - canEditPlaces(tripId: number, userId: number): boolean; - // --- Days + itinerary (the 'day_edit' permission) --- - canEditDays(tripId: number, userId: number): boolean; - /** Assign a place to a day (both trip-scoped by the wiring); returns the assignment. */ - /** Remove a day-assignment (trip-scoped by the wiring). */ - // --- Trip (the 'trip_edit' permission) --- - // --- Trip creation (the 'trip_create' permission; owner = acting user) --- - // --- Exchange rates (tenant-free, like weather) --- - // --- Cross-trip reads (membership baked in — every trip the acting user can access) --- - /** Every trip the acting user owns or is a member of (the listTrips baseline). */ - /** Every reservation across the acting user's accessible trips. */ - /** A trip's days with their assignments + day notes — the planner GET's shape. */ - /** A trip's reservations, hydrated like the REST list (endpoints, day_positions, joins). */ - /** A trip's lodging blocks (day_accommodations) with the joined place fields. */ - // --- Accommodations write (the 'day_edit' permission, like the accommodations REST path) --- - /** Create a lodging block (auto-creates its partner hotel reservation + broadcasts); returns it. */ - /** Update a lodging block (syncs the partner reservation); throws if it isn't on the trip. */ - /** Delete a lodging block (cascades the partner reservation/budget row); returns { deleted: true }. */ - // --- Reservations (the 'reservation_edit' permission) --- - /** Create a reservation (accommodation/budget side effects + broadcasts, as the web app); returns it. */ - /** Update a reservation on a trip (same side effects); returns it, or throws if it isn't on the trip. */ - /** Delete a reservation from a trip (same side effects); returns { deleted: true }. */ - // --- Packing (the 'packing_edit' permission; #858 privacy-scoped broadcasts) --- - /** Create a packing item (owner = acting user); privacy-scoped packing:created broadcast; returns it. */ - /** Update a packing item; four-case public<->private broadcast; throws if not on the trip. */ - /** Delete a packing item; owner+recipients-scoped packing:deleted broadcast; returns { deleted: true }. */ - // --- Packing bags (packing_edit; no privacy — broadcast to the whole room) --- - // --- Read-convenience: weather (tenant-free), categories (global), the trip roster --- - // --- Tags (the acting user's own; no trip) --- - // --- Todos (core, trip-scoped; the 'packing_edit' permission, like the REST path) --- - // --- Plugin metadata on core entities (db:meta) --- - /** The trip a trip/place/day belongs to (for the membership gate), or undefined. */ - metaEntityTrip(entityType: string, entityId: number): number | undefined; - metaGet(entityType: string, entityId: number, key: string): unknown; - metaSet(entityType: string, entityId: number, key: string, value: unknown): unknown; - metaList(entityType: string, entityId: number): unknown; - metaDelete(entityType: string, entityId: number, key: string): unknown; + /** Optional sink for the capability audit log (host-side, hash-chained). */ + audit?(entry: { pluginId: string; actingUserId?: number; method: string; resource: string | null; code: string }): void; } type Handler = (params: Record, actingUserId: number | undefined) => unknown; -// Mirrors the STRING_LIMITS the places REST controller enforces (the @trek/shared -// schema doesn't), so the plugin write path rejects the same oversized fields. -const PLACE_STR_LIMITS: Record = { name: 200, description: 2000, address: 500, notes: 2000 }; -// Core entities a plugin may attach its own db:meta to. Each maps to an owning -// trip (metaEntityTrip) so the membership + edit gates are the standard ones. -const META_ENTITY_TYPES: ReadonlySet = new Set(['trip', 'place', 'day', 'reservation', 'accommodation']); -// Same idea for trips — the @trek/shared schema leaves the title/description open, -// so mirror the REST controller's field limits on the plugin write path too. -const TRIP_STR_LIMITS: Record = { title: 200, description: 2000 }; - export class PluginRpcHost { private methods = new Map(); @@ -184,306 +59,27 @@ export class PluginRpcHost { private readonly pluginId: string, granted: ReadonlySet, private readonly deps: HostDeps, - // Additive and OPTIONAL, the same convention plugin-runtime.service.ts already - // uses for its own late-added params. Every existing - // `new PluginRpcHost(id, granted, deps)` site keeps compiling untouched, which is - // what makes the decorator rollout a per-domain move instead of a big-bang one. - registry?: PluginRpcRegistry, + registry: PluginRpcRegistry, ) { - const has = (p: string) => granted.has(p); - - if (has('db:own')) { - this.methods.set('db.query', (p) => deps.data.query(str(p.sql, 'sql'), asArgs(p.args))); - this.methods.set('db.exec', (p) => deps.data.exec(str(p.sql, 'sql'), asArgs(p.args))); - this.methods.set('db.migrate', (p) => deps.data.migrate(str(p.id, 'id'), str(p.sql, 'sql'))); - this.methods.set('db.tx', (p) => deps.data.tx(asTxOps(p.ops))); - } - - // trips.*, reservations.listMine and the member roster now live in - // src/nest/trips/trips.rpc.ts. - // files.* now lives in src/nest/files/files.rpc.ts. - - // User-scoped addon reads: the acting user's OWN journals/atlas/vacay across all - // their trips (not one trip), so — like costs.listMine — they are gated on a bound - // acting user, not a tripId; the wiring additionally refuses a disabled addon. - // collab, journal, atlas, vacay and collections now live in their own - // domains' *.rpc.ts, each carrying its addon gate with it. - - // costs.* now lives in src/nest/budget/costs.rpc.ts. - - - // --- Core planner writes (#1429). Each mirrors costs.create: validate the - // input against the SAME @trek/shared schema the web app uses, then gate on - // trip access + the entity's edit permission for the HOST-bound acting user - // (a job/onLoad has no user, so its writes are refused). The delegating deps - // reuse the real services + broadcast the same events, so the app stays live. --- - // places.* now lives in src/nest/places/places.rpc.ts. - - // days.* now lives in src/nest/days/days.rpc.ts. - - // itinerary.* now lives in src/nest/assignments/itinerary.rpc.ts. - - - - // rates.get now lives in src/nest/budget/exchange-rates.rpc.ts. - - // daynotes.* now live in src/nest/days/day-notes.rpc.ts. - - // reservations.* now lives in src/nest/reservations/reservations.rpc.ts. - - // accommodations.* now lives in src/nest/days/accommodations.rpc.ts. - - // packing.* now lives in src/nest/packing/packing.rpc.ts. - - // weather.get and categories.list now live in their own domains' *.rpc.ts. - // tags.* now lives in src/nest/tags/tags.rpc.ts, bound through the registry below. - // todos.* now live in src/nest/todo/todo.rpc.ts. - - if (has('db:meta')) { - // A plugin's OWN namespaced key/value store attached to a core entity. Not - // core data — but the entity must belong to a trip the acting user can - // ACCESS, so a plugin can't stash/read metadata against another tenant's rows. - this.methods.set('meta.get', (p, uid) => { const e = this.metaEntity(p, uid, false); return deps.metaGet(e.entityType, e.entityId, str(p.key, 'key')); }); - this.methods.set('meta.set', (p, uid) => { const e = this.metaEntity(p, uid, true); return deps.metaSet(e.entityType, e.entityId, str(p.key, 'key'), p.value); }); - this.methods.set('meta.list', (p, uid) => { const e = this.metaEntity(p, uid, false); return deps.metaList(e.entityType, e.entityId); }); - this.methods.set('meta.delete', (p, uid) => { const e = this.metaEntity(p, uid, true); return deps.metaDelete(e.entityType, e.entityId, str(p.key, 'key')); }); - } - - if (has('db:read:users')) { - // Scope to people the acting user can actually see (self or a trip they - // share) so a plugin can't enumerate every account's profile by looping ids. - this.methods.set('users.getById', (p, uid) => { - const id = num(p.id, 'id'); - if (uid === undefined) throw new ForbiddenResource('user reads require an authenticated user context'); - if (id !== uid && !this.deps.canSeeUser(uid, id)) throw new ForbiddenResource(`no access to user ${id}`); - return deps.db.prepare('SELECT id, username, display_name, avatar FROM users WHERE id = ?').get(id); - }); - } - - if (has('ws:broadcast:trip')) { - // Gate the TARGET the same way reads are gated: a plugin may only push to a - // trip room the acting user is a member of — never an arbitrary/other-tenant - // trip. (Event-type namespacing alone doesn't cross the membership boundary.) - this.methods.set('ws.broadcastToTrip', (p, uid) => { - const tripId = num(p.tripId, 'tripId'); - if (uid === undefined) throw new ForbiddenResource('broadcasts require an authenticated user context'); - if (!this.deps.canAccessTrip(tripId, uid)) throw new ForbiddenResource(`no access to trip ${tripId}`); - deps.broadcastToTrip(tripId, str(p.event, 'event'), asPayload(p.data)); - return { ok: true }; - }); - } - if (has('ws:broadcast:user')) { - // Restrict to the acting user's own connections — a plugin may not push to - // an arbitrary user it has no relationship to. - this.methods.set('ws.broadcastToUser', (p, uid) => { - const userId = num(p.userId, 'userId'); - if (uid === undefined || userId !== uid) { - throw new ForbiddenResource('a plugin may only broadcast to the acting user'); - } - deps.broadcastToUser(userId, { event: str(p.event, 'event'), ...asPayload(p.data) }); - return { ok: true }; - }); - } - - if (has('notify:send')) { - // Host-mediated notification. The plugin supplies only plain text + a target; - // the host owns recipient resolution, channel fan-out and per-user preferences. - // Recipients are FORCED to the acting user (scope 'user', targetId===uid) or a - // trip the acting user is a member of (scope 'trip'); scope 'admin' is refused, - // so there is no arbitrary-recipient / admin-broadcast path (no spam, no phishing). - this.methods.set('notify.send', (p, uid) => { - const actor = this.requireActor(uid, 'notification'); - const input = asPayload(p.input); - // Emoji-stripped so a bell/push notification matches TREK's lucide-only UI (also - // trims + collapses whitespace). An all-emoji title collapses to '' → rejected. - const title = typeof input.title === 'string' ? stripEmoji(input.title) : ''; - const body = typeof input.body === 'string' ? stripEmoji(input.body) : ''; - if (!title || title.length > 200) throw new BadParams('notification title is required (max 200 chars)'); - if (!body || body.length > 1000) throw new BadParams('notification body is required (max 1000 chars)'); - const scope = input.scope; - if (scope !== 'user' && scope !== 'trip') throw new BadParams("scope must be 'user' or 'trip'"); - const targetId = num(input.targetId, 'targetId'); - if (scope === 'user' && targetId !== actor) throw new ForbiddenResource('a plugin may only notify the acting user'); - if (scope === 'trip' && !deps.canAccessTripForNotify(targetId, actor)) throw new ForbiddenResource('the acting user is not a member of that trip'); - let link: string | undefined; - if (typeof input.link === 'string' && input.link !== '') { - // In-app path only. A bare startsWith check misses `/\evil.com`, `//…` - // etc. that browsers normalize to protocol-relative, so resolve against a - // throwaway origin and require the result to stay on it (same rule as the - // proxy's toRelativeLocation). - let safe: string | null = null; - try { - const u = new URL(input.link, 'http://x.invalid'); - if (u.origin === 'http://x.invalid') safe = (u.pathname + u.search + u.hash); - } catch { /* invalid → rejected below */ } - if (!safe) throw new BadParams('link must be an in-app path starting with /'); - link = safe.slice(0, 512); - } - return deps.sendPluginNotification(this.pluginId, { title, body, link, scope, targetId }); - }); - } - if (has('ai:invoke')) { - // Host-mediated LLM. The host holds the (encrypted) credential and runs the - // call under the acting user's resolved provider config; the plugin passes only - // a prompt / JSON-schema. Output is returned as DATA — never auto-written — so - // prompt-injection cannot reach a write without the plugin's own gated call. - this.methods.set('ai.complete', (p, uid) => { - const actor = this.requireActor(uid, 'AI'); - if (!deps.aiConfigured(actor)) throw new BadParams('no AI provider is configured for this user'); - const prompt = typeof p.prompt === 'string' ? p.prompt : ''; - if (prompt.trim() === '') throw new BadParams('prompt is required'); - if (prompt.length > 20000) throw new BadParams('prompt exceeds the 20000-char cap'); - return deps.aiComplete(actor, prompt, typeof p.system === 'string' ? p.system.slice(0, 4000) : undefined); - }); - this.methods.set('ai.extract', (p, uid) => { - const actor = this.requireActor(uid, 'AI'); - if (!deps.aiConfigured(actor)) throw new BadParams('no AI provider is configured for this user'); - const text = typeof p.text === 'string' ? p.text : ''; - if (text.trim() === '') throw new BadParams('text is required'); - if (text.length > 20000) throw new BadParams('text exceeds the 20000-char cap'); - if (typeof p.jsonSchema !== 'object' || p.jsonSchema === null) throw new BadParams('jsonSchema (an object) is required'); - return deps.aiExtract(actor, text, p.jsonSchema as object, typeof p.prompt === 'string' ? p.prompt.slice(0, 4000) : undefined); - }); - } - if (has('oauth:client')) { - // Host-brokered outbound OAuth: return a short-lived access token for the acting - // user (the host ran the flow + holds the refresh token). Never yields a token for - // a userless context, and the plugin can never reach the refresh token/secret. - this.methods.set('oauth.getToken', async (_p, uid) => { - // Userless context (a job/scheduled task) → null, matching the SDK contract + mock - // ("null when the user hasn't connected or in a userless context"), rather than - // throwing RESOURCE_FORBIDDEN, which a background caller can't meaningfully handle. - if (uid === undefined) return { accessToken: null }; - return { accessToken: await deps.getOAuthToken(this.pluginId, uid) }; - }); - } - - if (has('jobs:run')) { - // Persistent scheduler: a plugin schedules a userless future callback (once or - // recurring) that survives restarts. Same grant + risk class as its cron jobs. - // Caps are enforced in the wiring (max entries, name/payload size, min interval). - this.methods.set('scheduler.set', (p) => { - const name = str(p.name, 'name'); - const dueAt = num(p.dueAt, 'dueAt'); - const everyMs = p.everyMs != null ? num(p.everyMs, 'everyMs') : undefined; - return deps.schedulerSet(name, dueAt, everyMs, p.payload ?? null); - }); - this.methods.set('scheduler.cancel', (p) => deps.schedulerCancel(str(p.name, 'name'))); - } - - // Inter-plugin capabilities (#plugins deps). Registered UNCONDITIONALLY — there - // is no permission for these; the router authorizes each call against the - // declared dependency edge + the target's `provides`/`emits` allowlist. The - // acting user is forwarded so the target's export runs as the caller's user. - this.methods.set('plugins.call', (p, uid) => - deps.callPlugin(str(p.targetId, 'targetId'), str(p.fn, 'fn'), p.args, uid), - ); - this.methods.set('events.emit', (p) => { - deps.emitPluginEvent(str(p.event, 'event'), p.payload); - return { ok: true }; - }); - // The plugin's OWN per-user settings (the acting user's `scope:'user'` values, - // decrypted host-side). Unconditional + not sensitive cross-tenant — it only ever - // returns THIS plugin's config for the acting user. A userless context (job/onLoad) - // has no user, so it returns undefined (the plugin falls back to ctx.config). - this.methods.set('settings.get', (p, uid) => { - if (uid === undefined) return { value: undefined }; - return { value: deps.getUserSetting(this.pluginId, uid, str(p.key, 'key')) }; - }); - - // LAST on purpose: a method still owned by one of the has() blocks above then - // collides loudly in bindInto instead of being silently overridden, which is the - // failure mode a half-finished domain move would otherwise have. - // - // `deps.data` is already the lazy per-access getter the factory supplies, so - // passing it through a getter here preserves the disable/re-enable semantics - // exactly and the kit never needs to know about plugin-host-state. - registry?.bindInto(this.methods, granted, (actingUserId) => ({ + // `deps.data` is the lazy per-access getter the factory supplies, so passing it + // through a getter here preserves the disable/re-enable semantics exactly and the + // kit never needs to know about plugin-host-state. + registry.bindInto(this.methods, granted, (actingUserId) => ({ pluginId, actingUserId, get data() { return deps.data; }, + // Per host, like everything else on the context: the router behind these binds + // THIS plugin as the caller, which is what lets it authorise a call against the + // declared dependency edge and the target's provides/emits allowlist. + plugins: { + call: (targetId, fn, args, uid) => deps.callPlugin(targetId, fn, args, uid), + emit: (event, payload) => deps.emitPluginEvent(event, payload), + }, })); } - /** - * Membership-check every trip read against the acting user. The acting user is - * bound by the HOST from the authenticated invocation (see the supervisor's - * invocation map) — NOT taken from a plugin-supplied `asUserId`, which a plugin - * could set to any id to read another user's trips. If no acting user is bound - * (a job / onLoad, or a forged call), the read is forbidden. - */ - private tripRead(p: Record, actingUserId: number | undefined, read: (userId: number) => unknown): unknown { - const tripId = num(p.tripId, 'tripId'); - if (actingUserId === undefined) { - throw new ForbiddenResource('trip reads require an authenticated user context'); - } - if (!this.deps.canAccessTrip(tripId, actingUserId)) { - throw new ForbiddenResource(`no access to trip ${tripId}`); - } - // The read runs only for a bound, membership-checked user — hand it through so - // per-user visibility filters (e.g. packing's #858 private items) can apply. - return read(actingUserId); - } - - /** - * Every write needs a HOST-bound acting user. A job / onLoad (no user) or a call - * with a forged/unknown invocation id resolves to undefined and is refused — a - * plugin can never write "as" an arbitrary user. - */ - private requireActor(uid: number | undefined, noun: string): number { - if (uid === undefined) throw new ForbiddenResource(`${noun} writes require an authenticated user context`); - return uid; - } - - /** - * The @trek/shared write schemas don't carry the string-length caps the REST - * controllers add, so mirror those caps here — otherwise a plugin could write a - * field the web app would reject with 400 (e.g. a 100k-char place name). - */ - private capStrings(input: Record, limits: Record): void { - for (const [field, max] of Object.entries(limits)) { - const v = input[field]; - if (typeof v === 'string' && v.length > max) throw new BadParams(`${field} must be ${max} characters or fewer`); - } - } - - /** A write is allowed only if the acting user can access AND edit the trip. */ - private requireTripEdit(tripId: number, uid: number, canEdit: (t: number, u: number) => boolean): void { - if (!this.deps.canAccessTrip(tripId, uid)) throw new ForbiddenResource(`no access to trip ${tripId}`); - if (!canEdit(tripId, uid)) throw new ForbiddenResource(`no permission to edit trip ${tripId}`); - } - - /** - * Validate a metadata target and gate it: the entity type must be one we support, - * and the trip it belongs to must be accessible to the host-bound acting user. - */ - private metaEntity(p: Record, uid: number | undefined, write: boolean): { entityType: string; entityId: number } { - const entityType = str(p.entityType, 'entityType'); - if (!META_ENTITY_TYPES.has(entityType)) { - throw new BadParams(`invalid entityType "${entityType}" (${[...META_ENTITY_TYPES].join('|')})`); - } - const entityId = num(p.entityId, 'entityId'); - if (uid === undefined) throw new ForbiddenResource('metadata requires an authenticated user context'); - const tripId = this.deps.metaEntityTrip(entityType, entityId); - if (tripId === undefined || !this.deps.canAccessTrip(tripId, uid)) { - throw new ForbiddenResource(`no access to ${entityType} ${entityId}`); - } - // Reads need trip access; WRITES additionally need the entity's edit permission - // — so a read-only member can't overwrite/delete metadata an editor created - // (matches how core writes are gated). Accommodations ride on day_edit (like - // the accommodation write path), reservations on reservation_edit. - if (write) { - const canEdit = entityType === 'trip' ? this.deps.canEditTrip - : entityType === 'place' ? this.deps.canEditPlaces - : entityType === 'reservation' ? this.deps.canEditReservations - : this.deps.canEditDays; // day + accommodation - if (!canEdit(tripId, uid)) throw new ForbiddenResource(`no permission to edit ${entityType} ${entityId}`); - } - return { entityType, entityId }; - } - async dispatch(req: RpcRequest, actingUserId?: number): Promise { const raw = (req.params ?? {}) as Record; // The supervisor resolves the acting user from `_inv` BEFORE dispatch and passes @@ -550,16 +146,3 @@ export class PluginRpcHost { } } } - -/** - * The reservation body is passthrough by contract, but a malformed `endpoints` - * array would otherwise fail DEEP in the service (NOT-NULL mid-transaction) or be - * dropped silently (missing coords) — both miserable to debug from a plugin. So - * the plugin path pins the endpoint shape up front; absent stays absent - * (update semantics: omitted = keep, [] = delete all). - */ -function requireValidEndpoints(v: unknown): void { - if (v === undefined) return; - const parsed = reservationEndpointsInputSchema.safeParse(v); - if (!parsed.success) throw new BadParams(`invalid endpoints: ${parsed.error.issues[0]?.message ?? 'bad input'}`); -} diff --git a/server/src/nest/plugins/host/rpc-kit/README.md b/server/src/nest/plugins/host/rpc-kit/README.md index 0a2951d2c..4ea5b6ba2 100644 --- a/server/src/nest/plugins/host/rpc-kit/README.md +++ b/server/src/nest/plugins/host/rpc-kit/README.md @@ -74,5 +74,8 @@ from the generated `plugin-sdk/src/generated/host-facts.ts` instead. A `@PluginController()` class that is missing from its module's `providers: []` is invisible to discovery. Its methods are simply never bound, and every call to them comes back `PERMISSION_DENIED` with no other symptom. The compiler cannot see this. -What catches it is `server/tests/unit/plugins/rpc-coverage.test.ts`, and, once the -rollout is finished, `requireTotalCoverage: true` turning it into a boot failure. + +`requireTotalCoverage` is now on, so the app refuses to boot instead: `validate()` +names every `KNOWN_METHOD` left without a handler, and every hook left without a +host-side consumer. `server/tests/unit/plugins/rpc-coverage.test.ts` catches the same +gap in CI, before anyone starts the server. diff --git a/server/src/nest/plugins/host/rpc-kit/registry.service.ts b/server/src/nest/plugins/host/rpc-kit/registry.service.ts index 2a1f89e18..872e39232 100644 --- a/server/src/nest/plugins/host/rpc-kit/registry.service.ts +++ b/server/src/nest/plugins/host/rpc-kit/registry.service.ts @@ -17,12 +17,29 @@ export class PluginRpcRegistryService extends PluginRpcRegistry implements OnMod private readonly discovery: DiscoveryService, private readonly scanner: MetadataScanner, ) { - // Flipped to true in the closing PR of the rollout, once the legacy router map - // in rpc-host.ts is empty and every KNOWN_METHOD has a decorated owner. - super({ requireTotalCoverage: false }); + // The rollout is done: the legacy router map is gone, every KNOWN_METHOD has a + // decorated owner and every hook has a host-side consumer. Boot now FAILS on a + // gap rather than letting it surface as a runtime PERMISSION_DENIED, or as a + // hook:* grant on the consent screen that nothing behind it ever calls. + super({ requireTotalCoverage: true }); } onModuleInit(): void { + this.scanProviders(); + // Fail app boot on a wrong decorator argument, a double-owned method or a gap in + // the surface, rather than failing per-plugin at activation time. + this.validate(); + } + + /** + * Records every `@PluginController()` provider the container knows about. + * + * Separate from validate() because the two answer different questions: this one is + * about discovery (which providers were found, and once each), validate() is about + * the surface being complete and correctly declared. Keeping them apart lets the + * discovery behaviour be tested without assembling all 113 methods first. + */ + scanProviders(): void { // A provider can be wrapped once per module that lists it, so dedupe by instance // or every entry is recorded twice and the duplicate check fires spuriously. const seen = new Set(); @@ -33,8 +50,5 @@ export class PluginRpcRegistryService extends PluginRpcRegistry implements OnMod seen.add(instance); this.register(instance, this.scanner.getAllMethodNames(Object.getPrototypeOf(instance))); } - // Fail app boot on a wrong decorator argument or a double-owned method rather - // than failing per-plugin at activation time. - this.validate(); } } diff --git a/server/src/nest/plugins/host/rpc-kit/types.ts b/server/src/nest/plugins/host/rpc-kit/types.ts index 2346ad02c..c84c15a8a 100644 --- a/server/src/nest/plugins/host/rpc-kit/types.ts +++ b/server/src/nest/plugins/host/rpc-kit/types.ts @@ -19,6 +19,22 @@ export interface PluginRpcContext { readonly pluginId: string; readonly actingUserId: number | undefined; readonly data: PluginDataDb; + /** + * The inter-plugin capabilities. PER HOST, like everything else on this context: + * the router that backs them is created per plugin and binds THIS plugin as the + * caller, which is what lets it authorise a call against the declared dependency + * edge and the target's provides/emits allowlist. + * + * Typed structurally rather than imported, so the kit stays free of host imports + * (see rpc-kit/README.md). The host supplies the real router when it binds. + */ + readonly plugins: PluginRpcPeers; +} + +/** What a plugin may do to its declared dependencies. */ +export interface PluginRpcPeers { + call(targetId: string, fn: string, args: unknown, actingUserId: number | undefined): Promise; + emit(event: string, payload: unknown): void; } export type PluginRpcHandler = (params: Record, ctx: PluginRpcContext) => unknown; diff --git a/server/src/nest/plugins/host/rpc/db.rpc.ts b/server/src/nest/plugins/host/rpc/db.rpc.ts new file mode 100644 index 000000000..3c88c7f67 --- /dev/null +++ b/server/src/nest/plugins/host/rpc/db.rpc.ts @@ -0,0 +1,65 @@ +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'; + +/** + * A plugin's OWN sqlite, plus the three methods the router registers unconditionally. + * + * Every method reads `ctx.data` fresh. It must never be captured in a field or a + * closure: the handle is per plugin, and disable()/re-enable builds a new host while + * the old one's dispose() may still be closing the previous handle. A captured one + * would be both a stale-handle bug and, in a singleton like this, a cross-plugin data + * leak, since one instance serves every plugin. + */ +@PluginController() +export class DbRpc { + @PluginMethod('db.query', { permission: 'db:own' }) + query(params: Record, ctx: PluginRpcContext): unknown { + return ctx.data.query(str(params.sql, 'sql'), asArgs(params.args)); + } + + @PluginMethod('db.exec', { permission: 'db:own' }) + exec(params: Record, ctx: PluginRpcContext): unknown { + return ctx.data.exec(str(params.sql, 'sql'), asArgs(params.args)); + } + + @PluginMethod('db.migrate', { permission: 'db:own' }) + migrate(params: Record, ctx: PluginRpcContext): unknown { + return ctx.data.migrate(str(params.id, 'id'), str(params.sql, 'sql')); + } + + @PluginMethod('db.tx', { permission: 'db:own' }) + tx(params: Record, ctx: PluginRpcContext): unknown { + return ctx.data.tx(asTxOps(params.ops)); + } + + /** + * Inter-plugin call. No permission of its own: the router authorises it against + * the declared dependency edge and the target's `provides` allowlist. The acting + * user is forwarded so the target's export runs as the caller's user. + */ + @PluginOpenMethod('plugins.call') + callPlugin(params: Record, ctx: PluginRpcContext): Promise { + return ctx.plugins.call(str(params.targetId, 'targetId'), str(params.fn, 'fn'), params.args, ctx.actingUserId); + } + + /** Same deal: the router checks the emitter's `emits` allowlist. */ + @PluginOpenMethod('events.emit') + emitEvent(params: Record, ctx: PluginRpcContext): unknown { + ctx.plugins.emit(str(params.event, 'event'), params.payload); + return { ok: true }; + } + + /** + * The plugin's OWN per-user settings, decrypted host-side. Unconditional and not + * cross-tenant: it only ever returns THIS plugin's config for the acting user. A + * userless context (a job or onLoad) has no user, so it returns undefined and the + * plugin falls back to ctx.config. + */ + @PluginOpenMethod('settings.get') + getSetting(params: Record, ctx: PluginRpcContext): unknown { + if (ctx.actingUserId === undefined) return { value: undefined }; + return { value: readUserSettingDecrypted(ctx.pluginId, ctx.actingUserId, str(params.key, 'key')) }; + } +} diff --git a/server/src/nest/plugins/host/rpc/host-surface.rpc.ts b/server/src/nest/plugins/host/rpc/host-surface.rpc.ts new file mode 100644 index 000000000..3aba4c71e --- /dev/null +++ b/server/src/nest/plugins/host/rpc/host-surface.rpc.ts @@ -0,0 +1,250 @@ +import { PluginController, PluginMethod } from '../rpc-kit/decorators'; +import { PluginGuards } from '../plugin-guards.service'; +import { BadParams, ForbiddenResource } from '../rpc-errors'; +import { asPayload, num, str } from '../rpc-params'; +import type { PluginRpcContext } from '../rpc-kit/types'; +import { budgetFor } from '../plugin-host-state'; +import { DatabaseService } from '../../../database/database.service'; +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 { stripEmoji } from '../../text-sanitize'; + +/** Caps on the persistent scheduler, bounding the abuse surface. */ +const SCHED_MAX = 100; // entries per plugin +const SCHED_NAME_MAX = 128; +const SCHED_PAYLOAD_MAX = 8 * 1024; // 8 KB JSON +const SCHED_EVERY_MIN = 60_000; // 1 min floor for a recurring task +const SCHED_DUE_WINDOW = 366 * 24 * 60 * 60 * 1000; // at most ~a year out + +const AI_TEXT_MAX = 20_000; + +/** + * The host-mediated surface: the user lookup, the two broadcasts, notifications, the + * LLM bridge, the OAuth token and the persistent scheduler. + * + * What these have in common is that the HOST owns the dangerous half. The plugin + * supplies a prompt, a message or a target; recipient resolution, credentials, + * channel fan-out and the event namespace stay here, and every target is scoped to + * something the acting user may already reach. + */ +@PluginController() +export class HostSurfaceRpc { + constructor( + private readonly db: DatabaseService, + private readonly realtime: RealtimeService, + private readonly notifications: NotificationsService, + private readonly llmConfig: LlmConfigResolver, + private readonly oauth: PluginOAuthService, + private readonly guards: PluginGuards, + ) {} + + @PluginMethod('users.getById', { permission: 'db:read:users' }) + getUser(params: Record, ctx: PluginRpcContext): unknown { + // Scoped to people the acting user can actually see (themselves, or someone they + // share a trip with), so a plugin cannot enumerate every account by looping ids. + const id = num(params.id, 'id'); + if (ctx.actingUserId === undefined) throw new ForbiddenResource('user reads require an authenticated user context'); + if (id !== ctx.actingUserId && !this.sharesATrip(ctx.actingUserId, id)) { + throw new ForbiddenResource(`no access to user ${id}`); + } + return this.db.prepare('SELECT id, username, display_name, avatar FROM users WHERE id = ?').get(id); + } + + @PluginMethod('ws.broadcastToTrip', { permission: 'ws:broadcast:trip' }) + broadcastToTrip(params: Record, ctx: PluginRpcContext): unknown { + // The TARGET is gated like a read: only a trip room the acting user belongs to. + // Namespacing the event type alone does not cross the membership boundary. + const tripId = num(params.tripId, 'tripId'); + if (ctx.actingUserId === undefined) throw new ForbiddenResource('broadcasts require an authenticated user context'); + if (!this.db.canAccessTrip(tripId, ctx.actingUserId)) throw new ForbiddenResource(`no access to trip ${tripId}`); + // The host forces the plugin:{id}:{event} namespace, so a plugin cannot forge a + // core event. + this.realtime.broadcast(tripId, `plugin:${ctx.pluginId}:${str(params.event, 'event')}`, asPayload(params.data)); + return { ok: true }; + } + + @PluginMethod('ws.broadcastToUser', { permission: 'ws:broadcast:user' }) + broadcastToUser(params: Record, ctx: PluginRpcContext): unknown { + // Authorised by identity, not by a predicate: only the acting user's own sockets. + const userId = num(params.userId, 'userId'); + if (ctx.actingUserId === undefined || userId !== ctx.actingUserId) { + throw new ForbiddenResource('a plugin may only broadcast to the acting user'); + } + this.realtime.broadcastToUser(userId, { + type: `plugin:${ctx.pluginId}`, + event: str(params.event, 'event'), + ...asPayload(params.data), + }); + return { ok: true }; + } + + @PluginMethod('notify.send', { permission: 'notify:send' }) + async notify(params: Record, ctx: PluginRpcContext): Promise { + const actor = this.guards.requireActor(ctx, 'notification'); + const input = asPayload(params.input); + // Emoji-stripped so a bell or push notification matches the lucide-only UI; this + // also trims and collapses whitespace, so an all-emoji title collapses to '' and + // is rejected below. + const title = typeof input.title === 'string' ? stripEmoji(input.title) : ''; + const body = typeof input.body === 'string' ? stripEmoji(input.body) : ''; + if (!title || title.length > 200) throw new BadParams('notification title is required (max 200 chars)'); + if (!body || body.length > 1000) throw new BadParams('notification body is required (max 1000 chars)'); + const scope = input.scope; + // 'admin' is deliberately absent: there is no arbitrary-recipient or + // admin-broadcast path, so a plugin cannot spam or phish through the host. + if (scope !== 'user' && scope !== 'trip') throw new BadParams("scope must be 'user' or 'trip'"); + const targetId = num(input.targetId, 'targetId'); + if (scope === 'user' && targetId !== actor) throw new ForbiddenResource('a plugin may only notify the acting user'); + if (scope === 'trip' && !this.db.canAccessTrip(targetId, actor)) { + 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())) { + throw new BadParams('daily notification budget exhausted (resets at UTC midnight)'); + } + await this.notifications.send({ + event: 'plugin_notification', + actorId: null, + params: { title, body, ...(link ? { link } : {}) }, + scope, + targetId, + inApp: link ? { navigateTarget: link } : undefined, + }); + return { sent: true }; + } + + @PluginMethod('ai.complete', { permission: 'ai:invoke' }) + async aiComplete(params: Record, ctx: PluginRpcContext): Promise { + const actor = this.guards.requireActor(ctx, 'AI'); + const config = this.requireLlm(actor); + const prompt = typeof params.prompt === 'string' ? params.prompt : ''; + if (prompt.trim() === '') throw new BadParams('prompt is required'); + if (prompt.length > AI_TEXT_MAX) throw new BadParams(`prompt exceeds the ${AI_TEXT_MAX}-char cap`); + this.takeAiBudget(ctx); + const system = typeof params.system === 'string' ? params.system.slice(0, 4000) : undefined; + const results = await createLlmClient(config).extract({ + prompt: system || 'You are a helpful assistant. Reply with a JSON object of the form {"text": "..."} whose "text" field holds your answer.', + jsonSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] }, + model: config.model, + baseUrl: config.baseUrl, + apiKey: config.apiKey, + text: prompt, + }); + const first = results[0] as { text?: unknown } | undefined; + return { text: typeof first?.text === 'string' ? first.text : '' }; + } + + @PluginMethod('ai.extract', { permission: 'ai:invoke' }) + async aiExtract(params: Record, ctx: PluginRpcContext): Promise { + const actor = this.guards.requireActor(ctx, 'AI'); + const config = this.requireLlm(actor); + const text = typeof params.text === 'string' ? params.text : ''; + if (text.trim() === '') throw new BadParams('text is required'); + if (text.length > AI_TEXT_MAX) throw new BadParams(`text exceeds the ${AI_TEXT_MAX}-char cap`); + if (typeof params.jsonSchema !== 'object' || params.jsonSchema === null) { + throw new BadParams('jsonSchema (an object) is required'); + } + this.takeAiBudget(ctx); + const hint = typeof params.prompt === 'string' ? params.prompt.slice(0, 4000) : ''; + const results = await createLlmClient(config).extract({ + prompt: hint || 'Extract structured data from the text into the given JSON schema.', + jsonSchema: params.jsonSchema as object, + model: config.model, + baseUrl: config.baseUrl, + apiKey: config.apiKey, + text, + }); + return { results }; + } + + @PluginMethod('oauth.getToken', { permission: 'oauth:client' }) + async getOAuthToken(_params: Record, ctx: PluginRpcContext): Promise { + // A userless context (a job or scheduled task) gets null rather than a refusal, + // matching the SDK contract and its mock: a background caller cannot do anything + // useful with RESOURCE_FORBIDDEN. The plugin never sees the refresh token. + if (ctx.actingUserId === undefined) return { accessToken: null }; + return { accessToken: await this.oauth.getAccessToken(ctx.pluginId, ctx.actingUserId, Date.now()) }; + } + + @PluginMethod('scheduler.set', { permission: 'jobs:run' }) + schedulerSet(params: Record, ctx: PluginRpcContext): unknown { + const name = str(params.name, 'name'); + const dueAt = num(params.dueAt, 'dueAt'); + const everyMs = params.everyMs != null ? num(params.everyMs, 'everyMs') : undefined; + if (!name || name.length > SCHED_NAME_MAX) throw new BadParams(`scheduler name is required (max ${SCHED_NAME_MAX} chars)`); + if (!Number.isFinite(dueAt) || dueAt > Date.now() + SCHED_DUE_WINDOW) throw new BadParams('scheduler dueAt out of range'); + if (everyMs !== undefined && (!Number.isFinite(everyMs) || everyMs < SCHED_EVERY_MIN)) { + throw new BadParams(`recurring interval must be >= ${SCHED_EVERY_MIN} ms`); + } + const json = JSON.stringify(params.payload ?? null); + if (json.length > SCHED_PAYLOAD_MAX) throw new BadParams(`scheduler payload too large (max ${SCHED_PAYLOAD_MAX} bytes)`); + const existing = this.db + .prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?') + .get(ctx.pluginId, name) as { id: number } | undefined; + if (!existing) { + const n = (this.db.prepare('SELECT COUNT(*) AS c FROM plugin_scheduled_tasks WHERE plugin_id = ?').get(ctx.pluginId) as { c: number }).c; + if (n >= SCHED_MAX) throw new BadParams(`too many scheduled tasks (max ${SCHED_MAX})`); + } + // Upsert by (plugin, name): re-scheduling the same name replaces it. + this.db + .prepare(`INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, payload, every_ms) VALUES (?, ?, ?, ?, ?) + ON CONFLICT (plugin_id, name) DO UPDATE SET due_at = excluded.due_at, payload = excluded.payload, every_ms = excluded.every_ms`) + .run(ctx.pluginId, name, Math.max(dueAt, Date.now()), json, everyMs ?? null); + return { scheduled: true }; + } + + @PluginMethod('scheduler.cancel', { permission: 'jobs:run' }) + schedulerCancel(params: Record, ctx: PluginRpcContext): unknown { + const r = this.db + .prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?') + .run(ctx.pluginId, str(params.name, 'name')); + return { cancelled: r.changes > 0 }; + } + + /** Two users share a trip when both are owner-or-member of the same one. */ + private sharesATrip(actingUserId: number, targetUserId: number): boolean { + return !!this.db + .prepare( + `SELECT 1 FROM trips t + LEFT JOIN trip_members m1 ON m1.trip_id = t.id AND m1.user_id = ? + LEFT JOIN trip_members m2 ON m2.trip_id = t.id AND m2.user_id = ? + WHERE (t.user_id = ? OR m1.user_id IS NOT NULL) + AND (t.user_id = ? OR m2.user_id IS NOT NULL) + LIMIT 1`, + ) + .get(actingUserId, targetUserId, actingUserId, targetUserId); + } + + private requireLlm(userId: number) { + const config = this.llmConfig.resolve(userId); + if (!config) throw new BadParams('no AI provider is configured for this user'); + return config; + } + + private takeAiBudget(ctx: PluginRpcContext): void { + if (!budgetFor(ctx.pluginId).take('ai', Date.now())) { + throw new BadParams('daily AI budget exhausted (resets at UTC midnight)'); + } + } + + /** + * In-app paths only. A bare startsWith check misses `/\evil.com` and `//…`, + * which browsers normalize to protocol-relative, so resolve against a throwaway + * origin and require the result to stay on it. + */ + private safeLink(value: unknown): string | undefined { + if (typeof value !== 'string' || value === '') return undefined; + let safe: string | null = null; + try { + const u = new URL(value, 'http://x.invalid'); + if (u.origin === 'http://x.invalid') safe = u.pathname + u.search + u.hash; + } catch { + /* invalid, rejected below */ + } + if (!safe) throw new BadParams('link must be an in-app path starting with /'); + return safe.slice(0, 512); + } +} diff --git a/server/src/nest/plugins/host/rpc/meta.rpc.ts b/server/src/nest/plugins/host/rpc/meta.rpc.ts new file mode 100644 index 000000000..cbd40b7d8 --- /dev/null +++ b/server/src/nest/plugins/host/rpc/meta.rpc.ts @@ -0,0 +1,150 @@ +import { PluginController, PluginMethod } from '../rpc-kit/decorators'; +import { PluginGuards } from '../plugin-guards.service'; +import { BadParams, ForbiddenResource } from '../rpc-errors'; +import { num, str } from '../rpc-params'; +import type { PluginRpcContext } from '../rpc-kit/types'; +import { DatabaseService } from '../../../database/database.service'; + +/** Core entities a plugin may attach its own db:meta to. */ +const META_ENTITY_TYPES: ReadonlySet = new Set(['trip', 'place', 'day', 'reservation', 'accommodation']); + +// Quotas: a cheap disk-DoS guard on the shared trek.db volume. Generous for real use, +// small enough to bound abuse. +const META_VALUE_MAX = 64 * 1024; // serialized JSON bytes per value +const META_KEY_MAX = 256; // key length (the key is attacker-controlled too) +const META_KEYS_MAX = 100; // keys per (plugin, entity) + +/** Which edit permission an entity type rides on. Accommodations ride on days. */ +const EDIT_ACTION: Record = { + trip: 'trip_edit', + place: 'place_edit', + reservation: 'reservation_edit', + day: 'day_edit', + accommodation: 'day_edit', +}; + +/** Each of these tables has a NOT NULL trip_id, so the gate resolves the owning trip. */ +const ENTITY_TABLE: Record = { + place: 'places', + day: 'days', + reservation: 'reservations', + accommodation: 'day_accommodations', +}; + +/** + * A plugin's OWN namespaced key/value store, attached to a core entity (#plugins). + * + * The rows are not core data, but the entity they hang off must belong to a trip the + * acting user can access, so a plugin cannot stash or read metadata against another + * tenant's rows. Writes additionally need that entity's edit permission, so a + * read-only member cannot overwrite metadata an editor created. + * + * Every row is tagged with the plugin id, so one plugin never sees another's keys. + */ +@PluginController() +export class MetaRpc { + constructor( + private readonly db: DatabaseService, + private readonly guards: PluginGuards, + ) {} + + @PluginMethod('meta.get', { permission: 'db:meta' }) + get(params: Record, ctx: PluginRpcContext): unknown { + const { entityType, entityId } = this.resolveEntity(params, ctx, false); + const row = this.db + .prepare('SELECT value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?') + .get(ctx.pluginId, entityType, entityId, str(params.key, 'key')) as { value: string } | undefined; + if (!row) return null; + try { + return JSON.parse(row.value); + } catch { + return null; + } + } + + @PluginMethod('meta.set', { permission: 'db:meta' }) + set(params: Record, ctx: PluginRpcContext): unknown { + const { entityType, entityId } = this.resolveEntity(params, ctx, true); + const key = str(params.key, 'key'); + if (key.length > META_KEY_MAX) throw new BadParams(`metadata key too long (>${META_KEY_MAX} chars)`); + const json = JSON.stringify(params.value ?? null); + if (json.length > META_VALUE_MAX) throw new BadParams(`metadata value too large (>${META_VALUE_MAX} bytes)`); + const exists = this.db + .prepare('SELECT 1 FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?') + .get(ctx.pluginId, entityType, entityId, key); + if (!exists) { + const { n } = this.db + .prepare('SELECT COUNT(*) AS n FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=?') + .get(ctx.pluginId, entityType, entityId) as { n: number }; + if (n >= META_KEYS_MAX) throw new BadParams(`too many metadata keys on this ${entityType} (max ${META_KEYS_MAX})`); + } + this.db + .prepare(`INSERT INTO plugin_entity_metadata (plugin_id, entity_type, entity_id, key, value, updated_at) + VALUES (?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(plugin_id, entity_type, entity_id, key) + DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`) + .run(ctx.pluginId, entityType, entityId, key, json); + return { key, value: params.value ?? null }; + } + + @PluginMethod('meta.list', { permission: 'db:meta' }) + list(params: Record, ctx: PluginRpcContext): unknown { + const { entityType, entityId } = this.resolveEntity(params, ctx, false); + const rows = this.db + .prepare('SELECT key, value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? ORDER BY key') + .all(ctx.pluginId, entityType, entityId) as Array<{ key: string; value: string }>; + const out: Record = {}; + for (const r of rows) { + try { + out[r.key] = JSON.parse(r.value); + } catch { + out[r.key] = null; + } + } + return out; + } + + @PluginMethod('meta.delete', { permission: 'db:meta' }) + delete(params: Record, ctx: PluginRpcContext): unknown { + const { entityType, entityId } = this.resolveEntity(params, ctx, true); + const res = this.db + .prepare('DELETE FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?') + .run(ctx.pluginId, entityType, entityId, str(params.key, 'key')); + return { deleted: res.changes > 0 }; + } + + /** + * Validates the target and gates it: a supported entity type, an entity that + * resolves to a trip the acting user can access, and for a write that entity's own + * edit permission. + */ + private resolveEntity( + params: Record, + ctx: PluginRpcContext, + write: boolean, + ): { entityType: string; entityId: number } { + const entityType = str(params.entityType, 'entityType'); + if (!META_ENTITY_TYPES.has(entityType)) { + throw new BadParams(`invalid entityType "${entityType}" (${[...META_ENTITY_TYPES].join('|')})`); + } + const entityId = num(params.entityId, 'entityId'); + if (ctx.actingUserId === undefined) throw new ForbiddenResource('metadata requires an authenticated user context'); + const tripId = this.entityTrip(entityType, entityId); + if (tripId === undefined || !this.db.canAccessTrip(tripId, ctx.actingUserId)) { + throw new ForbiddenResource(`no access to ${entityType} ${entityId}`); + } + if (write && !this.guards.canEditAs(EDIT_ACTION[entityType], tripId, ctx.actingUserId)) { + throw new ForbiddenResource(`no permission to edit ${entityType} ${entityId}`); + } + return { entityType, entityId }; + } + + private entityTrip(entityType: string, entityId: number): number | undefined { + if (entityType === 'trip') { + return (this.db.prepare('SELECT id FROM trips WHERE id = ?').get(entityId) as { id: number } | undefined)?.id; + } + // The table name comes from a fixed map, never from the request. + const table = ENTITY_TABLE[entityType]; + return (this.db.prepare(`SELECT trip_id FROM ${table} WHERE id = ?`).get(entityId) as { trip_id: number } | undefined)?.trip_id; + } +} diff --git a/server/src/nest/plugins/journal-entry-rows.controller.ts b/server/src/nest/plugins/journal-entry-rows.controller.ts index b63e6ad4c..9c009aeaf 100644 --- a/server/src/nest/plugins/journal-entry-rows.controller.ts +++ b/server/src/nest/plugins/journal-entry-rows.controller.ts @@ -6,7 +6,7 @@ import { AddonsService } from '../addons/addons.service'; import { ADDON_IDS } from '../../addons'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { pluginsEnabled } from './kill-switch'; -import { PluginRuntimeService } from './plugin-runtime.service'; +import { PluginHooks } from './plugin-hooks.service'; import { stripEmoji } from './text-sanitize'; /** @@ -67,7 +67,7 @@ function normalize(raw: unknown): EntryRow[] { @UseGuards(JwtAuthGuard) export class JournalEntryRowsController { constructor( - private readonly runtime: PluginRuntimeService, + private readonly hooks: PluginHooks, private readonly dbs: DatabaseService, private readonly addons: AddonsService, private readonly journey: JourneyDomainService, @@ -87,11 +87,11 @@ export class JournalEntryRowsController { const row = this.dbs.connection.prepare('SELECT journey_id FROM journey_entries WHERE id = ?').get(entryId) as { journey_id: number } | undefined; if (!row || !this.journey.canAccessJourney(row.journey_id, userId)) return { providers: [] }; - const ids = this.runtime.providersOf('journalEntryProvider'); + const ids = this.hooks.providersOf('journalEntryProvider'); const results = await Promise.all( ids.map(async (id): Promise => { try { - const raw = await this.runtime.invokeHook(id, 'journalEntryProvider', 'getRows', [entryId], userId, 5000); + const raw = await this.hooks.journalRows(id, entryId, userId); const items = normalize(raw); return items.length > 0 ? { pluginId: id, items } : null; } catch { diff --git a/server/src/nest/plugins/map-layers.controller.ts b/server/src/nest/plugins/map-layers.controller.ts index 222746f52..c72d53585 100644 --- a/server/src/nest/plugins/map-layers.controller.ts +++ b/server/src/nest/plugins/map-layers.controller.ts @@ -3,7 +3,7 @@ import type { Request } from 'express'; import { DatabaseService } from '../database/database.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { pluginsEnabled } from './kill-switch'; -import { PluginRuntimeService } from './plugin-runtime.service'; +import { PluginHooks } from './plugin-hooks.service'; import { stripEmoji } from './text-sanitize'; /** @@ -146,7 +146,7 @@ function normalize(pluginId: string, raw: unknown): MapLayer[] { @UseGuards(JwtAuthGuard) export class MapLayersController { constructor( - private readonly runtime: PluginRuntimeService, + private readonly hooks: PluginHooks, private readonly dbs: DatabaseService, ) {} @@ -160,11 +160,11 @@ export class MapLayersController { const userId = req.user?.id; if (!Number.isFinite(tripId) || userId == null || !this.dbs.canAccessTrip(tripId, userId)) return { layers: [] }; - const ids = this.runtime.providersOf('mapLayerProvider'); + const ids = this.hooks.providersOf('mapLayerProvider'); const perProvider = await Promise.all( ids.map(async (id): Promise => { try { - const raw = await this.runtime.invokeHook(id, 'mapLayerProvider', 'getLayers', [tripId], userId, 5000); + const raw = await this.hooks.mapLayers(id, tripId, userId); return normalize(id, raw); } catch { return []; // a slow / failing provider contributes nothing diff --git a/server/src/nest/plugins/map-markers.controller.ts b/server/src/nest/plugins/map-markers.controller.ts index ca9824860..5f4ed6229 100644 --- a/server/src/nest/plugins/map-markers.controller.ts +++ b/server/src/nest/plugins/map-markers.controller.ts @@ -3,7 +3,7 @@ import type { Request } from 'express'; import { DatabaseService } from '../database/database.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { pluginsEnabled } from './kill-switch'; -import { PluginRuntimeService } from './plugin-runtime.service'; +import { PluginHooks } from './plugin-hooks.service'; import { stripEmoji } from './text-sanitize'; /** @@ -79,7 +79,7 @@ function normalize(pluginId: string, raw: unknown): MapMarker[] { @UseGuards(JwtAuthGuard) export class MapMarkersController { constructor( - private readonly runtime: PluginRuntimeService, + private readonly hooks: PluginHooks, private readonly dbs: DatabaseService, ) {} @@ -93,11 +93,11 @@ export class MapMarkersController { const userId = req.user?.id; if (!Number.isFinite(tripId) || userId == null || !this.dbs.canAccessTrip(tripId, userId)) return { markers: [] }; - const ids = this.runtime.providersOf('mapMarkerProvider'); + const ids = this.hooks.providersOf('mapMarkerProvider'); const perProvider = await Promise.all( ids.map(async (id): Promise => { try { - const raw = await this.runtime.invokeHook(id, 'mapMarkerProvider', 'getMarkers', [tripId], userId, 5000); + const raw = await this.hooks.mapMarkers(id, tripId, userId); return normalize(id, raw); } catch { return []; // a slow / failing provider contributes nothing diff --git a/server/src/nest/plugins/pdf-sections.controller.ts b/server/src/nest/plugins/pdf-sections.controller.ts index eadea1dfa..6cf6b0a6b 100644 --- a/server/src/nest/plugins/pdf-sections.controller.ts +++ b/server/src/nest/plugins/pdf-sections.controller.ts @@ -3,7 +3,7 @@ import type { Request } from 'express'; import { DatabaseService } from '../database/database.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { pluginsEnabled } from './kill-switch'; -import { PluginRuntimeService } from './plugin-runtime.service'; +import { PluginHooks } from './plugin-hooks.service'; import { stripEmoji } from './text-sanitize'; /** @@ -75,7 +75,7 @@ function normalize(pluginId: string, raw: unknown): PdfSection[] { @UseGuards(JwtAuthGuard) export class PdfSectionsController { constructor( - private readonly runtime: PluginRuntimeService, + private readonly hooks: PluginHooks, private readonly dbs: DatabaseService, ) {} @@ -89,11 +89,11 @@ export class PdfSectionsController { const userId = req.user?.id; if (!Number.isFinite(tripId) || userId == null || !this.dbs.canAccessTrip(tripId, userId)) return { sections: [] }; - const ids = this.runtime.providersOf('pdfSectionProvider'); + const ids = this.hooks.providersOf('pdfSectionProvider'); const perProvider = await Promise.all( ids.map(async (id): Promise => { try { - const raw = await this.runtime.invokeHook(id, 'pdfSectionProvider', 'getSections', [tripId], userId, 5000); + const raw = await this.hooks.pdfSections(id, tripId, userId); return normalize(id, raw); } catch { return []; // a slow / failing provider contributes nothing diff --git a/server/src/nest/plugins/place-details.controller.ts b/server/src/nest/plugins/place-details.controller.ts index 2efc07af0..7ffd2cfe5 100644 --- a/server/src/nest/plugins/place-details.controller.ts +++ b/server/src/nest/plugins/place-details.controller.ts @@ -3,7 +3,7 @@ import type { Request } from 'express'; import { DatabaseService } from '../database/database.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { pluginsEnabled } from './kill-switch'; -import { PluginRuntimeService } from './plugin-runtime.service'; +import { PluginHooks } from './plugin-hooks.service'; import { stripEmoji } from './text-sanitize'; /** @@ -62,7 +62,7 @@ function normalize(raw: unknown): DetailItem[] { @UseGuards(JwtAuthGuard) export class PlaceDetailsController { constructor( - private readonly runtime: PluginRuntimeService, + private readonly hooks: PluginHooks, private readonly dbs: DatabaseService, ) {} @@ -80,11 +80,11 @@ export class PlaceDetailsController { const row = this.dbs.connection.prepare('SELECT trip_id FROM places WHERE id = ?').get(placeId) as { trip_id: number } | undefined; if (!row || !this.dbs.canAccessTrip(row.trip_id, userId)) return { providers: [] }; - const ids = this.runtime.providersOf('placeDetailProvider'); + const ids = this.hooks.providersOf('placeDetailProvider'); const results = await Promise.all( ids.map(async (id): Promise => { try { - const raw = await this.runtime.invokeHook(id, 'placeDetailProvider', 'getDetails', [placeId], userId, 5000); + const raw = await this.hooks.placeDetails(id, placeId, userId); const items = normalize(raw); return items.length > 0 ? { pluginId: id, items } : null; } catch { diff --git a/server/src/nest/plugins/plugin-calendar.controller.ts b/server/src/nest/plugins/plugin-calendar.controller.ts index 07dc33ef7..5236ec761 100644 --- a/server/src/nest/plugins/plugin-calendar.controller.ts +++ b/server/src/nest/plugins/plugin-calendar.controller.ts @@ -2,7 +2,7 @@ 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 { PluginRuntimeService } from './plugin-runtime.service'; +import { PluginHooks } from './plugin-hooks.service'; import { stripEmoji } from './text-sanitize'; /** @@ -39,7 +39,7 @@ function normalizeEvents(pluginId: string, source: string, raw: unknown): DevEve @Controller('api/plugin-calendar') @UseGuards(JwtAuthGuard) export class PluginCalendarController { - constructor(private readonly runtime: PluginRuntimeService) {} + constructor(private readonly hooks: PluginHooks) {} @Get() async get( @@ -54,13 +54,13 @@ export class PluginCalendarController { const s = isoish(start) ?? new Date(Date.now() - 90 * 864e5).toISOString(); const e = isoish(end) ?? new Date(Date.now() + 366 * 864e5).toISOString(); - const ids = this.runtime.providersOf('calendarSource'); + const ids = this.hooks.providersOf('calendarSource'); const results = await Promise.all( ids.map(async (id) => { try { const [rawName, rawEvents] = await Promise.all([ - this.runtime.invokeHook(id, 'calendarSource', 'getName', [], userId, 3000).catch(() => id), - this.runtime.invokeHook(id, 'calendarSource', 'getEvents', [userId, s, e], userId, 5000), + this.hooks.calendarName(id, userId).catch(() => id), + this.hooks.calendarEvents(id, userId, s, e), ]); const name = cap(rawName, 80) || id; const events = normalizeEvents(id, name, rawEvents); diff --git a/server/src/nest/plugins/plugin-hooks.service.ts b/server/src/nest/plugins/plugin-hooks.service.ts new file mode 100644 index 000000000..1daf276f9 --- /dev/null +++ b/server/src/nest/plugins/plugin-hooks.service.ts @@ -0,0 +1,149 @@ +import { PluginController, PluginHook } from './host/rpc-kit/decorators'; +import { PluginRuntimeService } from './plugin-runtime.service'; + +/** What a notification channel is handed for one message. */ +export interface HookChannelMessage { + event: string; + title: string; + body: string; + url?: string; + tripName?: string; +} + +/** The waypoint request a route provider is asked to solve. */ +export interface HookRouteRequest { + tripId: number; + dayId: number | null; + profile: string; + waypoints: unknown[]; +} + +/** + * Every host-to-plugin hook call, in one place. + * + * The 15 hooks the consent screen offers used to be invoked straight from the + * controllers, with the fn name and the timeout written out at each call site. That + * made three things impossible to check: that a granted `hook:*` permission actually + * has a consumer (a dead grant on the consent screen looks exactly like a live one), + * that the fn name the host asks for matches the one the SDK documents, and that two + * call sites for the same hook agree on the budget. + * + * Each method here carries a `@PluginHook` declaration, which the registry validates + * against HOOK_PERMISSION at boot and, with requireTotalCoverage on, fails the app for + * any hook that no longer has a consumer. The declaration and the invocation are the + * same method on purpose, so the two cannot drift apart. + * + * Timeouts are the plugin's budget to answer. They are deliberately not uniform: + * a name lookup is cheap, a route solve over a full day is not, and a notification + * channel is talking to somebody else's API. + */ +@PluginController() +export class PluginHooks { + constructor(private readonly runtime: PluginRuntimeService) {} + + /** The plugins that declared `hook`, in manifest order. */ + providersOf(hook: string): string[] { + return this.runtime.providersOf(hook); + } + + @PluginHook('photoProvider', { permission: 'hook:photo-provider', fn: 'search', timeoutMs: 5000 }) + searchPhotos(pluginId: string, query: string, page: number, limit: number, userId: number): Promise { + return this.runtime.invokeHook(pluginId, 'photoProvider', 'search', [query, { page, limit }], userId, 5000); + } + + @PluginHook('photoProvider', { permission: 'hook:photo-provider', fn: 'getById', timeoutMs: 5000 }) + getPhoto(pluginId: string, photoId: string, userId: number): Promise { + return this.runtime.invokeHook(pluginId, 'photoProvider', 'getById', [photoId], userId, 5000); + } + + // A display name is a one-field read, so it gets a shorter leash than the feed it + // labels; the caller falls back to the plugin id when it times out. + @PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getName', timeoutMs: 3000 }) + calendarName(pluginId: string, userId: number): Promise { + return this.runtime.invokeHook(pluginId, 'calendarSource', 'getName', [], userId, 3000); + } + + @PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getEvents', timeoutMs: 5000 }) + calendarEvents(pluginId: string, userId: number, start: string, end: string): Promise { + return this.runtime.invokeHook(pluginId, 'calendarSource', 'getEvents', [userId, start, end], userId, 5000); + } + + @PluginHook('placeDetailProvider', { permission: 'hook:place-detail-provider', fn: 'getDetails', timeoutMs: 5000 }) + placeDetails(pluginId: string, placeId: number, userId: number): Promise { + return this.runtime.invokeHook(pluginId, 'placeDetailProvider', 'getDetails', [placeId], userId, 5000); + } + + @PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings', timeoutMs: 5000 }) + tripWarnings(pluginId: string, tripId: number, userId: number): Promise { + return this.runtime.invokeHook(pluginId, 'warningProvider', 'getWarnings', [tripId], userId, 5000); + } + + @PluginHook('tableContributor', { permission: 'hook:table-contributor', fn: 'getContributions', timeoutMs: 5000 }) + tableContributions(pluginId: string, view: string, tripId: number, userId: number): Promise { + return this.runtime.invokeHook(pluginId, 'tableContributor', 'getContributions', [view, tripId], userId, 5000); + } + + @PluginHook('mapMarkerProvider', { permission: 'hook:map-marker-provider', fn: 'getMarkers', timeoutMs: 5000 }) + mapMarkers(pluginId: string, tripId: number, userId: number): Promise { + return this.runtime.invokeHook(pluginId, 'mapMarkerProvider', 'getMarkers', [tripId], userId, 5000); + } + + @PluginHook('mapLayerProvider', { permission: 'hook:map-layer-provider', fn: 'getLayers', timeoutMs: 5000 }) + mapLayers(pluginId: string, tripId: number, userId: number): Promise { + return this.runtime.invokeHook(pluginId, 'mapLayerProvider', 'getLayers', [tripId], userId, 5000); + } + + // A route solve runs an external routing engine over a whole day of waypoints, so it + // gets four times the budget of a plain read. The caller falls back to straight lines. + @PluginHook('routeProvider', { permission: 'hook:route-provider', fn: 'getRoute', timeoutMs: 20_000 }) + route(pluginId: string, request: HookRouteRequest, userId: number): Promise { + return this.runtime.invokeHook(pluginId, 'routeProvider', 'getRoute', [request], userId, 20_000); + } + + @PluginHook('dayScheduleProvider', { permission: 'hook:day-schedule-provider', fn: 'getSchedule', timeoutMs: 5000 }) + daySchedule(pluginId: string, tripId: number, userId: number): Promise { + return this.runtime.invokeHook(pluginId, 'dayScheduleProvider', 'getSchedule', [tripId], userId, 5000); + } + + @PluginHook('dayTintProvider', { permission: 'hook:day-tint-provider', fn: 'getDayTints', timeoutMs: 5000 }) + dayTints(pluginId: string, tripId: number, userId: number): Promise { + return this.runtime.invokeHook(pluginId, 'dayTintProvider', 'getDayTints', [tripId], userId, 5000); + } + + @PluginHook('pdfSectionProvider', { permission: 'hook:pdf-section-provider', fn: 'getSections', timeoutMs: 5000 }) + pdfSections(pluginId: string, tripId: number, userId: number): Promise { + return this.runtime.invokeHook(pluginId, 'pdfSectionProvider', 'getSections', [tripId], userId, 5000); + } + + @PluginHook('atlasLayerProvider', { permission: 'hook:atlas-layer-provider', fn: 'getLayers', timeoutMs: 5000 }) + atlasLayers(pluginId: string, userId: number): Promise { + return this.runtime.invokeHook(pluginId, 'atlasLayerProvider', 'getLayers', [], userId, 5000); + } + + @PluginHook('journalEntryProvider', { permission: 'hook:journal-entry-provider', fn: 'getRows', timeoutMs: 5000 }) + journalRows(pluginId: string, entryId: number, userId: number): Promise { + return this.runtime.invokeHook(pluginId, 'journalEntryProvider', 'getRows', [entryId], userId, 5000); + } + + @PluginHook('tripCardProvider', { permission: 'hook:trip-card-provider', fn: 'getCards', timeoutMs: 5000 }) + tripCards(pluginId: string, tripIds: number[], userId: number): Promise { + return this.runtime.invokeHook(pluginId, 'tripCardProvider', 'getCards', [tripIds], userId, 5000); + } + + /** + * Both notification-channel calls run with NO acting user: a notification is + * host-initiated for an arbitrary recipient, so the hook gets that recipient's own + * decrypted config as an argument rather than the right to read anything as them. + * The budget is the longest of the read hooks because the channel is talking to a + * third-party API (Telegram, ntfy, a webhook) rather than to TREK. + */ + @PluginHook('notificationChannel', { permission: 'hook:notification-channel', fn: 'send', timeoutMs: 8000 }) + sendNotification(pluginId: string, message: HookChannelMessage, userSettings: unknown): Promise { + return this.runtime.invokeHook(pluginId, 'notificationChannel', 'send', [message, userSettings], undefined, 8000); + } + + @PluginHook('notificationChannel', { permission: 'hook:notification-channel', fn: 'test', timeoutMs: 8000 }) + testNotification(pluginId: string, userSettings: unknown): Promise { + return this.runtime.invokeHook(pluginId, 'notificationChannel', 'test', [userSettings], undefined, 8000); + } +} diff --git a/server/src/nest/plugins/plugin-photos.controller.ts b/server/src/nest/plugins/plugin-photos.controller.ts index 01cc9a733..22fd7becf 100644 --- a/server/src/nest/plugins/plugin-photos.controller.ts +++ b/server/src/nest/plugins/plugin-photos.controller.ts @@ -2,7 +2,7 @@ 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 { PluginRuntimeService } from './plugin-runtime.service'; +import { PluginHooks } from './plugin-hooks.service'; import { stripEmoji } from './text-sanitize'; /** @@ -57,12 +57,12 @@ function normalizePhotos(pluginId: string, raw: unknown): DevPhoto[] { @Controller('api/plugin-photos') @UseGuards(JwtAuthGuard) export class PluginPhotosController { - constructor(private readonly runtime: PluginRuntimeService) {} + constructor(private readonly hooks: PluginHooks) {} @Get('sources') sources(): { sources: Array<{ pluginId: string }> } { if (!pluginsEnabled()) return { sources: [] }; - return { sources: this.runtime.providersOf('photoProvider').map((pluginId) => ({ pluginId })) }; + return { sources: this.hooks.providersOf('photoProvider').map((pluginId) => ({ pluginId })) }; } @Get('search') @@ -77,11 +77,11 @@ export class PluginPhotosController { const query = cap(q, 200); const page = Math.max(1, Math.min(1000, Number(pageRaw) || 1)); - const ids = this.runtime.providersOf('photoProvider'); + const ids = this.hooks.providersOf('photoProvider'); const results = await Promise.all( ids.map(async (id) => { try { - const raw = (await this.runtime.invokeHook(id, 'photoProvider', 'search', [query, { page, limit: MAX_PHOTOS }], userId, 5000)) as + const raw = (await this.hooks.searchPhotos(id, query, page, MAX_PHOTOS, userId)) as | { photos?: unknown; total?: unknown; hasMore?: unknown } | undefined; const photos = normalizePhotos(id, raw?.photos); @@ -104,9 +104,9 @@ export class PluginPhotosController { if (!pluginsEnabled()) return { photo: null }; const userId = req.user?.id; if (userId == null || !pluginId || !id) return { photo: null }; - if (!this.runtime.providersOf('photoProvider').includes(pluginId)) return { photo: null }; + if (!this.hooks.providersOf('photoProvider').includes(pluginId)) return { photo: null }; try { - const raw = await this.runtime.invokeHook(pluginId, 'photoProvider', 'getById', [cap(id, 256)], userId, 5000); + const raw = await this.hooks.getPhoto(pluginId, cap(id, 256), userId); return { photo: normalizePhotos(pluginId, [raw])[0] ?? null }; } catch { return { photo: null }; diff --git a/server/src/nest/plugins/plugin-routes.controller.ts b/server/src/nest/plugins/plugin-routes.controller.ts index fef425cfd..e6f0065f6 100644 --- a/server/src/nest/plugins/plugin-routes.controller.ts +++ b/server/src/nest/plugins/plugin-routes.controller.ts @@ -4,7 +4,7 @@ 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 { PluginRuntimeService } from './plugin-runtime.service'; +import { PluginHooks } from './plugin-hooks.service'; import { stripEmoji } from './text-sanitize'; /** @@ -150,7 +150,7 @@ function normalize(pluginId: string, profile: string, waypointCount: number, raw @UseGuards(JwtAuthGuard) export class PluginRoutesController { constructor( - private readonly runtime: PluginRuntimeService, + private readonly hooks: PluginHooks, private readonly dbs: DatabaseService, ) {} @@ -174,18 +174,11 @@ export class PluginRoutesController { // Provider gate (implements the hook AND holds the grant) + the profile must be // one the manifest declared — re-validated from the DB row like the plugins feed, // so a hand-edited capabilities blob can't invent profiles. - if (!this.runtime.providersOf('routeProvider').includes(pluginId)) return { route: null }; + if (!this.hooks.providersOf('routeProvider').includes(pluginId)) return { route: null }; if (!declaredProfiles(this.dbs.connection, pluginId).includes(profileId)) return { route: null }; try { - const raw = await this.runtime.invokeHook( - pluginId, - 'routeProvider', - 'getRoute', - [{ tripId, dayId, profile: profileId, waypoints }], - userId, - 20_000, - ); + const raw = await this.hooks.route(pluginId, { tripId, dayId, profile: profileId, waypoints }, userId); return { route: normalize(pluginId, profileId, waypoints.length, raw) }; } catch { return { route: null }; // slow / failing provider — client falls back to straight lines diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index f299a8755..a6940caeb 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -14,7 +14,7 @@ import { decrypt_api_key } from '../common/crypto/apiKeyCrypto'; import { PluginSupervisor, type PluginRouteInfo } from './supervisor/plugin-supervisor'; import fs from 'node:fs'; import path from 'node:path'; -import { PluginHostDepsFactory } from './host/plugin-host-deps.factory'; +import { PluginRpcHostFactory } from './host/plugin-rpc-host.factory'; import { closePluginDataDb } from './host/plugin-host-state'; import { ForbiddenResource } from './host/rpc-host'; import { removePluginData } from './host/plugin-data.service'; @@ -123,11 +123,11 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { // The rpc-host factory is bound to `this` as the inter-plugin router, so a // plugin's ctx.plugins.call / ctx.events.emit resolve through callPlugin/ // emitPluginEvent below (which own the dependency-edge authorization). The - // arrow reads this.hostDeps lazily at spawn time, so the field-initializer + // arrow reads this.hostFactory lazily at spawn time, so the field-initializer // ordering (it runs before the constructor params are assigned) is safe. private readonly supervisor = new PluginSupervisor((id, granted) => { - if (!this.hostDeps) throw new Error('PluginHostDepsFactory not provided — tests that activate plugins must pass one'); - return this.hostDeps.create(id, granted, this); + if (!this.hostFactory) throw new Error('PluginRpcHostFactory not provided — tests that activate plugins must pass one'); + return this.hostFactory.create(id, granted, this); }, { // Both hooks run from child lifecycle EventEmitter callbacks (exit / stderr 'data'), // so a throw here becomes an uncaughtException that has no host-side handler. During a @@ -160,7 +160,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { // Coalesces overlapping erasure drains (the sweep and enqueue both trigger one). private drainInFlight: Promise | null = null; - // The registry and host-deps factory stay optional at the type level so tests + // The registry and host factory stay optional at the type level so tests // can construct the service without them; Nest always injects the real ones // (both providers are in the module). audit sits before the optionals // because a required param cannot follow optional ones. @@ -169,7 +169,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { private readonly audit: AuditService, private readonly addons: AddonsService, private readonly registry?: PluginRegistryService, - private readonly hostDeps?: PluginHostDepsFactory, + private readonly hostFactory?: PluginRpcHostFactory, ) {} private get db() { @@ -1048,6 +1048,9 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { // 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), + // Declared as a contract on PluginHooks.sendNotification, like every other + // hook. It is invoked here rather than through that class because PluginHooks + // injects this service, and going back the other way would close a DI cycle. sendToUser: (userId: number, msg: ChannelMessage) => this.invokeHook( id, diff --git a/server/src/nest/plugins/plugins.module.ts b/server/src/nest/plugins/plugins.module.ts index db13fa510..575f48489 100644 --- a/server/src/nest/plugins/plugins.module.ts +++ b/server/src/nest/plugins/plugins.module.ts @@ -25,10 +25,14 @@ 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 { PluginHostDepsFactory } from './host/plugin-host-deps.factory'; +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 { TagsModule } from '../tags/tags.module'; import { CategoriesModule } from '../categories/categories.module'; import { BudgetModule } from '../budget/budget.module'; @@ -59,15 +63,17 @@ import { TripMembershipModule } from '../trip-membership/trip-membership.module' * widget assets at /plugin-frame/:id/*. */ @Module({ - // The DI-native domain services the plugin host wiring injects - // (PluginHostDepsFactory); DatabaseModule is @Global, so not listed. - // DiscoveryModule is what lets PluginRpcRegistryService find the @PluginController - // 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. + // The domain modules whose @PluginController providers own the plugin wire 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, 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], - providers: [PluginsService, PluginRuntimeService, PluginRegistryService, PluginOAuthService, PluginHostDepsFactory, PluginRpcRegistryService], + // 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], diff --git a/server/src/nest/plugins/trip-card-contributions.controller.ts b/server/src/nest/plugins/trip-card-contributions.controller.ts index 01d79cb0b..5f40ab06a 100644 --- a/server/src/nest/plugins/trip-card-contributions.controller.ts +++ b/server/src/nest/plugins/trip-card-contributions.controller.ts @@ -3,7 +3,7 @@ import type { Request } from 'express'; import { DatabaseService } from '../database/database.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { pluginsEnabled } from './kill-switch'; -import { PluginRuntimeService } from './plugin-runtime.service'; +import { PluginHooks } from './plugin-hooks.service'; import { stripEmoji } from './text-sanitize'; /** @@ -83,7 +83,7 @@ function normalize(pluginId: string, raw: unknown, allowed: Set): TripCa @UseGuards(JwtAuthGuard) export class TripCardContributionsController { constructor( - private readonly runtime: PluginRuntimeService, + private readonly hooks: PluginHooks, private readonly dbs: DatabaseService, ) {} @@ -107,11 +107,11 @@ export class TripCardContributionsController { if (accessible.length === 0) return { contributions: [] }; const allowed = new Set(accessible); - const ids = this.runtime.providersOf('tripCardProvider'); + const ids = this.hooks.providersOf('tripCardProvider'); const perProvider = await Promise.all( ids.map(async (id): Promise => { try { - const raw = await this.runtime.invokeHook(id, 'tripCardProvider', 'getCards', [accessible], userId, 5000); + const raw = await this.hooks.tripCards(id, accessible, userId); return normalize(id, raw, allowed); } catch { return []; // a slow / failing provider contributes nothing diff --git a/server/src/nest/plugins/trip-warnings.controller.ts b/server/src/nest/plugins/trip-warnings.controller.ts index 41780d8a4..62422a835 100644 --- a/server/src/nest/plugins/trip-warnings.controller.ts +++ b/server/src/nest/plugins/trip-warnings.controller.ts @@ -3,7 +3,7 @@ import type { Request } from 'express'; import { DatabaseService } from '../database/database.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { pluginsEnabled } from './kill-switch'; -import { PluginRuntimeService } from './plugin-runtime.service'; +import { PluginHooks } from './plugin-hooks.service'; import { stripEmoji } from './text-sanitize'; /** @@ -28,7 +28,7 @@ const MESSAGE_MAX = 300; @UseGuards(JwtAuthGuard) export class TripWarningsController { constructor( - private readonly runtime: PluginRuntimeService, + private readonly hooks: PluginHooks, private readonly dbs: DatabaseService, ) {} @@ -42,11 +42,11 @@ export class TripWarningsController { const userId = req.user?.id; if (!Number.isFinite(tripId) || userId == null || !this.dbs.canAccessTrip(tripId, userId)) return { warnings: [] }; - const ids = this.runtime.providersOf('warningProvider'); + const ids = this.hooks.providersOf('warningProvider'); const perProvider = await Promise.all( ids.map(async (id): Promise => { try { - const raw = (await this.runtime.invokeHook(id, 'warningProvider', 'getWarnings', [tripId], userId, 5000)) as unknown; + const raw = (await this.hooks.tripWarnings(id, tripId, userId)) as unknown; const list = Array.isArray(raw) ? (raw as Array>) : []; // Drop non-object elements BEFORE the cap — otherwise one null in the array // throws inside map() and the catch below discards ALL of this provider's diff --git a/server/src/nest/plugins/view-contributions.controller.ts b/server/src/nest/plugins/view-contributions.controller.ts index e994b6c33..f15e19b13 100644 --- a/server/src/nest/plugins/view-contributions.controller.ts +++ b/server/src/nest/plugins/view-contributions.controller.ts @@ -3,7 +3,7 @@ import type { Request } from 'express'; import { DatabaseService } from '../database/database.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { pluginsEnabled } from './kill-switch'; -import { PluginRuntimeService } from './plugin-runtime.service'; +import { PluginHooks } from './plugin-hooks.service'; import { stripEmoji } from './text-sanitize'; /** @@ -103,7 +103,7 @@ function normalize(pluginId: string, raw: unknown): Contribution[] { @UseGuards(JwtAuthGuard) export class ViewContributionsController { constructor( - private readonly runtime: PluginRuntimeService, + private readonly hooks: PluginHooks, private readonly dbs: DatabaseService, ) {} @@ -118,11 +118,11 @@ export class ViewContributionsController { const userId = req.user?.id; if (!Number.isFinite(tripId) || userId == null || !this.dbs.canAccessTrip(tripId, userId)) return { contributions: [] }; - const ids = this.runtime.providersOf('tableContributor'); + const ids = this.hooks.providersOf('tableContributor'); const perProvider = await Promise.all( ids.map(async (id): Promise => { try { - const raw = await this.runtime.invokeHook(id, 'tableContributor', 'getContributions', [view, tripId], userId, 5000); + const raw = await this.hooks.tableContributions(id, view, tripId, userId); return normalize(id, raw); } catch { return []; // a slow / failing provider contributes nothing diff --git a/server/src/nest/reservations/reservations.bridge.ts b/server/src/nest/reservations/reservations.bridge.ts index 43efe9c12..2aad7e2f6 100644 --- a/server/src/nest/reservations/reservations.bridge.ts +++ b/server/src/nest/reservations/reservations.bridge.ts @@ -15,7 +15,7 @@ export type { EndpointInput, ReservationEndpoint, ReservationTraveler } from './ * MCP tools and resource moved to the DI-discovered reservations.mcp.ts, the * transit tools to the DI-discovered transit.mcp.ts (which injects * ReservationsService), and the plugin RPC host injects ReservationsService - * via PluginHostDepsFactory). Exports only the legacy services/reservationService + * via ReservationsRpc). Exports only the legacy services/reservationService * names still consumed outside the container, 1:1, so repointing a consumer is * an import-path-only diff. Inside the container, inject ReservationsService * instead. Delete exports here as their consumers migrate. diff --git a/server/src/nest/reservations/reservations.module.ts b/server/src/nest/reservations/reservations.module.ts index 79a6e5d53..e9002387f 100644 --- a/server/src/nest/reservations/reservations.module.ts +++ b/server/src/nest/reservations/reservations.module.ts @@ -25,7 +25,7 @@ import { AuthModule } from '../auth/auth.module'; imports: [DaysModule, PermissionsModule, BudgetModule, AuthModule, RealtimeModule, PluginGuardsModule], controllers: [ReservationsController, AccommodationsController, UpcomingReservationsController], providers: [ReservationsService, AccommodationsService, ReservationsMcp, ReservationsRpc], - // For in-container consumers (PluginHostDepsFactory, TripsService, BookingImportService). + // For in-container consumers (ReservationsRpc, TripsService, BookingImportService). exports: [ReservationsService], }) export class ReservationsModule {} diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index 62ebd4030..34430bef1 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -125,7 +125,7 @@ type AccommodationTimesMeta = { * Non-Nest consumers (legacy tripService, airtrail import/sync, the transit + * transports MCP registrars) go through reservations.bridge.ts instead of * importing this class directly; the plugin RPC host injects it via - * PluginHostDepsFactory. + * ReservationsRpc. */ @Injectable() export class ReservationsService { diff --git a/server/src/nest/tags/tags.module.ts b/server/src/nest/tags/tags.module.ts index 2d5fa8d0c..a4fa80c3a 100644 --- a/server/src/nest/tags/tags.module.ts +++ b/server/src/nest/tags/tags.module.ts @@ -13,7 +13,7 @@ import { AuthModule } from '../auth/auth.module'; // PROVIDERS only, and a missing entry here would leave tags.* answering // PERMISSION_DENIED with no other symptom. providers: [TagsService, TagsMcp, TagsRpc], - // For in-container consumers (PluginHostDepsFactory). + // For in-container consumers (TagsRpc). exports: [TagsService], }) export class TagsModule {} diff --git a/server/src/nest/tags/tags.service.ts b/server/src/nest/tags/tags.service.ts index eccd41e8a..d805bd0a1 100644 --- a/server/src/nest/tags/tags.service.ts +++ b/server/src/nest/tags/tags.service.ts @@ -6,7 +6,7 @@ import { DatabaseService } from '../database/database.service'; * Tags domain service — owns the tag SQL (moved 1:1 from the legacy * services/tagService.ts: identical statements, the #10b981 default and the * COALESCE update semantics). All consumers are in-container now — the plugin - * RPC host injects this class via PluginHostDepsFactory. + * RPC surface injects this class into TagsRpc. */ @Injectable() export class TagsService { diff --git a/server/src/nest/vacay/vacay.module.ts b/server/src/nest/vacay/vacay.module.ts index c6102543c..2274a71c2 100644 --- a/server/src/nest/vacay/vacay.module.ts +++ b/server/src/nest/vacay/vacay.module.ts @@ -8,7 +8,7 @@ import { AuthModule } from '../auth/auth.module'; /** * Vacay addon domain (S1 — Phase 2 trip sub-domain). Registered in AppModule. - * VacayService is exported for the plugin host wiring (PluginHostDepsFactory); + * VacayService is exported for the plugin host surface (VacayRpc); * VacayMcp carries the DI-discovered MCP tools/resources. */ @Module({ diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 6a462e3d4..d7a98480b 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -1,7 +1,14 @@ import { DatabaseService } from '../../src/nest/database/database.service'; import { PluginRuntimeService } from '../../src/nest/plugins/plugin-runtime.service'; import type { PluginRegistryService } from '../../src/nest/plugins/registry/registry.service'; -import { PluginHostDepsFactory } from '../../src/nest/plugins/host/plugin-host-deps.factory'; +import { PluginRpcHostFactory } 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 { 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 { BudgetService } from '../../src/nest/budget/budget.service'; import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.service'; @@ -22,22 +29,48 @@ import { TripsService } from '../../src/nest/trips/trips.service'; import { PlacesService } from '../../src/nest/places/places.service'; import { CollectionsService } from '../../src/nest/collections/collections.service'; import { AtlasService } from '../../src/nest/atlas/atlas.service'; -import { NotificationsService } from '../../src/nest/notifications/notifications.service'; import { MapsService } from '../../src/nest/maps/maps.service'; import { PermissionsService } from '../../src/nest/permissions/permissions.service'; import { AuditService } from '../../src/nest/audit/audit.service'; import { AddonsService } from '../../src/nest/addons/addons.service'; import { RealtimeService } from '../../src/nest/realtime/realtime.service'; import { QueryHelpersService } from '../../src/nest/query-helpers/query-helpers.service'; -import { makeNotificationsService, makeNotificationPreferencesService } from './notifications'; +import { TripMembershipService } from '../../src/nest/trip-membership/trip-membership.service'; +import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service'; +import { TagsRpc } from '../../src/nest/tags/tags.rpc'; +import { CategoriesRpc } from '../../src/nest/categories/categories.rpc'; +import { WeatherRpc } from '../../src/nest/weather/weather.rpc'; +import { WeatherService } from '../../src/nest/weather/weather.service'; +import { ExchangeRatesRpc } from '../../src/nest/budget/exchange-rates.rpc'; +import { TodoRpc } from '../../src/nest/todo/todo.rpc'; +import { DayNotesRpc } from '../../src/nest/days/day-notes.rpc'; +import { PackingRpc } from '../../src/nest/packing/packing.rpc'; +import { FilesRpc } from '../../src/nest/files/files.rpc'; +import { PlacesRpc } from '../../src/nest/places/places.rpc'; +import { DaysRpc } from '../../src/nest/days/days.rpc'; +import { AccommodationsRpc } from '../../src/nest/days/accommodations.rpc'; +import { ItineraryRpc } from '../../src/nest/assignments/itinerary.rpc'; +import { TripsRpc } from '../../src/nest/trips/trips.rpc'; +import { CostsRpc } from '../../src/nest/budget/costs.rpc'; +import { ReservationsRpc } from '../../src/nest/reservations/reservations.rpc'; +import { CollabRpc } from '../../src/nest/collab/collab.rpc'; +import { AtlasRpc } from '../../src/nest/atlas/atlas.rpc'; +import { VacayRpc } from '../../src/nest/vacay/vacay.rpc'; +import { JournalRpc } from '../../src/nest/journey/journal.rpc'; +import { CollectionsRpc } from '../../src/nest/collections/collections.rpc'; +import { makeNotificationsService } from './notifications'; /** * Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests * (same pattern as mcp-test-controllers.ts): real domain services over the * test DB, so runtime tests exercise the same wiring production gets from * the container. + * + * It used to build one 26-argument deps factory. The plugin surface now lives in the + * domains, so what it builds is the same set of `@PluginController()` instances the + * container would discover, handed to the host factory as a registry. */ -export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFactory { +export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostFactory { const permissions = new PermissionsService(dbs); const exchangeRates = new ExchangeRatesService(); const realtime = new RealtimeService(); @@ -52,35 +85,48 @@ export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFacto const vacay = new VacayService(dbs, realtime); const days = new DaysService(dbs, permissions, realtime, queryHelpers); const places = new PlacesService(dbs, permissions, realtime, new MapsService(dbs), queryHelpers); - return new PluginHostDepsFactory( - budget, - reservations, - new TagsService(dbs), - new CategoriesService(dbs), - todos, - packing, - new PluginOAuthService(dbs), - new DayNotesService(dbs, permissions, realtime), - new AssignmentsService(dbs, permissions, realtime, queryHelpers), - new LlmConfigResolver(new SettingsService(dbs), dbs, addons), - dbs, - files, - collab, - vacay, - days, - permissions, - exchangeRates, - addons, - realtime, - new TripsService(dbs, todos, packing, files, reservations, days, permissions, budget, collab, vacay, realtime, places), - places, - new CollectionsService(dbs, permissions, realtime), - new AtlasService(dbs), - makeNotificationsService(dbs, realtime), - ); + const collections = new CollectionsService(dbs, permissions, realtime); + const atlas = new AtlasService(dbs); + const dayNotes = new DayNotesService(dbs, permissions, realtime); + const assignments = new AssignmentsService(dbs, permissions, realtime, queryHelpers); + const journey = new JourneyDomainService(dbs); + const membership = new TripMembershipService(dbs, permissions, realtime); + const notifications = makeNotificationsService(dbs, realtime); + const llmConfig = new LlmConfigResolver(new SettingsService(dbs), dbs, addons); + const oauth = new PluginOAuthService(dbs); + const trips = new TripsService(dbs, todos, packing, files, reservations, days, permissions, budget, collab, vacay, realtime, places); + const guards = new PluginGuards(dbs, permissions, addons); + + const registry = createTestPluginRegistry([ + new TagsRpc(new TagsService(dbs)), + new CategoriesRpc(new CategoriesService(dbs)), + new WeatherRpc(new WeatherService()), + new ExchangeRatesRpc(exchangeRates), + new TodoRpc(todos, realtime, guards), + new DayNotesRpc(dayNotes, realtime, guards), + new PackingRpc(packing, realtime, guards), + new FilesRpc(files, realtime, dbs, guards), + new PlacesRpc(places, journey, realtime, guards), + new DaysRpc(days, realtime, guards), + new AccommodationsRpc(days, realtime, guards), + new ItineraryRpc(assignments, realtime, guards), + new TripsRpc(trips, reservations, days, membership, dbs, realtime, guards), + new CostsRpc(budget, dbs, realtime, guards), + new ReservationsRpc(reservations, realtime, guards), + new CollabRpc(collab, realtime, guards), + new AtlasRpc(atlas, guards), + new VacayRpc(vacay, guards), + new JournalRpc(journey, guards), + new CollectionsRpc(collections, guards), + new DbRpc(), + new MetaRpc(dbs, guards), + new HostSurfaceRpc(dbs, realtime, notifications, llmConfig, oauth, guards), + new PluginHooks(undefined as never), + ]); + return new PluginRpcHostFactory(dbs, registry as unknown as PluginRpcRegistryService); } -/** A PluginRuntimeService constructed the way Nest would: with a real host-deps factory. */ +/** 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, createHostDepsFactory(dbs)); + return new PluginRuntimeService(dbs, new AuditService(dbs), new AddonsService(dbs), registry, createPluginRpcHostFactory(dbs)); } diff --git a/server/tests/helpers/rpc-host-deps.ts b/server/tests/helpers/rpc-host-deps.ts index 0ed381008..ceb8ee74f 100644 --- a/server/tests/helpers/rpc-host-deps.ts +++ b/server/tests/helpers/rpc-host-deps.ts @@ -20,14 +20,18 @@ import { AtlasRpc } from '../../src/nest/atlas/atlas.rpc'; import { VacayRpc } from '../../src/nest/vacay/vacay.rpc'; import { JournalRpc } from '../../src/nest/journey/journal.rpc'; import { CollectionsRpc } from '../../src/nest/collections/collections.rpc'; +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'; /** - * The stubbed HostDeps every plugin router test builds on. It used to live inside - * rpc-host.test.ts; it moved here so the coverage ledger test can construct the same - * host without duplicating 150 lines of stubs. + * The stubbed HostDeps every plugin router test builds on. * - * The fixture encodes one access story throughout: user 42 may access and edit trip 1, - * and nothing else. Tests rely on that, so widen it only with care. + * It used to be a 150-line sheet of domain stubs, because the router held every + * handler. The handlers now live on the domains' `@PluginController()` classes and + * stub their own services, so what is left here is what the router itself still + * touches: the plugin's own db handle, the inter-plugin router, and the audit sink. */ export function makeDeps(): HostDeps { return { @@ -35,157 +39,19 @@ export function makeDeps(): HostDeps { query: vi.fn(() => [{ n: 1 }]), exec: vi.fn(() => ({ changes: 1 })), migrate: vi.fn(() => ({ applied: true })), + tx: vi.fn(() => ({ ok: true })), close: vi.fn(), } as unknown as HostDeps['data'], - db: { - prepare: vi.fn((sql: string) => ({ - all: () => [{ id: 7, name: 'Place' }], - get: () => - sql.includes('FROM trips') - ? { id: 1, title: 'Japan', start_date: '2027-01-01' } - : { id: 3, username: 'ada', display_name: 'Ada', avatar: null }, - })), - }, - // trip 1 is accessible to user 42; everything else is not - canAccessTrip: vi.fn((tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1 } : undefined)), - // user 42 may see user 3 (they share a trip); nobody else - canSeeUser: vi.fn((actingUserId: number, targetUserId: number) => actingUserId === 42 && targetUserId === 3), - broadcastToTrip: vi.fn(), - broadcastToUser: vi.fn(), - // Costs (budget) — addon on; user 42 may edit trip 1's costs. - budgetAddonEnabled: vi.fn(() => true), - canEditCosts: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42), - listPackingItems: vi.fn((tripId: number, _userId: number) => [{ id: 1, trip_id: tripId, name: 'Socks' }]), - listTripFiles: vi.fn((tripId: number) => [{ id: 2, trip_id: tripId, filename: 'visa.pdf' }]), - getTripFileContent: vi.fn((tripId: number, fileId: number) => ({ name: 'visa.pdf', mimetype: 'application/pdf', size: 3, content_base64: 'aGk=', _t: tripId, _f: fileId })), - listCollabNotes: vi.fn((tripId: number) => [{ id: 1, trip_id: tripId, title: 'Note' }]), - listCollabPolls: vi.fn((tripId: number) => [{ id: 2, trip_id: tripId, question: 'Q?' }]), - listCollabMessages: vi.fn((tripId: number, before: number | undefined) => [{ id: 3, trip_id: tripId, text: 'hi', _before: before ?? null }]), - journalEntriesForUser: vi.fn((uid: number, journeyId: number) => [{ id: 10, journey_id: journeyId, author_id: uid }]), - atlasBucketForUser: vi.fn((uid: number) => [{ id: 5, user_id: uid, name: 'Kyoto' }]), - canCreateTrip: vi.fn((userId: number) => userId === 42), - createTripForUser: vi.fn((userId: number, input: unknown) => ({ id: 99, user_id: userId, ...(input as object) })), - getRates: vi.fn(async (base: string) => ({ [base]: 1, USD: 1.08 })), - listCostsForTrip: vi.fn((tripId: number) => [{ id: 5, trip_id: tripId, name: 'Hotel', total_price: 100 }]), - listCostsForUser: vi.fn(() => [ - { id: 5, trip_id: 1, name: 'Hotel' }, - { id: 6, trip_id: 2, name: 'Food' }, - ]), - createCost: vi.fn((tripId: number, input: unknown) => ({ id: 9, trip_id: tripId, ...(input as object) })), - updateCost: vi.fn((tripId: number, itemId: number, input: unknown) => ({ id: itemId, trip_id: tripId, ...(input as object) })), - deleteCost: vi.fn(() => ({ deleted: true })), - // Planner writes — user 42 may edit trip 1 only (mirrors canAccessTrip). - canEditPlaces: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42), - createPlace: vi.fn((tripId: number, input: unknown) => ({ id: 10, trip_id: tripId, ...(input as object) })), - updatePlace: vi.fn((tripId: number, placeId: number, input: unknown) => ({ id: placeId, trip_id: tripId, ...(input as object) })), - deletePlace: vi.fn(() => ({ deleted: true })), - canEditDays: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42), - createDay: vi.fn((tripId: number, input: unknown) => ({ id: 20, trip_id: tripId, ...(input as object) })), - updateDay: vi.fn((tripId: number, dayId: number, input: unknown) => ({ id: dayId, trip_id: tripId, ...(input as object) })), - deleteDay: vi.fn(() => ({ deleted: true })), - assignPlaceToDay: vi.fn((tripId: number, dayId: number, placeId: number, notes: string | null) => ({ id: 30, day_id: dayId, place_id: placeId, notes })), - unassignPlace: vi.fn(() => ({ deleted: true })), - canEditTrip: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42), - updateTrip: vi.fn((tripId: number, _userId: number, input: unknown) => ({ id: tripId, ...(input as object) })), - // Cross-trip reads + reservations (bookings) — user 42 may edit trip 1 only. - listTripsForUser: vi.fn(() => [{ id: 1, title: 'Japan' }, { id: 2, title: 'Peru' }]), - listReservationsForUser: vi.fn(() => [{ id: 5, trip_id: 1, title: 'Hotel' }, { id: 6, trip_id: 2, title: 'Flight' }]), - canEditReservations: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42), - createReservation: vi.fn((tripId: number, input: unknown) => ({ id: 40, trip_id: tripId, ...(input as object) })), - updateReservation: vi.fn((tripId: number, reservationId: number, input: unknown) => ({ id: reservationId, trip_id: tripId, ...(input as object) })), - deleteReservation: vi.fn(() => ({ deleted: true })), - // Trip-scoped hydrated reads + accommodations (lodging blocks, day_edit-gated). - listTripDays: vi.fn((tripId: number) => [{ id: 3, trip_id: tripId, day_number: 1, assignments: [], notes_items: [] }]), - listTripReservations: vi.fn((tripId: number) => [{ id: 5, trip_id: tripId, title: 'Hotel', endpoints: [], day_positions: null }]), - listTripAccommodations: vi.fn((tripId: number) => [{ id: 11, trip_id: tripId, place_name: 'Ryokan' }]), - createAccommodation: vi.fn((tripId: number, input: unknown) => ({ id: 60, trip_id: tripId, ...(input as object) })), - updateAccommodation: vi.fn((tripId: number, accommodationId: number, input: unknown) => ({ id: accommodationId, trip_id: tripId, ...(input as object) })), - deleteAccommodation: vi.fn(() => ({ deleted: true })), - // User-scoped addon reads + day notes. - listJournalsForUser: vi.fn(() => [{ id: 1, title: 'Japan 2027' }]), - atlasVisitedForUser: vi.fn(() => ({ countries: [{ country_code: 'JP' }], regions: [] })), - vacayForUser: vi.fn(() => ({ plan: { id: 1 }, entries: [] })), - listCollectionsForUser: vi.fn(() => ({ collections: [{ id: 1, name: 'Tokyo eats' }] })), - getCollectionForUser: vi.fn((_userId: number, id: number) => ({ id, name: 'Tokyo eats', places: [] })), - listDayNotes: vi.fn((tripId: number, dayId: number) => [{ id: 1, day_id: dayId, trip_id: tripId, text: 'note' }]), - createDayNote: vi.fn((_tripId: number, dayId: number, input: unknown) => ({ id: 50, day_id: dayId, ...(input as object) })), - updateDayNote: vi.fn((_tripId: number, dayId: number, noteId: number, input: unknown) => ({ id: noteId, day_id: dayId, ...(input as object) })), - deleteDayNote: vi.fn(() => ({ deleted: true })), - canEditPacking: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42), - createPackingItem: vi.fn((tripId: number, input: unknown) => ({ id: 70, trip_id: tripId, ...(input as object) })), - updatePackingItem: vi.fn((tripId: number, itemId: number, input: unknown) => ({ id: itemId, trip_id: tripId, ...(input as object) })), - deletePackingItem: vi.fn(() => ({ deleted: true })), - listPackingBags: vi.fn(() => [{ id: 80, name: 'Backpack' }]), - createPackingBag: vi.fn((tripId: number, input: unknown) => ({ id: 80, trip_id: tripId, ...(input as object) })), - updatePackingBag: vi.fn((_tripId: number, bagId: number, input: unknown) => ({ id: bagId, ...(input as object) })), - deletePackingBag: vi.fn(() => ({ deleted: true })), - setPackingBagMembers: vi.fn((_tripId: number, bagId: number, userIds: number[]) => ({ bagId, members: userIds })), - getWeather: vi.fn(() => ({ temp: 20 })), - listCategories: vi.fn(() => [{ id: 1, name: 'Food' }]), - tripMembers: vi.fn(() => [{ id: 5, username: 'ada' }, { id: 6, username: 'bob' }]), - canUploadFiles: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42), - canEditFiles: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42), - canDeleteFiles: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42), - createTripFile: vi.fn((tripId: number, input: unknown, uid: number) => ({ id: 130, trip_id: tripId, uploaded_by: uid, ...(input as object) })), - createTripFileLink: vi.fn(() => [{ file_id: 130 }]), - updateTripFile: vi.fn((_tripId: number, fileId: number, input: unknown) => ({ id: fileId, ...(input as object) })), - softDeleteTripFile: vi.fn(() => ({ deleted: true })), - canEditCollab: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42), - createCollabNote: vi.fn((tripId: number, input: unknown, uid: number) => ({ id: 140, trip_id: tripId, created_by: uid, ...(input as object) })), - createCollabPoll: vi.fn((tripId: number, input: unknown) => ({ id: 141, trip_id: tripId, ...(input as object) })), - voteCollabPoll: vi.fn((_tripId: number, pollId: number) => ({ id: pollId, votes: 1 })), - createCollabMessage: vi.fn((tripId: number, text: string) => ({ id: 142, trip_id: tripId, text })), - canManageMembers: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42), - addTripMember: vi.fn((tripId: number, targetUserId: number, invitedBy: number) => ({ joined: true, tripId, targetUserId, invitedBy })), - removeTripMember: vi.fn((tripId: number, targetUserId: number) => ({ removed: true, tripId, targetUserId })), - canAccessTripForNotify: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42), - sendPluginNotification: vi.fn(async (pluginId: string, input: unknown) => ({ sent: true, pluginId, ...(input as object) })), - aiConfigured: vi.fn((userId: number) => userId === 42), - aiComplete: vi.fn(async (_uid: number, prompt: string) => ({ text: `echo:${prompt}` })), - aiExtract: vi.fn(async () => ({ results: [{ ok: true }] })), - getUserSetting: vi.fn((pluginId: string, userId: number, key: string) => (userId === 42 && key === 'apiKey' ? `secret-of-${pluginId}` : undefined)), - getOAuthToken: vi.fn(async (_pluginId: string, userId: number) => (userId === 42 ? 'access-token-xyz' : null)), - schedulerSet: vi.fn((name: string, dueAt: number, everyMs: number | undefined) => ({ scheduled: true, name, dueAt, everyMs })), - schedulerCancel: vi.fn((name: string) => ({ cancelled: name !== 'ghost' })), - createCollectionForUser: vi.fn((uid: number, input: unknown) => ({ id: 100, owner_id: uid, ...(input as object) })), - updateCollectionForUser: vi.fn((_uid: number, id: number, input: unknown) => ({ id, ...(input as object) })), - saveCollectionPlace: vi.fn((uid: number, input: unknown) => ({ id: 101, saved_by: uid, ...(input as object) })), - copyCollectionToTrip: vi.fn(() => ({ copied: 2, skipped: [] })), - deleteCollectionPlace: vi.fn(() => ({ deleted: true })), - markCountryVisited: vi.fn(() => ({ visited: true })), - unmarkCountryVisited: vi.fn(() => ({ visited: false })), - markRegionVisited: vi.fn(() => ({ visited: true })), - unmarkRegionVisited: vi.fn(() => ({ visited: false })), - createBucketItem: vi.fn((uid: number, input: unknown) => ({ id: 110, user_id: uid, ...(input as object) })), - deleteBucketItem: vi.fn(() => ({ deleted: true })), - vacayToggleEntry: vi.fn(() => ({ action: 'added' })), - vacayToggleCompanyHoliday: vi.fn(() => ({ action: 'added' })), - createJournalEntry: vi.fn((uid: number, journeyId: number, input: unknown) => ({ id: 120, journey_id: journeyId, created_by: uid, ...(input as object) })), - updateJournalEntry: vi.fn((_uid: number, entryId: number, input: unknown) => ({ id: entryId, ...(input as object) })), - deleteJournalEntry: vi.fn(() => ({ deleted: true })), - createJournal: vi.fn((uid: number, input: unknown) => ({ id: 130, user_id: uid, ...(input as object) })), - deleteJournal: vi.fn(() => ({ deleted: true })), - canEditTodos: vi.fn((tripId: number, userId: number) => tripId === 1 && userId === 42), - listTodos: vi.fn(() => [{ id: 1, name: 'Pack' }]), - createTodo: vi.fn((tripId: number, input: unknown) => ({ id: 90, trip_id: tripId, ...(input as object) })), - updateTodo: vi.fn((_tripId: number, todoId: number, input: unknown) => ({ id: todoId, ...(input as object) })), - deleteTodo: vi.fn(() => ({ deleted: true })), - // Metadata — trip 1 and place 7 resolve to trip 1 (accessible to 42); else undefined. - metaEntityTrip: vi.fn((entityType: string, entityId: number) => - (entityType === 'trip' && entityId === 1) || (entityType === 'place' && entityId === 7) || (entityType === 'day' && entityId === 3) - || (entityType === 'reservation' && entityId === 40) || (entityType === 'accommodation' && entityId === 11) ? 1 : undefined), - metaGet: vi.fn(() => ({ hello: 'world' })), - metaSet: vi.fn((_et: string, _eid: number, key: string, value: unknown) => ({ key, value })), - metaList: vi.fn(() => ({ a: 1 })), - metaDelete: vi.fn(() => ({ deleted: true })), + callPlugin: vi.fn(async (targetId: string, fn: string) => ({ calledTarget: targetId, calledFn: fn })), + emitPluginEvent: vi.fn(), }; } /** * Every @PluginController() class that owns wire methods, in the order the container - * would discover them. It grows by one entry per rollout PR, and the coverage ledger - * test (tests/unit/plugins/rpc-coverage.test.ts) is what turns a forgotten entry into - * a red build instead of a silent PERMISSION_DENIED in production. + * would discover them. The coverage ledger test (tests/unit/plugins/rpc-coverage.test.ts) + * is what turns a forgotten entry into a red build instead of a silent + * PERMISSION_DENIED in production. * * The services handed in are stubs: the ledger only binds the handlers, it never runs * them. Behaviour is asserted in each domain's own .rpc.test.ts. @@ -214,5 +80,10 @@ export function allRpcControllers(): object[] { new VacayRpc(anyService(), anyService()), new JournalRpc(anyService(), anyService()), new CollectionsRpc(anyService(), anyService()), + new DbRpc(), + 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. + new PluginHooks(anyService()), ]; } diff --git a/server/tests/integration/plugins/supervisor.test.ts b/server/tests/integration/plugins/supervisor.test.ts index 5da759e74..6d2c935f6 100644 --- a/server/tests/integration/plugins/supervisor.test.ts +++ b/server/tests/integration/plugins/supervisor.test.ts @@ -14,6 +14,8 @@ import path from 'node:path'; import { PluginSupervisor, type SupervisorHooks, type SupervisorTuning } from '../../../src/nest/plugins/supervisor/plugin-supervisor'; import { PluginRpcHost, type HostDeps } from '../../../src/nest/plugins/host/rpc-host'; import { PluginDataDb } from '../../../src/nest/plugins/host/plugin-data.service'; +import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing'; +import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc'; let codeRoot: string; let dataRoot: string; @@ -31,12 +33,12 @@ function makeSupervisor(events: Array<{ topic: string; data: unknown }>, tuning: const createRpcHost = (id: string, granted: ReadonlySet): PluginRpcHost => { const deps: HostDeps = { data: new PluginDataDb(id), - db: { prepare: () => ({ all: () => [], get: () => null }) }, - canAccessTrip: () => undefined, - broadcastToTrip: (tripId, event, payload) => broadcasts.push({ tripId, event, payload }), - broadcastToUser: () => {}, + callPlugin: async () => undefined, + emitPluginEvent: (event, payload) => broadcasts.push({ id, event, payload }), }; - return new PluginRpcHost(id, granted, deps); + // Only db.* is exercised from a child here; the rest of the surface has its own + // unit suites and would drag every domain service into this integration test. + return new PluginRpcHost(id, granted, deps, createTestPluginRegistry([new DbRpc()])); }; const hooks: SupervisorHooks = { onEvent: (_id, topic, data) => events.push({ topic, data }), diff --git a/server/tests/unit/plugins/atlas-layers.controller.test.ts b/server/tests/unit/plugins/atlas-layers.controller.test.ts index 91d9f0aa2..d42a41872 100644 --- a/server/tests/unit/plugins/atlas-layers.controller.test.ts +++ b/server/tests/unit/plugins/atlas-layers.controller.test.ts @@ -15,15 +15,15 @@ vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: () => vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); import { AtlasLayersController } from '../../../src/nest/plugins/atlas-layers.controller'; -import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any; function controller(invoke: (id: string) => unknown, providers = ['p1']) { const runtime = { providersOf: vi.fn(() => providers), - invokeHook: vi.fn(async (id: string) => invoke(id)), - } as unknown as PluginRuntimeService; + atlasLayers: vi.fn(async (id: string) => invoke(id)), + } as unknown as PluginHooks; return { c: new AtlasLayersController(runtime), runtime }; } const layer = (over: Record = {}) => ({ id: 'l1', countries: [{ code: 'FR' }], ...over }); @@ -53,7 +53,7 @@ describe('AtlasLayersController', () => { countries: [{ code: 'FR', tone: 'success', label: 'Paris trip' }], }]); // user-scoped: empty args, the acting user is bound host-side - expect(runtime.invokeHook).toHaveBeenCalledWith('p1', 'atlasLayerProvider', 'getLayers', [], 5, 5000); + expect(runtime.atlasLayers).toHaveBeenCalledWith('p1', 5); }); it('uppercase-coerces codes, drops anything that is not ISO alpha-2, defaults a bad tone', async () => { diff --git a/server/tests/unit/plugins/day-schedule.controller.test.ts b/server/tests/unit/plugins/day-schedule.controller.test.ts index c7236b0fc..323e82641 100644 --- a/server/tests/unit/plugins/day-schedule.controller.test.ts +++ b/server/tests/unit/plugins/day-schedule.controller.test.ts @@ -21,15 +21,15 @@ 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 type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any; function controller(invoke: (id: string) => unknown, providers = ['p1']) { const runtime = { providersOf: vi.fn(() => providers), - invokeHook: vi.fn(async (id: string) => invoke(id)), - } as unknown as PluginRuntimeService; + daySchedule: vi.fn(async (id: string) => invoke(id)), + } as unknown as PluginHooks; return { c: new DayScheduleController(runtime, new DatabaseService(dbConn)), runtime }; } const item = (over: Record = {}) => ({ id: 's1', dayId: 10, label: 'Charging', ...over }); @@ -97,6 +97,6 @@ describe('DayScheduleController', () => { it('skips the day lookup entirely when no provider is active', async () => { const { c, runtime } = controller(() => [item()], []); expect((await c.get('1', req(5))).items).toEqual([]); - expect(runtime.invokeHook).not.toHaveBeenCalled(); + expect(runtime.daySchedule).not.toHaveBeenCalled(); }); }); diff --git a/server/tests/unit/plugins/day-tints.controller.test.ts b/server/tests/unit/plugins/day-tints.controller.test.ts index 0b77fff21..0bb50603d 100644 --- a/server/tests/unit/plugins/day-tints.controller.test.ts +++ b/server/tests/unit/plugins/day-tints.controller.test.ts @@ -21,15 +21,15 @@ 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 type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any; function controller(invoke: (id: string) => unknown, providers = ['p1']) { const runtime = { providersOf: vi.fn(() => providers), - invokeHook: vi.fn(async (id: string) => invoke(id)), - } as unknown as PluginRuntimeService; + dayTints: vi.fn(async (id: string) => invoke(id)), + } as unknown as PluginHooks; return { c: new DayTintsController(runtime, new DatabaseService(dbConn)), runtime }; } const tint = (over: Record = {}) => ({ dayId: 10, tone: 'success', ...over }); @@ -222,6 +222,6 @@ describe('DayTintsController', () => { it('skips the day lookup entirely when no provider is active', async () => { const { c, runtime } = controller(() => [tint()], []); expect((await c.get('1', req(5))).tints).toEqual([]); - expect(runtime.invokeHook).not.toHaveBeenCalled(); + expect(runtime.dayTints).not.toHaveBeenCalled(); }); }); diff --git a/server/tests/unit/plugins/host-rpc.test.ts b/server/tests/unit/plugins/host-rpc.test.ts new file mode 100644 index 000000000..0cbca4777 --- /dev/null +++ b/server/tests/unit/plugins/host-rpc.test.ts @@ -0,0 +1,549 @@ +/** + * The plugin surface that belongs to no domain: the plugin's own sqlite (DbRpc), its + * namespaced entity metadata (MetaRpc) and the host-mediated calls (HostSurfaceRpc — + * user lookup, broadcasts, notifications, LLM, OAuth, scheduler). + * + * These cases used to live in plugin-host-deps.factory.test.ts, which tested the same + * behaviour through a 26-argument wiring sheet. Same requests, same expected codes; + * what changed is that they now run against three decorated classes and a real + * in-memory core DB, and that the host is built by the production factory. + */ +import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const { broadcast, broadcastToUser } = vi.hoisted(() => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); +// A real in-memory core db, so the metadata SQL and the entity->trip resolution run +// for real. Trip 1 is owned by user 5; user 6 is a member; user 9 shares nothing. +vi.mock('../../../src/db/database', () => { + const Database = require('better-sqlite3'); + const d = new Database(':memory:'); + d.exec(` + CREATE TABLE trips (id INTEGER PRIMARY KEY, user_id INTEGER); + CREATE TABLE places (id INTEGER PRIMARY KEY, trip_id INTEGER); + CREATE TABLE days (id INTEGER PRIMARY KEY, trip_id INTEGER); + CREATE TABLE reservations (id INTEGER PRIMARY KEY, trip_id INTEGER); + CREATE TABLE day_accommodations (id INTEGER PRIMARY KEY, trip_id INTEGER); + CREATE TABLE users (id INTEGER PRIMARY KEY, role TEXT, username TEXT, display_name TEXT, avatar TEXT, email TEXT); + CREATE TABLE trip_members (trip_id INTEGER, user_id INTEGER); + CREATE TABLE plugin_entity_metadata (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, entity_type TEXT, entity_id INTEGER, key TEXT, value TEXT, updated_at TEXT, UNIQUE(plugin_id, entity_type, entity_id, key)); + CREATE TABLE plugin_capability_audit (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, acting_user_id INTEGER, method TEXT, resource TEXT, code TEXT, ts TEXT, prev_hash TEXT, hash TEXT); + CREATE TABLE plugin_scheduled_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT NOT NULL, name TEXT NOT NULL, due_at INTEGER NOT NULL, payload TEXT NOT NULL DEFAULT 'null', every_ms INTEGER, created_at TEXT DEFAULT (datetime('now')), UNIQUE(plugin_id, name)); + `); + d.prepare('INSERT INTO trips (id, user_id) VALUES (1, 5)').run(); + d.prepare('INSERT INTO places (id, trip_id) VALUES (7, 1)').run(); + d.prepare('INSERT INTO days (id, trip_id) VALUES (3, 1)').run(); + d.prepare('INSERT INTO reservations (id, trip_id) VALUES (40, 1)').run(); + d.prepare('INSERT INTO day_accommodations (id, trip_id) VALUES (11, 1)').run(); + d.prepare("INSERT INTO users (id, role, username, display_name) VALUES (5, 'trip_owner', 'owner', 'Owner')").run(); + d.prepare("INSERT INTO users (id, role, username, display_name) VALUES (6, 'user', 'ada', 'Ada')").run(); + d.prepare("INSERT INTO users (id, role, username, display_name) VALUES (9, 'user', 'eve', 'Eve')").run(); + d.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (1, 6)').run(); + return { + db: d, + canAccessTrip: (tripId: number, userId: number) => + tripId === 1 && (userId === 5 || userId === 6) ? { id: 1, user_id: 5 } : undefined, + }; +}); +vi.mock('../../../src/websocket', () => ({ broadcast, broadcastToUser })); + +const notifySend = vi.fn(async () => undefined); +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 { 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'; +import { db as mockDb } from '../../../src/db/database'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; +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 { RpcError, RpcResponse } from '../../../src/nest/plugins/protocol/envelope'; + +const checkPermission = vi.fn(() => true as boolean); +const permissions = { checkPermission } as unknown as PermissionsService; +const addons = { isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService; +const notifications = { send: notifySend } as unknown as NotificationsService; +// user 7 has no provider configured; everyone else resolves to a stub config. +const llmConfig = { + resolve: vi.fn((uid: number) => (uid === 7 ? null : { provider: 'openai', model: 'gpt-x', baseUrl: undefined, apiKey: 'sekret' })), +} as unknown as LlmConfigResolver; +const oauth = { + async getAccessToken(_pid: string, uid: number) { + return uid === 5 ? 'tok-5' : null; + }, +} as unknown as PluginOAuthService; + +const dbs = new DatabaseService(mockDb); +const guards = new PluginGuards(dbs, permissions, addons); +const registry = createTestPluginRegistry([ + new DbRpc(), + new MetaRpc(dbs, guards), + new HostSurfaceRpc(dbs, new RealtimeService(), notifications, llmConfig, oauth, guards), +]); +const factory = new PluginRpcHostFactory(dbs, registry as unknown as PluginRpcRegistryService); +const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} }; +const makeHost = (id: string, ...perms: string[]) => factory.create(id, new Set(perms), stubRouter); +type Res = RpcResponse & Partial; +// The acting user is a REST arg, not a defaulted one: an explicit `undefined` has to +// stay undefined (that is the userless-context case), and a default parameter would +// quietly turn it back into user 5. +const call = ( + host: ReturnType, + method: string, + params: Record = {}, + ...uid: (number | undefined)[] +): Promise => host.dispatch({ k: 'req', id: 'x', method, params }, uid.length ? uid[0] : 5) as Promise; + +let tmp: string; +beforeAll(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-hostrpc-')); + process.env.TREK_PLUGINS_DATA_DIR = tmp; +}); +afterAll(() => { + closePluginDataDb('wired'); + delete process.env.TREK_PLUGINS_DATA_DIR; + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe('DbRpc — the plugin\'s own sqlite', () => { + it('HOSTRPC-001 caches one data db per plugin id', () => { + expect(getPluginDataDb('wired')).toBe(getPluginDataDb('wired')); + }); + + it('HOSTRPC-002 a granted db:own call runs against the plugin db', async () => { + const host = makeHost('wired', 'db:own'); + expect((await call(host, 'db.migrate', { id: '001', sql: 'CREATE TABLE t (v TEXT)' })).ok).toBe(true); + expect((await call(host, 'db.exec', { sql: "INSERT INTO t (v) VALUES ('a')" })).ok).toBe(true); + const rows = await call(host, 'db.query', { sql: 'SELECT v FROM t' }); + expect(rows.result).toEqual([{ v: 'a' }]); + }); + + it('HOSTRPC-003 db.tx dispatches an atomic batch; malformed ops are BAD_PARAMS', async () => { + const host = makeHost('wtx', 'db:own'); + await call(host, 'db.migrate', { id: '001', sql: 'CREATE TABLE kv (k TEXT, n INTEGER)' }); + const res = await call(host, 'db.tx', { + ops: [ + { sql: 'INSERT INTO kv (k, n) VALUES (?, ?)', args: ['a', 1] }, + { sql: 'SELECT n FROM kv WHERE k = ?', args: ['a'] }, + ], + }); + expect((res.result as { results: unknown[] }).results).toEqual([{ changes: 1 }, { rows: [{ n: 1 }] }]); + expect((await call(host, 'db.tx', { ops: [{ sql: 42 }] })).error?.code).toBe('BAD_PARAMS'); + closePluginDataDb('wtx'); + }); + + it('HOSTRPC-004 without db:own not one of the four is reachable', async () => { + const host = makeHost('wnogrant'); + for (const method of ['db.query', 'db.exec', 'db.migrate', 'db.tx']) { + expect((await call(host, method, { sql: 'SELECT 1', id: '1', ops: [] })).error?.code).toBe('PERMISSION_DENIED'); + } + }); + + it('HOSTRPC-005 two plugins never see each other\'s tables', async () => { + // The regression this guards: DbRpc is a SINGLETON that serves every plugin, so a + // handle captured in a field or a closure would hand plugin B the rows of whichever + // plugin happened to construct it first. + const a = makeHost('tenant-a', 'db:own'); + const b = makeHost('tenant-b', 'db:own'); + await call(a, 'db.migrate', { id: '001', sql: 'CREATE TABLE secrets (v TEXT)' }); + await call(a, 'db.exec', { sql: "INSERT INTO secrets (v) VALUES ('a-only')" }); + const leaked = await call(b, 'db.query', { sql: 'SELECT v FROM secrets' }); + expect(leaked.ok).toBe(false); + expect(leaked.error?.code).toBe('HOST_ERROR'); + expect(leaked.error?.message).toMatch(/no such table/i); + // …and plugin A still reads its own row afterwards. + expect((await call(a, 'db.query', { sql: 'SELECT v FROM secrets' })).result).toEqual([{ v: 'a-only' }]); + closePluginDataDb('tenant-a'); + closePluginDataDb('tenant-b'); + }); + + it('HOSTRPC-006 a handle closed WITHOUT eviction is recreated, not reused dead', () => { + const a = getPluginDataDb('closedcache'); + expect(a.isOpen()).toBe(true); + // The supervisor's dispose() path: close the handle but leave it cached. + a.close(); + const b = getPluginDataDb('closedcache'); + expect(b).not.toBe(a); + expect(b.isOpen()).toBe(true); + closePluginDataDb('closedcache'); + }); + + it('HOSTRPC-007 closePluginDataDb drops the cached handle', () => { + const a = getPluginDataDb('transient'); + closePluginDataDb('transient'); + const b = getPluginDataDb('transient'); + expect(a).not.toBe(b); + closePluginDataDb('transient'); + }); +}); + +describe('DbRpc — the unconditional three', () => { + afterAll(() => closePluginDataDb('open3')); + + it('HOSTRPC-008 plugins.call and events.emit route through the host\'s own router', async () => { + const calls: unknown[] = []; + const emits: unknown[] = []; + const router: PluginCallRouter = { + callPlugin: async (callerId, targetId, fn, args, uid) => { + calls.push({ callerId, targetId, fn, args, uid }); + return { echoed: true }; + }, + emitPluginEvent: (sourceId, event, payload) => { + emits.push({ sourceId, event, payload }); + }, + }; + // No permission granted at all — these two are registered unconditionally. + const host = factory.create('open3', new Set(), router); + const res = await call(host, 'plugins.call', { targetId: 'other', fn: 'sum', args: [1, 2] }, 5); + expect(res.result).toEqual({ echoed: true }); + // The caller id comes from the HOST, never from params, which is what lets the + // router authorise the dependency edge. + expect(calls).toEqual([{ callerId: 'open3', targetId: 'other', fn: 'sum', args: [1, 2], uid: 5 }]); + expect((await call(host, 'events.emit', { event: 'ping', payload: { a: 1 } }, 5)).result).toEqual({ ok: true }); + expect(emits).toEqual([{ sourceId: 'open3', event: 'ping', payload: { a: 1 } }]); + }); + + it('HOSTRPC-009 settings.get returns the acting user\'s decrypted value; userless yields undefined', async () => { + const host = makeHost('open3'); + expect((await call(host, 'settings.get', { key: 'apiKey' }, 5)).result).toEqual({ value: 'k-5' }); + expect((await call(host, 'settings.get', { key: 'apiKey' }, undefined)).result).toEqual({ value: undefined }); + // Another user's value is simply not there — the read is per acting user. + expect((await call(host, 'settings.get', { key: 'apiKey' }, 6)).result).toEqual({ value: undefined }); + }); + + it('HOSTRPC-010 a missing key is BAD_PARAMS, not a silent undefined', async () => { + expect((await call(makeHost('open3'), 'settings.get', {}, 5)).error?.code).toBe('BAD_PARAMS'); + }); +}); + +describe('MetaRpc — namespaced entity metadata', () => { + beforeEach(() => { + checkPermission.mockReset(); + checkPermission.mockReturnValue(true); + }); + afterAll(() => closePluginDataDb('meta')); + + it('HOSTRPC-011 round-trips and enforces the key/value/access limits', async () => { + const host = makeHost('meta', 'db:meta'); + expect((await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k', value: { a: 1 } })).ok).toBe(true); + expect((await call(host, 'meta.get', { entityType: 'trip', entityId: 1, key: 'k' })).result).toEqual({ a: 1 }); + expect((await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k', value: 2 })).ok).toBe(true); // upsert + expect((await call(host, 'meta.list', { entityType: 'place', entityId: 7 })).ok).toBe(true); // place -> trip 1 + expect((await call(host, 'meta.delete', { entityType: 'trip', entityId: 1, key: 'k' })).result).toEqual({ deleted: true }); + expect((await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'x'.repeat(300), value: 1 })).error?.code).toBe('BAD_PARAMS'); + expect((await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'big', value: 'y'.repeat(70000) })).error?.code).toBe('BAD_PARAMS'); + expect((await call(host, 'meta.set', { entityType: 'trip', entityId: 2, key: 'k', value: 1 })).error?.code).toBe('RESOURCE_FORBIDDEN'); + }); + + it('HOSTRPC-012 an unreadable value comes back as null rather than throwing', async () => { + const host = makeHost('meta', 'db:meta'); + await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'corrupt', value: 1 }); + (mockDb as unknown as { prepare(sql: string): { run(...a: unknown[]): unknown } }) + .prepare("UPDATE plugin_entity_metadata SET value='{not json' WHERE plugin_id='meta' AND key='corrupt'") + .run(); + expect((await call(host, 'meta.get', { entityType: 'trip', entityId: 1, key: 'corrupt' })).result).toBeNull(); + const listed = (await call(host, 'meta.list', { entityType: 'trip', entityId: 1 })).result as Record; + expect(listed.corrupt).toBeNull(); + await call(host, 'meta.delete', { entityType: 'trip', entityId: 1, key: 'corrupt' }); + }); + + it('HOSTRPC-013a an omitted value is stored as null, not as the string "undefined"', async () => { + const host = makeHost('meta', 'db:meta'); + const set = await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'blank' }); + expect(set.result).toEqual({ key: 'blank', value: null }); + expect((await call(host, 'meta.get', { entityType: 'trip', entityId: 1, key: 'blank' })).result).toBeNull(); + await call(host, 'meta.delete', { entityType: 'trip', entityId: 1, key: 'blank' }); + }); + + it('HOSTRPC-013 a key that was never set reads as null, and deleting it reports false', async () => { + const host = makeHost('meta', 'db:meta'); + expect((await call(host, 'meta.get', { entityType: 'trip', entityId: 1, key: 'ghost' })).result).toBeNull(); + expect((await call(host, 'meta.delete', { entityType: 'trip', entityId: 1, key: 'ghost' })).result).toEqual({ deleted: false }); + }); + + it('HOSTRPC-014 the per-entity key quota is enforced on new keys only', async () => { + const host = makeHost('metaquota', 'db:meta'); + for (let i = 0; i < 100; i++) { + expect((await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: `k${i}`, value: i })).ok).toBe(true); + } + const overflow = await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k100', value: 1 }); + expect(overflow.error?.code).toBe('BAD_PARAMS'); + expect(overflow.error?.message).toMatch(/too many metadata keys/); + // An existing key still updates — the quota counts keys, not writes. + expect((await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k0', value: 'again' })).ok).toBe(true); + closePluginDataDb('metaquota'); + }); + + it('HOSTRPC-015 every entity type resolves its own edit action, and a read-only member is refused a write', async () => { + const host = makeHost('meta', 'db:meta'); + const seen: string[] = []; + checkPermission.mockImplementation((action: unknown) => { + seen.push(String(action)); + return true; + }); + for (const [entityType, entityId] of [['trip', 1], ['place', 7], ['day', 3], ['reservation', 40], ['accommodation', 11]] as const) { + expect((await call(host, 'meta.set', { entityType, entityId, key: 'k', value: 1 })).ok).toBe(true); + } + // Accommodations deliberately ride on day_edit, like the accommodation write path. + expect(seen).toEqual(['trip_edit', 'place_edit', 'day_edit', 'reservation_edit', 'day_edit']); + + checkPermission.mockReturnValue(false); + const write = await call(host, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k', value: 2 }); + expect(write.error?.code).toBe('RESOURCE_FORBIDDEN'); + // …but a READ is only access-gated, so it still works for the same user. + expect((await call(host, 'meta.get', { entityType: 'trip', entityId: 1, key: 'k' })).result).toBe(1); + }); + + it('HOSTRPC-016 an unknown entityType is BAD_PARAMS and a userless context is refused', async () => { + const host = makeHost('meta', 'db:meta'); + expect((await call(host, 'meta.set', { entityType: 'user', entityId: 1, key: 'k', value: 1 })).error?.code).toBe('BAD_PARAMS'); + expect((await call(host, 'meta.get', { entityType: 'trip', entityId: 1, key: 'k' }, undefined)).error?.code).toBe('RESOURCE_FORBIDDEN'); + // An entity id that resolves to nothing is refused rather than reported as empty. + expect((await call(host, 'meta.get', { entityType: 'reservation', entityId: 999, key: 'k' })).error?.code).toBe('RESOURCE_FORBIDDEN'); + }); + + it('HOSTRPC-017 one plugin never reads another\'s keys on the same entity', async () => { + const a = makeHost('meta-a', 'db:meta'); + const b = makeHost('meta-b', 'db:meta'); + await call(a, 'meta.set', { entityType: 'trip', entityId: 1, key: 'shared', value: 'a-value' }); + expect((await call(b, 'meta.get', { entityType: 'trip', entityId: 1, key: 'shared' })).result).toBeNull(); + expect((await call(b, 'meta.list', { entityType: 'trip', entityId: 1 })).result).toEqual({}); + closePluginDataDb('meta-a'); + closePluginDataDb('meta-b'); + }); + + it('HOSTRPC-018 without db:meta not one of the four is reachable', async () => { + const host = makeHost('meta', 'db:read:trips'); + for (const method of ['meta.get', 'meta.set', 'meta.list', 'meta.delete']) { + expect((await call(host, method, { entityType: 'trip', entityId: 1, key: 'k' })).error?.code).toBe('PERMISSION_DENIED'); + } + }); +}); + +describe('HostSurfaceRpc — users, broadcasts, notify, ai, oauth, scheduler', () => { + beforeEach(() => { + checkPermission.mockReset(); + checkPermission.mockReturnValue(true); + notifySend.mockClear(); + llmExtract.mockClear(); + broadcast.mockClear(); + broadcastToUser.mockClear(); + }); + afterAll(() => closePluginDataDb('surface')); + + it('HOSTRPC-019 users.getById is scoped to people the acting user shares a trip with', async () => { + const host = makeHost('surface', 'db:read:users'); + const shared = await call(host, 'users.getById', { id: 6 }, 5); + expect(shared.result).toEqual({ id: 6, username: 'ada', display_name: 'Ada', avatar: null }); + // Reading yourself needs no shared trip. + expect((await call(host, 'users.getById', { id: 5 }, 5)).ok).toBe(true); + // A stranger is refused, which is what stops id enumeration. + expect((await call(host, 'users.getById', { id: 9 }, 5)).error?.code).toBe('RESOURCE_FORBIDDEN'); + expect((await call(host, 'users.getById', { id: 6 }, undefined)).error?.code).toBe('RESOURCE_FORBIDDEN'); + }); + + it('HOSTRPC-020 trip broadcasts are force-namespaced and membership-gated', async () => { + const host = makeHost('surface', 'ws:broadcast:trip'); + expect((await call(host, 'ws.broadcastToTrip', { tripId: 1, event: 'ping', data: { a: 1 } }, 5)).ok).toBe(true); + expect(broadcast).toHaveBeenCalledWith(1, 'plugin:surface:ping', { a: 1 }); + // A primitive payload is wrapped rather than dropped. + await call(host, 'ws.broadcastToTrip', { tripId: 1, event: 'ping', data: 'primitive' }, 5); + expect(broadcast).toHaveBeenLastCalledWith(1, 'plugin:surface:ping', { value: 'primitive' }); + broadcast.mockClear(); + expect((await call(host, 'ws.broadcastToTrip', { tripId: 2, event: 'x', data: {} }, 5)).error?.code).toBe('RESOURCE_FORBIDDEN'); + expect((await call(host, 'ws.broadcastToTrip', { tripId: 1, event: 'x', data: {} }, undefined)).error?.code).toBe('RESOURCE_FORBIDDEN'); + expect(broadcast).not.toHaveBeenCalled(); + }); + + it('HOSTRPC-021 a per-user broadcast may only target the acting user', async () => { + const host = makeHost('surface', 'ws:broadcast:user'); + expect((await call(host, 'ws.broadcastToUser', { userId: 5, event: 'hi', data: { x: 2 } }, 5)).ok).toBe(true); + expect(broadcastToUser).toHaveBeenCalledWith(5, { type: 'plugin:surface', event: 'hi', x: 2 }); + expect((await call(host, 'ws.broadcastToUser', { userId: 6, event: 'hi', data: {} }, 5)).error?.code).toBe('RESOURCE_FORBIDDEN'); + expect((await call(host, 'ws.broadcastToUser', { userId: 5, event: 'hi', data: {} }, undefined)).error?.code).toBe('RESOURCE_FORBIDDEN'); + }); + + it('HOSTRPC-022 notify.send forces the recipient and carries the plugin_notification event', async () => { + const host = makeHost('surface', 'notify:send'); + expect((await call(host, 'notify.send', { input: { title: 'Delay', body: 'AB123 late', scope: 'user', targetId: 5, link: '/trips/1' } }, 5)).ok).toBe(true); + expect(notifySend).toHaveBeenCalledWith(expect.objectContaining({ + event: 'plugin_notification', + actorId: null, + scope: 'user', + targetId: 5, + params: expect.objectContaining({ title: 'Delay', body: 'AB123 late', link: '/trips/1' }), + })); + expect((await call(host, 'notify.send', { input: { title: 't', body: 'b', scope: 'trip', targetId: 1 } }, 5)).ok).toBe(true); + // Another user, a trip the acting user isn't in, and the admin scope are all refused. + expect((await call(host, 'notify.send', { input: { title: 't', body: 'b', scope: 'user', targetId: 9 } }, 5)).error?.code).toBe('RESOURCE_FORBIDDEN'); + expect((await call(host, 'notify.send', { input: { title: 't', body: 'b', scope: 'trip', targetId: 2 } }, 5)).error?.code).toBe('RESOURCE_FORBIDDEN'); + expect((await call(host, 'notify.send', { input: { title: 't', body: 'b', scope: 'admin', targetId: 0 } }, 5)).error?.code).toBe('BAD_PARAMS'); + expect((await call(host, 'notify.send', { input: { title: 't', body: 'b', scope: 'user', targetId: 5 } }, undefined)).error?.code).toBe('RESOURCE_FORBIDDEN'); + }); + + it('HOSTRPC-023 notify.send rejects an off-site link and an empty or emoji-only title', async () => { + const host = makeHost('surface', 'notify:send'); + const base = { body: 'b', scope: 'user', targetId: 5 }; + for (const link of ['//evil.com', 'https://evil.com/x', '/\\evil.com', 'javascript:alert(1)']) { + expect((await call(host, 'notify.send', { input: { ...base, title: 't', link } }, 5)).error?.code).toBe('BAD_PARAMS'); + } + expect((await call(host, 'notify.send', { input: { ...base, title: '' } }, 5)).error?.code).toBe('BAD_PARAMS'); + // stripEmoji collapses an all-emoji title to '', so it lands on the same refusal. + expect((await call(host, 'notify.send', { input: { ...base, title: '🎉🎉' } }, 5)).error?.code).toBe('BAD_PARAMS'); + expect((await call(host, 'notify.send', { input: { ...base, title: 'x'.repeat(201) } }, 5)).error?.code).toBe('BAD_PARAMS'); + expect((await call(host, 'notify.send', { input: { title: 't', body: 'y'.repeat(1001), scope: 'user', targetId: 5 } }, 5)).error?.code).toBe('BAD_PARAMS'); + // A non-string title or body lands on the same refusal as an empty one, rather + // than reaching stripEmoji and throwing there. + expect((await call(host, 'notify.send', { input: { ...base, title: 42 } }, 5)).error?.code).toBe('BAD_PARAMS'); + expect((await call(host, 'notify.send', { input: { title: 't', body: 42, scope: 'user', targetId: 5 } }, 5)).error?.code).toBe('BAD_PARAMS'); + expect(notifySend).not.toHaveBeenCalled(); + }); + + it('HOSTRPC-024 ai.complete/extract run under the resolved provider, with caps', async () => { + const host = makeHost('surface', 'ai:invoke'); + const completed = await call(host, 'ai.complete', { prompt: 'Summarize' }, 5); + expect(completed.result).toMatchObject({ text: expect.stringContaining('answer:') }); + const extracted = await call(host, 'ai.extract', { text: 'AB123 JFK', jsonSchema: { type: 'object' } }, 5); + expect(Array.isArray((extracted.result as { results: unknown[] }).results)).toBe(true); + // user 7 has no provider configured + expect((await call(host, 'ai.complete', { prompt: 'hi' }, 7)).error?.code).toBe('BAD_PARAMS'); + expect((await call(host, 'ai.complete', { prompt: '' }, 5)).error?.code).toBe('BAD_PARAMS'); + expect((await call(host, 'ai.complete', { prompt: 'x'.repeat(20001) }, 5)).error?.code).toBe('BAD_PARAMS'); + expect((await call(host, 'ai.extract', { text: 'x', jsonSchema: 'not-an-object' }, 5)).error?.code).toBe('BAD_PARAMS'); + expect((await call(host, 'ai.extract', { text: '', jsonSchema: {} }, 5)).error?.code).toBe('BAD_PARAMS'); + expect((await call(host, 'ai.extract', { text: 'x'.repeat(20001), jsonSchema: {} }, 5)).error?.code).toBe('BAD_PARAMS'); + expect((await call(host, 'ai.complete', { prompt: 'hi' }, undefined)).error?.code).toBe('RESOURCE_FORBIDDEN'); + // A non-string prompt/text is the same refusal as an empty one, not a crash. + expect((await call(host, 'ai.complete', { prompt: 42 }, 5)).error?.code).toBe('BAD_PARAMS'); + expect((await call(host, 'ai.extract', { text: 42, jsonSchema: {} }, 5)).error?.code).toBe('BAD_PARAMS'); + // …and a null jsonSchema is refused despite typeof null === 'object'. + expect((await call(host, 'ai.extract', { text: 'x', jsonSchema: null }, 5)).error?.code).toBe('BAD_PARAMS'); + }); + + it('HOSTRPC-025 the plugin\'s system prompt and extraction hint are passed through, with a default', async () => { + const host = makeHost('surface', 'ai:invoke'); + await call(host, 'ai.complete', { prompt: 'p', system: 'be terse' }, 5); + expect(llmExtract).toHaveBeenLastCalledWith(expect.objectContaining({ prompt: 'be terse', text: 'p' })); + await call(host, 'ai.complete', { prompt: 'p' }, 5); + expect(llmExtract).toHaveBeenLastCalledWith(expect.objectContaining({ prompt: expect.stringContaining('helpful assistant') })); + await call(host, 'ai.extract', { text: 't', jsonSchema: { type: 'object' }, prompt: '' }, 5); + expect(llmExtract).toHaveBeenLastCalledWith(expect.objectContaining({ prompt: expect.stringContaining('Extract structured data') })); + }); + + it('HOSTRPC-026 a model that answers with no text yields an empty string, not undefined', async () => { + llmExtract.mockResolvedValueOnce([{ text: 42 } as never]); + expect((await call(makeHost('surface', 'ai:invoke'), 'ai.complete', { prompt: 'p' }, 5)).result).toEqual({ text: '' }); + llmExtract.mockResolvedValueOnce([]); + expect((await call(makeHost('surface', 'ai:invoke'), 'ai.complete', { prompt: 'p' }, 5)).result).toEqual({ text: '' }); + }); + + it('HOSTRPC-027 oauth.getToken returns only the acting user\'s access token', async () => { + const host = makeHost('surface', 'oauth:client'); + expect((await call(host, 'oauth.getToken', {}, 5)).result).toEqual({ accessToken: 'tok-5' }); + expect((await call(host, 'oauth.getToken', {}, 7)).result).toEqual({ accessToken: null }); // not connected + // A userless context gets null, matching the SDK contract, not RESOURCE_FORBIDDEN. + expect((await call(host, 'oauth.getToken', {}, undefined)).result).toEqual({ accessToken: null }); + expect((await call(makeHost('surface'), 'oauth.getToken', {}, 5)).error?.code).toBe('PERMISSION_DENIED'); + }); + + it('HOSTRPC-028 scheduler.set upserts by name with caps; cancel removes', async () => { + const host = makeHost('wsched', 'jobs:run'); + const count = () => + (mockDb as unknown as { prepare(s: string): { get(): { n: number } } }) + .prepare("SELECT COUNT(*) AS n FROM plugin_scheduled_tasks WHERE plugin_id='wsched'") + .get().n; + const due = Date.now() + 120_000; + // Userless is fine — a scheduled task is like a job. + expect((await call(host, 'scheduler.set', { name: 'poll', dueAt: due, payload: { a: 1 } }, undefined)).ok).toBe(true); + expect(count()).toBe(1); + expect((await call(host, 'scheduler.set', { name: 'poll', dueAt: due + 1000 }, undefined)).ok).toBe(true); + expect(count()).toBe(1); // upsert by (plugin, name) + expect((await call(host, 'scheduler.cancel', { name: 'poll' }, undefined)).result).toEqual({ cancelled: true }); + expect(count()).toBe(0); + expect((await call(host, 'scheduler.cancel', { name: 'ghost' }, undefined)).result).toEqual({ cancelled: false }); + }); + + it('HOSTRPC-029 the scheduler caps bound name, horizon, interval and payload', async () => { + const host = makeHost('wsched2', 'jobs:run'); + const due = Date.now() + 120_000; + const bad = async (params: Record) => (await call(host, 'scheduler.set', params, undefined)).error?.code; + expect(await bad({ name: '', dueAt: due })).toBe('BAD_PARAMS'); + expect(await bad({ name: 'x'.repeat(129), dueAt: due })).toBe('BAD_PARAMS'); + expect(await bad({ name: 'far', dueAt: Date.now() + 400 * 24 * 60 * 60 * 1000 })).toBe('BAD_PARAMS'); + expect(await bad({ name: 'nan', dueAt: Number.POSITIVE_INFINITY })).toBe('BAD_PARAMS'); + expect(await bad({ name: 'fast', dueAt: due, everyMs: 5000 })).toBe('BAD_PARAMS'); + expect(await bad({ name: 'fat', dueAt: due, payload: { blob: 'z'.repeat(9000) } })).toBe('BAD_PARAMS'); + // A dueAt in the past is clamped to now rather than refused, so a plugin that wakes + // up late still gets its task fired on the next sweep. + expect((await call(host, 'scheduler.set', { name: 'late', dueAt: 1 }, undefined)).ok).toBe(true); + const row = (mockDb as unknown as { prepare(s: string): { get(): { due_at: number } } }) + .prepare("SELECT due_at FROM plugin_scheduled_tasks WHERE plugin_id='wsched2' AND name='late'") + .get(); + expect(row.due_at).toBeGreaterThan(1); + }); + + it('HOSTRPC-030 a plugin cannot hoard more than 100 scheduled tasks', async () => { + const host = makeHost('wschedmax', 'jobs:run'); + const due = Date.now() + 120_000; + for (let i = 0; i < 100; i++) { + expect((await call(host, 'scheduler.set', { name: `t${i}`, dueAt: due }, undefined)).ok).toBe(true); + } + const over = await call(host, 'scheduler.set', { name: 't100', dueAt: due }, undefined); + expect(over.error?.message).toMatch(/too many scheduled tasks/); + // An existing name still upserts — the cap counts entries, not calls. + expect((await call(host, 'scheduler.set', { name: 't0', dueAt: due + 5 }, undefined)).ok).toBe(true); + }); + + it('HOSTRPC-031 the daily notify + AI budgets are enforced, seeded from today\'s audit rows', async () => { + // Seed the audit with a plugin that has already spent its whole day (default caps + // are 100 notify / 200 ai) so the budget is exhausted on first use. + const today = `${new Date().toISOString().slice(0, 10)}T08:00:00.000Z`; + const insert = (mockDb as unknown as { prepare(s: string): { run(...a: unknown[]): unknown } }) + .prepare("INSERT INTO plugin_capability_audit (plugin_id, method, code, ts) VALUES (?, ?, 'ok', ?)"); + for (let i = 0; i < 100; i++) insert.run('wbudget', 'notify.send', today); + for (let i = 0; i < 200; i++) insert.run('wbudget', 'ai.complete', today); + const host = makeHost('wbudget', 'notify:send', 'ai:invoke'); + const notified = await call(host, 'notify.send', { input: { title: 't', body: 'b', scope: 'user', targetId: 5 } }, 5); + expect(notified.error?.code).toBe('BAD_PARAMS'); + expect(notified.error?.message).toMatch(/budget/i); + expect(notifySend).not.toHaveBeenCalled(); + expect((await call(host, 'ai.complete', { prompt: 'hi' }, 5)).error?.message).toMatch(/budget/i); + closePluginDataDb('wbudget'); + }); + + it('HOSTRPC-032 every host-surface method is unreachable without its own grant', async () => { + const host = makeHost('surface'); + const denied: Record> = { + 'users.getById': { id: 6 }, + 'ws.broadcastToTrip': { tripId: 1, event: 'x' }, + 'ws.broadcastToUser': { userId: 5, event: 'x' }, + 'notify.send': { input: { title: 't', body: 'b', scope: 'user', targetId: 5 } }, + 'ai.complete': { prompt: 'x' }, + 'ai.extract': { text: 'x', jsonSchema: {} }, + 'oauth.getToken': {}, + 'scheduler.set': { name: 'x', dueAt: Date.now() + 1000 }, + 'scheduler.cancel': { name: 'x' }, + }; + for (const [method, params] of Object.entries(denied)) { + expect((await call(host, method, params, 5)).error?.code).toBe('PERMISSION_DENIED'); + } + }); +}); diff --git a/server/tests/unit/plugins/journal-entry-rows.controller.test.ts b/server/tests/unit/plugins/journal-entry-rows.controller.test.ts index dac19fec5..9e64ca00e 100644 --- a/server/tests/unit/plugins/journal-entry-rows.controller.test.ts +++ b/server/tests/unit/plugins/journal-entry-rows.controller.test.ts @@ -23,7 +23,7 @@ import type { JourneyDomainService } from '../../../src/nest/journey/journey-dom vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); import { JournalEntryRowsController } from '../../../src/nest/plugins/journal-entry-rows.controller'; -import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; import type { AddonsService } from '../../../src/nest/addons/addons.service'; const addonsStub = { isAddonEnabled } as unknown as AddonsService; @@ -33,8 +33,8 @@ const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) a function controller(invoke: (id: string) => unknown, providers = ['p1']) { const runtime = { providersOf: vi.fn(() => providers), - invokeHook: vi.fn(async (id: string) => invoke(id)), - } as unknown as PluginRuntimeService; + journalRows: vi.fn(async (id: string) => invoke(id)), + } as unknown as PluginHooks; return { c: new JournalEntryRowsController(runtime, new DatabaseService(dbConn), addonsStub, { canAccessJourney } as unknown as JourneyDomainService), runtime }; } const row = (over: Record = {}) => ({ label: 'Distance', value: '12 km', ...over }); @@ -77,7 +77,7 @@ describe('JournalEntryRowsController', () => { { label: 'Steps', value: '9000', url: 'mailto:x@y.z' }, ], }]); - expect(runtime.invokeHook).toHaveBeenCalledWith('p1', 'journalEntryProvider', 'getRows', [7], 5, 5000); + expect(runtime.journalRows).toHaveBeenCalledWith('p1', 7, 5); }); it('drops javascript:/empty/unparseable urls, caps lengths, drops label-less rows and non-objects', async () => { diff --git a/server/tests/unit/plugins/map-layers.controller.test.ts b/server/tests/unit/plugins/map-layers.controller.test.ts index c956559e4..4bcac3682 100644 --- a/server/tests/unit/plugins/map-layers.controller.test.ts +++ b/server/tests/unit/plugins/map-layers.controller.test.ts @@ -15,7 +15,7 @@ vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: () => vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); import { MapLayersController } from '../../../src/nest/plugins/map-layers.controller'; -import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; import type { DatabaseService } from '../../../src/nest/database/database.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -23,8 +23,8 @@ const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) a function controller(invoke: (id: string) => unknown, providers = ['p1']) { const runtime = { providersOf: vi.fn(() => providers), - invokeHook: vi.fn(async (id: string) => invoke(id)), - } as unknown as PluginRuntimeService; + mapLayers: vi.fn(async (id: string) => invoke(id)), + } as unknown as PluginHooks; return { c: new MapLayersController(runtime, { canAccessTrip } as unknown as DatabaseService), runtime }; } const line = (n = 3, over: Record = {}) => ({ diff --git a/server/tests/unit/plugins/map-markers.controller.test.ts b/server/tests/unit/plugins/map-markers.controller.test.ts index e885d3e90..f01a7bacd 100644 --- a/server/tests/unit/plugins/map-markers.controller.test.ts +++ b/server/tests/unit/plugins/map-markers.controller.test.ts @@ -14,7 +14,7 @@ vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: () => vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); import { MapMarkersController } from '../../../src/nest/plugins/map-markers.controller'; -import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; import type { DatabaseService } from '../../../src/nest/database/database.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -22,8 +22,8 @@ const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) a function controller(invoke: (id: string) => unknown, providers = ['p1']) { const runtime = { providersOf: vi.fn(() => providers), - invokeHook: vi.fn(async (id: string) => invoke(id)), - } as unknown as PluginRuntimeService; + mapMarkers: vi.fn(async (id: string) => invoke(id)), + } as unknown as PluginHooks; return { c: new MapMarkersController(runtime, { canAccessTrip } as unknown as DatabaseService), runtime }; } const mk = (over: Record = {}) => ({ id: 'm1', lat: 48.85, lng: 2.35, ...over }); diff --git a/server/tests/unit/plugins/pdf-sections.controller.test.ts b/server/tests/unit/plugins/pdf-sections.controller.test.ts index 22663bd03..34de54f28 100644 --- a/server/tests/unit/plugins/pdf-sections.controller.test.ts +++ b/server/tests/unit/plugins/pdf-sections.controller.test.ts @@ -15,7 +15,7 @@ vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: () => vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); import { PdfSectionsController } from '../../../src/nest/plugins/pdf-sections.controller'; -import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; import type { DatabaseService } from '../../../src/nest/database/database.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -23,8 +23,8 @@ const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) a function controller(invoke: (id: string) => unknown, providers = ['p1']) { const runtime = { providersOf: vi.fn(() => providers), - invokeHook: vi.fn(async (id: string) => invoke(id)), - } as unknown as PluginRuntimeService; + pdfSections: vi.fn(async (id: string) => invoke(id)), + } as unknown as PluginHooks; return { c: new PdfSectionsController(runtime, { canAccessTrip } as unknown as DatabaseService), runtime }; } const sec = (over: Record = {}) => ({ title: 'Weather', ...over }); @@ -56,7 +56,7 @@ describe('PdfSectionsController', () => { paragraphs: ['Sunny all week'], table: { headers: ['Day', 'Temp'], rows: [['Mon', '24°C']] }, }]); - expect(runtime.invokeHook).toHaveBeenCalledWith('p1', 'pdfSectionProvider', 'getSections', [1], 5, 5000); + expect(runtime.pdfSections).toHaveBeenCalledWith('p1', 1, 5); }); it('drops non-objects, untitled sections and a non-array result; coerces + caps the title', async () => { diff --git a/server/tests/unit/plugins/place-details.controller.test.ts b/server/tests/unit/plugins/place-details.controller.test.ts index 4f9b758a6..a5d9fb27e 100644 --- a/server/tests/unit/plugins/place-details.controller.test.ts +++ b/server/tests/unit/plugins/place-details.controller.test.ts @@ -14,16 +14,16 @@ 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 type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any; -function controller(over: Partial = {}) { +function controller(over: Partial = {}) { const runtime = { providersOf: vi.fn(() => ['p1', 'p2']), - invokeHook: vi.fn(async (id: string) => (id === 'p2' ? [{ label: 'Rating', value: '4.5' }] : [{ label: 'Reviews', value: '12', url: 'https://x' }])), + placeDetails: vi.fn(async (id: string) => (id === 'p2' ? [{ label: 'Rating', value: '4.5' }] : [{ label: 'Reviews', value: '12', url: 'https://x' }])), ...over, - } as unknown as PluginRuntimeService; + } as unknown as PluginHooks; return { c: new PlaceDetailsController(runtime, new DatabaseService(dbConn)), runtime }; } @@ -51,26 +51,26 @@ describe('PlaceDetailsController', () => { it('merges every provider that returns items and skips one that throws (graceful)', async () => { const { c, runtime } = controller({ - invokeHook: vi.fn(async (id: string) => { + placeDetails: vi.fn(async (id: string) => { if (id === 'p2') throw new Error('slow provider'); return [{ label: 'Reviews', value: '12' }]; - }) as unknown as PluginRuntimeService['invokeHook'], + }) as unknown as PluginHooks['placeDetails'], }); const res = await c.get('7', req(5)); expect(res.providers).toEqual([{ pluginId: 'p1', items: [{ label: 'Reviews', value: '12' }] }]); - expect(runtime.invokeHook).toHaveBeenCalledWith('p1', 'placeDetailProvider', 'getDetails', [7], 5, 5000); + expect(runtime.placeDetails).toHaveBeenCalledWith('p1', 7, 5); }); it('normalizes rows: drops a javascript: url, caps lengths, drops non-objects + label-less rows', async () => { const { c } = controller({ providersOf: vi.fn(() => ['p1']), - invokeHook: vi.fn(async () => [ + placeDetails: vi.fn(async () => [ { label: 'x'.repeat(200), value: 'y'.repeat(500), url: 'javascript:alert(1)' }, { label: 'Site', url: 'https://ok.example' }, { value: 'no label' }, // dropped 'not an object', // dropped { label: 'Mail', url: 'mailto:a@b.c' }, - ]) as unknown as PluginRuntimeService['invokeHook'], + ]) as unknown as PluginHooks['placeDetails'], }); const items = (await c.get('7', req(5))).providers[0].items; expect(items).toEqual([ @@ -83,11 +83,11 @@ describe('PlaceDetailsController', () => { it('caps the row count per provider at 12 and drops a provider that yields nothing usable', async () => { const { c } = controller({ providersOf: vi.fn(() => ['flood', 'empty']), - invokeHook: vi.fn(async (id: string) => + placeDetails: vi.fn(async (id: string) => id === 'flood' ? Array.from({ length: 50 }, (_v, i) => ({ label: `r${i}` })) : [{ value: 'no label' }, 'junk'], - ) as unknown as PluginRuntimeService['invokeHook'], + ) as unknown as PluginHooks['placeDetails'], }); const res = await c.get('7', req(5)); expect(res.providers).toHaveLength(1); // 'empty' contributes nothing -> dropped diff --git a/server/tests/unit/plugins/plugin-hooks.test.ts b/server/tests/unit/plugins/plugin-hooks.test.ts new file mode 100644 index 000000000..15cd8603d --- /dev/null +++ b/server/tests/unit/plugins/plugin-hooks.test.ts @@ -0,0 +1,143 @@ +/** + * The host-to-plugin hook contracts. + * + * The 15 hooks used to be invoked straight from the controllers, with the fn name and + * the timeout spelled out at each call site. Nothing checked that a `hook:*` grant on + * the consent screen had a consumer behind it, that the fn name matched what the SDK + * documents, or that two call sites for the same hook agreed on the budget. + * + * These tests pin all three, plus the argument shape each hook is handed. + */ +import { describe, it, expect, vi } from 'vitest'; +import { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; +import { PluginsModule } from '../../../src/nest/plugins/plugins.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'; + +type Invocation = [string, string, string, unknown[], number | undefined, number]; + +function hooks() { + const invokeHook = vi.fn(async () => ({ ok: true })); + const providersOf = vi.fn(() => ['p1', 'p2']); + const runtime = { invokeHook, providersOf } as unknown as PluginRuntimeService; + return { hooks: new PluginHooks(runtime), invokeHook, providersOf }; +} + +/** The (hook, fn, timeout) triple every call site used to carry on its own. */ +const CONTRACTS: Array<[hook: string, fn: string, timeoutMs: number]> = [ + ['photoProvider', 'search', 5000], + ['photoProvider', 'getById', 5000], + ['calendarSource', 'getName', 3000], + ['calendarSource', 'getEvents', 5000], + ['placeDetailProvider', 'getDetails', 5000], + ['warningProvider', 'getWarnings', 5000], + ['tableContributor', 'getContributions', 5000], + ['mapMarkerProvider', 'getMarkers', 5000], + ['mapLayerProvider', 'getLayers', 5000], + ['routeProvider', 'getRoute', 20_000], + ['dayScheduleProvider', 'getSchedule', 5000], + ['dayTintProvider', 'getDayTints', 5000], + ['pdfSectionProvider', 'getSections', 5000], + ['atlasLayerProvider', 'getLayers', 5000], + ['journalEntryProvider', 'getRows', 5000], + ['tripCardProvider', 'getCards', 5000], + ['notificationChannel', 'send', 8000], + ['notificationChannel', 'test', 8000], +]; + +describe('PluginHooks contracts', () => { + const registry = createTestPluginRegistry([new PluginHooks({} as never)]); + + it('PLUGHOOK-001 every hook the consent screen offers has a host-side consumer', () => { + const covered = new Set([...registry.hookContracts().values()].map((c) => c.hook)); + expect([...Object.keys(HOOK_PERMISSION)].filter((h) => !covered.has(h))).toEqual([]); + }); + + it('PLUGHOOK-002 the declared (hook, fn) pairs are exactly the ones the host calls', () => { + const declared = [...registry.hookContracts().keys()].sort(); + expect(declared).toEqual(CONTRACTS.map(([hook, fn]) => `${hook}#${fn}`).sort()); + }); + + it('PLUGHOOK-003 each contract carries the permission HOOK_PERMISSION assigns and its own budget', () => { + for (const [hook, fn, timeoutMs] of CONTRACTS) { + const contract = registry.hookContract(hook, fn); + expect(contract.permission).toBe((HOOK_PERMISSION as Record)[hook]); + expect(contract.timeoutMs).toBe(timeoutMs); + expect(contract.consumer).toMatch(/^PluginHooks\./); + } + }); + + it('PLUGHOOK-004 a call site asking for a fn nobody declared is a throw, not a silent miss', () => { + expect(() => registry.hookContract('photoProvider', 'searchAll')).toThrow(/no @PluginHook declaration/); + }); + + it('PLUGHOOK-005 providersOf is passed straight through to the runtime', () => { + const { hooks: h, providersOf } = hooks(); + expect(h.providersOf('photoProvider')).toEqual(['p1', 'p2']); + expect(providersOf).toHaveBeenCalledWith('photoProvider'); + }); + + it('PLUGHOOK-006 the read hooks forward their arguments and the acting user', async () => { + const { hooks: h, invokeHook } = hooks(); + const last = (): Invocation => invokeHook.mock.calls.at(-1) as unknown as Invocation; + + await h.searchPhotos('p', 'beach', 3, 60, 7); + expect(last()).toEqual(['p', 'photoProvider', 'search', ['beach', { page: 3, limit: 60 }], 7, 5000]); + await h.getPhoto('p', 'ph-1', 7); + expect(last()).toEqual(['p', 'photoProvider', 'getById', ['ph-1'], 7, 5000]); + await h.calendarName('p', 9); + expect(last()).toEqual(['p', 'calendarSource', 'getName', [], 9, 3000]); + await h.calendarEvents('p', 9, 'a', 'b'); + expect(last()).toEqual(['p', 'calendarSource', 'getEvents', [9, 'a', 'b'], 9, 5000]); + await h.placeDetails('p', 7, 5); + expect(last()).toEqual(['p', 'placeDetailProvider', 'getDetails', [7], 5, 5000]); + await h.tripWarnings('p', 1, 5); + expect(last()).toEqual(['p', 'warningProvider', 'getWarnings', [1], 5, 5000]); + await h.tableContributions('p', 'places', 1, 5); + expect(last()).toEqual(['p', 'tableContributor', 'getContributions', ['places', 1], 5, 5000]); + await h.mapMarkers('p', 1, 5); + expect(last()).toEqual(['p', 'mapMarkerProvider', 'getMarkers', [1], 5, 5000]); + await h.mapLayers('p', 1, 5); + expect(last()).toEqual(['p', 'mapLayerProvider', 'getLayers', [1], 5, 5000]); + await h.daySchedule('p', 1, 5); + expect(last()).toEqual(['p', 'dayScheduleProvider', 'getSchedule', [1], 5, 5000]); + await h.dayTints('p', 1, 5); + expect(last()).toEqual(['p', 'dayTintProvider', 'getDayTints', [1], 5, 5000]); + await h.pdfSections('p', 1, 5); + expect(last()).toEqual(['p', 'pdfSectionProvider', 'getSections', [1], 5, 5000]); + await h.atlasLayers('p', 5); + expect(last()).toEqual(['p', 'atlasLayerProvider', 'getLayers', [], 5, 5000]); + await h.journalRows('p', 7, 5); + expect(last()).toEqual(['p', 'journalEntryProvider', 'getRows', [7], 5, 5000]); + await h.tripCards('p', [1, 2], 5); + expect(last()).toEqual(['p', 'tripCardProvider', 'getCards', [[1, 2]], 5, 5000]); + }); + + it('PLUGHOOK-007 a route solve gets four times the budget of a plain read', async () => { + const { hooks: h, invokeHook } = hooks(); + const request = { tripId: 1, dayId: 7, profile: 'ev', waypoints: [] }; + await h.route('p', request, 5); + expect(invokeHook).toHaveBeenCalledWith('p', 'routeProvider', 'getRoute', [request], 5, 20_000); + }); + + it('PLUGHOOK-008 both notification-channel calls run with NO acting user', async () => { + // A notification is host-initiated for an arbitrary recipient, so the hook is + // handed that recipient's own decrypted config rather than the right to read + // anything as them. A bound acting user here would be exactly that right. + const { hooks: h, invokeHook } = hooks(); + const message = { event: 'trip_reminder', title: 't', body: 'b' }; + await h.sendNotification('p', message, { token: 'x' }); + expect(invokeHook).toHaveBeenCalledWith('p', 'notificationChannel', 'send', [message, { token: 'x' }], undefined, 8000); + await h.testNotification('p', { token: 'x' }); + expect(invokeHook).toHaveBeenCalledWith('p', 'notificationChannel', 'test', [{ token: 'x' }], undefined, 8000); + }); + + it('PLUGHOOK-009 the class is listed in its module providers', () => { + // 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); + }); +}); diff --git a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts deleted file mode 100644 index 899dce03e..000000000 --- a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts +++ /dev/null @@ -1,588 +0,0 @@ -/** - * The production wiring (PluginHostDepsFactory) that connects a plugin's - * capability host to the real privileged modules (#plugins, M1). Verifies the - * per-plugin data db is cached, a granted db:own call works through the wired - * host, and trip broadcasts are force-namespaced to plugin:{id}:{event}. - * DI-native domains (budget/exchange-rates/reservations/tags/categories/todo/ - * packing/day-notes/days/assignments/oauth/llm-config/files/collab/vacay/ - * permissions/trips/places/collections/atlas/notifications) - * are constructor-injected stubs; legacy services/* domains stay path-mocked - * until their own DI migration lands. - */ -import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - -const { broadcast, broadcastToUser } = vi.hoisted(() => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -// A real in-memory core db so the metadata deps (inline SQL) and metaEntityTrip -// resolution run for real; trip 1 is owned by user 5. canAccessTrip is stubbed so -// user 5 (owner) can access trip 1 and user 6 cannot. -vi.mock('../../../src/db/database', () => { - const Database = require('better-sqlite3'); - const d = new Database(':memory:'); - d.exec(` - CREATE TABLE trips (id INTEGER PRIMARY KEY, user_id INTEGER); - CREATE TABLE places (id INTEGER PRIMARY KEY, trip_id INTEGER); - CREATE TABLE days (id INTEGER PRIMARY KEY, trip_id INTEGER); - CREATE TABLE users (id INTEGER PRIMARY KEY, role TEXT, username TEXT, display_name TEXT, avatar TEXT, email TEXT); - CREATE TABLE trip_members (trip_id INTEGER, user_id INTEGER); - CREATE TABLE plugin_entity_metadata (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, entity_type TEXT, entity_id INTEGER, key TEXT, value TEXT, updated_at TEXT, UNIQUE(plugin_id, entity_type, entity_id, key)); - CREATE TABLE packing_items (id INTEGER PRIMARY KEY, trip_id INTEGER, is_private INTEGER, owner_id INTEGER); - CREATE TABLE plugin_capability_audit (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, acting_user_id INTEGER, method TEXT, resource TEXT, code TEXT, ts TEXT, prev_hash TEXT, hash TEXT); - CREATE TABLE plugin_scheduled_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT NOT NULL, name TEXT NOT NULL, due_at INTEGER NOT NULL, payload TEXT NOT NULL DEFAULT 'null', every_ms INTEGER, created_at TEXT DEFAULT (datetime('now')), UNIQUE(plugin_id, name)); - `); - d.prepare('INSERT INTO trips (id, user_id) VALUES (1, 5)').run(); - d.prepare('INSERT INTO packing_items (id, trip_id, is_private, owner_id) VALUES (70, 1, 0, 5)').run(); // public before an update - d.prepare('INSERT INTO packing_items (id, trip_id, is_private, owner_id) VALUES (71, 1, 1, 5)').run(); // private before an update - d.prepare('INSERT INTO places (id, trip_id) VALUES (7, 1)').run(); - d.prepare('INSERT INTO days (id, trip_id) VALUES (3, 1)').run(); - d.prepare('INSERT INTO users (id, role) VALUES (5, ?)').run('trip_owner'); - d.prepare('INSERT INTO users (id, role) VALUES (6, ?)').run('user'); - d.prepare("INSERT INTO users (id, role, email) VALUES (77, 'user', 'demo@trek.app')").run(); // a demo-account member for the DEMO_MODE upload guard - d.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (1, 6)').run(); // user 6 shares trip 1 with owner 5 - d.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (1, 77)').run(); - return { db: d, canAccessTrip: (tripId: number, userId: number) => (tripId === 1 && (userId === 5 || userId === 6 || userId === 77) ? { id: 1, user_id: 5 } : undefined) }; -}); -vi.mock('../../../src/websocket', () => ({ broadcast, broadcastToUser })); -// Addon gate — flip per test to exercise the "addon disabled" branch of the reads. -const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true as boolean) })); -// DI-native services are constructor-injected into PluginHostDepsFactory below — -// stub instances instead of path mocks (same behaviors as before the DI move). -const budgetStub = { - async create(tid: string, input: Record) { return { id: 1, trip_id: Number(tid), ...input }; }, - async update(id: string, tid: string, input: Record) { - return id === '404' ? null : { id: Number(id), trip_id: Number(tid), ...input }; - }, - remove(id: string, _tid: string) { return id !== '404'; }, - // The listCostsForTrip/listCostsForUser closures inject this since the budget - // fold (was the legacy listBudgetItems path import). - listBudgetItems: vi.fn(() => []), -} as unknown as BudgetService; - -// Edit permission — flip per test to exercise the gates (constructor-injected -// stub since the permissions DI migration). -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const checkPermission = vi.fn((..._a: any[]) => true as boolean); -const permissionsStub = { checkPermission } as unknown as PermissionsService; - -// Trips are a constructor-injected stub since the trip fold (same behaviors as -// the old services/tripService path mock). The throws use the REAL error -// classes from trips.service so the factory's instanceof mapping keeps -// matching — see the imports below the mocks. -// Exchange rates are a constructor-injected stub since the budget-domain fold -// (was a path mock of the deleted services/exchangeRateService — same behavior). -const exchangeRatesStub = { - getRates: vi.fn(async (base: string) => ({ [base]: 1, USD: 1.08, GBP: 0.85 })), -} as unknown as ExchangeRatesService; -// Places are a constructor-injected stub since the place fold (same behaviors -// as the old services/placeService path mock, mapped onto the PlacesService -// method names). `remove` is asserted by invocation order against the journey -// hook, so it stays a vi.fn. -const removePlaceStub = vi.fn((_tid: string, pid: string) => pid !== '99'); -const placesStub = { - create: vi.fn((tid: string, body: Record) => ({ id: 10, trip_id: Number(tid), ...body })), - update: vi.fn((_tid: string, pid: string) => (pid === '99' ? null : { id: Number(pid) })), - remove: removePlaceStub, - // Trip-scoped read the delete path uses to reject a foreign id before the - // journey hook runs — place 99 belongs to another trip. - get: vi.fn((_tid: string, pid: string) => (pid === '99' ? null : { id: Number(pid) })), - // trips.service (loaded for real since the trip fold) injects this for its - // offline bundle — the stub must carry every method it calls. - list: vi.fn(() => []), -} as unknown as PlacesService; -// Days are a constructor-injected stub (same behaviors as the old path mock, -// mapped onto the DaysService method names). -const daysStub = { - create: vi.fn((tid: number) => ({ id: 20, trip_id: tid, assignments: [] })), - getDay: vi.fn((id: number) => (id === 99 ? undefined : { id, title: null })), - update: vi.fn((id: number) => ({ id, assignments: [] })), - remove: vi.fn(), - list: vi.fn((tid: number) => ({ days: [{ id: 3, trip_id: Number(tid), day_number: 1, assignments: [], notes_items: [] }] })), - listAccommodations: vi.fn((tid: number) => [{ id: 11, trip_id: Number(tid), place_name: 'Ryokan' }]), - // place 999 / day 88 don't belong to the trip - validateAccommodationRefs: vi.fn((_tid: number, placeId?: number, startDayId?: number, endDayId?: number) => { - const errors: { field: string; message: string }[] = []; - if (placeId === 999) errors.push({ field: 'place_id', message: 'Place not found' }); - if (startDayId === 88 || endDayId === 88) errors.push({ field: 'start_day_id', message: 'Start day not found' }); - return errors; - }), - createAccommodation: vi.fn((tid: number, data: Record) => ({ id: 60, trip_id: Number(tid), ...data })), - getAccommodation: vi.fn((id: number) => (Number(id) === 404 ? undefined : { id: Number(id), place_id: 7, start_day_id: 3, end_day_id: 4 })), - updateAccommodation: vi.fn((id: number, _existing: unknown, fields: Record) => ({ id: Number(id), ...fields })), - deleteAccommodation: vi.fn((id: number) => (Number(id) === 61 ? { linkedReservationId: 40, deletedBudgetItemId: 9 } : { linkedReservationId: null, deletedBudgetItemId: null })), -} as unknown as DaysService; -// Assignments is a constructor-injected stub (same behaviors as the old path mock). -const assignmentsStub = { - createAssignment: vi.fn((dayId: number, placeId: number, notes: string | null) => ({ id: 30, day_id: dayId, place_id: placeId, notes })), - deleteAssignment: vi.fn(), - dayExists: vi.fn((dayId: number) => dayId === 3), - placeExists: vi.fn((placeId: number) => placeId === 7), - getAssignmentForTrip: vi.fn((id: number) => (id === 99 ? undefined : { id, day_id: 3 })), - reconcile: vi.fn(), -} as unknown as AssignmentsService; -// Packing is a constructor-injected stub (same behaviors as the old path mock). -const packingStub = { - listItems: vi.fn((tid: number | string, userId?: number) => [{ id: 1, trip_id: tid, name: 'Socks', _uid: userId }]), - // Return the item with the #858 privacy fields the host deps scope on. - createItem: vi.fn((tid: number | string, input: { name: string; visibility?: string; recipient_ids?: number[] }, ownerId?: number) => { - if (input.visibility === 'personal') return { id: 70, trip_id: Number(tid), name: input.name, is_private: 1, owner_id: ownerId, recipients: [] }; - if (input.visibility === 'shared') return { id: 70, trip_id: Number(tid), name: input.name, is_private: 1, owner_id: ownerId, recipients: (input.recipient_ids || []).map((id) => ({ user_id: id })) }; - return { id: 70, trip_id: Number(tid), name: input.name, is_private: 0, owner_id: ownerId }; - }), - // itemId 99 => a stale-write conflict result; otherwise the after-state (is_private per input). - updateItem: vi.fn((tid: number | string, id: string, input: { is_private?: boolean }) => - Number(id) === 99 ? { conflict: true, server: { id: 99 } } : { id: Number(id), trip_id: Number(tid), is_private: input.is_private ? 1 : 0, owner_id: 5 }, - ), - // The raw deleted row (owner-only for a private item, no recipients — #858). - deleteItem: vi.fn((_tid: number | string, id: string) => (Number(id) === 404 ? null : Number(id) === 71 ? { id: 71, is_private: 1, owner_id: 5 } : { id: Number(id), is_private: 0 })), - // Mirrors the packing_items rows seeded above: 70 = public, 71 = private (was - // the inline packingItemPrivacy SQL before the DI move). - getItemPrivacy: vi.fn((tid: number | string, id: number | string) => - Number(tid) === 1 && Number(id) === 70 ? { is_private: 0, owner_id: 5 } - : Number(tid) === 1 && Number(id) === 71 ? { is_private: 1, owner_id: 5 } - : undefined), - listBags: vi.fn((tid: number | string) => [{ id: 80, trip_id: Number(tid), name: 'Backpack' }]), - createBag: vi.fn((tid: number | string, data: { name: string }) => ({ id: 80, trip_id: Number(tid), name: data.name })), - updateBag: vi.fn((_tid: number | string, bagId: string) => (Number(bagId) === 404 ? null : { id: Number(bagId), name: 'Renamed' })), - deleteBag: vi.fn((_tid: number | string, bagId: string) => Number(bagId) !== 404), - setBagMembers: vi.fn((_tid: number | string, bagId: string, userIds: number[]) => (Number(bagId) === 404 ? null : userIds.map((u) => ({ user_id: u })))), -} as unknown as PackingService; -vi.mock('../../../src/nest/common/conflictResult', () => ({ isUpdateConflict: (r: unknown) => !!(r as { conflict?: boolean })?.conflict })); -vi.mock('../../../src/nest/weather/weather.impl', () => ({ getWeather: vi.fn(async (lat: string, lng: string) => ({ lat, lng, temp: 20 })) })); -const categoriesStub = { list: vi.fn(() => [{ id: 1, name: 'Food' }]) } as unknown as CategoriesService; -const tagsStub = { - list: vi.fn((uid: number) => [{ id: 1, user_id: uid, name: 'work' }]), - create: vi.fn((uid: number, name: string, color?: string) => ({ id: 9, user_id: uid, name, color })), - getByIdAndUser: vi.fn((tagId: number, _uid: number) => (Number(tagId) === 404 ? undefined : { id: Number(tagId) })), - update: vi.fn((tagId: number, name?: string) => ({ id: Number(tagId), name })), - remove: vi.fn(), -} as unknown as TagsService; -const todoStub = { - listItems: vi.fn((tid: number) => [{ id: 1, trip_id: Number(tid), name: 'Pack' }]), - createItem: vi.fn((tid: number, data: { name: string }) => ({ id: 90, trip_id: Number(tid), name: data.name })), - updateItem: vi.fn((_tid: number, id: string) => (Number(id) === 404 ? null : { id: Number(id), name: 'Done' })), - deleteItem: vi.fn((_tid: number, id: string) => Number(id) !== 404), -} as unknown as TodoService; -const { testFilesDir } = vi.hoisted(() => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const osm = require('node:os') as typeof import('node:os'); - // eslint-disable-next-line @typescript-eslint/no-require-imports - const pm = require('node:path') as typeof import('node:path'); - return { testFilesDir: pm.join(osm.tmpdir(), 'trek-crh-files-test') }; -}); -// The load-time constants the factory imports from files.constants — mocked so -// createTripFile's mkdirSync/writeFileSync land in the tmpdir, never in the -// real server/uploads/files (same redirection the old fileService path mock did). -vi.mock('../../../src/nest/files/files.constants', () => ({ - filesDir: testFilesDir, - BLOCKED_EXTENSIONS: ['.exe', '.bat', '.sh'], -})); -// Files: injected stub since the fileService DI migration (same behaviors as -// the old path mock). -const filesStub = { - listFiles: vi.fn((tid: number, trash: boolean) => [{ id: 2, trip_id: tid, trash }]), - createFile: vi.fn((tid: number, file: { filename: string; originalname: string; size: number }, uploadedBy: number) => ({ id: 130, trip_id: Number(tid), ...file, uploaded_by: uploadedBy })), - createFileLink: vi.fn(() => [{ file_id: 130 }]), - getFileById: vi.fn((id: number) => (Number(id) === 404 ? undefined : Number(id) === 500 ? { id: 500, filename: 'huge.mp4', original_name: 'huge.mp4', mime_type: 'video/mp4', file_size: 400 * 1024 * 1024, deleted_at: null } : { id: Number(id), filename: 'visa.pdf', original_name: 'visa.pdf', mime_type: 'application/pdf', file_size: 3, deleted_at: null, description: 'old', place_id: null, reservation_id: null })), - resolveFilePath: vi.fn((filename: string) => ({ resolved: `${testFilesDir}/${filename}`, safe: filename !== 'evil' })), - updateFile: vi.fn((id: number, _cur: unknown, updates: unknown) => ({ id: Number(id), ...(updates as object) })), - softDeleteFile: vi.fn(), - // reservation_id/place_id 999 = belongs to another trip - findForeignLinkTarget: vi.fn((_tid: number, opts: { reservation_id?: number | null; place_id?: number | null }) => - (Number(opts.reservation_id) === 999 ? 'reservation_id' : Number(opts.place_id) === 999 ? 'place_id' : null)), -} as unknown as FilesService; -// Collab: injected stub since the collabService DI migration (same behaviors -// as the old path mock). -const collabStub = { - listNotes: vi.fn((tid: number) => [{ id: 1, trip_id: Number(tid), title: 'Note' }]), - listPolls: vi.fn((tid: number) => [{ id: 2, trip_id: Number(tid), question: 'Q?' }]), - listMessages: vi.fn((tid: number, before?: number) => [{ id: 3, trip_id: Number(tid), text: 'hi', _before: before ?? null }]), - createNote: vi.fn((tid: number, uid: number, data: { title: string }) => ({ id: 140, trip_id: Number(tid), created_by: uid, title: data.title })), - createPoll: vi.fn((tid: number, _uid: number, data: { question: string }) => ({ id: 141, trip_id: Number(tid), question: data.question })), - votePoll: vi.fn((_tid: number, pollId: number, _uid: number, optionIndex: number) => - (optionIndex > 5 ? { error: 'Invalid option' } : { poll: { id: Number(pollId), votes: 1 } })), - createMessage: vi.fn((tid: number, uid: number, text: string) => - (text === 'toolong' ? { error: 'Message too long' } : { message: { id: 142, trip_id: Number(tid), user_id: uid, text } })), -} as unknown as CollabService; -// Injected stub since the membership fold (was a path mock of services/tripMembership). -const membershipStub = { - joinTripAsMember: vi.fn((tripId: number, userId: number) => ({ joined: userId !== 5, tripId })), // owner add = no-op -} as unknown as TripMembershipService; -// Notifications are a constructor-injected stub since the notifications fold -// (same behavior as the old services/notificationService path mock). -const notifySend = vi.fn(async () => undefined); -const notificationsStub = { send: notifySend } as unknown as NotificationsService; -// userId 7 = no provider configured; everyone else resolves to a stub config. -// Constructor-injected stub since the resolver became DI-native (settings migration). -const llmConfigStub = { - resolve: vi.fn((uid: number) => (uid === 7 ? null : { provider: 'openai', model: 'gpt-x', baseUrl: undefined, apiKey: 'sekret' })), -} as unknown as LlmConfigResolver; -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 })) })); -// The per-user settings read the runtime deps delegate to (still a path mock — -// readUserSettingDecrypted is a bare function export, not an injected service). -vi.mock('../../../src/nest/plugins/plugins.service', () => ({ readUserSettingDecrypted: vi.fn((_pid: string, uid: number, key: string) => (uid === 5 && key === 'apiKey' ? 'k-5' : undefined)) })); -const oauthStub = { async getAccessToken(_pid: string, uid: number) { return uid === 5 ? 'tok-5' : null; } } as unknown as PluginOAuthService; -// Reservations: injected stub so the reservation deps' side-effect branches -// (accommodation / budget-sync / notify) run. -const reservationsStub = { - create(tid: string, input: Record) { - return { reservation: { id: 40, trip_id: Number(tid), ...input }, accommodationCreated: input.title === 'Stay' }; - }, - getReservation(id: string) { return id === '404' ? undefined : { id: Number(id), title: 'Old', type: 'flight' }; }, - update(id: string, tid: string, input: Record) { - return { reservation: { id: Number(id), trip_id: Number(tid), ...input }, accommodationChanged: input.title === 'New' }; - }, - remove(id: string) { - if (id === '404') return { deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null }; - return { deleted: { id: Number(id), title: 'Gone', type: 'hotel', accommodation_id: 7 }, accommodationDeleted: true, deletedBudgetItemId: 9 }; - }, - list(tid: string) { return [{ id: 1, trip_id: Number(tid), title: 'Flight' }]; }, - syncBudgetOnCreate() {}, - syncBudgetOnUpdate() {}, - notifyBookingChange() {}, -} as unknown as ReservationsService; -// Journey is a constructor-injected stub since the journey fold (same behaviors -// as the old services/journeyService path mock, keyed by the service methods). -const { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } = vi.hoisted(() => ({ - onPlaceCreated: vi.fn(), onPlaceUpdated: vi.fn(), onPlaceDeleted: vi.fn(), -})); -const journeyStub = { - // The skeleton hooks the place writes fire (#1705). - onPlaceCreated, onPlaceUpdated, onPlaceDeleted, - listJourneys: vi.fn((uid: number) => [{ id: 1, owner: uid }]), - // journeyId 88 = no access (listEntries self-gates to null); else returns entries - listEntries: vi.fn((journeyId: number, uid: number) => (journeyId === 88 ? null : [{ id: 10, journey_id: journeyId, author_id: uid }])), - // journeyId 99 = not editable by the acting user (canEdit inside returns null/false) - createEntry: vi.fn((journeyId: number, uid: number, data: unknown) => (journeyId === 99 ? null : { id: 120, journey_id: journeyId, created_by: uid, ...(data as object) })), - updateEntry: vi.fn((entryId: number, _uid: number, data: unknown) => (entryId === 99 ? null : { id: entryId, ...(data as object) })), - deleteEntry: vi.fn((entryId: number) => entryId !== 99), - createJourney: vi.fn((uid: number, data: unknown) => ({ id: 130, owner: uid, ...(data as object) })), - deleteJourney: vi.fn((journeyId: number) => journeyId !== 99), -} as unknown as JourneyDomainService; -// Atlas is a constructor-injected stub since the atlas fold (same behaviors as -// the old services/atlasService path mock, keyed by the service method names). -const atlasStub = { - listVisitedCountries: vi.fn(() => [{ country_code: 'JP' }]), - listManuallyVisitedRegions: vi.fn(() => [{ region_code: 'JP-13' }]), - bucketList: vi.fn((uid: number) => [{ id: 5, user_id: uid, name: 'Kyoto' }]), - markCountry: vi.fn(), - unmarkCountry: vi.fn(), - markRegion: vi.fn(), - unmarkRegion: vi.fn(), - createBucketItem: vi.fn((uid: number, data: { name: string }) => ({ id: 110, user_id: uid, name: data.name })), - deleteBucketItem: vi.fn((_uid: number, itemId: number) => Number(itemId) !== 404), -} as unknown as AtlasService; -// Collections is a constructor-injected stub since the collections fold (same -// behaviors as the old services/collectionsService path mock — the service -// throws status-tagged errors the factory maps onto the RPC error classes). -const collectionsHttpError = (status: number, message: string) => { const e = new Error(message) as Error & { status: number }; e.status = status; throw e; }; -const collectionsStub = { - listCollections: vi.fn((uid: number) => ({ collections: [{ id: 1, owner: uid }] })), - getCollection: vi.fn((uid: number, id: number) => ({ id, owner: uid, places: [] })), - createCollection: vi.fn((uid: number, body: unknown) => ({ id: 100, owner_id: uid, ...(body as object) })), - // id 99 = viewer-only (403); id 404 = invisible (404) — the service throws status-tagged errors - updateCollection: vi.fn((_uid: number, id: number, body: unknown) => { if (id === 99) collectionsHttpError(403, 'read-only'); if (id === 404) collectionsHttpError(404, 'Collection not found'); return { id, ...(body as object) }; }), - savePlace: vi.fn((uid: number, body: unknown) => ({ id: 101, saved_by: uid, ...(body as object) })), - copyToTrip: vi.fn(() => ({ copied: 2, skipped: [] })), - deletePlace: vi.fn((_uid: number, placeId: number) => { if (placeId === 404) collectionsHttpError(404, 'Collection not found'); }), -} as unknown as CollectionsService; -// Day notes are a constructor-injected stub (same behaviors as the old path mock). -const dayNotesStub = { - list: vi.fn((dayId: number, tripId: number) => [{ id: 1, day_id: dayId, trip_id: tripId }]), - create: vi.fn((dayId: number, _tripId: number, text: string) => ({ id: 50, day_id: dayId, text })), - getNote: vi.fn((id: number) => (id === 99 ? undefined : { id, text: 'Old' })), - update: vi.fn((id: number, _current: unknown, fields: Record) => ({ id, ...fields })), - remove: vi.fn(), - dayExists: vi.fn((dayId: number) => dayId === 3), -} as unknown as DayNotesService; - -// Vacay is a constructor-injected stub (same behaviors as the old path mock). -const vacayStub = { - getPlanData: vi.fn((uid: number) => ({ plan: { id: 1, owner: uid } })), - getActivePlanId: vi.fn(() => 77), - toggleEntry: vi.fn((uid: number, planId: number) => ({ action: 'added', uid, planId })), - toggleCompanyHoliday: vi.fn((planId: number) => ({ action: 'added', planId })), -} as unknown as VacayService; - -import { PluginHostDepsFactory, type PluginCallRouter } from '../../../src/nest/plugins/host/plugin-host-deps.factory'; -import { getPluginDataDb, closePluginDataDb } from '../../../src/nest/plugins/host/plugin-host-state'; -import { db as mockDb } from '../../../src/db/database'; -import type { BudgetService } from '../../../src/nest/budget/budget.service'; -import type { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service'; -import type { ReservationsService } from '../../../src/nest/reservations/reservations.service'; -import type { TagsService } from '../../../src/nest/tags/tags.service'; -import type { CategoriesService } from '../../../src/nest/categories/categories.service'; -import type { TodoService } from '../../../src/nest/todo/todo.service'; -import type { PackingService } from '../../../src/nest/packing/packing.service'; -import type { DayNotesService } from '../../../src/nest/days/day-notes.service'; -import type { DaysService } from '../../../src/nest/days/days.service'; -import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service'; -import type { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; -import type { PluginOAuthService } from '../../../src/nest/plugins/plugin-oauth.service'; -import type { LlmConfigResolver } from '../../../src/nest/llm-parse/llm-config.resolver'; -import type { FilesService } from '../../../src/nest/files/files.service'; -import type { CollabService } from '../../../src/nest/collab/collab.service'; -import type { VacayService } from '../../../src/nest/vacay/vacay.service'; -import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; -import type { PlacesService } from '../../../src/nest/places/places.service'; -import type { CollectionsService } from '../../../src/nest/collections/collections.service'; -import type { AtlasService } from '../../../src/nest/atlas/atlas.service'; -import type { NotificationsService } from '../../../src/nest/notifications/notifications.service'; -import type { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service'; -import { DatabaseService } from '../../../src/nest/database/database.service'; -import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; -import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service'; - -// The factory under test, wired exactly like PluginsModule does — but with the -// DI-native domain services replaced by the stubs above. The shim keeps the -// ~45 historical call sites unchanged and supplies a default no-op router. -const addonsStub = { isAddonEnabled } as unknown as import('../../../src/nest/addons/addons.service').AddonsService; -const tripsStub = { - updateTrip: (tripId: number, _u: number, input: Record) => { - if (input.title === 'boom') throw new ValidationError('bad dates'); - if (input.title === 'gone') throw new NotFoundError('no trip'); - if (input.title === 'crash') throw new Error('unexpected'); - return { updatedTrip: { id: tripId, ...input } }; - }, - create: (userId: number, input: Record) => { - if (input.title === 'boom') throw new ValidationError('bad dates'); - return { trip: { id: 99, user_id: userId, ...input }, tripId: 99, reminderDays: 3 }; - }, - list: () => [{ id: 1 }], - removeMember: vi.fn(), -} as unknown as import('../../../src/nest/trips/trips.service').TripsService; -const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub, collabStub, vacayStub, daysStub, permissionsStub, exchangeRatesStub, addonsStub, new RealtimeService(), tripsStub, placesStub, collectionsStub, atlasStub, notificationsStub, membershipStub, journeyStub); -const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} }; -const createRealRpcHost = (id: string, granted: ReadonlySet, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router); - -let tmp: string; -beforeAll(() => { - tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-crh-')); - process.env.TREK_PLUGINS_DATA_DIR = tmp; -}); -afterAll(() => { - closePluginDataDb('wired'); - delete process.env.TREK_PLUGINS_DATA_DIR; - fs.rmSync(tmp, { recursive: true, force: true }); -}); - -describe('plugin host wiring (PluginHostDepsFactory)', () => { - it('caches one data db per plugin id', () => { - const a = getPluginDataDb('wired'); - const b = getPluginDataDb('wired'); - expect(a).toBe(b); - }); - - it('a granted db:own call runs against the plugin db, and a trip broadcast is namespaced', async () => { - const host = createRealRpcHost('wired', new Set(['db:own', 'ws:broadcast:trip'])); - const migrated = await host.dispatch({ k: 'req', id: '1', method: 'db.migrate', params: { id: '001', sql: 'CREATE TABLE t (v TEXT)' } }); - expect(migrated.ok).toBe(true); - - // acting user 5 is a member of trip 1 (mocked canAccessTrip) → broadcast allowed + namespaced - await host.dispatch({ k: 'req', id: '2', method: 'ws.broadcastToTrip', params: { tripId: 1, event: 'ping', data: { a: 1 } } }, 5); - expect(broadcast).toHaveBeenCalledWith(1, 'plugin:wired:ping', { a: 1 }); - - const bcastUser = createRealRpcHost('wired', new Set(['ws:broadcast:user'])); - // a per-user broadcast may only target the acting user themselves - await bcastUser.dispatch({ k: 'req', id: '3', method: 'ws.broadcastToUser', params: { userId: 5, event: 'hi', data: {} } }, 5); - expect(broadcastToUser).toHaveBeenCalledWith(5, { type: 'plugin:wired', event: 'hi' }); - }); - - it('db.tx dispatches an atomic batch through to the plugin db under db:own', async () => { - const host = createRealRpcHost('wtx', new Set(['db:own'])); - await host.dispatch({ k: 'req', id: '1', method: 'db.migrate', params: { id: '001', sql: 'CREATE TABLE kv (k TEXT, n INTEGER)' } }); - const res = await host.dispatch({ k: 'req', id: '2', method: 'db.tx', params: { ops: [ - { sql: 'INSERT INTO kv (k, n) VALUES (?, ?)', args: ['a', 1] }, - { sql: 'SELECT n FROM kv WHERE k = ?', args: ['a'] }, - ] } }); - expect(res.ok).toBe(true); - expect((res as { result: { results: unknown[] } }).result.results).toEqual([{ changes: 1 }, { rows: [{ n: 1 }] }]); - // malformed ops → BAD_PARAMS, and no db:own grant → not reachable - const bad = await host.dispatch({ k: 'req', id: '3', method: 'db.tx', params: { ops: [{ sql: 42 }] } }); - expect(bad.ok).toBe(false); - const noGrant = createRealRpcHost('wtx2', new Set()); - expect((await noGrant.dispatch({ k: 'req', id: '4', method: 'db.tx', params: { ops: [] } })).ok).toBe(false); - closePluginDataDb('wtx'); - closePluginDataDb('wtx2'); - }); - - it('closePluginDataDb closes and drops the cached handle', () => { - getPluginDataDb('transient'); - closePluginDataDb('transient'); - // a fresh get after close returns a NEW instance (cache was cleared) - const a = getPluginDataDb('transient'); - closePluginDataDb('transient'); - const b = getPluginDataDb('transient'); - expect(a).not.toBe(b); - closePluginDataDb('transient'); - }); - - it('getPluginDataDb recreates a handle that was closed WITHOUT eviction (terminal-failure dispose)', () => { - const a = getPluginDataDb('closedcache'); - expect(a.isOpen()).toBe(true); - // simulate the supervisor's dispose() path: close the handle but leave it cached - a.close(); - expect(a.isOpen()).toBe(false); - const b = getPluginDataDb('closedcache'); // must NOT return the dead handle - expect(b).not.toBe(a); - expect(b.isOpen()).toBe(true); - expect(b.exec('CREATE TABLE t (v)').changes).toBe(0); // usable again - closePluginDataDb('closedcache'); - }); -}); - -describe('host-deps factory — planner write + metadata deps', () => { - const host = (...perms: string[]) => createRealRpcHost('writer', new Set(perms)); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const call = async (h: ReturnType, method: string, params: Record, uid = 5): Promise => - h.dispatch({ k: 'req', id: 'x', method, params }, uid); - beforeEach(() => { checkPermission.mockReset(); checkPermission.mockReturnValue(true); }); - afterAll(() => closePluginDataDb('writer')); - - // places, days and itinerary left this factory with the decorator migration; the - // cases now run in tests/unit/places/ and tests/unit/days/. - // #1705: a place write from a plugin has to update a linked journey the same way - // the REST route does, otherwise the journey shows the old title (or an entry for - // a place that no longer exists) until it is reloaded. - // A journey link that blows up must not turn a successful place write into an - // RPC error — every other caller of these hooks swallows them too. - // #1705: a plugin itinerary write has to reach open sessions exactly like the REST - // route. The delete payload needs the dayId the client reducer evicts by, and both - // directions run the same journey-skeleton reconcile the controller/MCP tool run. - // trips.*, the cross-trip feeds and member management left this factory with the - // decorator migration; the cases now run in tests/unit/trips/trips.rpc.test.ts. - it('metadata: round-trips and enforces the key/value/access limits', async () => { - const h = host('db:meta'); - expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k', value: { a: 1 } })).ok).toBe(true); - expect((await call(h, 'meta.get', { entityType: 'trip', entityId: 1, key: 'k' })).result).toEqual({ a: 1 }); - expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 1, key: 'k', value: 2 })).ok).toBe(true); // upsert path - expect((await call(h, 'meta.list', { entityType: 'place', entityId: 7 })).ok).toBe(true); // place → trip 1 - expect((await call(h, 'meta.delete', { entityType: 'trip', entityId: 1, key: 'k' })).result).toEqual({ deleted: true }); - expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 1, key: 'x'.repeat(300), value: 1 })).error.code).toBe('BAD_PARAMS'); - expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 1, key: 'big', value: 'y'.repeat(70000) })).error.code).toBe('BAD_PARAMS'); - expect((await call(h, 'meta.set', { entityType: 'trip', entityId: 2, key: 'k', value: 1 })).error.code).toBe('RESOURCE_FORBIDDEN'); - }); - - // costs, reservations and accommodations left this factory with the decorator - // migration; the cases now run in tests/unit/budget/ and tests/unit/reservations/. - it('users.getById is scoped to people the acting user shares a trip with', async () => { - const h = host('db:read:users'); - expect((await call(h, 'users.getById', { id: 6 }, 5)).ok).toBe(true); // 5 (owner) + 6 (member) share trip 1 - expect((await call(h, 'users.getById', { id: 999 }, 5)).error.code).toBe('RESOURCE_FORBIDDEN'); - }); -}); - -// The 'reservations, day notes, cross-trip + addon reads' suite is gone: every case in it moved onto the decorators. - -// The packing write deps left this factory with the decorator migration. The #858 -// transitions are asserted in tests/unit/packing/packing.rpc.test.ts, against the -// PackingService methods the REST controller uses too, so there is one copy now. - -// The Wave 1 wiring suite is gone: weather, categories, rates, tags, todos, the -// roster and the packing bags all moved onto the decorators, so there was nothing -// left in it. Their cases live in the domain suites now. - -// The 'Wave 2 wiring' suite is gone: every case in it moved onto the decorators. - -// The 'Wave 3 wiring' suite is gone: every case in it moved onto the decorators. - -describe('host-deps factory — Wave 4 wiring (notify / ai)', () => { - const host = (...perms: string[]) => createRealRpcHost('w4', new Set(perms)) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const call = async (h: ReturnType, method: string, params: Record, uid: number | undefined = 5): Promise => - h.dispatch({ k: 'req', id: 'x', method, params }, uid) - beforeEach(() => { notifySend.mockClear(); llmExtract.mockClear() }) - afterAll(() => closePluginDataDb('w4')) - - it('notify.send delegates to notificationService with the plugin_notification event + forced target', async () => { - const h = host('notify:send') - const r = await call(h, 'notify.send', { input: { title: 'Delay', body: 'AB123 late', scope: 'user', targetId: 5, link: '/trips/1' } }, 5) - expect(r.ok).toBe(true) - expect(notifySend).toHaveBeenCalledWith(expect.objectContaining({ - event: 'plugin_notification', actorId: null, scope: 'user', targetId: 5, - params: expect.objectContaining({ title: 'Delay', body: 'AB123 late', link: '/trips/1' }), - })) - // trip scope for a trip the acting user (owner 5) can access - expect((await call(h, 'notify.send', { input: { title: 't', body: 'b', scope: 'trip', targetId: 1 } }, 5)).ok).toBe(true) - }) - - it('ai.complete/extract run under the resolved provider; unconfigured user → BAD_PARAMS', async () => { - const h = host('ai:invoke') - const c = await call(h, 'ai.complete', { prompt: 'Summarize' }, 5) - expect(c.ok).toBe(true) - expect(c.result).toMatchObject({ text: expect.stringContaining('answer:') }) - expect(llmExtract).toHaveBeenCalled() - const e = await call(h, 'ai.extract', { text: 'AB123 JFK', jsonSchema: { type: 'object' } }, 5) - expect(e.ok).toBe(true) - expect(Array.isArray(e.result.results)).toBe(true) - // user 7 has no provider → the router's aiConfigured() check trips first - expect(((await call(h, 'ai.complete', { prompt: 'hi' }, 7)) as { error: { code: string } }).error.code).toBe('BAD_PARAMS') - }) - - it('scheduler.set upserts by name with caps; cancel removes; recurring floor enforced', async () => { - const h = createRealRpcHost('wsched', new Set(['jobs:run'])); - const c = async (method: string, params: Record): Promise<{ ok: boolean; result?: unknown; error?: { code: string; message: string } }> => - h.dispatch({ k: 'req', id: 'x', method, params }, undefined) as never; - const due = Date.now() + 120_000; - expect((await c('scheduler.set', { name: 'poll', dueAt: due, payload: { a: 1 } })).ok).toBe(true); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const dbAny = mockDb as any; - expect(dbAny.prepare("SELECT COUNT(*) AS n FROM plugin_scheduled_tasks WHERE plugin_id='wsched'").get().n).toBe(1); - // re-set same name = upsert (still 1 row, new due) - expect((await c('scheduler.set', { name: 'poll', dueAt: due + 1000 })).ok).toBe(true); - expect(dbAny.prepare("SELECT COUNT(*) AS n FROM plugin_scheduled_tasks WHERE plugin_id='wsched'").get().n).toBe(1); - // recurring below the 60s floor is refused - expect((await c('scheduler.set', { name: 'fast', dueAt: due, everyMs: 5000 })).error?.code).toBe('BAD_PARAMS'); - // cancel removes it - expect((await c('scheduler.cancel', { name: 'poll' })).result).toMatchObject({ cancelled: true }); - expect(dbAny.prepare("SELECT COUNT(*) AS n FROM plugin_scheduled_tasks WHERE plugin_id='wsched'").get().n).toBe(0); - closePluginDataDb('wsched'); - }) - - it('enforces the daily notify + AI budgets, seeded from today\'s audit rows', async () => { - // Seed the audit with a plugin that has already spent its whole day (default caps - // are 100 notify / 200 ai) so the budget is exhausted on first use. - const today = new Date().toISOString().slice(0, 10) + 'T08:00:00.000Z'; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const dbAny = mockDb as any; - const ins = dbAny.prepare("INSERT INTO plugin_capability_audit (plugin_id, method, code, ts) VALUES (?, ?, 'ok', ?)"); - for (let i = 0; i < 100; i++) ins.run('wbudget', 'notify.send', today); - for (let i = 0; i < 200; i++) ins.run('wbudget', 'ai.complete', today); - const h = createRealRpcHost('wbudget', new Set(['notify:send', 'ai:invoke'])); - const c = async (method: string, params: Record): Promise<{ ok: boolean; error?: { code: string; message: string } }> => - h.dispatch({ k: 'req', id: 'x', method, params }, 5) as never; - const n = await c('notify.send', { input: { title: 't', body: 'b', scope: 'user', targetId: 5 } }); - expect(n.ok).toBe(false); - expect(n.error?.code).toBe('BAD_PARAMS'); - expect(n.error?.message).toMatch(/budget/i); - const a = await c('ai.complete', { prompt: 'hi' }); - expect(a.ok).toBe(false); - expect(a.error?.message).toMatch(/budget/i); - closePluginDataDb('wbudget'); - }) -}) - -describe('host-deps factory — Wave 8 wiring (settings.get / oauth.getToken)', () => { - const host = (...perms: string[]) => createRealRpcHost('w8', new Set(perms)) - // uid is explicit here (no default) — an undefined must stay undefined, not fall back. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const call = async (h: ReturnType, method: string, params: Record, uid: number | undefined): Promise => - h.dispatch({ k: 'req', id: 'x', method, params }, uid) - afterAll(() => closePluginDataDb('w8')) - - it('settings.get returns the acting user\'s decrypted value; userless → undefined', async () => { - const h = host() // no permission needed — the plugin's own settings - expect((await call(h, 'settings.get', { key: 'apiKey' }, 5)).result).toEqual({ value: 'k-5' }) - expect((await call(h, 'settings.get', { key: 'apiKey' }, undefined)).result).toEqual({ value: undefined }) - }) - - it('oauth.getToken returns only the acting user\'s access token, gated by oauth:client', async () => { - const h = host('oauth:client') - expect((await call(h, 'oauth.getToken', {}, 5)).result).toEqual({ accessToken: 'tok-5' }) - expect((await call(h, 'oauth.getToken', {}, 7)).result).toEqual({ accessToken: null }) // not connected - expect((await call(h, 'oauth.getToken', {}, undefined)).result).toEqual({ accessToken: null }) // userless → null (SDK contract) - expect((await call(host(), 'oauth.getToken', {}, 5)).ok).toBe(false) // no grant - }) -}) diff --git a/server/tests/unit/plugins/plugin-photos-calendar.controller.test.ts b/server/tests/unit/plugins/plugin-photos-calendar.controller.test.ts index d437e8918..aca789e61 100644 --- a/server/tests/unit/plugins/plugin-photos-calendar.controller.test.ts +++ b/server/tests/unit/plugins/plugin-photos-calendar.controller.test.ts @@ -11,15 +11,18 @@ 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 type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any; function runtime(providers: string[], invoke: (id: string, fn: string, args: unknown[]) => unknown) { return { providersOf: vi.fn(() => providers), - invokeHook: vi.fn(async (id: string, _hook: string, fn: string, args: unknown[]) => invoke(id, fn, args)), - } as unknown as PluginRuntimeService; + searchPhotos: vi.fn(async (id: string, query: string, page: number, limit: number) => invoke(id, 'search', [query, { page, limit }])), + getPhoto: vi.fn(async (id: string, photoId: string) => invoke(id, 'getById', [photoId])), + calendarName: vi.fn(async (id: string) => invoke(id, 'getName', [])), + calendarEvents: vi.fn(async (id: string, userId: number, start: string, end: string) => invoke(id, 'getEvents', [userId, start, end])), + } as unknown as PluginHooks; } describe('PluginPhotosController (photoProvider hook)', () => { @@ -54,7 +57,7 @@ describe('PluginPhotosController (photoProvider hook)', () => { const c = new PluginPhotosController(rt); const out = (await c.search('beach', '3', req(7))).providers; expect(out.map((p) => p.pluginId)).toEqual(['ok']); - expect(rt.invokeHook).toHaveBeenCalledWith('ok', 'photoProvider', 'search', ['beach', { page: 3, limit: 60 }], 7, 5000); + expect(rt.searchPhotos).toHaveBeenCalledWith('ok', 'beach', 3, 60, 7); }); it('item: returns one normalized photo from a known provider, null for an unknown one', async () => { @@ -81,7 +84,7 @@ describe('PluginCalendarController (calendarSource hook)', () => { expect(out[0].events).toHaveLength(1); expect(out[0].events[0]).toMatchObject({ id: 'e1', title: 'Flight', allDay: false }); // getEvents got the user + the window - expect(rt.invokeHook).toHaveBeenCalledWith('cal', 'calendarSource', 'getEvents', [9, '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z'], 9, 5000); + expect(rt.calendarEvents).toHaveBeenCalledWith('cal', 9, '2026-07-01T00:00:00Z', '2026-08-01T00:00:00Z'); }); it('gates on user + plugins-enabled, and skips a failing source', async () => { diff --git a/server/tests/unit/plugins/plugin-routes.controller.test.ts b/server/tests/unit/plugins/plugin-routes.controller.test.ts index afc172d91..463f19ec9 100644 --- a/server/tests/unit/plugins/plugin-routes.controller.test.ts +++ b/server/tests/unit/plugins/plugin-routes.controller.test.ts @@ -21,15 +21,15 @@ 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 type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any; function controller(invoke: () => unknown, providers = ['ev-plug']) { const runtime = { providersOf: vi.fn(() => providers), - invokeHook: vi.fn(async () => invoke()), - } as unknown as PluginRuntimeService; + route: vi.fn(async () => invoke()), + } as unknown as PluginHooks; return { c: new PluginRoutesController(runtime, new DatabaseService(dbConn)), runtime }; } const wp = (n = 3) => Array.from({ length: n }, (_, i) => ({ lat: 48 + i * 0.1, lng: 2 + i * 0.1 })); @@ -74,7 +74,7 @@ describe('PluginRoutesController', () => { for (const b of cases) { const { c, runtime } = controller(() => goodRoute()); expect((await c.route('ev-plug', 'ev', b as never, req(5))).route).toBeNull(); - expect(runtime.invokeHook).not.toHaveBeenCalled(); + expect(runtime.route).not.toHaveBeenCalled(); } }); @@ -97,11 +97,7 @@ describe('PluginRoutesController', () => { expect(route!.profile).toBe('ev'); expect(route!.legs[0].note).toBe('charge to 80%'); expect(route!.viaPoints[0]).toMatchObject({ label: 'Fastned', tone: 'success', dwellSeconds: 1501 }); - expect(runtime.invokeHook).toHaveBeenCalledWith( - 'ev-plug', 'routeProvider', 'getRoute', - [{ tripId: 1, dayId: 7, profile: 'ev', waypoints: wp() }], - 5, 20_000, - ); + expect(runtime.route).toHaveBeenCalledWith('ev-plug', { tripId: 1, dayId: 7, profile: 'ev', waypoints: wp() }, 5); }); it('rejects a malformed route whole: wrong leg count, bad vertex, negative numbers', async () => { diff --git a/server/tests/unit/plugins/rpc-coverage.test.ts b/server/tests/unit/plugins/rpc-coverage.test.ts index 026b009fd..f7a5cf5c9 100644 --- a/server/tests/unit/plugins/rpc-coverage.test.ts +++ b/server/tests/unit/plugins/rpc-coverage.test.ts @@ -1,17 +1,18 @@ /** * The coverage ledger: the anchor for the whole decorator rollout. * - * Every wire method is owned exactly once, either by the legacy router blocks in - * rpc-host.ts or by a decorated *.rpc.ts class in the registry. Both sets are derived - * by construction, so no list is maintained by hand and the test cannot drift. + * Every wire method is owned by exactly one decorated *.rpc.ts class in the registry. + * Both sets are derived by construction, so no list is maintained by hand and the test + * cannot drift. * - * It fails the moment a migration PR moves a method and forgets to delete the legacy - * registration, deletes one without adding it, or forgets to list a new *.rpc.ts class - * in allRpcControllers(). + * The rollout is finished, so the legacy set is empty and the ledger's job flips from + * "how far along is the move" to "nothing fell out of it": a method deleted without a + * replacement, or a new *.rpc.ts class that no one listed in allRpcControllers(). */ import { describe, it, expect } from 'vitest'; import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host'; import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing'; +import { PluginRpcRegistryService } from '../../../src/nest/plugins/host/rpc-kit/registry.service'; import { KNOWN_METHODS, KNOWN_PERMISSIONS, @@ -29,130 +30,48 @@ describe('plugin RPC coverage ledger', () => { const host = new PluginRpcHost('probe', new Set(KNOWN_PERMISSIONS), makeDeps(), registry); const all = boundMethods(host); const fromRegistry = registry.methodNames(); - const legacy = new Set([...all].filter((m) => !fromRegistry.has(m))); + const vocabulary = new Set([...KNOWN_METHODS, ...UNCONDITIONAL_METHODS]); - it('RPCLEDGER-001 no method is owned by both the registry and the legacy router', () => { - expect([...fromRegistry].filter((m) => legacy.has(m))).toEqual([]); + it('RPCLEDGER-001 every bound method comes from the registry, none from anywhere else', () => { + expect([...all].filter((m) => !fromRegistry.has(m))).toEqual([]); }); it('RPCLEDGER-002 every registered method is one the wire protocol knows', () => { - const vocabulary = new Set([...KNOWN_METHODS, ...UNCONDITIONAL_METHODS]); expect([...all].filter((m) => !vocabulary.has(m))).toEqual([]); }); it('RPCLEDGER-003 no method in the vocabulary is orphaned', () => { - const vocabulary = new Set([...KNOWN_METHODS, ...UNCONDITIONAL_METHODS]); expect([...vocabulary].filter((m) => !all.has(m))).toEqual([]); }); - it('RPCLEDGER-004 the two owner sets partition the vocabulary exactly', () => { - expect(all).toEqual(new Set([...KNOWN_METHODS, ...UNCONDITIONAL_METHODS])); - expect(fromRegistry.size + legacy.size).toBe(all.size); + it('RPCLEDGER-004 the registry covers the vocabulary exactly', () => { + expect(all).toEqual(vocabulary); + expect(fromRegistry.size).toBe(KNOWN_METHODS.length + UNCONDITIONAL_METHODS.length); }); - it('RPCLEDGER-005 the migration ledger reads as expected for this PR', () => { - // Update these two numbers in every rollout PR. They make the diff state the size - // of the move out loud, which is the number a reviewer wants to see. - const total = KNOWN_METHODS.length + UNCONDITIONAL_METHODS.length; - expect(fromRegistry.size).toBe(93); - expect(legacy.size).toBe(total - 93); + it('RPCLEDGER-005 the surface is the 113 methods the protocol declares', () => { + // Pinned rather than derived: a method DELETED from KNOWN_METHODS together with + // its handler would keep every test above green, and this is the line that makes + // that show up in the diff. + expect(KNOWN_METHODS.length).toBe(110); + expect(UNCONDITIONAL_METHODS.length).toBe(3); + expect(fromRegistry.size).toBe(113); }); - it('RPCLEDGER-006 the migrated methods are exactly the ones this PR claims', () => { - expect([...fromRegistry].sort()).toEqual([ - 'accommodations.create', - 'accommodations.delete', - 'accommodations.update', - 'atlas.bucketList', - 'atlas.createBucketItem', - 'atlas.deleteBucketItem', - 'atlas.markCountry', - 'atlas.markRegion', - 'atlas.unmarkCountry', - 'atlas.unmarkRegion', - 'atlas.visited', - 'categories.list', - 'collab.createMessage', - 'collab.createNote', - 'collab.createPoll', - 'collab.listMessages', - 'collab.listNotes', - 'collab.listPolls', - 'collab.votePoll', - 'collections.copyToTrip', - 'collections.create', - 'collections.deletePlace', - 'collections.get', - 'collections.listMine', - 'collections.savePlace', - 'collections.update', - 'costs.create', - 'costs.delete', - 'costs.getByTrip', - 'costs.listMine', - 'costs.update', - 'daynotes.create', - 'daynotes.delete', - 'daynotes.list', - 'daynotes.update', - 'days.create', - 'days.delete', - 'days.update', - 'files.create', - 'files.createLink', - 'files.getContent', - 'files.list', - 'files.softDelete', - 'files.update', - 'itinerary.assign', - 'itinerary.unassign', - 'journal.createEntry', - 'journal.createJourney', - 'journal.deleteEntry', - 'journal.deleteJourney', - 'journal.getEntries', - 'journal.listMine', - 'journal.updateEntry', - 'packing.create', - 'packing.createBag', - 'packing.delete', - 'packing.deleteBag', - 'packing.list', - 'packing.listBags', - 'packing.setBagMembers', - 'packing.update', - 'packing.updateBag', - 'places.create', - 'places.delete', - 'places.update', - 'rates.get', - 'reservations.create', - 'reservations.delete', - 'reservations.listMine', - 'reservations.update', - 'tags.create', - 'tags.delete', - 'tags.list', - 'tags.update', - 'todos.create', - 'todos.delete', - 'todos.list', - 'todos.update', - 'trips.addMember', - 'trips.create', - 'trips.getAccommodations', - 'trips.getById', - 'trips.getDays', - 'trips.getPlaces', - 'trips.getReservations', - 'trips.listMine', - 'trips.members', - 'trips.removeMember', - 'trips.update', - 'vacay.mine', - 'vacay.toggleCompanyHoliday', - 'vacay.toggleEntry', - 'weather.get', - ]); + it('RPCLEDGER-006 boot-time total coverage is armed in production', () => { + // The rollout's closing move: the registry service now FAILS APP BOOT when a + // KNOWN_METHOD has no decorated owner, instead of letting it surface as a runtime + // PERMISSION_DENIED nobody can explain. + const options = (new PluginRpcRegistryService({} as never, {} as never) as unknown as { + options: { requireTotalCoverage?: boolean }; + }).options; + expect(options.requireTotalCoverage).toBe(true); + }); + + it('RPCLEDGER-007 a registry missing a method fails validate() rather than booting', () => { + // Drops TagsRpc, a method owner. Dropping the last entry would drop PluginHooks + // instead, and the failure would be about hooks rather than methods. + const short = allRpcControllers().slice(1); + expect(() => createTestPluginRegistry(short, { requireTotalCoverage: true })).toThrow(/has no handler/); }); }); diff --git a/server/tests/unit/plugins/rpc-host-inv-strip.test.ts b/server/tests/unit/plugins/rpc-host-inv-strip.test.ts index c9fdeabbb..e6893d832 100644 --- a/server/tests/unit/plugins/rpc-host-inv-strip.test.ts +++ b/server/tests/unit/plugins/rpc-host-inv-strip.test.ts @@ -15,11 +15,15 @@ import { PackingRpc } from '../../../src/nest/packing/packing.rpc'; import type { PackingService } from '../../../src/nest/packing/packing.service'; import type { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { TripsRpc } from '../../../src/nest/trips/trips.rpc'; +import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc'; import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service'; import type { DatabaseService } from '../../../src/nest/database/database.service'; const req = (method: string, params: Record): RpcRequest => ({ k: 'req', id: 'x', method, params }); +/** db.* is the cheapest handler to watch, and it needs no services of its own. */ +const dbRegistry = () => createTestPluginRegistry([new DbRpc()]); + /** trips.getById lives on the decorators now, so the audit cases bind it through them. */ const tripsRegistry = () => { const db = { @@ -35,13 +39,13 @@ const tripsRegistry = () => { describe('dispatch strips the supervisor _inv marker', () => { it('RPCINV-001 the handler never sees _inv', async () => { const deps = makeDeps(); - const host = new PluginRpcHost('p', new Set(['db:own']), deps); + const host = new PluginRpcHost('p', new Set(['db:own']), deps, dbRegistry()); await host.dispatch(req('db.query', { sql: 'SELECT 1', args: [], _inv: 'req-7' })); expect(deps.data.query).toHaveBeenCalledWith('SELECT 1', []); }); it('RPCINV-002 the result is identical with and without the marker', async () => { - const host = new PluginRpcHost('p', new Set(['db:own']), makeDeps()); + const host = new PluginRpcHost('p', new Set(['db:own']), makeDeps(), dbRegistry()); const withMarker = (await host.dispatch(req('db.query', { sql: 'SELECT 1', _inv: 'req-7' }))) as RpcResponse; const without = (await host.dispatch(req('db.query', { sql: 'SELECT 1' }))) as RpcResponse; expect(withMarker.ok).toBe(true); @@ -73,7 +77,7 @@ describe('dispatch strips the supervisor _inv marker', () => { it('RPCINV-005 params without the marker are passed through untouched', async () => { const deps = makeDeps(); - const host = new PluginRpcHost('p', new Set(['db:own']), deps); + const host = new PluginRpcHost('p', new Set(['db:own']), deps, dbRegistry()); await host.dispatch(req('db.exec', { sql: 'INSERT INTO t VALUES (1)', args: [1] })); expect(deps.data.exec).toHaveBeenCalledWith('INSERT INTO t VALUES (1)', [1]); }); diff --git a/server/tests/unit/plugins/rpc-host.test.ts b/server/tests/unit/plugins/rpc-host.test.ts index aa8eb9b3a..0f320454d 100644 --- a/server/tests/unit/plugins/rpc-host.test.ts +++ b/server/tests/unit/plugins/rpc-host.test.ts @@ -1,296 +1,202 @@ /** - * The capability router is the plugin permission boundary (#plugins, M1). These - * tests prove that an ungranted method is never reachable, a granted method - * works, a granted trip read is still membership-checked against the acting - * user, and bad params / unknown methods are rejected — without ever spawning a - * child (the router runs in the host). + * The capability router is the plugin permission boundary (#plugins, M1). + * + * Every handler now lives on a decorated *.rpc.ts class in its own domain, and each of + * those has its own suite. What is left here is the router itself: an ungranted method + * is never reachable, an unknown one is told apart from a denied one, a thrown error + * maps to the right wire code, and the audit sink sees the call — without ever + * spawning a child (the router runs in the host). */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import Database from 'better-sqlite3'; -import { createTables } from '../../../src/db/schema'; -import { PluginRpcHost, type HostDeps } from '../../../src/nest/plugins/host/rpc-host'; +import { describe, it, expect, vi } from 'vitest'; +import { PluginRpcHost, BadParams, ForbiddenResource, type HostDeps } from '../../../src/nest/plugins/host/rpc-host'; +import { PluginController, PluginMethod, PluginOpenMethod } from '../../../src/nest/plugins/host/rpc-kit/decorators'; +import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing'; +import type { PluginRpcContext } from '../../../src/nest/plugins/host/rpc-kit/types'; import type { RpcRequest, RpcResponse, RpcError } from '../../../src/nest/plugins/protocol/envelope'; import { makeDeps } from '../../helpers/rpc-host-deps'; - const req = (method: string, params: Record = {}): RpcRequest => ({ k: 'req', id: 'x', method, params }); const ok = (r: RpcResponse | RpcError): r is RpcResponse => r.ok === true; +const code = (r: RpcResponse | RpcError): string => (r as RpcError).error.code; + +/** + * A throwaway controller over two real wire methods, so the router can be exercised + * without dragging a domain's services in. `tags.list` and `tags.create` are used + * because their permissions differ, which is what the split-grant cases need. + */ +@PluginController() +class ProbeRpc { + constructor(private readonly behaviour: (params: Record, ctx: PluginRpcContext) => unknown) {} + + @PluginMethod('tags.list', { permission: 'db:read:tags' }) + read(params: Record, ctx: PluginRpcContext): unknown { + return this.behaviour(params, ctx); + } + + @PluginMethod('tags.create', { permission: 'db:write:tags' }) + write(params: Record, ctx: PluginRpcContext): unknown { + return this.behaviour(params, ctx); + } + + @PluginOpenMethod('events.emit') + open(params: Record, ctx: PluginRpcContext): unknown { + return this.behaviour(params, ctx); + } +} + +const makeHost = ( + granted: string[], + behaviour: (params: Record, ctx: PluginRpcContext) => unknown = () => ({ done: true }), + deps: HostDeps = makeDeps(), +): PluginRpcHost => + new PluginRpcHost('p', new Set(granted), deps, createTestPluginRegistry([new ProbeRpc(behaviour)])); describe('PluginRpcHost — capability enforcement', () => { - let deps: HostDeps; - beforeEach(() => { deps = makeDeps(); }); - - it('registers only granted methods; an ungranted method is PERMISSION_DENIED', async () => { - const host = new PluginRpcHost('p', new Set(['db:own']), deps); - const denied = await host.dispatch(req('trips.getById', { tripId: 1, asUserId: 42 })); - expect(denied.ok).toBe(false); - expect((denied as RpcError).error.code).toBe('PERMISSION_DENIED'); - expect(deps.canAccessTrip).not.toHaveBeenCalled(); + it('RPCHOST-001 registers only granted methods; an ungranted one is PERMISSION_DENIED', async () => { + const seen = vi.fn(() => ({ done: true })); + const host = makeHost(['db:read:tags'], seen); + expect(ok(await host.dispatch(req('tags.list'), 42))).toBe(true); + expect(code(await host.dispatch(req('tags.create'), 42))).toBe('PERMISSION_DENIED'); + // The refusal happens before the handler, so the domain never runs at all. + expect(seen).toHaveBeenCalledTimes(1); }); - it('a granted db:own method runs against the plugin db', async () => { - const host = new PluginRpcHost('p', new Set(['db:own']), deps); - const res = await host.dispatch(req('db.query', { sql: 'SELECT 1', args: [] })); - expect(ok(res)).toBe(true); - expect((res as RpcResponse).result).toEqual([{ n: 1 }]); - expect(deps.data.query).toHaveBeenCalledWith('SELECT 1', []); - }); - - it('an unknown method is UNKNOWN_METHOD, not PERMISSION_DENIED', async () => { - const host = new PluginRpcHost('p', new Set(['db:own']), deps); + it('RPCHOST-002 an unknown method is UNKNOWN_METHOD, not PERMISSION_DENIED', async () => { + const host = makeHost(['db:read:tags']); const res = await host.dispatch(req('fs.readFile', { path: '/etc/passwd' })); - expect((res as RpcError).error.code).toBe('UNKNOWN_METHOD'); + expect(code(res)).toBe('UNKNOWN_METHOD'); + expect((res as RpcError).error.message).toBe('unknown method fs.readFile'); }); - // The trips, roster and member cases moved to tests/unit/trips/trips.rpc.test.ts. - it('db:read:users returns only the public projection for a visible user', async () => { - const host = new PluginRpcHost('p', new Set(['db:read:users']), deps); - const res = await host.dispatch(req('users.getById', { id: 3 }), 42); - expect(ok(res)).toBe(true); - expect((res as RpcResponse).result).toEqual({ id: 3, username: 'ada', display_name: 'Ada', avatar: null }); - // the SELECT column list is host-controlled — no password/token columns - expect(deps.db.prepare).toHaveBeenCalledWith(expect.stringContaining('id, username, display_name, avatar')); + it('RPCHOST-003 a denied known method names the plugin, so the log says who asked', async () => { + const res = await makeHost([]).dispatch(req('tags.list'), 42); + expect((res as RpcError).error.message).toBe('tags.list requires a permission "p" was not granted'); }); - it('db:read:users is RESOURCE_FORBIDDEN for a user the acting user cannot see (no enumeration)', async () => { - const host = new PluginRpcHost('p', new Set(['db:read:users']), deps); - const forbidden = await host.dispatch(req('users.getById', { id: 999 }), 42); - expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); - // and with no bound acting user (job / forged call), also denied - const noUser = await host.dispatch(req('users.getById', { id: 3 }), undefined); - expect((noUser as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); - }); - - it('ws:broadcast:trip forwards to the (namespaced) broadcaster for a member', async () => { - const host = new PluginRpcHost('p', new Set(['ws:broadcast:trip']), deps); - const res = await host.dispatch(req('ws.broadcastToTrip', { tripId: 1, event: 'ping', data: { a: 1 } }), 42); - expect(ok(res)).toBe(true); - expect(deps.broadcastToTrip).toHaveBeenCalledWith(1, 'ping', { a: 1 }); - }); - - it('ws:broadcast:trip is RESOURCE_FORBIDDEN for a non-member trip (no cross-tenant push)', async () => { - const host = new PluginRpcHost('p', new Set(['ws:broadcast:trip']), deps); - const forbidden = await host.dispatch(req('ws.broadcastToTrip', { tripId: 999, event: 'x', data: {} }), 42); - expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); - expect(deps.broadcastToTrip).not.toHaveBeenCalled(); - // and a broadcast with no bound acting user is denied too - const noUser = await host.dispatch(req('ws.broadcastToTrip', { tripId: 1, event: 'x', data: {} }), undefined); - expect((noUser as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); - }); - - it('bad params are BAD_PARAMS', async () => { - const host = new PluginRpcHost('p', new Set(['db:own']), deps); - const res = await host.dispatch(req('db.query', { args: [] })); - expect((res as RpcError).error.code).toBe('BAD_PARAMS'); - }); - - it('with no permissions, every real method is denied', async () => { - const host = new PluginRpcHost('p', new Set(), deps); - for (const m of ['db.query', 'trips.getById', 'users.getById', 'ws.broadcastToTrip']) { - const res = await host.dispatch(req(m, { tripId: 1, asUserId: 42, id: 3, event: 'x', sql: 'SELECT 1' })); - expect((res as RpcError).error.code).toBe('PERMISSION_DENIED'); + it('RPCHOST-004 with no permissions at all, every permissioned method is denied', async () => { + const host = makeHost([]); + for (const method of ['tags.list', 'tags.create']) { + expect(code(await host.dispatch(req(method), 42))).toBe('PERMISSION_DENIED'); } }); - it('ws:broadcast:user forwards only to the acting user (never an arbitrary one)', async () => { - const host = new PluginRpcHost('p', new Set(['ws:broadcast:user']), deps); - const res = await host.dispatch(req('ws.broadcastToUser', { userId: 42, event: 'poke', data: { x: 2 } }), 42); - expect(ok(res)).toBe(true); - expect(deps.broadcastToUser).toHaveBeenCalledWith(42, { event: 'poke', x: 2 }); - // broadcasting to a DIFFERENT user is refused - const forbidden = await host.dispatch(req('ws.broadcastToUser', { userId: 9, event: 'poke', data: {} }), 42); - expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); + it('RPCHOST-005 an unconditional method needs no grant', async () => { + expect(ok(await makeHost([]).dispatch(req('events.emit'), 42))).toBe(true); }); - it('non-array db args are BAD_PARAMS; a primitive ws payload is wrapped', async () => { - const host = new PluginRpcHost('p', new Set(['db:own', 'ws:broadcast:trip']), deps); - const bad = await host.dispatch(req('db.query', { sql: 'SELECT 1', args: 'nope' })); - expect((bad as RpcError).error.code).toBe('BAD_PARAMS'); - - await host.dispatch(req('ws.broadcastToTrip', { tripId: 1, event: 'ping', data: 'primitive' }), 42); - expect(deps.broadcastToTrip).toHaveBeenCalledWith(1, 'ping', { value: 'primitive' }); - }); - - it('dispose() closes the plugin data db', () => { - const host = new PluginRpcHost('p', new Set(['db:own']), deps); - host.dispose(); - expect(deps.data.close).toHaveBeenCalled(); - }); - - // The costs, reservation and accommodation cases moved to their domain suites. - // The addon-gated domains moved to their own suites; addon-gates.rpc.test.ts - // covers every gate in one table. - // The daynotes cases moved to tests/unit/days/day-notes.rpc.test.ts. - - // weather.get, categories.list and rates.get moved to tests/unit/plugins/tenant-free.rpc.test.ts. - - // The tags cases moved to tests/unit/tags/tags.rpc.test.ts with the handlers. - - // The files cases moved to tests/unit/files/files.rpc.test.ts with the handlers. - it('notify.send forces the recipient to the acting user or a member trip; admin scope refused', async () => { - const host = new PluginRpcHost('p', new Set(['notify:send']), deps); - expect(ok(await host.dispatch(req('notify.send', { input: { title: 'Delay', body: 'AB123 is late', scope: 'user', targetId: 42 } }), 42))).toBe(true); - expect(deps.sendPluginNotification).toHaveBeenCalledWith('p', expect.objectContaining({ scope: 'user', targetId: 42 })); - expect(ok(await host.dispatch(req('notify.send', { input: { title: 'Trip', body: 'x', scope: 'trip', targetId: 1 } }), 42))).toBe(true); - // another user (scope user, foreign targetId) → forbidden - expect(((await host.dispatch(req('notify.send', { input: { title: 't', body: 'b', scope: 'user', targetId: 99 } }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); - // a trip the acting user isn't in → forbidden - expect(((await host.dispatch(req('notify.send', { input: { title: 't', body: 'b', scope: 'trip', targetId: 2 } }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); - // admin scope → bad params (not an allowed scope) - expect((await host.dispatch(req('notify.send', { input: { title: 't', body: 'b', scope: 'admin', targetId: 0 } }), 42)).ok).toBe(false); - // a protocol-relative / absolute link → rejected - expect((await host.dispatch(req('notify.send', { input: { title: 't', body: 'b', scope: 'user', targetId: 42, link: '//evil.com' } }), 42)).ok).toBe(false); - expect((await host.dispatch(req('notify.send', { input: { title: '', body: 'b', scope: 'user', targetId: 42 } }), 42)).ok).toBe(false); - }); - - it('ai.complete/extract require a configured provider + a bound user, with caps', async () => { - const host = new PluginRpcHost('p', new Set(['ai:invoke']), deps); - expect(ok(await host.dispatch(req('ai.complete', { prompt: 'Summarize my trip' }), 42))).toBe(true); - expect(ok(await host.dispatch(req('ai.extract', { text: 'AB123 JFK 10:00', jsonSchema: { type: 'object' } }), 42))).toBe(true); - expect((await host.dispatch(req('ai.complete', { prompt: '' }), 42)).ok).toBe(false); - expect((await host.dispatch(req('ai.extract', { text: 'x', jsonSchema: 'not-an-object' }), 42)).ok).toBe(false); - expect((await host.dispatch(req('ai.complete', { prompt: 'x'.repeat(20001) }), 42)).ok).toBe(false); - // user 7 has no provider configured → bad params (not a crash) - expect((await host.dispatch(req('ai.complete', { prompt: 'hi' }), 7)).ok).toBe(false); - // no user context at all → forbidden - expect((await host.dispatch(req('ai.complete', { prompt: 'hi' }), undefined)).ok).toBe(false); - }); - - it('settings.get returns the acting user\'s own value; a userless context yields undefined', async () => { - // No permission required — it only ever returns THIS plugin's config for the acting user. - const host = new PluginRpcHost('p', new Set(), deps); - const bound = await host.dispatch(req('settings.get', { key: 'apiKey' }), 42); - expect(ok(bound)).toBe(true); - expect((bound as RpcResponse).result).toEqual({ value: 'secret-of-p' }); - expect(deps.getUserSetting).toHaveBeenCalledWith('p', 42, 'apiKey'); - const userless = await host.dispatch(req('settings.get', { key: 'apiKey' }), undefined); - expect((userless as RpcResponse).result).toEqual({ value: undefined }); - }); - - it('oauth.getToken needs the grant + a bound user; returns only the access token', async () => { - const host = new PluginRpcHost('p', new Set(['oauth:client']), deps); - const ok1 = await host.dispatch(req('oauth.getToken', {}), 42); - expect(ok(ok1)).toBe(true); - expect((ok1 as RpcResponse).result).toEqual({ accessToken: 'access-token-xyz' }); - expect(deps.getOAuthToken).toHaveBeenCalledWith('p', 42); - // no bound user (a job/onLoad) → null, matching the SDK contract, not an error - const userless = await host.dispatch(req('oauth.getToken', {}), undefined); - expect(ok(userless)).toBe(true); - expect((userless as RpcResponse).result).toEqual({ accessToken: null }); - // without the grant → not reachable - const noGrant = new PluginRpcHost('p', new Set(), deps); - expect((await noGrant.dispatch(req('oauth.getToken', {}), 42)).ok).toBe(false); - }); - - it('scheduler.set/cancel ride on jobs:run and are userless (no acting user needed)', async () => { - const host = new PluginRpcHost('p', new Set(['jobs:run']), deps); - // userless (no bound user) is fine — scheduled tasks are like jobs - const set = await host.dispatch(req('scheduler.set', { name: 'poll', dueAt: 123, everyMs: 60000, payload: { x: 1 } }), undefined); - expect(ok(set)).toBe(true); - expect(deps.schedulerSet).toHaveBeenCalledWith('poll', 123, 60000, { x: 1 }); - const cancel = await host.dispatch(req('scheduler.cancel', { name: 'poll' }), undefined); - expect(ok(cancel)).toBe(true); - // without jobs:run → not reachable - const noGrant = new PluginRpcHost('p', new Set(), deps); - expect((await noGrant.dispatch(req('scheduler.set', { name: 'x', dueAt: 1 }), 42) as RpcError).error.code).toBe('PERMISSION_DENIED'); - }); - - it('a read scope is denied without its own permission', async () => { - const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps); - expect((await host.dispatch(req('journal.listMine'), 42)).ok).toBe(false); - expect((await host.dispatch(req('daynotes.list', { tripId: 1, dayId: 5 }), 42)).ok).toBe(false); - }); - - it('an error thrown by a handler becomes HOST_ERROR', async () => { - (deps.data.query as ReturnType).mockImplementation(() => { - throw new Error('disk gone'); + it('RPCHOST-006 the host-bound acting user reaches the handler, never a params field', async () => { + let seen: PluginRpcContext | undefined; + const host = makeHost(['db:read:tags'], (_p, ctx) => { + seen = ctx; + return null; }); - const host = new PluginRpcHost('p', new Set(['db:own']), deps); - const res = await host.dispatch(req('db.query', { sql: 'SELECT 1', args: [] })); - expect((res as RpcError).error.code).toBe('HOST_ERROR'); + await host.dispatch(req('tags.list', { asUserId: 999 }), 42); + expect(seen?.actingUserId).toBe(42); + expect(seen?.pluginId).toBe('p'); + await host.dispatch(req('tags.list'), undefined); + expect(seen?.actingUserId).toBeUndefined(); + }); + + it('RPCHOST-007 the context exposes the plugin db handle and the inter-plugin peers', async () => { + const deps = makeDeps(); + let seen: PluginRpcContext | undefined; + const host = makeHost(['db:read:tags'], (_p, ctx) => { + seen = ctx; + return null; + }, deps); + await host.dispatch(req('tags.list'), 42); + expect(seen?.data).toBe(deps.data); + await seen?.plugins.call('other', 'sum', [1], 42); + expect(deps.callPlugin).toHaveBeenCalledWith('other', 'sum', [1], 42); + seen?.plugins.emit('ping', { a: 1 }); + expect(deps.emitPluginEvent).toHaveBeenCalledWith('ping', { a: 1 }); + }); + + it('RPCHOST-008 BadParams and ForbiddenResource map to their own wire codes', async () => { + expect(code(await makeHost(['db:read:tags'], () => { + throw new BadParams('sql must be a string'); + }).dispatch(req('tags.list'), 42))).toBe('BAD_PARAMS'); + expect(code(await makeHost(['db:read:tags'], () => { + throw new ForbiddenResource('no access to trip 9'); + }).dispatch(req('tags.list'), 42))).toBe('RESOURCE_FORBIDDEN'); + }); + + it('RPCHOST-009 any other error becomes HOST_ERROR with its message', async () => { + const res = await makeHost(['db:read:tags'], () => { + throw new Error('disk gone'); + }).dispatch(req('tags.list'), 42); + expect(code(res)).toBe('HOST_ERROR'); expect((res as RpcError).error.message).toBe('disk gone'); }); - it('a non-Error thrown by a handler still maps to HOST_ERROR', async () => { - (deps.data.query as ReturnType).mockImplementation(() => { + it('RPCHOST-010 a non-Error throw still maps to HOST_ERROR, with a generic message', async () => { + const res = await makeHost(['db:read:tags'], () => { throw 'raw string'; - }); - const host = new PluginRpcHost('p', new Set(['db:own']), deps); - const res = await host.dispatch(req('db.query', { sql: 'SELECT 1' })); - expect((res as RpcError).error.code).toBe('HOST_ERROR'); + }).dispatch(req('tags.list'), 42); + expect(code(res)).toBe('HOST_ERROR'); expect((res as RpcError).error.message).toBe('internal error'); }); - // ── Costs (budget items): db:read:costs / db:write:costs ──────────────────── - - // --- Planner writes (#1429) --- - // The places, days and itinerary cases moved to their own domain suites. - it('places.create is PERMISSION_DENIED without db:write:places', async () => { - const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps); - const res = await host.dispatch(req('places.create', { tripId: 1, input: { name: 'X' } }), 42); - expect((res as RpcError).error.code).toBe('PERMISSION_DENIED'); + it('RPCHOST-011 an async handler is awaited before the response is built', async () => { + const host = makeHost(['db:read:tags'], async () => { + await Promise.resolve(); + return { late: true }; + }); + expect(((await host.dispatch(req('tags.list'), 42)) as RpcResponse).result).toEqual({ late: true }); }); - // --- Plugin metadata (db:meta) --- - it('db:meta stores and reads namespaced metadata on an accessible entity', async () => { - const host = new PluginRpcHost('p', new Set(['db:meta']), deps); - const set = await host.dispatch(req('meta.set', { entityType: 'trip', entityId: 1, key: 'rating', value: 5 }), 42); - expect(ok(set)).toBe(true); - expect(deps.metaSet).toHaveBeenCalledWith('trip', 1, 'rating', 5); - const placeOk = await host.dispatch(req('meta.get', { entityType: 'place', entityId: 7, key: 'x' }), 42); - expect(ok(placeOk)).toBe(true); // place 7 resolves to accessible trip 1 + it('RPCHOST-012 a rejected promise is mapped like a throw', async () => { + const host = makeHost(['db:read:tags'], async () => { + throw new BadParams('async refusal'); + }); + expect(code(await host.dispatch(req('tags.list'), 42))).toBe('BAD_PARAMS'); }); - it('meta.set is PERMISSION_DENIED without db:meta', async () => { - const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps); - const res = await host.dispatch(req('meta.set', { entityType: 'trip', entityId: 1, key: 'x', value: 1 }), 42); - expect((res as RpcError).error.code).toBe('PERMISSION_DENIED'); + it('RPCHOST-013 missing params are treated as an empty object, not a crash', async () => { + let seen: Record | undefined; + const host = makeHost(['db:read:tags'], (params) => { + seen = params; + return null; + }); + expect(ok(await host.dispatch({ k: 'req', id: 'x', method: 'tags.list' }, 42))).toBe(true); + expect(seen).toEqual({}); }); - it('meta is RESOURCE_FORBIDDEN on an entity the acting user cannot access', async () => { - const host = new PluginRpcHost('p', new Set(['db:meta']), deps); - const res = await host.dispatch(req('meta.set', { entityType: 'trip', entityId: 2, key: 'x', value: 1 }), 42); - expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); - expect(deps.metaSet).not.toHaveBeenCalled(); + it('RPCHOST-014 the audit sink sees the outcome of an auditable call, denials included', async () => { + const audit = vi.fn(); + const deps = { ...makeDeps(), audit }; + expect(ok(await makeHost(['db:read:tags'], () => null, deps).dispatch(req('tags.list'), 42))).toBe(true); + expect(audit).toHaveBeenCalledWith(expect.objectContaining({ pluginId: 'p', actingUserId: 42, method: 'tags.list', code: 'ok' })); + audit.mockClear(); + await makeHost([], () => null, deps).dispatch(req('tags.list'), 42); + expect(audit).toHaveBeenCalledWith(expect.objectContaining({ method: 'tags.list', code: 'PERMISSION_DENIED' })); }); - it('meta with an unknown entityType is BAD_PARAMS', async () => { - const host = new PluginRpcHost('p', new Set(['db:meta']), deps); - const res = await host.dispatch(req('meta.set', { entityType: 'user', entityId: 1, key: 'x', value: 1 }), 42); - expect((res as RpcError).error.code).toBe('BAD_PARAMS'); + it('RPCHOST-015 a throwing audit sink never breaks the call it was auditing', async () => { + const audit = vi.fn(() => { + throw new Error('audit table is gone'); + }); + const host = makeHost(['db:read:tags'], () => ({ done: true }), { ...makeDeps(), audit }); + expect(ok(await host.dispatch(req('tags.list'), 42))).toBe(true); }); - it('meta writes resolve the entity edit permission per type (place→place_edit, day→day_edit) and refuse no-user', async () => { - const host = new PluginRpcHost('p', new Set(['db:meta']), deps); - expect(ok(await host.dispatch(req('meta.set', { entityType: 'place', entityId: 7, key: 'k', value: 1 }), 42))).toBe(true); - expect(deps.canEditPlaces).toHaveBeenCalled(); - expect(ok(await host.dispatch(req('meta.set', { entityType: 'day', entityId: 3, key: 'k', value: 1 }), 42))).toBe(true); - expect(deps.canEditDays).toHaveBeenCalled(); - // reservation metadata uses reservation_edit; accommodation uses day_edit - expect(ok(await host.dispatch(req('meta.set', { entityType: 'reservation', entityId: 40, key: 'ext_id', value: 'AB1' }), 42))).toBe(true); - expect(deps.canEditReservations).toHaveBeenCalled(); - expect(ok(await host.dispatch(req('meta.set', { entityType: 'accommodation', entityId: 11, key: 'k', value: 1 }), 42))).toBe(true); - // an entity of another trip is refused - expect((await host.dispatch(req('meta.get', { entityType: 'reservation', entityId: 999, key: 'k' }), 42) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); - // no host-bound acting user (a job / forged call) → refused - const noUser = await host.dispatch(req('meta.get', { entityType: 'trip', entityId: 1, key: 'k' }), undefined); - expect((noUser as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); + it('RPCHOST-016 dispose() closes the plugin data db, and a second close is harmless', () => { + const deps = makeDeps(); + const host = makeHost(['db:read:tags'], () => null, deps); + host.dispose(); + expect(deps.data.close).toHaveBeenCalled(); + (deps.data.close as ReturnType).mockImplementation(() => { + throw new Error('already closed'); + }); + expect(() => host.dispose()).not.toThrow(); }); - // Regression (#getPlaces): the mocked `db.prepare` above never parses the SQL, so an - // invalid column sails through. Drive the trip reads against a REAL schema-loaded - // SQLite instead — `places` has no day_id/position (those live on day_assignments), - // so the old `ORDER BY day_id, position` threw `no such column` on every call. - it('meta WRITES need the entity edit permission — a read-only member is RESOURCE_FORBIDDEN', async () => { - // Member can access the trip but not edit it (viewer role). - (deps.canEditTrip as ReturnType).mockReturnValue(false); - const host = new PluginRpcHost('p', new Set(['db:meta']), deps); - const write = await host.dispatch(req('meta.set', { entityType: 'trip', entityId: 1, key: 'x', value: 1 }), 42); - expect((write as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); - expect(deps.metaSet).not.toHaveBeenCalled(); - // …but a READ is only access-gated, so it still works. - const read = await host.dispatch(req('meta.get', { entityType: 'trip', entityId: 1, key: 'x' }), 42); - expect(ok(read)).toBe(true); - expect(deps.metaGet).toHaveBeenCalled(); + it('RPCHOST-017 a method the registry does not own is rejected, not silently answered', async () => { + // The registry is the ONLY source of handlers now, so a class that was never + // listed in its module makes every one of its methods PERMISSION_DENIED. This is + // the failure mode that has no other symptom, which is why each domain suite also + // asserts its class is in the module providers. + const bare = new PluginRpcHost('p', new Set(['db:read:tags']), makeDeps(), createTestPluginRegistry([])); + expect(code(await bare.dispatch(req('tags.list'), 42))).toBe('PERMISSION_DENIED'); }); }); diff --git a/server/tests/unit/plugins/rpc-kit/registry.service.test.ts b/server/tests/unit/plugins/rpc-kit/registry.service.test.ts index a8c38c26f..a2a84c25a 100644 --- a/server/tests/unit/plugins/rpc-kit/registry.service.test.ts +++ b/server/tests/unit/plugins/rpc-kit/registry.service.test.ts @@ -45,14 +45,14 @@ function serviceFor(instances: object[]): PluginRpcRegistryService { describe('PluginRpcRegistryService', () => { it('RPCKIT-SVC-001 registers the marked providers at boot', () => { const service = serviceFor([new TagsRpc(), new PlainProvider()]); - service.onModuleInit(); + service.scanProviders(); expect(service.methodNames()).toEqual(new Set(['tags.list'])); }); it('RPCKIT-SVC-002 the same instance wrapped twice is registered once', () => { const instance = new TagsRpc(); const service = serviceFor([instance, instance]); - service.onModuleInit(); + service.scanProviders(); // Without the dedupe this would be two entries and validate() would report // tags.list as double-owned. expect(service.list()).toHaveLength(1); @@ -60,7 +60,7 @@ describe('PluginRpcRegistryService', () => { it('RPCKIT-SVC-003 unmarked providers are skipped rather than rejected', () => { const service = serviceFor([new PlainProvider()]); - expect(() => service.onModuleInit()).not.toThrow(); + expect(() => service.scanProviders()).not.toThrow(); expect(service.list()).toEqual([]); }); @@ -70,7 +70,7 @@ describe('PluginRpcRegistryService', () => { } as unknown as DiscoveryService; const scanner = { getAllMethodNames: () => [] } as unknown as MetadataScanner; const service = new PluginRpcRegistryService(discovery, scanner); - expect(() => service.onModuleInit()).not.toThrow(); + expect(() => service.scanProviders()).not.toThrow(); expect(service.list()).toEqual([]); }); @@ -81,9 +81,19 @@ describe('PluginRpcRegistryService', () => { ); }); - it('RPCKIT-SVC-006 total coverage is NOT required while the legacy router still owns methods', () => { + it('RPCKIT-SVC-006 boot fails when the surface is incomplete', () => { + // The rollout is finished, so a decorated class that no module registers is no + // longer a silent PERMISSION_DENIED at runtime: the app refuses to start, and the + // message names the method that lost its owner. const service = serviceFor([new TagsRpc()]); - expect(() => service.onModuleInit()).not.toThrow(); + expect(() => service.onModuleInit()).toThrow(/has no handler/); + }); + + it('RPCKIT-SVC-008 a hook nobody consumes fails boot too, so no grant is dead', () => { + // A hook:* permission on the consent screen with nothing behind it looks exactly + // like a live one to the user granting it. + const service = serviceFor([new TagsRpc()]); + expect(() => service.onModuleInit()).toThrow(/has no host-side consumer/); }); it('RPCKIT-SVC-007 only providers are scanned, never controllers', () => { @@ -93,7 +103,7 @@ describe('PluginRpcRegistryService', () => { getControllers, } as unknown as DiscoveryService; const scanner = { getAllMethodNames: () => [] } as unknown as MetadataScanner; - new PluginRpcRegistryService(discovery, scanner).onModuleInit(); + new PluginRpcRegistryService(discovery, scanner).scanProviders(); expect(getControllers).not.toHaveBeenCalled(); }); }); diff --git a/server/tests/unit/plugins/trip-card-contributions.controller.test.ts b/server/tests/unit/plugins/trip-card-contributions.controller.test.ts index 22544ce97..f4f3eb91d 100644 --- a/server/tests/unit/plugins/trip-card-contributions.controller.test.ts +++ b/server/tests/unit/plugins/trip-card-contributions.controller.test.ts @@ -16,7 +16,7 @@ vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: () => vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); import { TripCardContributionsController } from '../../../src/nest/plugins/trip-card-contributions.controller'; -import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; import type { DatabaseService } from '../../../src/nest/database/database.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -24,8 +24,8 @@ const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) a function controller(invoke: (id: string, args: unknown[]) => unknown, providers = ['p1']) { const runtime = { providersOf: vi.fn(() => providers), - invokeHook: vi.fn(async (id: string, _h: string, _fn: string, args: unknown[]) => invoke(id, args)), - } as unknown as PluginRuntimeService; + tripCards: vi.fn(async (id: string, _h: string, _fn: string, args: unknown[]) => invoke(id, args)), + } as unknown as PluginHooks; return { c: new TripCardContributionsController(runtime, { canAccessTrip } as unknown as DatabaseService), runtime }; } const badge = (over: Record = {}) => ({ tripId: 1, id: 'b1', label: 'Visa', ...over }); @@ -52,7 +52,7 @@ describe('TripCardContributionsController', () => { it('passes only the accessible, deduped tripIds to the hook once', async () => { const { c, runtime } = controller(() => [badge()]); await c.get('1,2,2,99,abc', req(5)); // 99 inaccessible, abc invalid, 2 duplicated - expect(runtime.invokeHook).toHaveBeenCalledWith('p1', 'tripCardProvider', 'getCards', [[1, 2]], 5, 5000); + expect(runtime.tripCards).toHaveBeenCalledWith('p1', [1, 2], 5); }); it('drops a badge whose tripId was not requested / is inaccessible', async () => { diff --git a/server/tests/unit/plugins/trip-warnings.controller.test.ts b/server/tests/unit/plugins/trip-warnings.controller.test.ts index df22c40db..f09445aed 100644 --- a/server/tests/unit/plugins/trip-warnings.controller.test.ts +++ b/server/tests/unit/plugins/trip-warnings.controller.test.ts @@ -8,18 +8,18 @@ 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 type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; import type { DatabaseService } from '../../../src/nest/database/database.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any; -function controller(over: Partial = {}) { +function controller(over: Partial = {}) { const runtime = { providersOf: vi.fn(() => ['p1', 'p2']), - invokeHook: vi.fn(async (id: string) => + tripWarnings: vi.fn(async (id: string) => id === 'p2' ? [{ level: 'error', message: 'Day 3 is overpacked', dayId: 3 }] : [{ level: 'warning', message: 'Museum closed Mon', placeId: 7 }]), ...over, - } as unknown as PluginRuntimeService; + } as unknown as PluginHooks; return { c: new TripWarningsController(runtime, { canAccessTrip } as unknown as DatabaseService), runtime }; } @@ -37,8 +37,8 @@ describe('TripWarningsController', () => { it('merges provider warnings, tags each with the plugin id, defaults an odd level to warning', async () => { const { c } = controller({ - invokeHook: vi.fn(async (id: string) => - id === 'p1' ? [{ level: 'bogus', message: 'Check timings' }, { message: '' }] : [{ level: 'info', message: 'Rainy week' }]) as unknown as PluginRuntimeService['invokeHook'], + tripWarnings: vi.fn(async (id: string) => + id === 'p1' ? [{ level: 'bogus', message: 'Check timings' }, { message: '' }] : [{ level: 'info', message: 'Rainy week' }]) as unknown as PluginHooks['tripWarnings'], }); const res = await c.get('1', req(5)); expect(res.warnings).toEqual([ @@ -49,17 +49,17 @@ describe('TripWarningsController', () => { it('skips a provider that throws (graceful) and calls the hook with the trip + acting user', async () => { const { c, runtime } = controller({ - invokeHook: vi.fn(async (id: string) => { if (id === 'p2') throw new Error('slow'); return [{ level: 'warning', message: 'ok' }]; }) as unknown as PluginRuntimeService['invokeHook'], + tripWarnings: vi.fn(async (id: string) => { if (id === 'p2') throw new Error('slow'); return [{ level: 'warning', message: 'ok' }]; }) as unknown as PluginHooks['tripWarnings'], }); const res = await c.get('1', req(5)); expect(res.warnings).toEqual([{ pluginId: 'p1', level: 'warning', message: 'ok', dayId: undefined, placeId: undefined }]); - expect(runtime.invokeHook).toHaveBeenCalledWith('p1', 'warningProvider', 'getWarnings', [1], 5, 5000); + expect(runtime.tripWarnings).toHaveBeenCalledWith('p1', 1, 5); }); it('strips emojis from the warning message (rendered natively in TREK chrome)', async () => { const { c } = controller({ providersOf: vi.fn(() => ['p1']), - invokeHook: vi.fn(async () => [{ level: 'error', message: '🔥 Overbooked!' }]) as unknown as PluginRuntimeService['invokeHook'], + tripWarnings: vi.fn(async () => [{ level: 'error', message: '🔥 Overbooked!' }]) as unknown as PluginHooks['tripWarnings'], }); const res = await c.get('1', req(5)); expect(res.warnings[0].message).toBe('Overbooked!'); @@ -68,10 +68,10 @@ describe('TripWarningsController', () => { it('caps a flooding provider at 20 warnings and truncates an oversized message', async () => { const { c } = controller({ providersOf: vi.fn(() => ['flood']), - invokeHook: vi.fn(async () => [ + tripWarnings: vi.fn(async () => [ { level: 'warning', message: 'z'.repeat(1000) }, ...Array.from({ length: 50 }, (_v, i) => ({ level: 'info', message: `w${i}` })), - ]) as unknown as PluginRuntimeService['invokeHook'], + ]) as unknown as PluginHooks['tripWarnings'], }); const res = await c.get('1', req(5)); expect(res.warnings).toHaveLength(20); // per-provider count cap diff --git a/server/tests/unit/plugins/view-contributions.controller.test.ts b/server/tests/unit/plugins/view-contributions.controller.test.ts index 35d38e1f9..60f468535 100644 --- a/server/tests/unit/plugins/view-contributions.controller.test.ts +++ b/server/tests/unit/plugins/view-contributions.controller.test.ts @@ -15,7 +15,7 @@ vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: () => vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); import { ViewContributionsController } from '../../../src/nest/plugins/view-contributions.controller'; -import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; import type { DatabaseService } from '../../../src/nest/database/database.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -23,8 +23,8 @@ const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) a function controller(invoke: (id: string) => unknown, providers = ['p1']) { const runtime = { providersOf: vi.fn(() => providers), - invokeHook: vi.fn(async (id: string) => invoke(id)), - } as unknown as PluginRuntimeService; + tableContributions: vi.fn(async (id: string) => invoke(id)), + } as unknown as PluginHooks; return { c: new ViewContributionsController(runtime, { canAccessTrip } as unknown as DatabaseService), runtime }; } const col = (over: Record = {}) => ({ kind: 'column', entityId: 1, id: 'c1', label: 'X', ...over }); @@ -115,6 +115,6 @@ describe('ViewContributionsController', () => { const { c, runtime } = controller((id) => { if (id === 'p2') throw new Error('slow'); return [col({ id: 'from-p1' })]; }, ['p1', 'p2']); const out = (await c.get('reservations', '1', req(5))).contributions; expect(out.map((o) => o.id)).toEqual(['from-p1']); - expect(runtime.invokeHook).toHaveBeenCalledWith('p1', 'tableContributor', 'getContributions', ['reservations', 1], 5, 5000); + expect(runtime.tableContributions).toHaveBeenCalledWith('p1', 'reservations', 1, 5); }); });