refactor(server): plan 4 — inline canAccessTrip/isOwner off DatabaseService (plugins + MCP surfaces)

This commit is contained in:
jubnl
2026-09-24 05:47:29 +02:00
parent 51327e75c0
commit ea7b93de5d
51 changed files with 232 additions and 179 deletions
+7 -3
View File
@@ -4,8 +4,10 @@ import { PluginGuards } from '../plugins/host/plugin-guards.service';
import { BadParams, ForbiddenResource } from '../plugins/host/rpc-errors';
import { num, schemaMessage } from '../plugins/host/rpc-params';
import type { PluginRpcContext } from '../plugins/host/rpc-kit/types';
import { InjectRepository } from '@mikro-orm/nestjs';
import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService } from '../database/database.service';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
import { TripMembershipService } from '../trip-membership/trip-membership.service';
import { ADDON_IDS } from '../../addons';
import { BudgetService } from './budget.service';
@@ -30,7 +32,9 @@ const BUDGET_EDIT_ACTION = 'budget_edit';
export class CostsRpc {
constructor(
private readonly budget: BudgetService,
private readonly db: DatabaseService,
// Plan 4 Task 2 — canAccessTrip's own DatabaseService delegation is gone:
// this injects TripsRepository directly and calls findAccessible.
@InjectRepository(Trips) private readonly trips: TripsRepository,
private readonly realtime: RealtimeService,
private readonly guards: PluginGuards,
private readonly membership: TripMembershipService,
@@ -120,7 +124,7 @@ export class CostsRpc {
/** Trip access plus budget_edit, with the cost-specific refusal message. */
private async requireCostEdit(tripId: number, userId: number): Promise<void> {
if (!(await this.db.canAccessTrip(tripId, userId))) throw new ForbiddenResource(`no access to trip ${tripId}`);
if (!(await this.trips.findAccessible(tripId, userId))) throw new ForbiddenResource(`no access to trip ${tripId}`);
if (!(await this.guards.canEditAs(BUDGET_EDIT_ACTION, tripId, userId))) {
throw new ForbiddenResource(`no permission to edit costs on trip ${tripId}`);
}
+8 -3
View File
@@ -5,7 +5,9 @@ import {
} from '../../nest-mcp';
import { z } from 'zod';
import { getAppUrl } from '../../app-config';
import { DatabaseService } from '../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { DemoService } from '../common/demo.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
@@ -35,7 +37,10 @@ import { FeedsService } from './feeds.service';
export class FeedsMcp {
constructor(
private readonly feeds: FeedsService,
private readonly db: DatabaseService,
// Plan 4 Task 2 — canAccessTrip's own DatabaseService delegation is gone:
// this injects TripsRepository directly (same constructor slot) and
// calls findAccessible.
@InjectRepository(Trips) private readonly trips: TripsRepository,
private readonly env: RuntimeEnvService,
private readonly guards: McpToolGuardsService,
private readonly demo: DemoService,
@@ -59,7 +64,7 @@ export class FeedsMcp {
/** Trip access first (404-equivalent), then share_manage, exactly as TripFeedTokenController is gated. */
private async denyTripFeed(tripId: number, userId: number) {
if (!(await this.db.canAccessTrip(tripId, userId))) return noAccess();
if (!(await this.trips.findAccessible(tripId, userId))) return noAccess();
if (!(await this.guards.hasTripPermission('share_manage', tripId, userId))) return permissionDenied();
return null;
}
@@ -1,7 +1,8 @@
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { InjectRepository } from '@mikro-orm/nestjs';
import { DatabaseService } from '../../database/database.service';
import { Trips } from '../../../db/entities/Trips.entity';
import type { TripsRepository } from '../../../db/repositories/Trips.repository';
import { Days } from '../../../db/entities/Days.entity';
import type { DaysRepository } from '../../../db/repositories/Days.repository';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
@@ -83,7 +84,7 @@ function normalize(pluginId: string, tripDayIds: ReadonlySet<number>, raw: unkno
export class DayScheduleController {
constructor(
private readonly hooks: PluginHooks,
private readonly dbs: DatabaseService,
@InjectRepository(Trips) private readonly trips: TripsRepository,
// CT1 (Plan 3j Task 5) — the trip's day-id set, converted onto Days.repository.ts.
@InjectRepository(Days) private readonly days: DaysRepository,
) {}
@@ -96,7 +97,7 @@ export class DayScheduleController {
if (!pluginsEnabled()) return { items: [] };
const tripId = Number(tripIdRaw);
const userId = req.user?.id;
if (!Number.isFinite(tripId) || userId == null || !(await this.dbs.canAccessTrip(tripId, userId))) return { items: [] };
if (!Number.isFinite(tripId) || userId == null || !(await this.trips.findAccessible(tripId, userId))) return { items: [] };
const ids = this.hooks.providersOf('dayScheduleProvider');
if (ids.length === 0) return { items: [] };
@@ -1,7 +1,8 @@
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { InjectRepository } from '@mikro-orm/nestjs';
import { DatabaseService } from '../../database/database.service';
import { Trips } from '../../../db/entities/Trips.entity';
import type { TripsRepository } from '../../../db/repositories/Trips.repository';
import { Days } from '../../../db/entities/Days.entity';
import type { DaysRepository } from '../../../db/repositories/Days.repository';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
@@ -160,7 +161,7 @@ function normalize(pluginId: string, tripDayIds: ReadonlySet<number>, raw: unkno
export class DayTintsController {
constructor(
private readonly hooks: PluginHooks,
private readonly dbs: DatabaseService,
@InjectRepository(Trips) private readonly trips: TripsRepository,
// CT2 (Plan 3j Task 5) — the trip's day-id set, converted onto Days.repository.ts.
@InjectRepository(Days) private readonly days: DaysRepository,
) {}
@@ -173,7 +174,7 @@ export class DayTintsController {
if (!pluginsEnabled()) return { tints: [] };
const tripId = Number(tripIdRaw);
const userId = req.user?.id;
if (!Number.isFinite(tripId) || userId == null || !(await this.dbs.canAccessTrip(tripId, userId))) return { tints: [] };
if (!Number.isFinite(tripId) || userId == null || !(await this.trips.findAccessible(tripId, userId))) return { tints: [] };
const ids = this.hooks.providersOf('dayTintProvider');
if (ids.length === 0) return { tints: [] };
@@ -1,6 +1,8 @@
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { DatabaseService } from '../../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Trips } from '../../../db/entities/Trips.entity';
import type { TripsRepository } from '../../../db/repositories/Trips.repository';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { pluginsEnabled } from '../kill-switch';
import { PluginHooks } from '../plugin-hooks.service';
@@ -147,7 +149,7 @@ function normalize(pluginId: string, raw: unknown): MapLayer[] {
export class MapLayersController {
constructor(
private readonly hooks: PluginHooks,
private readonly dbs: DatabaseService,
@InjectRepository(Trips) private readonly trips: TripsRepository,
) {}
@Get(':tripId')
@@ -158,7 +160,7 @@ export class MapLayersController {
if (!pluginsEnabled()) return { layers: [] };
const tripId = Number(tripIdRaw);
const userId = req.user?.id;
if (!Number.isFinite(tripId) || userId == null || !(await this.dbs.canAccessTrip(tripId, userId))) return { layers: [] };
if (!Number.isFinite(tripId) || userId == null || !(await this.trips.findAccessible(tripId, userId))) return { layers: [] };
const ids = this.hooks.providersOf('mapLayerProvider');
const perProvider = await Promise.all(
@@ -1,6 +1,8 @@
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { DatabaseService } from '../../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Trips } from '../../../db/entities/Trips.entity';
import type { TripsRepository } from '../../../db/repositories/Trips.repository';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { pluginsEnabled } from '../kill-switch';
import { PluginHooks } from '../plugin-hooks.service';
@@ -80,7 +82,7 @@ function normalize(pluginId: string, raw: unknown): MapMarker[] {
export class MapMarkersController {
constructor(
private readonly hooks: PluginHooks,
private readonly dbs: DatabaseService,
@InjectRepository(Trips) private readonly trips: TripsRepository,
) {}
@Get(':tripId')
@@ -91,7 +93,7 @@ export class MapMarkersController {
if (!pluginsEnabled()) return { markers: [] };
const tripId = Number(tripIdRaw);
const userId = req.user?.id;
if (!Number.isFinite(tripId) || userId == null || !(await this.dbs.canAccessTrip(tripId, userId))) return { markers: [] };
if (!Number.isFinite(tripId) || userId == null || !(await this.trips.findAccessible(tripId, userId))) return { markers: [] };
const ids = this.hooks.providersOf('mapMarkerProvider');
const perProvider = await Promise.all(
@@ -1,6 +1,8 @@
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { DatabaseService } from '../../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Trips } from '../../../db/entities/Trips.entity';
import type { TripsRepository } from '../../../db/repositories/Trips.repository';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { pluginsEnabled } from '../kill-switch';
import { PluginHooks } from '../plugin-hooks.service';
@@ -76,7 +78,7 @@ function normalize(pluginId: string, raw: unknown): PdfSection[] {
export class PdfSectionsController {
constructor(
private readonly hooks: PluginHooks,
private readonly dbs: DatabaseService,
@InjectRepository(Trips) private readonly trips: TripsRepository,
) {}
@Get(':tripId')
@@ -87,7 +89,7 @@ export class PdfSectionsController {
if (!pluginsEnabled()) return { sections: [] };
const tripId = Number(tripIdRaw);
const userId = req.user?.id;
if (!Number.isFinite(tripId) || userId == null || !(await this.dbs.canAccessTrip(tripId, userId))) return { sections: [] };
if (!Number.isFinite(tripId) || userId == null || !(await this.trips.findAccessible(tripId, userId))) return { sections: [] };
const ids = this.hooks.providersOf('pdfSectionProvider');
const perProvider = await Promise.all(
@@ -1,7 +1,8 @@
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { InjectRepository } from '@mikro-orm/nestjs';
import { DatabaseService } from '../../database/database.service';
import { Trips } from '../../../db/entities/Trips.entity';
import type { TripsRepository } from '../../../db/repositories/Trips.repository';
import { Places } from '../../../db/entities/Places.entity';
import type { PlacesRepository } from '../../../db/repositories/Places.repository';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
@@ -66,7 +67,7 @@ function normalize(raw: unknown): DetailItem[] {
export class PlaceDetailsController {
constructor(
private readonly hooks: PluginHooks,
private readonly dbs: DatabaseService,
@InjectRepository(Trips) private readonly trips: TripsRepository,
// CT7 (Plan 3j Task 5) — the place's owning trip id, converted onto Places.repository.ts.
@InjectRepository(Places) private readonly places: PlacesRepository,
) {}
@@ -83,7 +84,7 @@ export class PlaceDetailsController {
// The place must belong to a trip the caller can access — same gate as a read.
const tripId = await this.places.findTripId(placeId); // CT7 — Plan 3j
if (tripId === undefined || !(await this.dbs.canAccessTrip(tripId, userId))) return { providers: [] };
if (tripId === undefined || !(await this.trips.findAccessible(tripId, userId))) return { providers: [] };
const ids = this.hooks.providersOf('placeDetailProvider');
const results = await Promise.all(
@@ -7,6 +7,7 @@ import { JourneyEntries } from '../../../db/entities/JourneyEntries.entity';
import { Plugins } from '../../../db/entities/Plugins.entity';
import { Days } from '../../../db/entities/Days.entity';
import { Places } from '../../../db/entities/Places.entity';
import { Trips } from '../../../db/entities/Trips.entity';
import { PlaceDetailsController } from './place-details.controller';
import { PluginSearchController } from './plugin-search.controller';
import { TripWarningsController } from './trip-warnings.controller';
@@ -59,7 +60,7 @@ import { DemoModule } from '../../common/demo.module';
* dependency unresolved.
*/
@Module({
imports: [PluginsRuntimeModule, AddonsModule, JourneyDomainModule, DemoModule, MikroOrmModule.forFeature([JourneyEntries, Plugins, Days, Places])],
imports: [PluginsRuntimeModule, AddonsModule, JourneyDomainModule, DemoModule, MikroOrmModule.forFeature([JourneyEntries, Plugins, Days, Places, Trips])],
controllers: [
PlaceDetailsController,
PluginSearchController,
@@ -1,7 +1,8 @@
import { Body, Controller, Param, Post, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { InjectRepository } from '@mikro-orm/nestjs';
import { DatabaseService } from '../../database/database.service';
import { Trips } from '../../../db/entities/Trips.entity';
import type { TripsRepository } from '../../../db/repositories/Trips.repository';
import { Plugins } from '../../../db/entities/Plugins.entity';
import type { PluginsRepository } from '../../../db/repositories/Plugins.repository';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
@@ -35,7 +36,7 @@ export type { PluginRouteOut } from './plugin-route-normalize';
export class PluginRoutesController {
constructor(
private readonly hooks: PluginHooks,
private readonly dbs: DatabaseService,
@InjectRepository(Trips) private readonly trips: TripsRepository,
@InjectRepository(Plugins) private readonly plugins: PluginsRepository,
) {}
@@ -49,7 +50,7 @@ export class PluginRoutesController {
if (!pluginsEnabled()) return { route: null };
const userId = req.user?.id;
const tripId = Number(body?.tripId);
if (userId == null || !Number.isFinite(tripId) || !(await this.dbs.canAccessTrip(tripId, userId))) return { route: null };
if (userId == null || !Number.isFinite(tripId) || !(await this.trips.findAccessible(tripId, userId))) return { route: null };
if (!PROFILE_RE.test(profileId)) return { route: null };
const waypoints = readWaypoints(body?.waypoints);
if (!waypoints) return { route: null };
@@ -1,6 +1,8 @@
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { DatabaseService } from '../../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Trips } from '../../../db/entities/Trips.entity';
import type { TripsRepository } from '../../../db/repositories/Trips.repository';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { pluginsEnabled } from '../kill-switch';
import { PluginHooks } from '../plugin-hooks.service';
@@ -84,7 +86,7 @@ function normalize(pluginId: string, raw: unknown, allowed: Set<number>): TripCa
export class TripCardContributionsController {
constructor(
private readonly hooks: PluginHooks,
private readonly dbs: DatabaseService,
@InjectRepository(Trips) private readonly trips: TripsRepository,
) {}
@Get()
@@ -114,7 +116,7 @@ export class TripCardContributionsController {
// produces (the filtered `uniqueRequested` order) is load-bearing.
const accessible: number[] = [];
for (const id of uniqueRequested) {
if (await this.dbs.canAccessTrip(id, userId)) accessible.push(id);
if (await this.trips.findAccessible(id, userId)) accessible.push(id);
}
if (accessible.length === 0) return { contributions: [] };
const allowed = new Set(accessible);
@@ -1,6 +1,8 @@
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { DatabaseService } from '../../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Trips } from '../../../db/entities/Trips.entity';
import type { TripsRepository } from '../../../db/repositories/Trips.repository';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { pluginsEnabled } from '../kill-switch';
import { PluginHooks } from '../plugin-hooks.service';
@@ -29,7 +31,7 @@ const MESSAGE_MAX = 300;
export class TripWarningsController {
constructor(
private readonly hooks: PluginHooks,
private readonly dbs: DatabaseService,
@InjectRepository(Trips) private readonly trips: TripsRepository,
) {}
@Get(':tripId')
@@ -40,7 +42,7 @@ export class TripWarningsController {
if (!pluginsEnabled()) return { warnings: [] };
const tripId = Number(tripIdRaw);
const userId = req.user?.id;
if (!Number.isFinite(tripId) || userId == null || !(await this.dbs.canAccessTrip(tripId, userId))) return { warnings: [] };
if (!Number.isFinite(tripId) || userId == null || !(await this.trips.findAccessible(tripId, userId))) return { warnings: [] };
const ids = this.hooks.providersOf('warningProvider');
const perProvider = await Promise.all(
@@ -1,7 +1,9 @@
import { z } from 'zod';
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext, type McpTextResult } from '../../../nest-mcp';
import { noAccess } from '../../../mcp/tools/_shared';
import { DatabaseService } from '../../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Trips } from '../../../db/entities/Trips.entity';
import type { TripsRepository } from '../../../db/repositories/Trips.repository';
import { pluginsEnabled } from '../kill-switch';
import { PluginHooks } from '../plugin-hooks.service';
import { stripEmoji } from '../text-sanitize';
@@ -40,7 +42,7 @@ const MESSAGE_MAX = 300;
export class TripWarningsMcp {
constructor(
private readonly hooks: PluginHooks,
private readonly dbs: DatabaseService,
@InjectRepository(Trips) private readonly trips: TripsRepository,
) {}
@Tool({
@@ -55,7 +57,7 @@ export class TripWarningsMcp {
async getTripWarnings({ tripId }: { tripId: number }, ctx: McpContext): Promise<McpTextResult> {
// Access first, so the answer to "may I look at this trip" does not depend on
// whether an admin has the plugin system switched on.
if (!(await this.dbs.canAccessTrip(tripId, ctx.userId))) return noAccess();
if (!(await this.trips.findAccessible(tripId, ctx.userId))) return noAccess();
if (!pluginsEnabled()) return ok({ warnings: [] });
const ids = this.hooks.providersOf('warningProvider');
@@ -1,6 +1,8 @@
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { DatabaseService } from '../../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Trips } from '../../../db/entities/Trips.entity';
import type { TripsRepository } from '../../../db/repositories/Trips.repository';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { pluginsEnabled } from '../kill-switch';
import { PluginHooks } from '../plugin-hooks.service';
@@ -104,7 +106,7 @@ function normalize(pluginId: string, raw: unknown): Contribution[] {
export class ViewContributionsController {
constructor(
private readonly hooks: PluginHooks,
private readonly dbs: DatabaseService,
@InjectRepository(Trips) private readonly trips: TripsRepository,
) {}
@Get(':view/:tripId')
@@ -116,7 +118,7 @@ export class ViewContributionsController {
if (!pluginsEnabled() || !VIEWS.has(view)) return { contributions: [] };
const tripId = Number(tripIdRaw);
const userId = req.user?.id;
if (!Number.isFinite(tripId) || userId == null || !(await this.dbs.canAccessTrip(tripId, userId))) return { contributions: [] };
if (!Number.isFinite(tripId) || userId == null || !(await this.trips.findAccessible(tripId, userId))) return { contributions: [] };
const ids = this.hooks.providersOf('tableContributor');
const perProvider = await Promise.all(
@@ -3,6 +3,7 @@ import { MikroOrmModule } from '@mikro-orm/nestjs';
import { PermissionsModule } from '../../permissions/permissions.module';
import { AddonsModule } from '../../addons/addons.module';
import { Users } from '../../../db/entities/Users.entity';
import { Trips } from '../../../db/entities/Trips.entity';
import { PluginGuards } from './plugin-guards.service';
/**
@@ -23,7 +24,7 @@ import { PluginGuards } from './plugin-guards.service';
* AuthModule <-> NotificationsModule.
*/
@Module({
imports: [PermissionsModule, AddonsModule, MikroOrmModule.forFeature([Users])],
imports: [PermissionsModule, AddonsModule, MikroOrmModule.forFeature([Users, Trips])],
providers: [PluginGuards],
exports: [PluginGuards],
})
@@ -1,10 +1,11 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { DatabaseService } from '../../database/database.service';
import { PermissionsService } from '../../permissions/permissions.service';
import { AddonsService } from '../../addons/addons.service';
import { Users } from '../../../db/entities/Users.entity';
import type { UsersRepository } from '../../../db/repositories/Users.repository';
import { Trips } from '../../../db/entities/Trips.entity';
import type { TripsRepository } from '../../../db/repositories/Trips.repository';
import { BadParams, ForbiddenResource } from './rpc-errors';
import { num } from './rpc-params';
import type { PluginRpcContext } from './rpc-kit/types';
@@ -23,7 +24,10 @@ import type { PluginRpcContext } from './rpc-kit/types';
@Injectable()
export class PluginGuards {
constructor(
private readonly db: DatabaseService,
// Plan 4 Task 2 — canAccessTrip's own DatabaseService delegation is gone:
// this injects TripsRepository directly (same constructor slot) and
// calls findAccessible.
@InjectRepository(Trips) private readonly trips: TripsRepository,
private readonly permissions: PermissionsService,
private readonly addons: AddonsService,
@InjectRepository(Users) private readonly users: UsersRepository,
@@ -40,7 +44,7 @@ export class PluginGuards {
if (ctx.actingUserId === undefined) {
throw new ForbiddenResource('trip reads require an authenticated user context');
}
if (!(await this.db.canAccessTrip(tripId, ctx.actingUserId))) {
if (!(await this.trips.findAccessible(tripId, ctx.actingUserId))) {
throw new ForbiddenResource(`no access to trip ${tripId}`);
}
// The read runs only for a bound, membership-checked user, so hand the id through
@@ -61,7 +65,7 @@ export class PluginGuards {
/** A write is allowed only if the acting user can access AND edit the trip. */
async requireTripEdit(tripId: number, userId: number, action: string): Promise<void> {
if (!(await this.db.canAccessTrip(tripId, userId))) throw new ForbiddenResource(`no access to trip ${tripId}`);
if (!(await this.trips.findAccessible(tripId, userId))) throw new ForbiddenResource(`no access to trip ${tripId}`);
if (!(await this.canEditAs(action, tripId, userId))) throw new ForbiddenResource(`no permission to edit trip ${tripId}`);
}
@@ -71,7 +75,7 @@ export class PluginGuards {
* message the refusal carries.
*/
async canEditAs(action: string, tripId: number, userId: number): Promise<boolean> {
const trip = await this.db.canAccessTrip(tripId, userId);
const trip = await this.trips.findAccessible(tripId, userId);
if (!trip) return false;
// UsersRepository.getRole (PG3, Plan 3j Task 1) — `SELECT role FROM users
// WHERE id = ?`, converted. Its `string | null` return already folds "no
@@ -13,7 +13,6 @@ import { Trips } from '../../../../db/entities/Trips.entity';
import type { TripsRepository } from '../../../../db/repositories/Trips.repository';
import { PluginScheduledTasks } from '../../../../db/entities/PluginScheduledTasks.entity';
import type { PluginScheduledTasksRepository } from '../../../../db/repositories/PluginScheduledTasks.repository';
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';
@@ -45,7 +44,8 @@ const AI_TEXT_MAX = 20_000;
@PluginController()
export class HostSurfaceRpc {
constructor(
private readonly db: DatabaseService,
// Plan 4 Task 2 — canAccessTrip's own DatabaseService delegation is gone:
// this reuses the `trips: TripsRepository` param below (findAccessible).
private readonly realtime: RealtimeService,
private readonly notifications: NotificationsService,
private readonly llmConfig: LlmConfigResolver,
@@ -82,7 +82,7 @@ export class HostSurfaceRpc {
// 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 (!(await this.db.canAccessTrip(tripId, ctx.actingUserId))) throw new ForbiddenResource(`no access to trip ${tripId}`);
if (!(await this.trips.findAccessible(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));
@@ -121,7 +121,7 @@ export class HostSurfaceRpc {
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' && !(await this.db.canAccessTrip(targetId, actor))) {
if (scope === 'trip' && !(await this.trips.findAccessible(targetId, actor))) {
throw new ForbiddenResource('the acting user is not a member of that trip');
}
const link = this.safeLink(input.link);
+3 -3
View File
@@ -4,7 +4,6 @@ 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';
import { PluginEntityMetadata } from '../../../../db/entities/PluginEntityMetadata.entity';
import type { PluginEntityMetadataRepository } from '../../../../db/repositories/PluginEntityMetadata.repository';
import { Trips } from '../../../../db/entities/Trips.entity';
@@ -49,7 +48,8 @@ const EDIT_ACTION: Record<string, string> = {
@PluginController()
export class MetaRpc {
constructor(
private readonly db: DatabaseService,
// Plan 4 Task 2 — canAccessTrip's own DatabaseService delegation is gone:
// this reuses the `trips: TripsRepository` param below (findAccessible).
private readonly guards: PluginGuards,
@InjectRepository(PluginEntityMetadata) private readonly meta: PluginEntityMetadataRepository,
@InjectRepository(Trips) private readonly trips: TripsRepository,
@@ -124,7 +124,7 @@ export class MetaRpc {
const entityId = num(params.entityId, 'entityId');
if (ctx.actingUserId === undefined) throw new ForbiddenResource('metadata requires an authenticated user context');
const tripId = await this.entityTrip(entityType, entityId);
if (tripId === undefined || !(await this.db.canAccessTrip(tripId, ctx.actingUserId))) {
if (tripId === undefined || !(await this.trips.findAccessible(tripId, ctx.actingUserId))) {
throw new ForbiddenResource(`no access to ${entityType} ${entityId}`);
}
if (write && !(await this.guards.canEditAs(EDIT_ACTION[entityType], tripId, ctx.actingUserId))) {
@@ -9,7 +9,9 @@ import { ADDON_IDS } from '../../addons';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { AuthService } from '../auth/auth.service';
import { DatabaseService } from '../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { AirtrailImportService } from '../integrations/airtrail-import.service';
@@ -42,7 +44,10 @@ const MAX_MCP_AIRTRAIL_FLIGHTS = 50;
export class ReservationImportMcp {
constructor(
private readonly airtrailImport: AirtrailImportService,
private readonly db: DatabaseService,
// Plan 4 Task 2 — canAccessTrip's own DatabaseService delegation is gone:
// this injects TripsRepository directly (same constructor slot) and
// calls findAccessible.
@InjectRepository(Trips) private readonly trips: TripsRepository,
private readonly auth: AuthService,
private readonly guards: McpToolGuardsService,
readonly addons: AddonsService,
@@ -70,7 +75,7 @@ export class ReservationImportMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
if (!(await this.trips.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
if (flightIds.length > MAX_MCP_AIRTRAIL_FLIGHTS) {
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { ReservationImportController } from './reservation-import.controller';
import { ReservationImportMcp } from './reservation-import.mcp';
import { BookingImportModule } from '../booking-import/booking-import.module';
@@ -7,6 +8,7 @@ import { AddonsModule } from '../addons/addons.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { AuthModule } from '../auth/auth.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { Trips } from '../../db/entities/Trips.entity';
/**
* The one route prefix that turns something external into reservations.
@@ -21,7 +23,7 @@ import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
* permission/broadcast guards; neither is @Global, so both are named here.
*/
@Module({
imports: [BookingImportModule, AirtrailModule, AddonsModule, PermissionsModule, AuthModule, McpSharedModule],
imports: [BookingImportModule, AirtrailModule, AddonsModule, PermissionsModule, AuthModule, McpSharedModule, MikroOrmModule.forFeature([Trips])],
controllers: [ReservationImportController],
providers: [ReservationImportMcp],
})
+8 -3
View File
@@ -16,7 +16,9 @@ import {
} from './transit-itinerary.helpers';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { RateLimitService } from '../common/rate-limit.service';
import { DatabaseService } from '../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
import { DaysService } from '../days/days.service';
import { ReservationsService } from '../reservations/reservations.service';
import { SCHEDULED_TRANSIT_MODES, type TransitItinerary } from './transit.helpers';
@@ -63,7 +65,10 @@ export class TransitMcp {
private readonly transit: TransitService,
private readonly days: DaysService,
private readonly reservations: ReservationsService,
private readonly db: DatabaseService,
// Plan 4 Task 2 — canAccessTrip's own DatabaseService delegation is gone:
// this injects TripsRepository directly (same constructor slot) and
// calls findAccessible.
@InjectRepository(Trips) private readonly trips: TripsRepository,
private readonly auth: AuthService,
private readonly guards: McpToolGuardsService,
) {}
@@ -184,7 +189,7 @@ export class TransitMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.db.canAccessTrip(tripId, ctx.userId))) return noAccess();
if (!(await this.trips.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
const day = await this.days.getDay(dayId, tripId);
if (!day) {
+5 -5
View File
@@ -11,6 +11,7 @@ import { AuthModule } from '../auth/auth.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { AppSettings } from '../../db/entities/AppSettings.entity';
import { Users } from '../../db/entities/Users.entity';
import { Trips } from '../../db/entities/Trips.entity';
/**
* Transit domain (#1065) — the Transitous/MOTIS proxy, with the optional
@@ -18,18 +19,17 @@ import { Users } from '../../db/entities/Users.entity';
* decorator-registered MCP tools; DaysModule/ReservationsModule feed
* create_transit_journey. Exports TransitService for in-container consumers.
*
* MikroOrmModule.forFeature([AppSettings, Users]): GoogleTransitProvider
* MikroOrmModule.forFeature([AppSettings, Users, Trips]): GoogleTransitProvider
* passes its own AppSettingsRepository/UsersRepository to
* instance-api-keys.ts's resolveApiKey (Plan 3a Task 5) and, since Plan 4
* Task 1, to `transit-provider.ts`'s `readTransitProvider`/
* `writeTransitProvider` too — the SAME `AppSettingsRepository` this module
* already registers. `transit.mcp.ts`'s remaining `DatabaseService`
* injection is unrelated: it is the `canAccessTrip` delegate (Plan 4
* Task 2/3's facade-inline sweep, not this plan's).
* already registers. `transit.mcp.ts`'s own `canAccessTrip` delegate is now
* `TripsRepository.findAccessible` directly (Plan 4 Task 2).
*/
@Module({
// DaysModule + ReservationsModule: TransitMcp's create_transit_journey injects both.
imports: [McpSharedModule, RateLimitModule, DaysModule, ReservationsModule, AuthModule, MikroOrmModule.forFeature([AppSettings, Users])],
imports: [McpSharedModule, RateLimitModule, DaysModule, ReservationsModule, AuthModule, MikroOrmModule.forFeature([AppSettings, Users, Trips])],
controllers: [TransitController],
providers: [TransitService, TransitMcp, GoogleTransitProvider],
exports: [TransitService],