mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -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 (`<domain>/<domain>.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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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().
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<AtlasLayer[]> => {
|
||||
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
|
||||
|
||||
@@ -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<number>, 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<number> = new Set(dayRows.map((d) => d.id));
|
||||
@@ -101,7 +101,7 @@ export class DayScheduleController {
|
||||
const perProvider = await Promise.all(
|
||||
ids.map(async (id): Promise<DayScheduleItem[]> => {
|
||||
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
|
||||
|
||||
@@ -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<number>, 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<number> = new Set(dayRows.map((d) => d.id));
|
||||
@@ -178,7 +178,7 @@ export class DayTintsController {
|
||||
const perProvider = await Promise.all(
|
||||
ids.map(async (id): Promise<DayTint[]> => {
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<T>(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<unknown>;
|
||||
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<string>, 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<string, unknown> = {};
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<unknown>;
|
||||
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<string>, 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>): void;
|
||||
/** Namespaced per-user broadcast. */
|
||||
broadcastToUser(userId: number, payload: Record<string, unknown>): 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<unknown>;
|
||||
// --- 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<unknown>;
|
||||
aiExtract(userId: number, text: string, jsonSchema: object, prompt: string | undefined): Promise<unknown>;
|
||||
/** 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<string | null>;
|
||||
/** 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<unknown>;
|
||||
/** 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<string, unknown>, 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<string, number> = { 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<string> = 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<string, number> = { title: 200, description: 2000 };
|
||||
|
||||
export class PluginRpcHost {
|
||||
private methods = new Map<string, Handler>();
|
||||
|
||||
@@ -184,306 +59,27 @@ export class PluginRpcHost {
|
||||
private readonly pluginId: string,
|
||||
granted: ReadonlySet<string>,
|
||||
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`, `/<tab>/…`
|
||||
// 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<string, unknown>, 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<string, unknown>, limits: Record<string, number>): 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<string, unknown>, 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<RpcResponse | RpcError> {
|
||||
const raw = (req.params ?? {}) as Record<string, unknown>;
|
||||
// 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'}`);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<object>();
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<unknown>;
|
||||
emit(event: string, payload: unknown): void;
|
||||
}
|
||||
|
||||
export type PluginRpcHandler = (params: Record<string, unknown>, ctx: PluginRpcContext) => unknown;
|
||||
|
||||
@@ -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<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
return ctx.data.query(str(params.sql, 'sql'), asArgs(params.args));
|
||||
}
|
||||
|
||||
@PluginMethod('db.exec', { permission: 'db:own' })
|
||||
exec(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
return ctx.data.exec(str(params.sql, 'sql'), asArgs(params.args));
|
||||
}
|
||||
|
||||
@PluginMethod('db.migrate', { permission: 'db:own' })
|
||||
migrate(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
return ctx.data.migrate(str(params.id, 'id'), str(params.sql, 'sql'));
|
||||
}
|
||||
|
||||
@PluginMethod('db.tx', { permission: 'db:own' })
|
||||
tx(params: Record<string, unknown>, 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<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
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<string, unknown>, 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<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
if (ctx.actingUserId === undefined) return { value: undefined };
|
||||
return { value: readUserSettingDecrypted(ctx.pluginId, ctx.actingUserId, str(params.key, 'key')) };
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
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<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
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<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
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<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
|
||||
// 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<string, unknown>, 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<string, unknown>, 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 `/<tab>/…`,
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -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<string> = 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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown> = {};
|
||||
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<string, unknown>, 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<string, unknown>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<ProviderResult | null> => {
|
||||
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 {
|
||||
|
||||
@@ -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<MapLayer[]> => {
|
||||
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
|
||||
|
||||
@@ -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<MapMarker[]> => {
|
||||
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
|
||||
|
||||
@@ -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<PdfSection[]> => {
|
||||
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
|
||||
|
||||
@@ -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<ProviderResult | null> => {
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
return this.runtime.invokeHook(pluginId, 'notificationChannel', 'test', [userSettings], undefined, 8000);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> | 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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<number>): 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<TripCardBadge[]> => {
|
||||
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
|
||||
|
||||
@@ -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<Warning[]> => {
|
||||
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<Record<string, unknown>>) : [];
|
||||
// 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
|
||||
|
||||
@@ -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<Contribution[]> => {
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 <domain>.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()),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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<string>): 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 }),
|
||||
|
||||
@@ -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<string, unknown> = {}) => ({ 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 () => {
|
||||
|
||||
@@ -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<string, unknown> = {}) => ({ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> = {}) => ({ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<RpcError>;
|
||||
// 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<typeof makeHost>,
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
...uid: (number | undefined)[]
|
||||
): Promise<Res> => host.dispatch({ k: 'req', id: 'x', method, params }, uid.length ? uid[0] : 5) as Promise<Res>;
|
||||
|
||||
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<string, unknown>;
|
||||
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<string, unknown>) => (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<string, Record<string, unknown>> = {
|
||||
'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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {}) => ({ 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 () => {
|
||||
|
||||
@@ -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<string, unknown> = {}) => ({
|
||||
|
||||
@@ -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<string, unknown> = {}) => ({ id: 'm1', lat: 48.85, lng: 2.35, ...over });
|
||||
|
||||
@@ -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<string, unknown> = {}) => ({ 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 () => {
|
||||
|
||||
@@ -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<PluginRuntimeService> = {}) {
|
||||
function controller(over: Partial<PluginHooks> = {}) {
|
||||
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
|
||||
|
||||
@@ -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<string, string>)[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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) { return { id: 1, trip_id: Number(tid), ...input }; },
|
||||
async update(id: string, tid: string, input: Record<string, unknown>) {
|
||||
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<string, unknown>) => ({ 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<string, unknown>) => ({ 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<string, unknown>) => ({ 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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>) => ({ 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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string>, 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<typeof host>, method: string, params: Record<string, unknown>, uid = 5): Promise<any> =>
|
||||
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<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined = 5): Promise<any> =>
|
||||
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<string, unknown>): 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<string, unknown>): 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<typeof host>, method: string, params: Record<string, unknown>, uid: number | undefined): Promise<any> =>
|
||||
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
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string>(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<string>([...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<string>([...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<string>([...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<string>([...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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>): 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]);
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> = {}): 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<string, unknown>, ctx: PluginRpcContext) => unknown) {}
|
||||
|
||||
@PluginMethod('tags.list', { permission: 'db:read:tags' })
|
||||
read(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
return this.behaviour(params, ctx);
|
||||
}
|
||||
|
||||
@PluginMethod('tags.create', { permission: 'db:write:tags' })
|
||||
write(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
return this.behaviour(params, ctx);
|
||||
}
|
||||
|
||||
@PluginOpenMethod('events.emit')
|
||||
open(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
||||
return this.behaviour(params, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
const makeHost = (
|
||||
granted: string[],
|
||||
behaviour: (params: Record<string, unknown>, 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<string>(), 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<typeof vi.fn>).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<typeof vi.fn>).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<string, unknown> | 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<typeof vi.fn>).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<typeof vi.fn>).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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> = {}) => ({ 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 () => {
|
||||
|
||||
@@ -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<PluginRuntimeService> = {}) {
|
||||
function controller(over: Partial<PluginHooks> = {}) {
|
||||
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
|
||||
|
||||
@@ -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<string, unknown> = {}) => ({ 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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user