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:
Maurice
2026-08-09 17:15:53 +02:00
committed by Maurice
parent 611f94e820
commit f72aba0c91
74 changed files with 1942 additions and 2164 deletions
+10
View File
@@ -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.
+1 -1
View File
@@ -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().
*/
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 {}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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],
+1 -1
View File
@@ -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({
+1 -1
View File
@@ -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,
);
}
}
+27 -444
View File
@@ -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,
+14 -8
View File
@@ -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 {
+1 -1
View File
@@ -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 {}
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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({
+78 -32
View File
@@ -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 -149
View File
@@ -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();
});
});
+549
View File
@@ -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 () => {
+35 -116
View File
@@ -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]);
});
+159 -253
View File
@@ -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);
});
});