refactor(mcp): the demo-mode write block runs once, in the registry's tool gate

Every write tool used to open with its own demo check, 196 copies across 30
tool files, most of them injecting AuthService for nothing else. The
registry now takes a toolGate (built from the container) that runs before
each registered tool handler, and TREK's gate refuses a demo account on
every tool not annotated read-only with the same canned result. The
per-tool checks, the AuthService injections, AuthService.isDemoUser and the
AuthModule imports that only fed them are gone; ESLint refuses a demo check
in a tool file, and a registry-wide test calls every write tool as the
demo account.
This commit is contained in:
Maurice
2026-10-10 11:59:56 +02:00
parent 8350d04a63
commit 1a9855cf70
79 changed files with 691 additions and 527 deletions
+24 -1
View File
@@ -1,4 +1,4 @@
import type { McpAccessGroup, McpAccessMode, McpAccessPolicy, McpAccessValidator } from '../nest-mcp';
import { demoDenied, type McpAccessGroup, type McpAccessMode, type McpAccessPolicy, type McpAccessValidator, type McpToolGate, type ToolOptions } from '../nest-mcp';
import { ALL_SCOPES, canRead, canWrite, type Scope, type ScopeGroup } from './scopes';
/** The mode half of every scope: 'read' | 'write' | 'delete' | 'share'. */
@@ -45,3 +45,26 @@ const VALID_GROUP_MODES: ReadonlySet<string> = new Set(ALL_SCOPES);
*/
export const trekMcpValidateAccess: McpAccessValidator = ({ group, mode }) =>
VALID_GROUP_MODES.has(`${group}:${mode}`) ? null : `no '${group}:${mode}' scope in SCOPES`;
/**
* True for a tool the demo gate holds back: every tool that does not declare
* itself read-only. The annotation, not `access.mode`, is the signal, because
* the share/content scopes cover reads too (get_share_link,
* get_trip_calendar_feed, read_trip_file) and the predicate-gated tools carry
* no mode at all. tests/unit/mcp/demo-tool-gate.test.ts holds the two in step:
* every `mode: 'read'` tool is read-only and every `mode: 'write'` one is not.
*/
export function isDemoGatedTool(tool: ToolOptions): boolean {
return tool.annotations?.readOnlyHint !== true;
}
/**
* The demo-mode write block for every registered tool, in one place: a demo
* account calling a tool that is not read-only gets the canned refusal, the
* same result each tool used to return from its own first line. Given to
* McpModule.forRoot as `toolGate` (AppModule) and to the MCP test registry.
* Plugin tools are dynamic and keep their own gate in PluginMcpToolsService.
*/
export function trekDemoToolGate(isDemoUser: (userId: number) => Promise<boolean>): McpToolGate {
return async (tool, ctx) => (isDemoGatedTool(tool) && (await isDemoUser(ctx.userId)) ? demoDenied() : undefined);
}
+23 -1
View File
@@ -60,11 +60,33 @@ import { ok, errorResult, demoDenied, TOOL_ANNOTATIONS_READONLY } from '../../ne
ok({ tags }) // { content: [{ type: 'text', text: <pretty JSON> }] }
errorResult('Tag not found.') // { content: [...], isError: true } — message verbatim
demoDenied() // canned demo-mode write refusal
demoDenied() // canned demo-mode write refusal (the host's tool gate returns it)
```
Six `TOOL_ANNOTATIONS_*` presets cover the read/write/delete/idempotency matrix (plus `OPEN_WORLD` variants). TREK-specific canned errors (permission wording, RBAC lookups) belong in the host layer, built on `errorResult` — see `server/src/mcp/tools/_shared.ts`, which re-exports the generic helpers from here.
### Tool gate
`toolGate` runs before every registered tool handler, after the SDK validated
the arguments and inside `around` (so the trace and the `onInvoke` audit still
see a refused call). Returning a result answers the call with it; `undefined`
runs the handler. Dynamic tools are not gated: their source owns its checks.
```ts
McpModule.forRoot({
accessPolicy,
toolGate: { inject: [DemoService], useFactory: (demo) => (tool, ctx) => /* result or undefined */ },
})
createTestRegistry(instances, { accessPolicy, toolGate: (tool, ctx) => undefined });
```
The gate is built from the container (`imports`/`inject`/`useFactory`), because
a check that reads the database cannot live in static `forRoot` options. TREK's
is `trekDemoToolGate` in `src/mcp/nest-mcp-policy.ts`: a demo account calling a
tool not annotated `readOnlyHint: true` gets `demoDenied()`. Tools therefore
never check demo mode themselves; ESLint refuses `isDemoUser`/`isDemoUserId`/
`demoDenied()` in `*.mcp.ts`.
### Fail-fast validation
`McpRegistryService` runs `registry.validate()` at boot (`onModuleInit`), and `createTestRegistry` runs it on construction, so misconfiguration breaks startup instead of MCP session creation:
+3
View File
@@ -17,6 +17,7 @@ export { McpRegistry, type McpRegistryOptions } from './registry';
export { createTestRegistry } from './testing';
export {
MCP_MODULE_OPTIONS,
MCP_TOOL_GATE,
type McpAccess,
type McpAccessGroup,
type McpAccessGroupRegistry,
@@ -34,6 +35,8 @@ export {
type McpEntryKind,
type McpModuleOptions,
type McpRegistryListing,
type McpToolGate,
type McpToolGateProvider,
type McpZodSchema,
type PromptArgsShape,
type PromptOptions,
+3 -2
View File
@@ -1,6 +1,6 @@
import { isMcpController } from './metadata';
import { McpRegistry } from './registry';
import { MCP_MODULE_OPTIONS, type McpModuleOptions } from './types';
import { MCP_MODULE_OPTIONS, MCP_TOOL_GATE, type McpModuleOptions, type McpToolGate } from './types';
import { Inject, Injectable, type OnModuleInit } from '@nestjs/common';
import { DiscoveryService, MetadataScanner } from '@nestjs/core';
@@ -15,8 +15,9 @@ export class McpRegistryService extends McpRegistry implements OnModuleInit {
private readonly discovery: DiscoveryService,
private readonly scanner: MetadataScanner,
@Inject(MCP_MODULE_OPTIONS) options: McpModuleOptions,
@Inject(MCP_TOOL_GATE) toolGate: McpToolGate | null,
) {
super(options);
super({ accessPolicy: options.accessPolicy, validateAccess: options.validateAccess, toolGate: toolGate ?? undefined });
}
onModuleInit(): void {
+12 -4
View File
@@ -1,5 +1,5 @@
import { McpRegistryService } from './mcp-registry.service';
import { MCP_MODULE_OPTIONS, type McpModuleOptions } from './types';
import { MCP_MODULE_OPTIONS, MCP_TOOL_GATE, type McpModuleOptions } from './types';
import { Module, type DynamicModule } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';
@@ -8,14 +8,22 @@ export class McpModule {
/**
* Registers the discovery-backed `McpRegistryService` globally. The
* `accessPolicy` resolves every declarative `access: { group, mode }`
* marker — the package itself defines no scope semantics.
* marker — the package itself defines no scope semantics. `toolGate` is
* built from the container, so the gate can inject what it needs.
*/
static forRoot(options: McpModuleOptions = {}): DynamicModule {
const gate = options.toolGate;
return {
module: McpModule,
global: true,
imports: [DiscoveryModule],
providers: [{ provide: MCP_MODULE_OPTIONS, useValue: options }, McpRegistryService],
imports: [DiscoveryModule, ...(gate?.imports ?? [])],
providers: [
{ provide: MCP_MODULE_OPTIONS, useValue: options },
gate
? { provide: MCP_TOOL_GATE, useFactory: gate.useFactory, inject: gate.inject ?? [] }
: { provide: MCP_TOOL_GATE, useValue: null },
McpRegistryService,
],
exports: [McpRegistryService],
};
}
+20 -1
View File
@@ -9,6 +9,7 @@ import type {
McpEntry,
McpEntryKind,
McpRegistryListing,
McpToolGate,
PromptOptions,
ResourceOptions,
ResourceTemplateOptions,
@@ -37,6 +38,8 @@ function strictInputSchema(schema: ToolOptions['inputSchema']): unknown {
export interface McpRegistryOptions {
accessPolicy?: McpAccessPolicy;
validateAccess?: McpAccessValidator;
/** Runs before every registered tool handler; see `McpToolGate`. */
toolGate?: McpToolGate;
}
type AnyHandler = (this: unknown, ...handlerArgs: unknown[]) => unknown;
@@ -102,12 +105,14 @@ export class McpRegistry {
private readonly bound: BoundEntry[] = [];
private readonly accessPolicy?: McpAccessPolicy;
private readonly validateAccess?: McpAccessValidator;
private readonly toolGate?: McpToolGate;
/** Memoised `reservedNames()`; dropped by register() so it can never go stale. */
private reserved?: ReadonlySet<string>;
constructor(options: McpRegistryOptions = {}) {
this.accessPolicy = options.accessPolicy;
this.validateAccess = options.validateAccess;
this.toolGate = options.toolGate;
}
/**
@@ -140,7 +145,7 @@ export class McpRegistry {
const handler = (instance as unknown as Record<string, AnyHandler>)[entry.methodName];
switch (entry.kind) {
case 'tool':
this.attachTool(registrar, entry.options, instance, handler, ctx, opts);
this.attachTool(registrar, entry.options, instance, this.gated(entry.options, handler, ctx), ctx, opts);
break;
case 'resource':
this.attachResource(registrar, entry.options, instance, handler, ctx, opts);
@@ -301,6 +306,20 @@ export class McpRegistry {
return this.accessPolicy(access, ctx);
}
/**
* The handler as the session calls it: behind the host's tool gate when one
* is configured. Only registered tools come through here; a dynamic tool's
* source owns its own checks.
*/
private gated(options: ToolOptions, handler: AnyHandler, ctx: McpContext): AnyHandler {
const gate = this.toolGate;
if (!gate) return handler;
return async function (this: unknown, ...handlerArgs: unknown[]) {
const refusal = await gate(options, ctx);
return refusal !== undefined ? refusal : handler.apply(this, handlerArgs);
};
}
// D6: this callback is invoked from inside McpTransportController's @Post/@Get/
// @Delete handlers (mcp-transport.controller.ts), which are ordinary Nest routes —
// `@Public()` only exempts the auth guard, and the raw-body parser exemption in
+29
View File
@@ -1,5 +1,6 @@
import type { Scope, ScopeGroup } from '../mcp/scopes';
import type { InjectionToken, ModuleMetadata } from '@nestjs/common';
import type { ZodRawShape, ZodType } from 'zod';
/**
@@ -254,10 +255,38 @@ export interface McpRegistryListing {
*/
export type McpAccessValidator = (access: McpDeclarativeAccess, entry: McpRegistryListing) => string | null | undefined;
/**
* Host-supplied check run before every registered tool handler, after the
* SDK validated the arguments and inside the `around` wrapper. Return a result
* to answer the call with it instead of running the handler, or undefined to
* let the handler run. The package attaches no meaning to it; TREK's demo-mode
* write block lives in `src/mcp/nest-mcp-policy.ts`.
*
* Registered entries only: a dynamic tool's source owns its own checks,
* because the host cannot know what a contributor's annotations promise.
*/
export type McpToolGate = (tool: ToolOptions, ctx: McpContext) => unknown;
/**
* How the module builds its `McpToolGate`: a factory with the providers it
* needs, resolved from the container like any other provider. A gate that
* reads the database has to come from DI, and `forRoot` options are static.
*/
export interface McpToolGateProvider {
/** Modules exporting what `inject` names, unless those providers are global. */
imports?: NonNullable<ModuleMetadata['imports']>;
inject?: InjectionToken[];
useFactory: (...deps: never[]) => McpToolGate;
}
export interface McpModuleOptions {
accessPolicy?: McpAccessPolicy;
validateAccess?: McpAccessValidator;
toolGate?: McpToolGateProvider;
}
/** Injection token for the resolved `McpToolGate` (null when none was configured). */
export const MCP_TOOL_GATE = Symbol('MCP_TOOL_GATE');
/** Injection token for the options object given to `McpModule.forRoot()`. */
export const MCP_MODULE_OPTIONS = Symbol('MCP_MODULE_OPTIONS');
@@ -1,12 +1,11 @@
import {
McpController, Tool, ResourceTemplate, type McpContext,
TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
ok,
} from '../../nest-mcp';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { placeWebsiteSchema } from '@trek/shared';
import { z } from 'zod';
import { AuthService } from '../auth/auth.service';
import { PlacesService } from '../places/places.service';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { UnitOfWork } from '../database/unit-of-work';
@@ -40,7 +39,6 @@ export class AccommodationsMcp {
constructor(
private readonly accommodations: AccommodationsService,
private readonly places: PlacesService,
private readonly auth: AuthService,
private readonly guards: McpToolGuardsService,
private readonly uow: UnitOfWork,
) {}
@@ -75,7 +73,6 @@ export class AccommodationsMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.accommodations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const errors = await this.accommodations.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
@@ -126,7 +123,6 @@ export class AccommodationsMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.accommodations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const dayErrors = await this.accommodations.validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id);
@@ -178,7 +174,6 @@ export class AccommodationsMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.accommodations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const existing = await this.accommodations.getAccommodation(accommodationId, tripId);
@@ -202,7 +197,6 @@ export class AccommodationsMcp {
access: { group: 'trips', mode: 'write' },
})
async deleteAccommodation({ tripId, accommodationId }: { tripId: number; accommodationId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.accommodations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.accommodations.getAccommodation(accommodationId, tripId))) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
@@ -4,7 +4,6 @@ import { AccommodationsRpc } from './accommodations.rpc';
import { AccommodationsMcp } from './accommodations.mcp';
import { PlacesModule } from '../places/places.module';
import { AccommodationsDomainModule } from './accommodations-domain.module';
import { AuthModule } from '../auth/auth.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
@@ -27,7 +26,7 @@ import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
* which comes near accommodations.
*/
@Module({
imports: [McpSharedModule, PermissionsModule, RealtimeModule, PluginGuardsModule, PlacesModule, AccommodationsDomainModule, AuthModule],
imports: [McpSharedModule, PermissionsModule, RealtimeModule, PluginGuardsModule, PlacesModule, AccommodationsDomainModule],
controllers: [AccommodationsController],
providers: [AccommodationsRpc, AccommodationsMcp],
// The MODULE, not the provider: Nest refuses to export a provider that belongs to
+7 -2
View File
@@ -1,4 +1,4 @@
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../mcp/nest-mcp-policy';
import { trekDemoToolGate, trekMcpAccessPolicy, trekMcpValidateAccess } from '../mcp/nest-mcp-policy';
import mikroOrmConfig from '../mikro-orm.config';
import { AppSettings } from '../db/entities/AppSettings.entity';
import { Users } from '../db/entities/Users.entity';
@@ -24,6 +24,7 @@ import { CategoriesModule } from './categories/categories.module';
import { CollabModule } from './collab/collab.module';
import { CollectionsModule } from './collections/collections.module';
import { DemoModule } from './common/demo.module';
import { DemoService } from './common/demo.service';
import { IdempotencyCleanupJob } from './common/idempotency-cleanup.job';
import { IdempotencyInterceptor } from './common/idempotency.interceptor';
import { ManagedGuard } from './common/managed.guard';
@@ -100,7 +101,11 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
RealtimeModule,
RealtimeGatewayModule,
SchedulingModule,
McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }),
McpModule.forRoot({
accessPolicy: trekMcpAccessPolicy,
validateAccess: trekMcpValidateAccess,
toolGate: { inject: [DemoService], useFactory: (demo: DemoService) => trekDemoToolGate((id) => demo.isDemoUserId(id)) },
}),
HealthModule,
PlatformModule,
McpTransportModule,
+1 -14
View File
@@ -2,12 +2,11 @@ import {
McpController, Tool, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
errorResult, ok,
} from '../../nest-mcp';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { z } from 'zod';
import { assignmentEndDayRequestSchema, type AssignmentEndDayRequest } from '@trek/shared';
import { AuthService } from '../auth/auth.service';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { AssignmentsService } from './assignments.service';
import { DaysService } from '../days/days.service';
@@ -26,7 +25,6 @@ export class AssignmentsMcp {
constructor(
private readonly assignments: AssignmentsService,
private readonly days: DaysService,
private readonly auth: AuthService,
private readonly guards: McpToolGuardsService,
) {}
@@ -46,7 +44,6 @@ export class AssignmentsMcp {
{ tripId, dayId, placeId, notes }: { tripId: number; dayId: number; placeId: number; notes?: string },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.assignments.dayExists(dayId, tripId))) return errorResult('Day not found.');
@@ -72,7 +69,6 @@ export class AssignmentsMcp {
{ tripId, assignmentId, end_day }: AssignmentEndDayRequest & { tripId: number; assignmentId: number },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.assignments.getAssignmentForTrip(assignmentId, tripId))) return errorResult('Assignment not found.');
@@ -96,7 +92,6 @@ export class AssignmentsMcp {
{ tripId, dayId, assignmentId }: { tripId: number; dayId: number; assignmentId: number },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.assignments.assignmentExistsInDay(assignmentId, dayId, tripId)))
@@ -121,7 +116,6 @@ export class AssignmentsMcp {
{ tripId, dayId }: { tripId: number; dayId: number },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.assignments.dayExists(dayId, tripId))) return errorResult('Day not found.');
@@ -149,7 +143,6 @@ export class AssignmentsMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const existing = await this.assignments.getAssignmentForTrip(assignmentId, tripId);
@@ -185,7 +178,6 @@ export class AssignmentsMcp {
{ tripId, assignmentId, notes }: { tripId: number; assignmentId: number; notes: string | null },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.assignments.getAssignmentForTrip(assignmentId, tripId))) return errorResult('Assignment not found.');
@@ -212,7 +204,6 @@ export class AssignmentsMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.assignments.getAssignmentForTrip(assignmentId, tripId))) return errorResult('Assignment not found.');
@@ -238,7 +229,6 @@ export class AssignmentsMcp {
{ tripId, assignmentId, excluded }: { tripId: number; assignmentId: number; excluded: boolean },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.assignments.getAssignmentForTrip(assignmentId, tripId))) return errorResult('Assignment not found.');
@@ -266,7 +256,6 @@ export class AssignmentsMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.assignments.getAssignmentForTrip(assignmentId, tripId))) return errorResult('Assignment not found.');
@@ -315,7 +304,6 @@ export class AssignmentsMcp {
{ tripId, assignmentId, userIds }: { tripId: number; assignmentId: number; userIds: number[] },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.assignments.getAssignmentForTrip(assignmentId, tripId))) return errorResult('Assignment not found.');
@@ -339,7 +327,6 @@ export class AssignmentsMcp {
{ tripId, dayId, assignmentIds }: { tripId: number; dayId: number; assignmentIds: number[] },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.assignments.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.days.getDay(dayId, tripId))) return errorResult('Day not found.');
@@ -7,7 +7,6 @@ import { ItineraryRpc } from './itinerary.rpc';
import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { AssignmentsMcp } from './assignments.mcp';
import { AuthModule } from '../auth/auth.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
/**
@@ -21,7 +20,7 @@ import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
@Module({
// DaysModule: AssignmentsMcp injects DaysService for the target-day checks.
// PermissionsModule: the controllers' TripAccessGuard injects PermissionsService.
imports: [McpSharedModule, AssignmentsDomainModule, DaysModule, PermissionsModule, AuthModule, RealtimeModule, PluginGuardsModule],
imports: [McpSharedModule, AssignmentsDomainModule, DaysModule, PermissionsModule, RealtimeModule, PluginGuardsModule],
controllers: [DayAssignmentsController, AssignmentOpsController],
providers: [AssignmentsMcp, ItineraryRpc],
exports: [AssignmentsDomainModule],
+1 -10
View File
@@ -2,14 +2,13 @@ import {
McpController, Tool, Resource, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
errorResult, ok,
} from '../../nest-mcp';
import { z } from 'zod';
import { ADDON_IDS } from '../../addons';
import { AtlasService, BucketItemExistsError } from './atlas.service';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { AuthService } from '../auth/auth.service';
/** Legacy registrar gate: the whole atlas surface (tools AND resources) rides
* the atlas addon — unlike the REST controller, which is deliberately ungated
@@ -58,7 +57,6 @@ export class AtlasMcp {
constructor(
private readonly atlas: AtlasService,
readonly addons: AddonsService,
private readonly auth: AuthService,
) {}
// ── Bucket list ─────────────────────────────────────────────────────────
@@ -87,7 +85,6 @@ export class AtlasMcp {
{ name, lat, lng, country_code, notes, target_date, region_code }: { name: string; lat?: number; lng?: number; country_code?: string; notes?: string; target_date?: string | null; region_code?: string },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
// The same rule the REST contract enforces: a region comes with its own country.
if (region_code && (!country_code || !region_code.toUpperCase().startsWith(`${country_code.toUpperCase()}-`))) {
return errorResult('region_code must belong to country_code.');
@@ -112,7 +109,6 @@ export class AtlasMcp {
access: { group: 'atlas', mode: 'write' },
})
async deleteBucketListItem({ itemId }: { itemId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const deleted = await this.atlas.deleteBucketItem(ctx.userId, itemId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true };
return ok({ success: true });
@@ -131,7 +127,6 @@ export class AtlasMcp {
access: { group: 'atlas', mode: 'write' },
})
async markCountryVisited({ country_code }: { country_code: string }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
await this.atlas.markCountry(ctx.userId, country_code.toUpperCase());
return ok({ success: true, country_code: country_code.toUpperCase() });
}
@@ -147,7 +142,6 @@ export class AtlasMcp {
access: { group: 'atlas', mode: 'write' },
})
async unmarkCountryVisited({ country_code }: { country_code: string }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
await this.atlas.unmarkCountry(ctx.userId, country_code.toUpperCase());
return ok({ success: true, country_code: country_code.toUpperCase() });
}
@@ -231,7 +225,6 @@ export class AtlasMcp {
{ regionCode, regionName, countryCode }: { regionCode: string; regionName: string; countryCode: string },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
// Post-fold quirk fix: uppercase both codes, matching the REST controller
// (the legacy registrar passed them through verbatim, so a lowercase mark
// created a row REST's uppercased unmark could never hit).
@@ -256,7 +249,6 @@ export class AtlasMcp {
access: { group: 'atlas', mode: 'write' },
})
async unmarkRegionVisited({ regionCode }: { regionCode: string }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
// Post-fold quirk fix: uppercase, matching the REST controller.
await this.atlas.unmarkRegion(ctx.userId, regionCode.toUpperCase());
return ok({ success: true });
@@ -307,7 +299,6 @@ export class AtlasMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
let item: unknown;
try {
item = await this.atlas.updateBucketItem(ctx.userId, itemId, { name, notes, lat, lng, country_code, target_date });
+2 -2
View File
@@ -66,8 +66,8 @@ import { AllowedFileTypesModule } from '../files/allowed-file-types.module';
* AuthModule when BudgetMcp's demo guard moved off AuthService, so this module
* imports it and UserCleanupService injects BudgetService (which retired
* budget.bridge). AuthService is
* exported for the in-container consumers (the domain *.mcp.ts demo guards,
* OidcService, PasskeyEnabledGuard); PasskeyService for AdminService's passkey
* exported for the in-container consumers (VacayMcp, OidcService,
* PasskeyEnabledGuard); PasskeyService for AdminService's passkey
* reset; UserCleanupService for the two account-deletion paths (AdminService)
* and the guest deletion in TripsService; everything outside the container goes
* through auth.bridge.ts.
+3 -9
View File
@@ -1102,19 +1102,13 @@ export class AuthService {
}
// -------------------------------------------------------------------------
// Demo gate + JWT verification
// JWT verification
//
// The MCP token half of this section moved to tokens/token.service.ts. What
// stays is login identity (verifyJwtToken) and the demo check, neither of
// which is about minting a token.
// stays is login identity (verifyJwtToken), which is not about minting a token.
// The MCP demo check moved to DemoService, behind the registry's tool gate.
// -------------------------------------------------------------------------
async isDemoUser(userId: number): Promise<boolean> {
if (!readEnv().demo.enabled) return false;
const email = await this.usersRepo.getEmail(userId);
return isDemoEmail(email ?? undefined);
}
/**
* Verify a JWT the same way `auth/jwt-verify.ts#verifyJwtAndLoadUser`
* does — including the `password_version` check — so that stolen tokens
+1 -18
View File
@@ -2,12 +2,11 @@ import {
McpController, Tool, ResourceTemplate, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
errorResult, ok,
} from '../../nest-mcp';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { z } from 'zod';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { DemoService } from '../common/demo.service';
import { ADDON_IDS } from '../../addons';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { TripMembershipService } from '../trip-membership/trip-membership.service';
@@ -94,17 +93,11 @@ export class BudgetMcp {
private readonly uow: UnitOfWork,
@InjectRepository(Places) private readonly places: PlacesRepository,
@InjectRepository(Trips) private readonly trips: TripsRepository,
private readonly demo: DemoService,
// Plan 4 Task 3 — DatabaseService.rosterUserIds inlined onto
// TripMembersRepository.rosterUserIds directly.
@InjectRepository(TripMembers) private readonly tripMembers: TripMembersRepository,
) {}
/** Plan 3i Task 3: the AuthService.isDemoUser check via the injected DemoService (common/demo.service.ts), not the free-function demo-write.ts helper. */
private async isDemoUser(userId: number): Promise<boolean> {
return await this.demo.isDemoUserId(userId);
}
/**
* Resolve the equal-split participants for a new budget item. When member_ids
* is omitted, default to the whole trip (owner + all members), deduped —
@@ -217,7 +210,6 @@ export class BudgetMcp {
},
ctx: McpContext,
) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
if (members !== undefined && member_ids !== undefined) return errorResult('Pass either members (uneven split) or member_ids (equal split), not both.');
@@ -250,7 +242,6 @@ export class BudgetMcp {
access: { group: 'budget', mode: 'write' },
})
async deleteBudgetItem({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
const deleted = await this.budget.deleteBudgetItem(itemId, tripId);
@@ -294,7 +285,6 @@ export class BudgetMcp {
},
ctx: McpContext,
) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
if (members !== undefined && member_ids !== undefined) return errorResult('Pass either members (uneven split) or member_ids (equal split), not both.');
@@ -342,7 +332,6 @@ export class BudgetMcp {
},
ctx: McpContext,
) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
if (place_id != null && !(await this.placeOnTrip(tripId, place_id))) return errorResult('place_id does not belong to this trip.');
@@ -374,7 +363,6 @@ export class BudgetMcp {
access: { group: 'budget', mode: 'write' },
})
async setBudgetItemMembers({ tripId, itemId, userIds }: { tripId: number; itemId: number; userIds: number[] }, ctx: McpContext) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
const result = await this.budget.updateMembers(itemId, tripId, userIds);
@@ -398,7 +386,6 @@ export class BudgetMcp {
access: { group: 'budget', mode: 'write' },
})
async toggleBudgetMemberPaid({ tripId, itemId, memberId, paid }: { tripId: number; itemId: number; memberId: number; paid: boolean }, ctx: McpContext) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
const member = await this.budget.toggleMemberPaid(itemId, tripId, memberId, paid);
@@ -463,7 +450,6 @@ export class BudgetMcp {
{ tripId, from_user_id, to_user_id, amount, currency, settled_at, note }: { tripId: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null; note?: string | null },
ctx: McpContext,
) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
// Freeze-then-write composite, same as the REST path: the rate for the display
@@ -495,7 +481,6 @@ export class BudgetMcp {
{ tripId, settlementId, from_user_id, to_user_id, amount, currency, settled_at, note }: { tripId: number; settlementId: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null; note?: string | null },
ctx: McpContext,
) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
// Freeze-then-write composite, same as the REST path: an edit that leaves the
@@ -518,7 +503,6 @@ export class BudgetMcp {
access: { group: 'budget', mode: 'write' },
})
async deleteSettlement({ tripId, settlementId }: { tripId: number; settlementId: number }, ctx: McpContext) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
const deleted = await this.budget.deleteSettlement(settlementId, tripId);
@@ -540,7 +524,6 @@ export class BudgetMcp {
access: { group: 'budget', mode: 'write' },
})
async freezeBudgetRates({ tripId }: { tripId: number }, ctx: McpContext) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('budget_edit', tripId, ctx.userId))) return permissionDenied();
// The same service call as POST …/budget/freeze-rates, without a lent rate table:
+2 -16
View File
@@ -2,12 +2,11 @@ import {
McpController, Tool, Resource, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
errorResult, ok,
} from '../../nest-mcp';
import { z } from 'zod';
import { createCategoryRequestSchema, updateCategoryRequestSchema } from '@trek/shared';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { DemoService } from '../common/demo.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { adminRequired } from '../../mcp/tools/_shared';
import { CategoriesService } from './categories.service';
@@ -32,21 +31,11 @@ import { CategoriesService } from './categories.service';
export class CategoriesMcp {
constructor(
private readonly categories: CategoriesService,
// Plan 3i Task 3: `isDemoUser` now goes through the injected
// `DemoService` below, not `DatabaseService`/`RuntimeEnvService`
// directly — this domain's own reads/writes are all repository-backed
// (unrelated to `env`). Plan 4 Task 4 dropped the now-unused
// `DatabaseService` injection.
// The demo gate runs in the MCP registry (trekDemoToolGate), not here.
private readonly env: RuntimeEnvService,
private readonly guards: McpToolGuardsService,
private readonly demo: DemoService,
) {}
/** Plan 3i Task 3: the AuthService.isDemoUser check via the injected DemoService (common/demo.service.ts), not the free-function demo-write.ts helper. */
private async isDemoUser(userId: number): Promise<boolean> {
return await this.demo.isDemoUserId(userId);
}
@Tool({
name: 'list_categories',
description: 'List all available place categories with their id, name, icon and color. Use category_id when creating or updating places.',
@@ -71,7 +60,6 @@ export class CategoriesMcp {
access: { group: 'places', mode: 'write' },
})
async createCategory({ name, color, icon }: { name: string; color?: string; icon?: string }, ctx: McpContext) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
// The palette is instance-wide; the REST route restricts management to admins. Match it.
if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired();
const category = await this.categories.create(ctx.userId, name, color, icon);
@@ -91,7 +79,6 @@ export class CategoriesMcp {
access: { group: 'places', mode: 'write' },
})
async updateCategory({ categoryId, name, color, icon }: { categoryId: number; name?: string; color?: string; icon?: string }, ctx: McpContext) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired();
if (!(await this.categories.getById(categoryId))) return errorResult('Category not found');
const category = await this.categories.update(categoryId, name, color, icon);
@@ -108,7 +95,6 @@ export class CategoriesMcp {
access: { group: 'places', mode: 'write' },
})
async deleteCategory({ categoryId }: { categoryId: number }, ctx: McpContext) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired();
if (!(await this.categories.getById(categoryId))) return errorResult('Category not found');
await this.categories.remove(categoryId);
+1 -13
View File
@@ -2,11 +2,10 @@ import {
McpController, Tool, ResourceTemplate, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
errorResult, ok,
} from '../../nest-mcp';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { z } from 'zod';
import { AuthService } from '../auth/auth.service';
import { ADDON_IDS } from '../../addons';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { CollabService } from './collab.service';
@@ -65,7 +64,6 @@ function jsonContent(uri: string, data: unknown) {
export class CollabMcp {
constructor(
private readonly collab: CollabService,
private readonly auth: AuthService,
readonly addons: AddonsService,
private readonly guards: McpToolGuardsService,
) {}
@@ -94,7 +92,6 @@ export class CollabMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const note = await this.collab.createNote(tripId, ctx.userId, { title, content, category, color, website, pinned });
@@ -125,7 +122,6 @@ export class CollabMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const note = await this.collab.updateNote(tripId, noteId, { title, content, category, color, website, pinned });
@@ -146,7 +142,6 @@ export class CollabMcp {
access: { group: 'collab', mode: 'write' },
})
async deleteCollabNote({ tripId, noteId }: { tripId: number; noteId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const deleted = await this.collab.deleteNote(tripId, noteId);
@@ -193,7 +188,6 @@ export class CollabMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const poll = await this.collab.createPoll(tripId, ctx.userId, { question, options, multiple, deadline });
@@ -214,7 +208,6 @@ export class CollabMcp {
access: { group: 'collab', mode: 'write' },
})
async voteCollabPoll({ tripId, pollId, optionIndex }: { tripId: number; pollId: number; optionIndex: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const result = await this.collab.votePoll(tripId, pollId, ctx.userId, optionIndex);
@@ -235,7 +228,6 @@ export class CollabMcp {
access: { group: 'collab', mode: 'write' },
})
async closeCollabPoll({ tripId, pollId }: { tripId: number; pollId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const poll = await this.collab.closePoll(tripId, pollId);
@@ -256,7 +248,6 @@ export class CollabMcp {
access: { group: 'collab', mode: 'write' },
})
async deleteCollabPoll({ tripId, pollId }: { tripId: number; pollId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const deleted = await this.collab.deletePoll(tripId, pollId);
@@ -295,7 +286,6 @@ export class CollabMcp {
access: { group: 'collab', mode: 'write' },
})
async sendCollabMessage({ tripId, text, replyTo }: { tripId: number; text: string; replyTo?: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const result = await this.collab.createMessage(tripId, ctx.userId, text, replyTo ?? null);
@@ -316,7 +306,6 @@ export class CollabMcp {
access: { group: 'collab', mode: 'write' },
})
async deleteCollabMessage({ tripId, messageId }: { tripId: number; messageId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const result = await this.collab.deleteMessage(tripId, messageId, ctx.userId);
@@ -338,7 +327,6 @@ export class CollabMcp {
access: { group: 'collab', mode: 'write' },
})
async reactCollabMessage({ tripId, messageId, emoji }: { tripId: number; messageId: number; emoji: string }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied();
const result = await this.collab.reactMessage(messageId, tripId, ctx.userId, emoji);
+1 -2
View File
@@ -8,7 +8,6 @@ import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { CollabMcp } from './collab.mcp';
import { PermissionsModule } from '../permissions/permissions.module';
import { AuthModule } from '../auth/auth.module';
import { AddonsModule } from '../addons/addons.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
@@ -43,7 +42,7 @@ import { Trips } from '../../db/entities/Trips.entity';
// "registered here only for one read, never the owning module" shape
// `FilesModule`'s own forFeature list documents).
MikroOrmModule.forFeature([CollabNotes, CollabMessageReactions, CollabPolls, CollabPollVotes, CollabLinks, CollabMessages, Trips]),
McpSharedModule, NotificationsModule, PermissionsModule, AuthModule, RealtimeModule, PluginGuardsModule, AddonsModule, RateLimitModule],
McpSharedModule, NotificationsModule, PermissionsModule, RealtimeModule, PluginGuardsModule, AddonsModule, RateLimitModule],
controllers: [CollabController],
providers: [CollabService, CollabMcp, CollabRpc],
// For in-container consumers (CollabRpc).
+1 -30
View File
@@ -2,11 +2,10 @@ import {
McpController, Tool, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
ok,
} from '../../nest-mcp';
import { z } from 'zod';
import { InjectRepository } from '@mikro-orm/nestjs';
import { AuthService } from '../auth/auth.service';
import { ADDON_IDS } from '../../addons';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
@@ -64,14 +63,9 @@ export class CollectionsMcp {
constructor(
private readonly collections: CollectionsService,
@InjectRepository(Users) private readonly users: UsersRepository,
private readonly auth: AuthService,
readonly addons: AddonsService,
) {}
private async denyDemo(userId: number) {
return (await this.auth.isDemoUser(userId)) ? demoDenied() : null;
}
// ── Read ──────────────────────────────────────────────────────────────
@Tool({
@@ -150,7 +144,6 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async createCollection(body: CollectionCreateRequest, ctx: McpContext) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok({ collection: await this.collections.createCollection(ctx.userId, body) }); } catch (err) { return fail(err); }
}
@@ -163,7 +156,6 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async updateCollection({ collectionId, ...body }: { collectionId: number } & CollectionUpdateRequest, ctx: McpContext) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok({ collection: await this.collections.updateCollection(ctx.userId, collectionId, body) }); } catch (err) { return fail(err); }
}
@@ -176,7 +168,6 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async deleteCollection({ collectionId }: { collectionId: number }, ctx: McpContext) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { await this.collections.deleteCollection(ctx.userId, collectionId); return ok({ success: true }); } catch (err) { return fail(err); }
}
@@ -189,7 +180,6 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async reorderCollections({ orderedIds }: { orderedIds: number[] }, ctx: McpContext) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { await this.collections.reorderCollections(ctx.userId, orderedIds); return ok({ success: true }); } catch (err) { return fail(err); }
}
@@ -204,7 +194,6 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async savePlaceToCollection(body: CollectionSavePlaceRequest, ctx: McpContext) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok(await this.collections.savePlace(ctx.userId, body)); } catch (err) { return fail(err); }
}
@@ -225,7 +214,6 @@ export class CollectionsMcp {
{ collectionId, tripId, placeIds, force }: { collectionId: number; tripId: number; placeIds: number[]; force?: boolean },
ctx: McpContext,
) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok(await this.collections.saveFromTripPlaces(ctx.userId, collectionId, tripId, placeIds, force)); } catch (err) { return fail(err); }
}
@@ -238,7 +226,6 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async updateCollectionPlace({ placeId, ...body }: { placeId: number } & CollectionPlaceUpdateRequest, ctx: McpContext) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok({ place: await this.collections.updatePlace(ctx.userId, placeId, body) }); } catch (err) { return fail(err); }
}
@@ -251,7 +238,6 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async setCollectionPlaceStatus({ placeId, status }: { placeId: number; status: CollectionStatus }, ctx: McpContext) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok({ place: await this.collections.setStatus(ctx.userId, placeId, status) }); } catch (err) { return fail(err); }
}
@@ -267,7 +253,6 @@ export class CollectionsMcp {
{ trip_id, place_ids, status }: CollectionSetStatusFromTripRequest,
ctx: McpContext,
) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok(await this.collections.setStatusFromTrip(ctx.userId, trip_id, place_ids, status)); } catch (err) { return fail(err); }
}
@@ -283,7 +268,6 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async rateCollectionPlace({ placeId, rating }: { placeId: number; rating?: number | null }, ctx: McpContext) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok({ place: await this.collections.setRating(ctx.userId, placeId, rating ?? null) }); } catch (err) { return fail(err); }
}
@@ -296,7 +280,6 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async deleteCollectionPlace({ placeId }: { placeId: number }, ctx: McpContext) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { await this.collections.deletePlace(ctx.userId, placeId); return ok({ success: true }); } catch (err) { return fail(err); }
}
@@ -309,7 +292,6 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async copyCollectionPlacesToTrip(body: CollectionCopyToTripRequest, ctx: McpContext) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok(await this.collections.copyToTrip(ctx.userId, body)); } catch (err) { return fail(err); }
}
@@ -324,7 +306,6 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async createCollectionLabel({ collection_id, name, color }: CollectionLabelCreateRequest, ctx: McpContext) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok({ label: await this.collections.createLabel(ctx.userId, collection_id, name, color) }); } catch (err) { return fail(err); }
}
@@ -337,7 +318,6 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async updateCollectionLabel({ labelId, ...body }: { labelId: number } & CollectionLabelUpdateRequest, ctx: McpContext) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok({ label: await this.collections.updateLabel(ctx.userId, labelId, body) }); } catch (err) { return fail(err); }
}
@@ -350,7 +330,6 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async deleteCollectionLabel({ labelId }: { labelId: number }, ctx: McpContext) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { await this.collections.deleteLabel(ctx.userId, labelId); return ok({ success: true }); } catch (err) { return fail(err); }
}
@@ -366,7 +345,6 @@ export class CollectionsMcp {
{ label_ids, place_ids, remove }: CollectionLabelAssignRequest & { remove?: boolean },
ctx: McpContext,
) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { return ok(await this.collections.assignLabels(ctx.userId, label_ids, place_ids, remove ?? false)); } catch (err) { return fail(err); }
}
@@ -381,7 +359,6 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async inviteToCollection({ collection_id, user_id, role }: CollectionInviteRequest, ctx: McpContext) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
// try/catch added with the post-fold quirk pass — the legacy handler was one
// of two where an unexpected throw escaped to the SDK instead of isError.
try {
@@ -408,7 +385,6 @@ export class CollectionsMcp {
{ collectionId, userId: targetUserId, role }: { collectionId: number; userId: number; role: CollectionRole },
ctx: McpContext,
) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { await this.collections.setMemberRole(ctx.userId, collectionId, targetUserId, role); return ok({ success: true }); } catch (err) { return fail(err); }
}
@@ -424,7 +400,6 @@ export class CollectionsMcp {
{ collectionId, userId: targetUserId }: { collectionId: number; userId: number },
ctx: McpContext,
) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { await this.collections.removeMember(ctx.userId, collectionId, targetUserId); return ok({ success: true }); } catch (err) { return fail(err); }
}
@@ -440,7 +415,6 @@ export class CollectionsMcp {
{ collectionId, userId: targetUserId }: { collectionId: number; userId: number },
ctx: McpContext,
) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { await this.collections.cancelInvite(collectionId, ctx.userId, targetUserId); return ok({ success: true }); } catch (err) { return fail(err); }
}
@@ -453,7 +427,6 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async acceptCollectionInvite({ collectionId }: { collectionId: number }, ctx: McpContext) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
// try/catch added with the post-fold quirk pass (see inviteToCollection).
try {
const res = await this.collections.acceptInvite(ctx.userId, collectionId, undefined);
@@ -471,7 +444,6 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async declineCollectionInvite({ collectionId }: { collectionId: number }, ctx: McpContext) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { await this.collections.declineInvite(ctx.userId, collectionId, undefined); return ok({ success: true }); } catch (err) { return fail(err); }
}
@@ -484,7 +456,6 @@ export class CollectionsMcp {
access: { group: 'collections', mode: 'write' },
})
async leaveCollection({ collectionId }: { collectionId: number }, ctx: McpContext) {
const demo = await this.denyDemo(ctx.userId); if (demo) return demo;
try { await this.collections.leaveCollection(ctx.userId, collectionId, undefined); return ok({ success: true }); } catch (err) { return fail(err); }
}
}
@@ -20,7 +20,6 @@ import { AppConfigModule } from '../app-config/app-config.module';
import { CollectionsMcp } from './collections.mcp';
import { AddonsModule } from '../addons/addons.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { MulterModule } from '@nestjs/platform-express';
import { StorageModule } from '../storage/storage.module';
@@ -48,7 +47,7 @@ import { MAX_COVER_SIZE } from './collections.controller';
}),
}),
StorageModule,
NotificationsModule, AddonsModule, PermissionsModule, AuthModule, AppConfigModule, PluginGuardsModule,
NotificationsModule, AddonsModule, PermissionsModule, AppConfigModule, PluginGuardsModule,
MikroOrmModule.forFeature([
Collections, CollectionMembers, CollectionLabels, CollectionPlaces, CollectionPlaceRatings,
Categories, Users, Trips, TripMembers, Places, PlaceRatings, Tags,
+1 -6
View File
@@ -1,12 +1,11 @@
import {
McpController, Tool, ResourceTemplate, type McpContext,
TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
ok,
} from '../../nest-mcp';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { z } from 'zod';
import { NOTE_COLORS, type NoteColor } from '@trek/shared';
import { AuthService } from '../auth/auth.service';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { DayNotesService } from './day-notes.service';
@@ -42,7 +41,6 @@ function parseId(value: string | string[]): number | null {
export class DayNotesMcp {
constructor(
private readonly notes: DayNotesService,
private readonly auth: AuthService,
private readonly guards: McpToolGuardsService,
) {}
@@ -67,7 +65,6 @@ export class DayNotesMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.notes.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.notes.dayExists(dayId, tripId))) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
@@ -98,7 +95,6 @@ export class DayNotesMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.notes.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const existing = await this.notes.getNote(noteId, dayId, tripId);
@@ -120,7 +116,6 @@ export class DayNotesMcp {
access: { group: 'trips', mode: 'write' },
})
async deleteDayNote({ tripId, dayId, noteId }: { tripId: number; dayId: number; noteId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.notes.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const note = await this.notes.getNote(noteId, dayId, tripId);
@@ -5,7 +5,6 @@ import { DayNotesService } from './day-notes.service';
import { DayNotesMcp } from './day-notes.mcp';
import { DayNotesRpc } from './day-notes.rpc';
import { PermissionsModule } from '../permissions/permissions.module';
import { AuthModule } from '../auth/auth.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
@@ -24,13 +23,12 @@ import { Trips } from '../../db/entities/Trips.entity';
* injection the same way. Wiring the modules to each other would buy
* nothing and cost a cycle.
*
* AuthModule is only for DayNotesMcp's demo-user gate, PluginGuardsModule only
* for DayNotesRpc.
* PluginGuardsModule is only for DayNotesRpc.
*/
@Module({
// DayNotes/Days: Plan 4 Task 1 — DayNotesService's own DayNotesRepository/
// DaysRepository.existsInTrip, replacing its raw `this.dbs.all/get/run`.
imports: [McpSharedModule, PermissionsModule, AuthModule, RealtimeModule, PluginGuardsModule, MikroOrmModule.forFeature([DayNotes, Days, Trips])],
imports: [McpSharedModule, PermissionsModule, RealtimeModule, PluginGuardsModule, MikroOrmModule.forFeature([DayNotes, Days, Trips])],
controllers: [DayNotesController],
providers: [DayNotesService, DayNotesMcp, DayNotesRpc],
exports: [DayNotesService],
+1 -8
View File
@@ -1,11 +1,10 @@
import {
McpController, Tool, ResourceTemplate, type McpContext,
TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
errorResult, ok,
} from '../../nest-mcp';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { z } from 'zod';
import { AuthService } from '../auth/auth.service';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import {
DAY_CREATE_DATED_CONFLICT, dayCreateRequestSchema, dayReorderRequestSchema, dayUpdateRequestSchema,
@@ -41,7 +40,6 @@ function parseId(value: string | string[]): number | null {
export class DaysMcp {
constructor(
private readonly days: DaysService,
private readonly auth: AuthService,
private readonly guards: McpToolGuardsService,
private readonly removal: DayRemovalService,
) {}
@@ -65,7 +63,6 @@ export class DaysMcp {
{ tripId, dayId, ...fields }: { tripId: number; dayId: number } & DayUpdateRequest,
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.days.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
const current = await this.days.getDay(dayId, tripId);
@@ -95,7 +92,6 @@ export class DaysMcp {
{ tripId, date, notes, position, dated }: { tripId: number } & DayCreateRequest,
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.days.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (dated) return await this.appendDated(tripId, notes, date, position, ctx);
@@ -156,7 +152,6 @@ export class DaysMcp {
{ tripId, orderedIds }: { tripId: number } & DayReorderRequest,
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.days.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
try {
@@ -183,7 +178,6 @@ export class DaysMcp {
access: { group: 'trips', mode: 'write' },
})
async deleteDay({ tripId, dayId }: { tripId: number; dayId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.days.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.days.getDay(dayId, tripId))) return errorResult('Day not found.');
@@ -217,7 +211,6 @@ export class DaysMcp {
{ tripId, dayId, transport_mode }: { tripId: number; dayId: number; transport_mode?: string | null },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.days.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.days.getDay(dayId, tripId))) return errorResult('Day not found.');
+1 -2
View File
@@ -12,7 +12,6 @@ import { RealtimeModule } from '../realtime/realtime.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { QueryHelpersModule } from '../query-helpers/query-helpers.module';
import { PlacesModule } from '../places/places.module';
import { AuthModule } from '../auth/auth.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { Days } from '../../db/entities/Days.entity';
import { DayAssignments } from '../../db/entities/DayAssignments.entity';
@@ -60,7 +59,7 @@ import { RoadtripDayBoundaries } from '../../db/entities/RoadtripDayBoundaries.e
PermissionsModule,
QueryHelpersModule,
PlacesModule,
AuthModule,
RealtimeModule,
PluginGuardsModule,
AccommodationsDomainModule,
+1 -2
View File
@@ -1,7 +1,6 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { AddonsModule } from '../addons/addons.module';
import { AuthModule } from '../auth/auth.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { FilesModule } from '../files/files.module';
import { AllowedFileTypesModule } from '../files/allowed-file-types.module';
@@ -68,7 +67,7 @@ import { WebdavClient } from './providers/webdav.client';
RealtimeModule,
SchedulingModule,
AddonsModule,
AuthModule,
McpSharedModule,
MikroOrmModule.forFeature([
DocumentConnections,
+1 -14
View File
@@ -1,7 +1,7 @@
import {
McpController, Tool, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
ok,
} from '../../nest-mcp';
import { z } from 'zod';
import { getAppUrl } from '../../app-config';
@@ -9,7 +9,6 @@ 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';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { FeedsService } from './feeds.service';
@@ -43,14 +42,8 @@ export class FeedsMcp {
@InjectRepository(Trips) private readonly trips: TripsRepository,
private readonly env: RuntimeEnvService,
private readonly guards: McpToolGuardsService,
private readonly demo: DemoService,
) {}
/** Plan 3i Task 3: the AuthService.isDemoUser check via the injected DemoService (common/demo.service.ts), not the free-function demo-write.ts helper. */
private async isDemoUser(userId: number): Promise<boolean> {
return await this.demo.isDemoUserId(userId);
}
/**
* The REST routes take the base URL from APP_URL and fall back to the calling
* request's own Host header. A tool call has no request to fall back to, so it
@@ -96,7 +89,6 @@ export class FeedsMcp {
access: { group: 'trips', mode: 'share' },
})
async enableTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
const denied = await this.denyTripFeed(tripId, ctx.userId);
if (denied) return denied;
return ok(await this.feeds.generateTripToken(tripId, ctx.userId, this.base()));
@@ -112,7 +104,6 @@ export class FeedsMcp {
access: { group: 'trips', mode: 'share' },
})
async rotateTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
const denied = await this.denyTripFeed(tripId, ctx.userId);
if (denied) return denied;
return ok(await this.feeds.rotateTripToken(tripId, ctx.userId, this.base()));
@@ -128,7 +119,6 @@ export class FeedsMcp {
access: { group: 'trips', mode: 'share' },
})
async disableTripCalendarFeed({ tripId }: { tripId: number }, ctx: McpContext) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
const denied = await this.denyTripFeed(tripId, ctx.userId);
if (denied) return denied;
await this.feeds.disableTripToken(tripId, ctx.userId);
@@ -159,7 +149,6 @@ export class FeedsMcp {
access: { group: 'trips', mode: 'share' },
})
async enableAllTripsCalendarFeed(_input: Record<string, never>, ctx: McpContext) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
return ok(await this.feeds.generateUserToken(ctx.userId, this.base()));
}
@@ -171,7 +160,6 @@ export class FeedsMcp {
access: { group: 'trips', mode: 'share' },
})
async rotateAllTripsCalendarFeed(_input: Record<string, never>, ctx: McpContext) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
return ok(await this.feeds.rotateUserToken(ctx.userId, this.base()));
}
@@ -183,7 +171,6 @@ export class FeedsMcp {
access: { group: 'trips', mode: 'share' },
})
async disableAllTripsCalendarFeed(_input: Record<string, never>, ctx: McpContext) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
await this.feeds.disableUserToken(ctx.userId);
return ok({ feed_url: null });
}
+1 -7
View File
@@ -2,12 +2,11 @@ import { z } from 'zod';
import {
McpController, Tool, type McpContext,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
demoDenied, errorResult, ok,
errorResult, ok,
} from '../../nest-mcp';
import { fileLinkRequestSchema, fileUpdateRequestSchema } from '@trek/shared';
import type { FileLinkRequest, FileUpdateRequest } from '@trek/shared';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { AuthService } from '../auth/auth.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { FilesService, FileContentError, FILE_CONTENT_MAX } from './files.service';
import { AllowedFileTypesService } from './allowed-file-types.service';
@@ -63,7 +62,6 @@ function contentRefusal(err: FileContentError): string {
export class FilesMcp {
constructor(
private readonly files: FilesService,
private readonly auth: AuthService,
private readonly guards: McpToolGuardsService,
private readonly allowedTypes: AllowedFileTypesService,
) {}
@@ -138,7 +136,6 @@ export class FilesMcp {
ctx: McpContext,
) {
if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.guards.hasTripPermission('file_upload', tripId, ctx.userId))) return permissionDenied();
// A path someone pasted in is not a name; keep only the last segment.
const originalname = filename.split(/[\\/]/).pop()!.trim();
@@ -180,7 +177,6 @@ export class FilesMcp {
{ tripId, fileId, ...fields }: { tripId: number; fileId: number } & FileUpdateRequest,
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('file_edit', tripId, ctx.userId))) return permissionDenied();
const current = await this.files.getFileById(fileId, tripId);
@@ -220,7 +216,6 @@ export class FilesMcp {
{ tripId, fileId, ...targets }: { tripId: number; fileId: number } & FileLinkRequest,
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('file_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.files.getFileById(fileId, tripId))) return errorResult('File not found.');
@@ -251,7 +246,6 @@ export class FilesMcp {
{ tripId, fileId, linkId }: { tripId: number; fileId: number; linkId: number },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('file_edit', tripId, ctx.userId))) return permissionDenied();
// deleteFileLink scopes by (linkId, fileId) only, so the file has to be resolved
+2 -5
View File
@@ -5,7 +5,6 @@ import { FilesDownloadController } from './files-download.controller';
import { FilesService } from './files.service';
import { FilesRpc } from './files.rpc';
import { FilesMcp } from './files.mcp';
import { AuthModule } from '../auth/auth.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
import { RealtimeModule } from '../realtime/realtime.module';
@@ -53,10 +52,8 @@ import { Trips } from '../../db/entities/Trips.entity';
// classes only, never the owning module — the `AccommodationsDomainModule`
// precedent).
MikroOrmModule.forFeature([TripFiles, FileLinks, Reservations, Places, DayAssignments, BudgetItems, Users, Trips]),
// AuthModule + McpSharedModule feed FilesMcp's demo and RBAC guards. Neither is
// @Global, and AuthModule reaches this domain only through the leaf
// AllowedFileTypesModule, so importing it here stays cycle-free.
EphemeralTokenModule, PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule, AuthModule, McpSharedModule,
// McpSharedModule feeds FilesMcp's RBAC guards; it is not @Global.
EphemeralTokenModule, PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule, McpSharedModule,
// FilesMcp's upload tool checks the same extension list as the multipart filter.
AllowedFileTypesModule],
controllers: [FilesController, FilesDownloadController],
+1 -7
View File
@@ -3,13 +3,12 @@ import {
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
errorResult, ok,
} from '../../nest-mcp';
import { z } from 'zod';
import { ADDON_IDS } from '../../addons';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { AuthService } from '../auth/auth.service';
import { AcceptError, DawarichSuggestionsService } from './dawarich-suggestions.service';
import { DawarichTracksService } from './dawarich-tracks.service';
@@ -50,7 +49,6 @@ export class DawarichMcp {
constructor(
private readonly suggestions: DawarichSuggestionsService,
private readonly tracks: DawarichTracksService,
private readonly auth: AuthService,
readonly addons: AddonsService,
) {}
@@ -108,7 +106,6 @@ export class DawarichMcp {
args: { suggestionId: number; tripId?: number; dayId?: number; name?: string; notes?: string; lat?: number; lng?: number },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const { suggestionId, ...rest } = args;
return this.run(() => this.suggestions.accept(ctx.userId, suggestionId, { target: 'place', ...rest }));
}
@@ -133,7 +130,6 @@ export class DawarichMcp {
args: { suggestionId: number; journalId: number; name?: string; notes?: string; date?: string; time?: string },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const { suggestionId, ...rest } = args;
return this.run(() => this.suggestions.accept(ctx.userId, suggestionId, { target: 'journal', ...rest }));
}
@@ -155,7 +151,6 @@ export class DawarichMcp {
{ suggestionId, bucketListItemId }: { suggestionId: number; bucketListItemId?: number },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
return this.run(() =>
this.suggestions.accept(ctx.userId, suggestionId, { target: 'bucket_list', bucketListItemId }),
);
@@ -177,7 +172,6 @@ export class DawarichMcp {
{ suggestionId, state }: { suggestionId: number; state?: 'dismissed' | 'new' },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const updated = await this.suggestions.setState(ctx.userId, suggestionId, state ?? 'dismissed');
if (!updated) return errorResult('Suggestion not found');
return ok({ suggestion: updated });
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { AddonsModule } from '../addons/addons.module';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { AtlasModule } from '../atlas/atlas.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { PlacesModule } from '../places/places.module';
@@ -61,7 +60,7 @@ import { DawarichTracksService } from './dawarich-tracks.service';
AuditModule,
// The MCP tools ask AuthService whether the caller is the demo user, the
// same gate every other write tool carries.
AuthModule,
AtlasModule,
// Accepting a stay writes a place and a day assignment, so it asks the same
// permissions the planner asks before doing either.
+1 -21
View File
@@ -2,7 +2,7 @@ import {
McpController, Tool, Resource, ResourceTemplate,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
demoDenied, ok, type McpContext,
ok, type McpContext,
} from '../../nest-mcp';
import { z } from 'zod';
import { ADDON_IDS } from '../../addons';
@@ -11,7 +11,6 @@ import { JourneyShareService } from './journey-share.service';
import type { JourneyContributor } from '../../types';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { AuthService } from '../auth/auth.service';
import { JourneyPhotoCaptureService } from './journey-photo-capture.service';
/** Legacy registrar gate: the whole journey surface rode the journey addon. */
@@ -109,7 +108,6 @@ export class JourneyMcp {
private readonly journey: JourneyDomainService,
private readonly share: JourneyShareService,
readonly addons: AddonsService,
private readonly auth: AuthService,
private readonly photoCapture: JourneyPhotoCaptureService,
) {}
@@ -232,7 +230,6 @@ export class JourneyMcp {
{ title, subtitle, trip_ids }: { title: string; subtitle?: string; trip_ids?: number[] },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const journey = await this.journey.createJourney(ctx.userId, { title, subtitle, trip_ids });
// Return the fully-hydrated journey (entries/contributors/trips/stats/my_role),
// matching get_journey, rather than the bare row.
@@ -264,7 +261,6 @@ export class JourneyMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const journey = await this.journey.updateJourney(journeyId, ctx.userId, data);
if (!journey) return notFound('Journey not found or access denied.');
return ok({ journey });
@@ -281,7 +277,6 @@ export class JourneyMcp {
access: { group: 'journey', mode: 'write' },
})
async restoreJourneySuggestions({ journeyId }: { journeyId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const result = await this.journey.restoreDismissedSuggestions(journeyId, ctx.userId);
if (!result) return notFound('Journey not found or access denied.');
return ok(result);
@@ -296,7 +291,6 @@ export class JourneyMcp {
access: { group: 'journey', mode: 'write' },
})
async deleteJourney({ journeyId }: { journeyId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.journey.deleteJourney(journeyId, ctx.userId))) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
@@ -310,7 +304,6 @@ export class JourneyMcp {
access: { group: 'journey', mode: 'write' },
})
async addJourneyTrip({ journeyId, tripId }: { journeyId: number; tripId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.journey.canAccessJourney(journeyId, ctx.userId))) return notFound('Journey not found or access denied.');
return ok({ success: await this.journey.addTripToJourney(journeyId, tripId, ctx.userId) });
}
@@ -324,7 +317,6 @@ export class JourneyMcp {
access: { group: 'journey', mode: 'write' },
})
async removeJourneyTrip({ journeyId, tripId }: { journeyId: number; tripId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const success = await this.journey.removeTripFromJourney(journeyId, tripId, ctx.userId);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success });
@@ -364,7 +356,6 @@ export class JourneyMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const entry = await this.journey.createEntry(journeyId, ctx.userId, data);
if (!entry) return notFound('Journey not found or access denied.');
// Return through the listEntries enrichment (parsed tags/pros_cons, photos, source_trip_name).
@@ -410,7 +401,6 @@ export class JourneyMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const entry = await this.journey.updateEntry(entryId, ctx.userId, data, undefined);
if (!entry) return notFound('Entry not found or access denied.');
// Return through the listEntries enrichment (parsed tags/pros_cons, photos), matching create_journey_entry.
@@ -427,7 +417,6 @@ export class JourneyMcp {
access: { group: 'journey', mode: 'write' },
})
async deleteJourneyEntry({ entryId }: { entryId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.journey.deleteEntry(entryId, ctx.userId, undefined))) return notFound('Entry not found or access denied.');
return ok({ success: true });
}
@@ -444,7 +433,6 @@ export class JourneyMcp {
access: { group: 'journey', mode: 'write' },
})
async reorderJourneyEntries({ journeyId, orderedIds }: { journeyId: number; orderedIds: number[] }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const success = await this.journey.reorderEntries(journeyId, ctx.userId, orderedIds, undefined);
if (!success) return notFound('Journey not found, access denied, or entry IDs do not belong to this journey.');
return ok({ success: true });
@@ -462,7 +450,6 @@ export class JourneyMcp {
access: { group: 'journey', mode: 'write' },
})
async reorderJourneyEntryPhotos({ entryId, orderedIds }: { entryId: number; orderedIds: number[] }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.journey.reorderEntryPhotos(entryId, ctx.userId, orderedIds, undefined))) {
return notFound('Entry not found, access denied, or the ids are not exactly the photos of this entry.');
}
@@ -485,7 +472,6 @@ export class JourneyMcp {
{ journeyId, targetUserId, role }: { journeyId: number; targetUserId: number; role: 'editor' | 'viewer' },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.journey.addContributor(journeyId, ctx.userId, targetUserId, role))) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
@@ -506,7 +492,6 @@ export class JourneyMcp {
{ journeyId, targetUserId, role }: { journeyId: number; targetUserId: number; role: 'editor' | 'viewer' },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.journey.updateContributorRole(journeyId, ctx.userId, targetUserId, role))) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
@@ -523,7 +508,6 @@ export class JourneyMcp {
access: { group: 'journey', mode: 'write' },
})
async removeJourneyContributor({ journeyId, targetUserId }: { journeyId: number; targetUserId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.journey.removeContributor(journeyId, ctx.userId, targetUserId))) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
@@ -543,7 +527,6 @@ export class JourneyMcp {
{ journeyId, hide_skeletons }: { journeyId: number; hide_skeletons?: boolean },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const result = await this.journey.updateJourneyPreferences(journeyId, ctx.userId, { hide_skeletons });
if (!result) return notFound('Journey not found or access denied.');
// Return the service result ({ hide_skeletons }), matching the REST route.
@@ -573,7 +556,6 @@ export class JourneyMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
// The REST routes derive the journey from the entry and let each per-asset
// add answer for itself, which in the array branch means a caller with no
// access gets an empty 200. Checking up front instead turns that silence
@@ -637,7 +619,6 @@ export class JourneyMcp {
access: { group: 'journey', mode: 'share' },
})
async createJourneyShareLink({ journeyId, ...permissions }: { journeyId: number; share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean; newest_first?: boolean }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const shareLink = await this.share.createOrUpdateJourneyShareLink(journeyId, ctx.userId, permissions);
if (!shareLink) return notFound('Journey not found or access denied.');
return ok({ shareLink });
@@ -652,7 +633,6 @@ export class JourneyMcp {
access: { group: 'journey', mode: 'share' },
})
async deleteJourneyShareLink({ journeyId }: { journeyId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.share.deleteJourneyShareLink(journeyId, ctx.userId))) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
+1 -2
View File
@@ -9,7 +9,6 @@ import { MemoriesModule } from '../memories/memories.module';
import { JourneyDomainModule } from './journey-domain.module';
import { JourneyPhotoCaptureModule } from './journey-photo-capture.module';
import { JourneyMcp } from './journey.mcp';
import { AuthModule } from '../auth/auth.module';
import { MulterModule } from '@nestjs/platform-express';
import { AllowedFileTypesModule } from '../files/allowed-file-types.module';
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
@@ -38,7 +37,7 @@ import { UserConnectionRepositoriesModule } from '../database/user-connection-re
}),
}),
StorageModule,
AuthModule, AddonsModule, MemoriesModule, JourneyDomainModule, JourneyPhotoCaptureModule,
AddonsModule, MemoriesModule, JourneyDomainModule, JourneyPhotoCaptureModule,
// JourneyService's JV1 read (`UserImmichRepository.getImmichAutoUpload`).
UserConnectionRepositoriesModule,
// Plan 3g Task 3: `JourneyService` (JV1, now through the module above)
@@ -1,10 +1,9 @@
import {
McpController, Tool, Resource, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
demoDenied, ok,
ok,
} from '../../nest-mcp';
import { z } from 'zod';
import { AuthService } from '../auth/auth.service';
import { NotificationsService } from './notifications.service';
function jsonContent(uri: string, data: unknown) {
@@ -33,7 +32,6 @@ function jsonContent(uri: string, data: unknown) {
export class NotificationsMcp {
constructor(
private readonly notifications: NotificationsService,
private readonly auth: AuthService,
) {}
@Tool({
@@ -77,7 +75,6 @@ export class NotificationsMcp {
access: { group: 'notifications', mode: 'write' },
})
async markNotificationRead({ notificationId }: { notificationId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const success = await this.notifications.markRead(notificationId, ctx.userId);
if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true };
return ok({ success: true });
@@ -93,7 +90,6 @@ export class NotificationsMcp {
access: { group: 'notifications', mode: 'write' },
})
async markNotificationUnread({ notificationId }: { notificationId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const success = await this.notifications.markUnread(notificationId, ctx.userId);
if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true };
return ok({ success: true });
@@ -107,7 +103,6 @@ export class NotificationsMcp {
access: { group: 'notifications', mode: 'write' },
})
async markAllNotificationsRead(_input: Record<string, never>, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const count = await this.notifications.markAllRead(ctx.userId);
return ok({ success: true, count });
}
+1 -18
View File
@@ -2,11 +2,10 @@ import {
McpController, Tool, ResourceTemplate, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
errorResult, ok,
} from '../../nest-mcp';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { z } from 'zod';
import { AuthService } from '../auth/auth.service';
import { ADDON_IDS } from '../../addons';
import { noAccess, permissionDenied, adminRequired } from '../../mcp/tools/_shared';
import { PackingService, isInvalidBagRef } from './packing.service';
@@ -49,7 +48,6 @@ function parseId(value: string | string[]): number | null {
export class PackingMcp {
constructor(
private readonly packing: PackingService,
private readonly auth: AuthService,
readonly addons: AddonsService,
private readonly guards: McpToolGuardsService,
) {}
@@ -79,7 +77,6 @@ export class PackingMcp {
{ tripId, name, category, bag_id, quantity, weight_grams, checked, is_private, visibility, recipient_ids }: { tripId: number; name: string; category?: string; bag_id?: number | null; quantity?: number; weight_grams?: number | null; checked?: boolean | number; is_private?: boolean; visibility?: PackingVisibility; recipient_ids?: number[] },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const item = await this.packing.createItem(tripId, {
@@ -116,7 +113,6 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async togglePackingItem({ tripId, itemId, checked }: { tripId: number; itemId: number; checked: boolean }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const item = await this.packing.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked'], undefined, ctx.userId);
@@ -139,7 +135,6 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async deletePackingItem({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const deleted = await this.packing.deleteItem(tripId, itemId, ctx.userId);
@@ -175,7 +170,6 @@ export class PackingMcp {
{ tripId, itemId, name, category, bag_id, quantity, packed_quantity, weight_grams, is_private }: { tripId: number; itemId: number; name?: string; category?: string; bag_id?: number | null; quantity?: number; packed_quantity?: number | null; weight_grams?: number | null; is_private?: boolean },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const fields = { name, category, bag_id, quantity, packed_quantity, weight_grams, is_private };
@@ -231,7 +225,6 @@ export class PackingMcp {
{ tripId, itemId, visibility, recipient_ids }: { tripId: number; itemId: number; visibility: PackingVisibility; recipient_ids?: number[] },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const item = await this.packing.setItemSharing(tripId, itemId, ctx.userId, visibility, recipient_ids ?? []);
@@ -259,7 +252,6 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async reorderPackingItems({ tripId, orderedIds }: { tripId: number; orderedIds: number[] }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
await this.packing.reorderItems(tripId, orderedIds);
@@ -297,7 +289,6 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async createPackingBag({ tripId, name, color, weight_limit_grams }: { tripId: number; name: string; color?: string; weight_limit_grams?: number | null }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
// createBag returns a bare row; hydrate with the empty members array that
@@ -326,7 +317,6 @@ export class PackingMcp {
{ tripId, bagId, name, color, weight_limit_grams, user_id }: { tripId: number; bagId: number; name?: string; color?: string; weight_limit_grams?: number | null; user_id?: number | null },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const fields: { name?: string; color?: string; weight_limit_grams?: number | null; user_id?: number | null } = {};
@@ -357,7 +347,6 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async deletePackingBag({ tripId, bagId }: { tripId: number; bagId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
await this.packing.deleteBag(tripId, bagId);
@@ -383,7 +372,6 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async setBagMembers({ tripId, bagId, userIds }: { tripId: number; bagId: number; userIds: number[] }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const members = await this.packing.setBagMembers(tripId, bagId, userIds);
@@ -421,7 +409,6 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async setPackingCategoryAssignees({ tripId, categoryName, userIds }: { tripId: number; categoryName: string; userIds: number[] }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const assignees = await this.packing.updateCategoryAssignees(tripId, categoryName, userIds);
@@ -441,7 +428,6 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async applyPackingTemplate({ tripId, templateId }: { tripId: number; templateId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const items = await this.packing.applyTemplate(tripId, templateId);
@@ -478,7 +464,6 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async savePackingTemplate({ tripId, templateName }: { tripId: number; templateName: string }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
// Templates are global; the REST route restricts saving to admins. Match it.
@@ -499,7 +484,6 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async deletePackingTemplate({ templateId }: { templateId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
// Templates are global; the REST route restricts management to admins. Match it.
if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired();
const result = await this.packing.deletePackingTemplate(String(templateId));
@@ -529,7 +513,6 @@ export class PackingMcp {
{ tripId, items }: { tripId: number; items: { name: string; category?: string; quantity?: number; bag?: string; weight_grams?: number; checked?: boolean }[] },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.packing.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const created = await this.packing.bulkImport(tripId, items, ctx.userId);
+1 -2
View File
@@ -9,7 +9,6 @@ import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { AddonsModule } from '../addons/addons.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
@@ -33,7 +32,7 @@ import { TripMembers } from '../../db/entities/TripMembers.entity';
// "registered here only for one read, never the owning module" shape
// `CollabModule`'s own forFeature list documents).
MikroOrmModule.forFeature([PackingItems, PackingItemContributors, PackingBags, PackingCategoryAssignees, PackingTemplates, PackingTemplateCategories, PackingTemplateItems, Trips, TripMembers]),
McpSharedModule, NotificationsModule, PermissionsModule, AuthModule, RealtimeModule, PluginGuardsModule, AddonsModule, AuditModule],
McpSharedModule, NotificationsModule, PermissionsModule, RealtimeModule, PluginGuardsModule, AddonsModule, AuditModule],
controllers: [PackingController, AdminPackingTemplatesController],
providers: [PackingService, PackingMcp, PackingRpc],
exports: [PackingService],
+1 -13
View File
@@ -2,7 +2,7 @@ import {
McpController, Tool, ResourceTemplate, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
errorResult, ok,
} from '../../nest-mcp';
import { InjectRepository } from '@mikro-orm/nestjs';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
@@ -20,7 +20,6 @@ import {
type RoadtripStopType,
} from '@trek/shared';
import { z } from 'zod';
import { AuthService } from '../auth/auth.service';
import { AssignmentsService } from '../assignments/assignments.service';
import { JourneyDomainService } from '../journey/journey-domain.service';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
@@ -63,7 +62,6 @@ export class PlacesMcp {
private readonly places: PlacesService,
private readonly maps: MapsService,
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
private readonly auth: AuthService,
private readonly journey: JourneyDomainService,
private readonly assignments: AssignmentsService,
private readonly guards: McpToolGuardsService,
@@ -108,7 +106,6 @@ export class PlacesMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
const place = await this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes, website, phone, email, opening_hours: hoursText(opening_hours), image_url, price, currency, stop_type });
@@ -156,7 +153,6 @@ export class PlacesMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.assignments.dayExists(dayId, tripId))) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
@@ -222,7 +218,6 @@ export class PlacesMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
const place = await this.places.update(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, email, opening_hours: opening_hours === undefined ? undefined : hoursText(opening_hours), image_url, transport_mode, osm_id, google_place_id, google_ftid, amap_poi_id, stop_type, fill_percent });
@@ -246,7 +241,6 @@ export class PlacesMcp {
{ tripId, placeId, fileId }: { tripId: number; placeId: number; fileId: number },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
const result = await this.places.setImageFromFile(String(tripId), String(placeId), fileId);
@@ -273,7 +267,6 @@ export class PlacesMcp {
{ tripId, placeId, rating }: { tripId: number; placeId: number; rating?: number | null },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
// Rating is a personal vote — any trip member may cast one, place_edit not required.
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
const place = await this.places.rate(String(tripId), String(placeId), ctx.userId, rating ?? null);
@@ -293,7 +286,6 @@ export class PlacesMcp {
access: { group: 'places', mode: 'write' },
})
async deletePlace({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
// Scope the id to the trip before the hook: onPlaceDeleted keys on the place
@@ -388,7 +380,6 @@ export class PlacesMcp {
{ tripId, url, source, enrich }: { tripId: number; url: string; source: 'google-list' | 'naver-list'; enrich?: boolean },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
@@ -419,7 +410,6 @@ export class PlacesMcp {
access: { group: 'places', mode: 'write' },
})
async importGpx(input: RoadtripGpxImport, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.tripsRepo.findAccessible(input.tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', input.tripId, ctx.userId))) return permissionDenied();
if (!input.importWaypoints && !input.importRoutes && !input.importTracks) return errorResult('No import types selected.');
@@ -471,7 +461,6 @@ export class PlacesMcp {
access: { group: 'places', mode: 'write' },
})
async bulkDeletePlaces({ tripId, placeIds }: { tripId: number; placeIds: number[] }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
@@ -528,7 +517,6 @@ export class PlacesMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
+1 -2
View File
@@ -24,7 +24,6 @@ import { UnsplashModule } from '../unsplash/unsplash.module';
import { PlacePhotosModule } from '../place-photos/place-photos.module';
import { QueryHelpersModule } from '../query-helpers/query-helpers.module';
import { MapsModule } from '../maps/maps.module';
import { AuthModule } from '../auth/auth.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { MulterModule } from '@nestjs/platform-express';
import { StorageModule } from '../storage/storage.module';
@@ -55,7 +54,7 @@ import { MAX_PLACE_IMAGE_SIZE } from '../common/place-image-upload';
StorageModule,
// AccommodationsDomainModule: deleting a place takes the nights booked at it with
// it, and the cascade behind a stay belongs to the domain that owns it.
McpSharedModule, PermissionsModule, QueryHelpersModule, MapsModule, AuthModule, AppConfigModule, UnsplashModule, PlacePhotosModule, JourneyDomainModule, RealtimeModule, PluginGuardsModule, AssignmentsDomainModule, AccommodationsDomainModule,
McpSharedModule, PermissionsModule, QueryHelpersModule, MapsModule, AppConfigModule, UnsplashModule, PlacePhotosModule, JourneyDomainModule, RealtimeModule, PluginGuardsModule, AssignmentsDomainModule, AccommodationsDomainModule,
// Plan 3c Task 4: `PlacesService`'s and `PlacesMcp`'s own `@InjectRepository`
// constructor params (`PlacesRepository`, `TagsRepository`,
// `PlaceRatingsRepository`, `TripMembersRepository`,
@@ -1,14 +1,13 @@
import {
McpController, Tool, type McpContext,
TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
errorResult, ok,
} from '../../nest-mcp';
import { z } from 'zod';
import { airtrailImportSchema } from '@trek/shared';
import { ADDON_IDS } from '../../addons';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { AuthService } from '../auth/auth.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
@@ -48,7 +47,6 @@ export class ReservationImportMcp {
// 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,
) {}
@@ -74,7 +72,6 @@ export class ReservationImportMcp {
{ tripId, flightIds, connections }: { tripId: number; flightIds: string[]; connections?: string[][] },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.trips.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
@@ -6,7 +6,6 @@ import { BookingImportModule } from '../booking-import/booking-import.module';
import { AirtrailModule } from '../integrations/airtrail.module';
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';
@@ -19,11 +18,11 @@ import { Trips } from '../../db/entities/Trips.entity';
* both declaring separately. ReservationImportMcp is the second surface over
* the same prefix, so it lives with the controller for the same reason.
*
* AuthModule and McpSharedModule are the tool's demo check and its
* permission/broadcast guards; neither is @Global, so both are named here.
* McpSharedModule holds the tool's permission/broadcast guards; it is not
* @Global, so it is named here.
*/
@Module({
imports: [BookingImportModule, AirtrailModule, AddonsModule, PermissionsModule, AuthModule, McpSharedModule, MikroOrmModule.forFeature([Trips])],
imports: [BookingImportModule, AirtrailModule, AddonsModule, PermissionsModule, McpSharedModule, MikroOrmModule.forFeature([Trips])],
controllers: [ReservationImportController],
providers: [ReservationImportMcp],
})
@@ -2,11 +2,10 @@ import {
McpController, Tool, ResourceTemplate, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
errorResult, ok,
} from '../../nest-mcp';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { z } from 'zod';
import { AuthService } from '../auth/auth.service';
import { BudgetService } from '../budget/budget.service';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { ReservationsService } from './reservations.service';
@@ -320,7 +319,6 @@ export class ReservationsMcp {
private readonly reservations: ReservationsService,
private readonly days: DaysService,
private readonly budget: BudgetService,
private readonly auth: AuthService,
// Appended, not inserted: the hand-wired MCP harnesses build this
// positionally.
private readonly assignments: AssignmentsService,
@@ -362,7 +360,6 @@ export class ReservationsMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
@@ -431,7 +428,6 @@ export class ReservationsMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
const existing = await this.reservations.getReservation(reservationId, tripId);
@@ -466,7 +462,6 @@ export class ReservationsMcp {
access: { group: 'reservations', mode: 'write' },
})
async deleteReservation({ tripId, reservationId }: { tripId: number; reservationId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
const { deleted, accommodationDeleted, deletedBudgetItemIds } = await this.reservations.remove(reservationId, tripId);
@@ -494,7 +489,6 @@ export class ReservationsMcp {
{ tripId, reservationId, user_ids }: { tripId: number; reservationId: number; user_ids: number[] },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
@@ -532,7 +526,6 @@ export class ReservationsMcp {
{ tripId, positions, dayId }: { tripId: number; positions: { id: number; day_plan_position: number }[]; dayId?: number },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
@@ -569,7 +562,6 @@ export class ReservationsMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
const current = await this.reservations.getReservation(reservationId, tripId);
@@ -693,7 +685,6 @@ export class ReservationsMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
@@ -793,7 +784,6 @@ export class ReservationsMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
@@ -892,7 +882,6 @@ export class ReservationsMcp {
access: { group: 'reservations', mode: 'write' },
})
async deleteTransport({ tripId, reservationId }: { tripId: number; reservationId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
const { deleted, deletedBudgetItemIds } = await this.reservations.remove(reservationId, tripId);
@@ -13,7 +13,6 @@ import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { ReservationsMcp } from './reservations.mcp';
import { UpcomingReservationsController } from './upcoming-reservations.controller';
import { AuthModule } from '../auth/auth.module';
import { AssignmentsModule } from '../assignments/assignments.module';
import { AccommodationsModule } from '../accommodations/accommodations.module';
import { NotificationsModule } from '../notifications/notifications.module';
@@ -53,7 +52,7 @@ import { Trips } from '../../db/entities/Trips.entity';
// that stay owes the day plan the same stop one entered under Days does. No edge
// back — accommodations reaches neither days nor reservations (ACC-002).
imports: [
McpSharedModule, NotificationsModule, DaysModule, AssignmentsModule, AccommodationsModule, PermissionsModule, BudgetModule, AuthModule, RealtimeModule, PluginGuardsModule, ReservationsReadModule, AirtrailCoreModule,
McpSharedModule, NotificationsModule, DaysModule, AssignmentsModule, AccommodationsModule, PermissionsModule, BudgetModule, RealtimeModule, PluginGuardsModule, ReservationsReadModule, AirtrailCoreModule,
MikroOrmModule.forFeature([Reservations, ReservationEndpoints, ReservationTravelers, ReservationDayPositions, DayAccommodations, Days, Places, DayAssignments, TripMembers, Users, Trips, BudgetItems]),
],
controllers: [ReservationsController, UpcomingReservationsController],
@@ -1,11 +1,10 @@
import { z } from 'zod';
import { MAX_TRIP_DAYS, roadtripDayBoundarySchema, type RoadtripDayBoundary } from '@trek/shared';
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, ok, type McpContext } from '../../nest-mcp';
import { demoDenied, noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
import { AuthService } from '../auth/auth.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { RealtimeService } from '../realtime/realtime.service';
import { AddonsService } from '../addons/addons.service';
@@ -21,7 +20,6 @@ export class DayBoundariesMcp {
constructor(
private readonly boundaries: DayBoundariesService,
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
private readonly auth: AuthService,
private readonly guards: McpToolGuardsService,
private readonly realtime: RealtimeService,
readonly addons: AddonsService,
@@ -44,7 +42,6 @@ export class DayBoundariesMcp {
annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'trips', mode: 'write' }, when,
})
async save({ tripId, dayNumber, boundary }: { tripId: number; dayNumber: number; boundary: RoadtripDayBoundary | null }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
// A stop from another trip is refused by the service, with the reason.
+1 -4
View File
@@ -5,13 +5,11 @@ import { ADDON_IDS } from '../../addons';
import { AddonsService } from '../addons/addons.service';
import { addonGate } from '../addons/addon-gate';
import { GoogleRouteService } from './google-route.service';
import { AuthService } from '../auth/auth.service';
import { demoDenied } from '../../mcp/tools/_shared';
import { answeringRefusals } from './roadtrip-mcp.helpers';
@McpController()
export class GoogleRouteMcp {
constructor(private readonly routes: GoogleRouteService, private readonly auth: AuthService, readonly addons: AddonsService) {}
constructor(private readonly routes: GoogleRouteService, readonly addons: AddonsService) {}
@Tool({ name: 'preview_google_maps_route', description: 'Read ordered stops from a Google Maps directions link. No changes are saved. Review geocoded positions and unresolved stops before importing. The exact Google road geometry is not imported.',
inputSchema: googleRoutePreviewRequestSchema.shape, annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP) })
@@ -26,7 +24,6 @@ export class GoogleRouteMcp {
// a client that withheld places:write was getting an itinerary written anyway.
access: { group: 'places', mode: 'write' }, when: addonGate(ADDON_IDS.ROADTRIP) })
async import(input: GoogleRouteImport & { tripId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
return answeringRefusals(async () => ok(await this.routes.import(input.tripId, ctx.userId, input)));
}
}
@@ -5,13 +5,11 @@ import {
Tool,
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
demoDenied,
ok,
type McpContext,
} from '../../nest-mcp';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { AuthService } from '../auth/auth.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
@@ -28,7 +26,6 @@ const when = addonGate(ADDON_IDS.ROADTRIP);
export class RoadtripPreferencesMcp {
constructor(
private readonly preferences: RoadtripPreferencesService,
private readonly auth: AuthService,
readonly addons: AddonsService,
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
private readonly guards: McpToolGuardsService,
@@ -58,7 +55,6 @@ export class RoadtripPreferencesMcp {
when,
})
async update({ tripId, settings }: { tripId: number; settings: RoadtripPreferences }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
// A day window that ends before it starts is refused by the service, with the reason.
+1 -8
View File
@@ -5,8 +5,7 @@ import { RoadtripService } from './roadtrip.service';
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 { demoDenied, noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { AuthService } from '../auth/auth.service';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { ADDON_IDS } from '../../addons';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
@@ -43,7 +42,6 @@ export class RoadtripMcp {
private readonly roadtrip: RoadtripService,
@InjectRepository(Trips) private readonly tripsRepo: TripsRepository,
private readonly guards: McpToolGuardsService,
private readonly auth: AuthService,
readonly addons: AddonsService,
) {}
@@ -85,7 +83,6 @@ export class RoadtripMcp {
{ tripId, dayId, after_order_index, lat, lng }: { tripId: number; dayId: number; after_order_index: number; lat: number; lng: number },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess();
@@ -127,7 +124,6 @@ export class RoadtripMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess();
@@ -166,7 +162,6 @@ export class RoadtripMcp {
{ tripId, dayId, vias, remove }: { tripId: number; dayId: number; vias: { id: number; after_order_index: number }[]; remove?: number[] },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess();
@@ -188,7 +183,6 @@ export class RoadtripMcp {
when: roadtripAddonOn,
})
async removeVia({ tripId, dayId, viaId }: { tripId: number; dayId: number; viaId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied();
if (!(await this.roadtrip.dayExists(dayId, tripId))) return noAccess();
@@ -204,7 +198,6 @@ export class RoadtripMcp {
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, access: { group: 'trips', mode: 'write' }, when: roadtripAddonOn,
})
async updateVia(input: RoadtripViaUpdateRequest & { tripId: number; dayId: number; viaId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.tripsRepo.findAccessible(input.tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('day_edit', input.tripId, ctx.userId))) return permissionDenied();
if (!(await this.roadtrip.dayExists(input.dayId, input.tripId))) return noAccess();
+1 -2
View File
@@ -8,7 +8,6 @@ import { GoogleRouteMcp } from './google-route.mcp';
import { PlacesModule } from '../places/places.module';
import { AssignmentsModule } from '../assignments/assignments.module';
import { AddonsModule } from '../addons/addons.module';
import { AuthModule } from '../auth/auth.module';
import { MapsModule } from '../maps/maps.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { PermissionsModule } from '../permissions/permissions.module';
@@ -75,7 +74,7 @@ import { ReservationDayPositions } from '../../db/entities/ReservationDayPositio
imports: [
McpSharedModule,
PermissionsModule,
AuthModule,
AddonsModule,
RealtimeModule,
SettingsModule,
+1 -4
View File
@@ -1,11 +1,10 @@
import {
McpController, Tool, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
demoDenied, errorResult, ok,
errorResult, ok,
} from '../../nest-mcp';
import { z } from 'zod';
import { MASKED_SETTING_VALUE, SUPPORTED_LANGUAGE_CODES, settingsBulkRequestSchema, weekStartSchema } from '@trek/shared';
import { AuthService } from '../auth/auth.service';
import { SettingsService } from './settings.service';
/**
@@ -99,7 +98,6 @@ const KEY_LIST = DISPLAY_PREFERENCE_KEYS.join(', ');
export class SettingsMcp {
constructor(
private readonly settings: SettingsService,
private readonly auth: AuthService,
) {}
/**
@@ -138,7 +136,6 @@ export class SettingsMcp {
access: { group: 'settings', mode: 'write' },
})
async updateDisplaySettings({ settings }: { settings: Record<string, unknown> }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const entries = Object.entries(settings);
if (entries.length === 0) {
+2 -4
View File
@@ -1,7 +1,6 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { AdminDefaultUserSettingsController, SettingsController } from './settings.controller';
import { AuthModule } from '../auth/auth.module';
import { AuditModule } from '../audit/audit.module';
import { SettingsService } from './settings.service';
import { SettingsMcp } from './settings.mcp';
@@ -11,8 +10,7 @@ import { Settings } from '../../db/entities/Settings.entity';
/** Exports SettingsService for in-container consumers (admin, share, llm-parse, journey, roadtrip, trips). */
@Module({
// AuthModule for the admin gate on the defaults routes, and for the
// AuthService that SettingsMcp's demo gate injects. MikroOrmModule.forFeature
// MikroOrmModule.forFeature
// registers AppSettingsRepository/SettingsRepository for the
// @InjectRepository(...) constructor params below (Plan 3a Task 0/5) — the
// forFeature + @InjectRepository wiring pattern every later domain copies.
@@ -22,7 +20,7 @@ import { Settings } from '../../db/entities/Settings.entity';
// earlier design tried that and broke ~185 hand-built unit tests, see
// task-5-review.md §4), so nothing in this module ever needs
// `@InjectRepository(Users)`.
imports: [AppConfigModule, AuthModule, AuditModule, MikroOrmModule.forFeature([AppSettings, Settings])],
imports: [AppConfigModule, AuditModule, MikroOrmModule.forFeature([AppSettings, Settings])],
controllers: [SettingsController, AdminDefaultUserSettingsController],
providers: [SettingsService, SettingsMcp],
exports: [SettingsService],
+1 -5
View File
@@ -1,11 +1,10 @@
import {
McpController, Tool, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
demoDenied, ok,
ok,
} from '../../nest-mcp';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { z } from 'zod';
import { AuthService } from '../auth/auth.service';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { canShareTrips } from '../../mcp/scopes';
import { ShareService } from './share.service';
@@ -22,7 +21,6 @@ import { ShareService } from './share.service';
export class ShareMcp {
constructor(
private readonly share: ShareService,
private readonly auth: AuthService,
private readonly guards: McpToolGuardsService,
) {}
@@ -69,7 +67,6 @@ export class ShareMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.share.verifyTripAccess(String(tripId), ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('share_manage', tripId, ctx.userId))) return permissionDenied();
// The zod .default()s above fill omitted flags, and ShareService applies
@@ -90,7 +87,6 @@ export class ShareMcp {
access: (ctx) => canShareTrips(ctx.scopes),
})
async deleteShareLink({ tripId }: { tripId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.share.verifyTripAccess(String(tripId), ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('share_manage', tripId, ctx.userId))) return permissionDenied();
await this.share.remove(String(tripId));
+1 -2
View File
@@ -8,7 +8,6 @@ import { PermissionsModule } from '../permissions/permissions.module';
import { QueryHelpersModule } from '../query-helpers/query-helpers.module';
import { PlacePhotosModule } from '../place-photos/place-photos.module';
import { StorageModule } from '../storage/storage.module';
import { AuthModule } from '../auth/auth.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { Reservations } from '../../db/entities/Reservations.entity';
import { ShareTokens } from '../../db/entities/ShareTokens.entity';
@@ -31,7 +30,7 @@ import { CollabMessages } from '../../db/entities/CollabMessages.entity';
// for the same reason).
@Module({
imports: [
McpSharedModule, SettingsModule, PermissionsModule, QueryHelpersModule, AuthModule, PlacePhotosModule, StorageModule,
McpSharedModule, SettingsModule, PermissionsModule, QueryHelpersModule, PlacePhotosModule, StorageModule,
MikroOrmModule.forFeature([
Reservations, ShareTokens, Trips, Days, DayAssignments, DayNotes, Places, PackingItems, BudgetItems, Categories, CollabMessages,
]),
+2 -6
View File
@@ -2,10 +2,9 @@ import {
McpController, Tool, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
errorResult, ok,
} from '../../nest-mcp';
import { z } from 'zod';
import { AuthService } from '../auth/auth.service';
import { TagsService } from './tags.service';
/**
@@ -16,7 +15,7 @@ import { TagsService } from './tags.service';
*/
@McpController()
export class TagsMcp {
constructor(private readonly tags: TagsService, private readonly auth: AuthService) {}
constructor(private readonly tags: TagsService) {}
@Tool({
name: 'list_tags',
@@ -41,7 +40,6 @@ export class TagsMcp {
access: { group: 'places', mode: 'write' },
})
async createTag({ name, color }: { name: string; color?: string }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const tag = await this.tags.create(ctx.userId, name, color);
return ok({ tag });
}
@@ -58,7 +56,6 @@ export class TagsMcp {
access: { group: 'places', mode: 'write' },
})
async updateTag({ tagId, name, color }: { tagId: number; name?: string; color?: string }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.tags.getByIdAndUser(tagId, ctx.userId))) return errorResult('Tag not found.');
const tag = await this.tags.update(tagId, name, color);
if (!(await tag)) return errorResult('Tag not found.');
@@ -75,7 +72,6 @@ export class TagsMcp {
access: { group: 'places', mode: 'write' },
})
async deleteTag({ tagId }: { tagId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.tags.getByIdAndUser(tagId, ctx.userId))) return errorResult('Tag not found.');
await this.tags.remove(tagId);
return ok({ success: true });
+1 -2
View File
@@ -4,7 +4,6 @@ import { TagsController } from './tags.controller';
import { TagsMcp } from './tags.mcp';
import { TagsRpc } from './tags.rpc';
import { TagsService } from './tags.service';
import { AuthModule } from '../auth/auth.module';
import { Tags } from '../../db/entities/Tags.entity';
/**
@@ -16,7 +15,7 @@ import { Tags } from '../../db/entities/Tags.entity';
* precedent comment).
*/
@Module({
imports: [AuthModule, MikroOrmModule.forFeature([Tags])],
imports: [MikroOrmModule.forFeature([Tags])],
controllers: [TagsController],
// TagsRpc must stay in providers: the plugin RPC registry discovers marked
// PROVIDERS only, and a missing entry here would leave tags.* answering
+1 -9
View File
@@ -2,11 +2,10 @@ import {
McpController, Tool, ResourceTemplate, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
errorResult, ok,
} from '../../nest-mcp';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { z } from 'zod';
import { AuthService } from '../auth/auth.service';
import { ADDON_IDS } from '../../addons';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { TodoService } from './todo.service';
@@ -34,7 +33,6 @@ function parseId(value: string | string[]): number | null {
export class TodoMcp {
constructor(
private readonly todos: TodoService,
private readonly auth: AuthService,
readonly addons: AddonsService,
private readonly guards: McpToolGuardsService,
) {}
@@ -77,7 +75,6 @@ export class TodoMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const item = await this.todos.createItem(tripId, { name, category, due_date, description, assigned_user_id, priority });
@@ -108,7 +105,6 @@ export class TodoMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
// Build bodyKeys to signal which nullable fields were explicitly provided
@@ -136,7 +132,6 @@ export class TodoMcp {
access: { group: 'todos', mode: 'write' },
})
async toggleTodo({ tripId, itemId, checked }: { tripId: number; itemId: number; checked: boolean }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const item = await this.todos.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, []);
@@ -157,7 +152,6 @@ export class TodoMcp {
access: { group: 'todos', mode: 'write' },
})
async deleteTodo({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const deleted = await this.todos.deleteItem(tripId, itemId);
@@ -178,7 +172,6 @@ export class TodoMcp {
access: { group: 'todos', mode: 'write' },
})
async reorderTodos({ tripId, orderedIds }: { tripId: number; orderedIds: number[] }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
await this.todos.reorderItems(tripId, orderedIds);
@@ -214,7 +207,6 @@ export class TodoMcp {
access: { group: 'todos', mode: 'write' },
})
async setTodoCategoryAssignees({ tripId, categoryName, userIds }: { tripId: number; categoryName: string; userIds: number[] }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
const assignees = await this.todos.updateCategoryAssignees(tripId, categoryName, userIds);
+1 -2
View File
@@ -7,7 +7,6 @@ import { TodoRpc } from './todo.rpc';
import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { AuthModule } from '../auth/auth.module';
import { AddonsModule } from '../addons/addons.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { TodoItems } from '../../db/entities/TodoItems.entity';
@@ -24,7 +23,7 @@ import { TripMembers } from '../../db/entities/TripMembers.entity';
* (Plan 4 Task 2): `verifyTripAccess`'s own canAccessTrip delegate, now
* TripsRepository directly. */
@Module({
imports: [MikroOrmModule.forFeature([TodoItems, TodoCategoryAssignees, Trips, TripMembers]), McpSharedModule, PermissionsModule, AuthModule, RealtimeModule, PluginGuardsModule, AddonsModule],
imports: [MikroOrmModule.forFeature([TodoItems, TodoCategoryAssignees, Trips, TripMembers]), McpSharedModule, PermissionsModule, RealtimeModule, PluginGuardsModule, AddonsModule],
controllers: [TodoController],
providers: [TodoService, TodoMcp, TodoRpc],
exports: [TodoService],
+1 -2
View File
@@ -43,8 +43,7 @@ type TokenKind = 'mcp' | 'api';
* before.
*
* Deliberately NOT here: verifyJwtToken (that is login identity, and it stays
* next to the cookie/JWT logic on AuthService) and isDemoUser (a demo gate that
* happens to read the users table).
* next to the cookie/JWT logic on AuthService).
*/
@Injectable()
export class TokenService {
+1 -2
View File
@@ -9,7 +9,6 @@ import { ToursImportController } from './tours-import.controller';
import { ToursService } from './tours.service';
import { PlacesModule } from '../places/places.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { AuthModule } from '../auth/auth.module';
import { AddonsModule } from '../addons/addons.module';
/**
@@ -23,7 +22,7 @@ import { AddonsModule } from '../addons/addons.module';
imports: [
PlacesModule,
PermissionsModule,
AuthModule,
AddonsModule,
MikroOrmModule.forFeature([Tours, TourTypes, TourWaypoints, Places]),
],
+1 -4
View File
@@ -1,11 +1,10 @@
import {
McpController, Tool, type McpContext,
TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT, TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
demoDenied, ok,
ok,
} from '../../nest-mcp';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { z } from 'zod';
import { AuthService } from '../auth/auth.service';
import {
buildTransitReservationParts,
cleanTransitItineraryNames,
@@ -69,7 +68,6 @@ export class TransitMcp {
// this injects TripsRepository directly (same constructor slot) and
// calls findAccessible.
@InjectRepository(Trips) private readonly trips: TripsRepository,
private readonly auth: AuthService,
private readonly guards: McpToolGuardsService,
// The limiter RateLimitModule provides, so its store is the one every
// other caller counts in. The MCP buckets (mcp_transit_*) are keyed by
@@ -193,7 +191,6 @@ export class TransitMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
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);
+1 -2
View File
@@ -7,7 +7,6 @@ import { TransitMcp } from './transit.mcp';
import { GoogleTransitProvider } from './google-transit.provider';
import { DaysModule } from '../days/days.module';
import { ReservationsModule } from '../reservations/reservations.module';
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';
@@ -33,7 +32,7 @@ import { AppConfigModule } from '../app-config/app-config.module';
*/
@Module({
// DaysModule + ReservationsModule: TransitMcp's create_transit_journey injects both.
imports: [AppConfigModule, McpSharedModule, RateLimitModule, DaysModule, ReservationsModule, AuthModule, MikroOrmModule.forFeature([AppSettings, Users, Trips]), GoogleQuotaModule],
imports: [AppConfigModule, McpSharedModule, RateLimitModule, DaysModule, ReservationsModule, MikroOrmModule.forFeature([AppSettings, Users, Trips]), GoogleQuotaModule],
controllers: [TransitController],
providers: [TransitService, TransitMcp, GoogleTransitProvider],
exports: [TransitService],
+1 -10
View File
@@ -1,13 +1,12 @@
import {
McpController, Tool, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
ok,
} from '../../nest-mcp';
import { z } from 'zod';
import { getAppUrl } from '../../app-config';
import { AuditService } from '../audit/audit.service';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { DemoService } from '../common/demo.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { canShareTrips, canWrite } from '../../mcp/scopes';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
@@ -36,14 +35,8 @@ export class TripInviteMcp {
private readonly env: RuntimeEnvService,
private readonly guards: McpToolGuardsService,
private readonly audit: AuditService,
private readonly demo: DemoService,
) {}
/** Plan 3i Task 3: the AuthService.isDemoUser check via the injected DemoService (common/demo.service.ts), not the free-function demo-write.ts helper. */
private async isDemoUser(userId: number): Promise<boolean> {
return await this.demo.isDemoUserId(userId);
}
/** Trip access first (404-equivalent), then share_manage, which is requireManage() in the controller. */
private async denyManage(tripId: number, userId: number) {
if (!(await this.invites.verifyTripAccess(String(tripId), userId))) return noAccess();
@@ -87,7 +80,6 @@ export class TripInviteMcp {
{ tripId, expires_in_days }: { tripId: number } & TripInviteLinkCreateRequest,
ctx: McpContext,
) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
const denied = await this.denyManage(tripId, ctx.userId);
if ((await denied)) return denied;
// The route's own coercion, kept verbatim: the shared contract admits a
@@ -121,7 +113,6 @@ export class TripInviteMcp {
access: (ctx) => canShareTrips(ctx.scopes) && canWrite(ctx.scopes, 'trips'),
})
async deleteTripInviteLink({ tripId }: { tripId: number }, ctx: McpContext) {
if (await this.isDemoUser(ctx.userId)) return demoDenied();
const denied = await this.denyManage(tripId, ctx.userId);
if ((await denied)) return denied;
await this.invites.remove(tripId);
+1 -13
View File
@@ -2,12 +2,11 @@ import {
McpController, Tool, Resource, ResourceTemplate, Prompt, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
errorResult, ok,
} from '../../nest-mcp';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { tripIdPromptArg } from '../mcp-shared/prompt-args';
import { z } from 'zod';
import { AuthService } from '../auth/auth.service';
import { CalendarService } from '../calendar/calendar.service';
import { TripMembersService } from '../trip-members/trip-members.service';
import { TripReadModelService } from '../trip-read-model/trip-read-model.service';
@@ -68,7 +67,6 @@ export class TripsMcp {
private readonly trips: TripsService,
private readonly todos: TodoService,
private readonly collab: CollabService,
private readonly auth: AuthService,
// Appended, not inserted: the hand-wired MCP test harnesses build this
// positionally, so an earlier slot would silently shift every one of them.
private readonly calendar: CalendarService,
@@ -104,7 +102,6 @@ export class TripsMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (start_date) {
const d = new Date(start_date + 'T00:00:00Z');
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
@@ -159,7 +156,6 @@ export class TripsMcp {
},
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.trips.canAccessTrip(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('trip_edit', tripId, ctx.userId))) return permissionDenied();
if (clear_dates && (start_date || end_date))
@@ -224,7 +220,6 @@ export class TripsMcp {
access: (ctx) => canDeleteTrips(ctx.scopes),
})
async deleteTrip({ tripId }: { tripId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.trips.isOwner(tripId, ctx.userId))) return noAccess();
await this.trips.remove(tripId, ctx.userId, 'user');
return ok({ success: true, tripId });
@@ -351,7 +346,6 @@ export class TripsMcp {
access: { group: 'trips', mode: 'write' },
})
async addTripMember({ tripId, identifier }: { tripId: number; identifier: string }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.trips.canAccessTrip(tripId, ctx.userId))) return noAccess();
const ownerRow = await this.trips.getOwner(tripId);
if (!ownerRow) return noAccess();
@@ -380,7 +374,6 @@ export class TripsMcp {
access: { group: 'trips', mode: 'write' },
})
async removeTripMember({ tripId, memberId }: { tripId: number; memberId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.trips.canAccessTrip(tripId, ctx.userId))) return noAccess();
// Giving up your own access is not member management, so it carries no permission
// requirement: the same self-removal bypass DELETE /api/trips/:id/members/:userId has.
@@ -401,7 +394,6 @@ export class TripsMcp {
access: { group: 'trips', mode: 'write' },
})
async leaveTrip({ tripId }: { tripId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.trips.canAccessTrip(tripId, ctx.userId))) return noAccess();
const ownerRow = await this.trips.getOwner(tripId);
if (!ownerRow) return noAccess();
@@ -435,7 +427,6 @@ export class TripsMcp {
access: { group: 'trips', mode: 'write' },
})
async createTripGuest({ tripId, name }: { tripId: number; name: string }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.trips.canAccessTrip(tripId, ctx.userId))) return noAccess();
const ownerRow = await this.trips.getOwner(tripId);
if (!ownerRow || ownerRow.user_id !== ctx.userId)
@@ -463,7 +454,6 @@ export class TripsMcp {
access: { group: 'trips', mode: 'write' },
})
async renameTripGuest({ tripId, guestId, name }: { tripId: number; guestId: number; name: string }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.trips.canAccessTrip(tripId, ctx.userId))) return noAccess();
const ownerRow = await this.trips.getOwner(tripId);
if (!ownerRow || ownerRow.user_id !== ctx.userId)
@@ -489,7 +479,6 @@ export class TripsMcp {
access: { group: 'trips', mode: 'write' },
})
async deleteTripGuest({ tripId, guestId }: { tripId: number; guestId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.trips.canAccessTrip(tripId, ctx.userId))) return noAccess();
const ownerRow = await this.trips.getOwner(tripId);
if (!ownerRow || ownerRow.user_id !== ctx.userId)
@@ -511,7 +500,6 @@ export class TripsMcp {
access: { group: 'trips', mode: 'write' },
})
async copyTrip({ tripId, title }: { tripId: number; title?: string }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.trips.canAccessTrip(tripId, ctx.userId))) return noAccess();
try {
const newTripId = await this.trips.copy(tripId, ctx.userId, title);
+1 -2
View File
@@ -20,7 +20,6 @@ import { TripMembershipModule } from '../trip-membership/trip-membership.module'
import { AppConfigModule } from '../app-config/app-config.module';
import { UnsplashModule } from '../unsplash/unsplash.module';
import { TripsMcp } from './trips.mcp';
import { AuthModule } from '../auth/auth.module';
import { CalendarModule } from '../calendar/calendar.module';
import { AccommodationsModule } from '../accommodations/accommodations.module';
import { TripMembersModule } from '../trip-members/trip-members.module';
@@ -49,7 +48,7 @@ import { SettingsModule } from '../settings/settings.module';
}),
}),
StorageModule,
McpSharedModule, TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AuthModule, AppConfigModule, UnsplashModule, RealtimeModule, PluginGuardsModule, AddonsModule, TripMembershipModule, CalendarModule, AccommodationsModule, TripMembersModule, TripReadModelModule, SettingsModule],
McpSharedModule, TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AppConfigModule, UnsplashModule, RealtimeModule, PluginGuardsModule, AddonsModule, TripMembershipModule, CalendarModule, AccommodationsModule, TripMembersModule, TripReadModelModule, SettingsModule],
controllers: [TripsController],
providers: [TripsService, TripsMcp, TripPromptsMcp, TripsRpc],
// Exported for FeedsModule (ICS feeds) and PluginsModule (RPC host injection).
+1 -18
View File
@@ -3,7 +3,7 @@ import {
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
errorResult, ok,
} from '../../nest-mcp';
import { z } from 'zod';
import {
@@ -127,7 +127,6 @@ export class VacayMcp {
}: VacayUpdatePlanRequest,
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = await this.vacay.getActivePlanId(ctx.userId);
// updatePlan already returns the fully-hydrated { plan }; surface it so the
// AI consumer sees the updated plan, matching get_vacay_plan.
@@ -149,7 +148,6 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async setVacayColor({ color }: { color: string }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = await this.vacay.getActivePlanId(ctx.userId);
await this.vacay.setUserColor(ctx.userId, planId, color, undefined);
// Echo the persisted color (mirrors the service default) so the AI consumer sees what was set.
@@ -181,7 +179,6 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async sendVacayInvite({ targetUserId }: { targetUserId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = await this.vacay.getActivePlanId(ctx.userId);
const me = await this.auth.getCurrentUser(ctx.userId);
if (!me) return errorResult('User not found.');
@@ -201,7 +198,6 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async acceptVacayInvite({ planId }: { planId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const result = await this.vacay.acceptInvite(ctx.userId, planId, undefined);
if (result.error) return errorResult(result.error);
return ok({ success: true });
@@ -218,7 +214,6 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async declineVacayInvite({ planId }: { planId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
await this.vacay.declineInvite(ctx.userId, planId, undefined);
return ok({ success: true });
}
@@ -234,7 +229,6 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async cancelVacayInvite({ targetUserId }: { targetUserId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = await this.vacay.getActivePlanId(ctx.userId);
await this.vacay.cancelInvite(planId, targetUserId);
return ok({ success: true });
@@ -249,7 +243,6 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async dissolveVacayPlan(_args: Record<string, never>, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
await this.vacay.dissolvePlan(ctx.userId, undefined);
return ok({ success: true });
}
@@ -279,7 +272,6 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async addVacayYear({ year }: { year: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = await this.vacay.getActivePlanId(ctx.userId);
const years = await this.vacay.addYear(planId, year, undefined);
return ok({ years });
@@ -296,7 +288,6 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async deleteVacayYear({ year }: { year: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = await this.vacay.getActivePlanId(ctx.userId);
const years = await this.vacay.deleteYear(planId, year, undefined);
return ok({ years });
@@ -335,7 +326,6 @@ export class VacayMcp {
{ date, fraction, kind, targetUserId }: { date: string; fraction?: 0.5 | 1; kind?: 'vacation' | 'comp'; targetUserId?: number },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = await this.vacay.getActivePlanId(ctx.userId);
let userId = ctx.userId;
if (targetUserId !== undefined && targetUserId !== ctx.userId) {
@@ -364,7 +354,6 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async toggleCompanyHoliday({ date, note, half }: { date: string; note?: string; half?: boolean }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = await this.vacay.getActivePlanId(ctx.userId);
const result = await this.vacay.toggleCompanyHoliday(planId, date, note, undefined, half ? 0.5 : 1);
return ok(result);
@@ -398,7 +387,6 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async updateVacayStats({ year, vacationDays }: { year: number; vacationDays: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = await this.vacay.getActivePlanId(ctx.userId);
await this.vacay.updateStats(ctx.userId, planId, year, vacationDays, undefined);
return ok({ success: true });
@@ -422,7 +410,6 @@ export class VacayMcp {
{ region, type, label, color, sortOrder }: { region: string; type?: 'public_holiday' | 'school_holiday'; label?: string | null; color?: string; sortOrder?: number },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = await this.vacay.getActivePlanId(ctx.userId);
// An omitted type stays undefined so the service default (and the default
// colour that goes with it) applies, exactly as on the REST route.
@@ -446,7 +433,6 @@ export class VacayMcp {
{ calendarId, label, color }: { calendarId: number; label?: string | null; color?: string },
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = await this.vacay.getActivePlanId(ctx.userId);
const cal = await this.vacay.updateHolidayCalendar(calendarId, planId, { label, color }, undefined);
if (!cal) return errorResult('Holiday calendar not found.');
@@ -464,7 +450,6 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async deleteHolidayCalendar({ calendarId }: { calendarId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = await this.vacay.getActivePlanId(ctx.userId);
await this.vacay.deleteHolidayCalendar(calendarId, planId, undefined);
return ok({ success: true });
@@ -574,7 +559,6 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async shareVacayCalendar({ targetUserId }: { targetUserId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const me = await this.auth.getCurrentUser(ctx.userId);
if (!me) return errorResult('User not found.');
const result = await this.vacay.shareCalendar(ctx.userId, me.email, targetUserId);
@@ -593,7 +577,6 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'write' },
})
async unshareVacayCalendar({ shareId }: { shareId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!(await this.vacay.removeShare(shareId, ctx.userId, undefined))) {
return errorResult('Share not found.');
}