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
+1 -1
View File
@@ -60,7 +60,7 @@ Nest owns everything. Every domain is a DI module under `src/nest/<domain>/` (`c
- **`src/bootstrap.ts` — `buildApp()`** is the single builder for production and the test harness. Everything on the raw Express instance (global middleware, uploads, discovery metadata, statics, the named `jsonParser`/`urlencodedParser` wrappers) is registered **before `app.init()`**, because Nest throws `NotFoundException` on unmatched routes and nothing after init is reachable. The wrappers exempt `/mcp` so the MCP SDK reads the raw stream — **never `@Body()` a `/mcp` route**. Don't add routes to that pre-init shell; it bypasses every global guard, interceptor and filter.
- **Realtime**: `/ws` is a Nest gateway in `src/nest/realtime/`. Inject `RealtimeService`; `src/websocket.ts` is a re-export stub for old call sites. Trip mutations forward `X-Socket-Id` so the originating client doesn't echo its own change.
- **MCP**: `src/nest-mcp/` is the decorator/registry layer (`@McpController`/`@Tool`/`@Resource`/`@Prompt`; see its `README.md`); tools live in `<domain>.mcp.ts` files beside each module; the HTTP transport is `src/nest/mcp-transport/`; `src/mcp/` holds process-wide state only (scopes, session manager, boot policy). Every tool invocation writes an audit row through the registry's `onInvoke` seam. MCP tools are adapters over the same services and permission checks as REST — a change on one side lands on the other in the same change.
- **MCP**: `src/nest-mcp/` is the decorator/registry layer (`@McpController`/`@Tool`/`@Resource`/`@Prompt`; see its `README.md`); tools live in `<domain>.mcp.ts` files beside each module; the HTTP transport is `src/nest/mcp-transport/`; `src/mcp/` holds process-wide state only (scopes, session manager, boot policy). Every tool invocation writes an audit row through the registry's `onInvoke` seam. The demo-mode write block is the registry's `toolGate` (`trekDemoToolGate`): every tool not annotated read-only refuses a demo account there, so a tool never checks demo mode itself (ESLint refuses it in `*.mcp.ts`). MCP tools are adapters over the same services and permission checks as REST — a change on one side lands on the other in the same change.
- **Scheduling**: `src/nest/scheduling/` (`CronRegistrarService`) is the one way to schedule a cron. Jobs are `*.job.ts` providers in their owning domain. The registrar refuses to schedule under `NODE_ENV=test` (an integration test pins it). **Never use a `@Cron` decorator**: it bypasses that gate. Every tick first takes the job's row in `scheduler_leases` (`SchedulerLeasesRepository.acquire`, one conditional upsert that decides the race), renews it while it runs and keeps it for a short settle window afterwards, so two processes on one database never run the same tick; a job needs nothing of its own for this. Claims on individual rows (the reminder jobs' `claimReminder`) stay where they are: the lease decides who runs a tick, the claim what a tick may send.
- **Plugins** (`src/nest/plugins/`): the child-process runtime (`supervisor/`, `runtime/plugin-host-entry.ts`, `protocol/envelope.ts`) is **intentionally not Nest**; it is the security boundary. `host/rpc-host.ts` registers an RPC method at spawn only if the plugin holds the unlocking permission (registration = authorization). Install-time gates live in `install/`; the manifest's `trek` version range fails closed, and `TREK_PLUGINS_IGNORE_TREK_RANGE` downgrades that to a warning. The protocol tables in `protocol/envelope.ts` (+ `src/plugin-event-sink.ts`) are **generated** into `plugin-sdk/` and `shared/`: run `gen:plugin-facts` after touching them and commit the output. What a plugin gets back is `protocol/output-contract.ts`: every wire method has an entry, and an entity result (`trips.getById`, `todos.list`, `vacay.mine`, ...) is cut down to its published fields in `rpc-host.ts` on the way out, together with every child row it carries whose field is listed in `PLUGIN_ENTITY_NESTED` (a day's `notes_items`, a reservation's `endpoints`). A new column on any whole-row table behind those entities, child tables included, fails `tests/unit/plugins/output-contract.schema.test.ts` until it is listed as published or withheld (the SDK's `PLUGIN_ENTITY_FIELDS` and `PLUGIN_ENTITY_NESTED` are generated from it); a method left as `readModel` must return a value built field by field from named columns. A plugin's own SQL (`host/plugin-data.service.ts`) is held to a row cap, and `query`, `tx` and an `exec` script to a time budget checked between rows and statements (`migrate` is deliberately not timed). `runtime/egress-policy.ts` is a hand-kept mirror guarded by a parity test in `plugin-sdk/`; change both sides together.
- **Storage**: `src/nest/storage/` is the upload driver abstraction (local / S3 / mirror behind one interface, with an admin surface and a usage job). Domain services never touch the filesystem or the S3 SDK directly. Where the data lives (`data/`, `uploads/`, backups, logs, key files, the DB file) is resolved in one place, `src/app-config/data-paths.ts`; a provider that reads one injects `DataPathsService`, plain helper modules shared with code outside the container call `resolveDataPaths()` (the list is in `src/app-config/README.md`), and nothing anchors a data path on its own `__dirname` or on `process.cwd()`.
+19
View File
@@ -130,6 +130,16 @@ const APP_SETTING_SELECTORS = [
{ selector: "CallExpression[callee.property.name='getValue'] > TemplateLiteral.arguments:first-child[expressions.length=0]", message: APP_SETTING_MESSAGE },
];
// The demo-mode write block runs once, in the MCP registry (trekDemoToolGate in
// src/mcp/nest-mcp-policy.ts). A tool asking for itself again is a second copy
// that drifts: it was 196 of them before the gate moved.
const DEMO_GATE_MESSAGE =
'MCP tools do not check demo mode themselves: the registry gate (trekDemoToolGate) refuses every tool not annotated read-only. Annotate the tool instead.';
const MCP_DEMO_SELECTORS = [
{ selector: "CallExpression[callee.property.name=/^isDemoUser(Id)?$/]", message: DEMO_GATE_MESSAGE },
{ selector: "CallExpression[callee.name='demoDenied']", message: DEMO_GATE_MESSAGE },
];
export default tseslint.config(
gitignore({ strict: false }),
{
@@ -247,6 +257,15 @@ export default tseslint.config(
'no-restricted-syntax': ['error', ...ENV_SELECTORS, ...FETCH_SELECTORS, ...APP_SETTING_SELECTORS],
},
},
{
// Restates the config-access and app-setting guards above for *.mcp.ts
// (a later block's options replace an earlier one's) and adds the
// demo-gate wall.
files: ['src/**/*.mcp.ts'],
rules: {
'no-restricted-syntax': ['error', ...ENV_SELECTORS, ...FETCH_SELECTORS, ...APP_SETTING_SELECTORS, ...MCP_DEMO_SELECTORS],
},
},
{
// src/services/ is gone. It was the legacy layer this migration existed to
// empty, and the last of it (airtrail) folded into src/nest/integrations/.
+1 -1
View File
@@ -7,7 +7,7 @@
"src/db/repositories/Users.repository.ts": 1446,
"src/nest/atlas/atlas-geo.ts": 1005,
"src/nest/atlas/atlas.service.ts": 1182,
"src/nest/auth/auth.service.ts": 1156,
"src/nest/auth/auth.service.ts": 1150,
"src/nest/budget/budget.service.ts": 1702,
"src/nest/collections/collections.service.ts": 1488,
"src/nest/doc-sync/doc-sync.service.ts": 1060,
+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.');
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Calls one decorated MCP tool method the way a session reaches it: behind the
* demo gate the registry puts in front of every registered tool. For suites
* that build a controller with stubs and call its methods directly, so they
* keep their stubs instead of attaching a whole registry (which would also run
* the `when` gates those stubs cannot answer).
*/
import { getEntry, type ClassRef } from '../../src/nest-mcp/metadata';
import { trekDemoToolGate } from '../../src/mcp/nest-mcp-policy';
import type { McpContext } from '../../src/nest-mcp';
export async function callGatedTool(
instance: object,
methodName: string,
args: unknown,
ctx: McpContext,
isDemoUser: (userId: number) => Promise<boolean>,
): Promise<unknown> {
const entry = getEntry((instance as { constructor: ClassRef }).constructor, methodName);
if (entry?.kind !== 'tool') throw new Error(`${methodName} is not a decorated tool`);
const refusal = await trekDemoToolGate(isDemoUser)(entry.options, ctx);
if (refusal !== undefined) return refusal;
const method = (instance as Record<string, (a: unknown, c: McpContext) => unknown>)[methodName];
return method.call(instance, args, ctx);
}
+33 -31
View File
@@ -2,7 +2,7 @@ import { createTestRegistry, type McpRegistry } from '../../src/nest-mcp';
import { SchoolHolidaysMcp } from '../../src/nest/school-holidays/school-holidays.mcp';
import { SchoolHolidaysService } from '../../src/nest/school-holidays/school-holidays.service';
import { db } from '../../src/db/database';
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../src/mcp/nest-mcp-policy';
import { trekDemoToolGate, trekMcpAccessPolicy, trekMcpValidateAccess } from '../../src/mcp/nest-mcp-policy';
import { AssignmentsMcp } from '../../src/nest/assignments/assignments.mcp';
import { AssignmentsService } from '../../src/nest/assignments/assignments.service';
import { AtlasMcp } from '../../src/nest/atlas/atlas.mcp';
@@ -208,9 +208,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
// `getPlaceWithTags` resolve `TripsRepository`/`TripMembersRepository`/
// `PlacesRepository` through it directly now; `DatabaseService` is gone.
const mcpOrm = await createTestOrm(db);
// Plan 3i Task 3: DemoService, hand-built for the 4 *.mcp.ts controllers
// below that used to take `isDemoUserId(env, db, userId)` as a free
// function and now inject DemoService instead.
// DemoService answers the registry's demo gate (trekDemoToolGate) below.
const demoService = new DemoService(new RuntimeEnvService(), mcpOrm.em);
const generalStorage = makeStorageFixture('').storage;
const appSettings = (await createTestOrm(db)).repo(AppSettings);
@@ -414,18 +412,18 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
const captureBackfill = new PhotoCaptureBackfillService(new PhotoResolverService(trekPhotos, new ThumbnailService(addonsService, generalStorage, mcpOrm.repo(TrekPhotos)), new TrekPhotoCacheService(mcpOrm.repo(TrekPhotoCacheMeta), generalStorage), new PhotoProviderRegistry([new ImmichPhotoProvider(immichService), new SynologyPhotoProvider(synologyService)]), generalStorage), trekPhotos, generalStorage);
return createTestRegistry(
[
new TagsMcp(new TagsService(await createTestTagsRepo(db)), authService),
new CategoriesMcp(new CategoriesService(await createTestCategoriesRepo(db)), new RuntimeEnvService(), guards, demoService),
new TagsMcp(new TagsService(await createTestTagsRepo(db))),
new CategoriesMcp(new CategoriesService(await createTestCategoriesRepo(db)), new RuntimeEnvService(), guards),
// The weather and airport tools left the legacy mapsWeather registrar.
new WeatherMcp(new WeatherService()),
new AirportsMcp(),
new AuthMcp(),
new TodoMcp(todoService, authService, addonsService, guards),
new PackingMcp(packingService, authService, addonsService, guards),
new BudgetMcp(budgetService, exchangeRatesService, new RuntimeEnvService(), new TripMembershipService(await createTestTripsRepo(db), await createTestTripMembersRepo(db)), addonsService, guards, await createTestUnitOfWork(db), await createTestPlacesRepo(db), await createTestTripsRepo(db), demoService, await createTestTripMembersRepo(db)),
new ReservationsMcp(reservationsService, daysService, budgetService, authService, assignmentsService, guards),
new DayNotesMcp(new DayNotesService(await createTestTripsRepo(db), permissionsService, realtimeService, await createTestDayNotesRepo(db), await createTestDaysRepo(db)), authService, guards),
new DaysMcp(daysService, authService, guards, dayRemovalService),
new TodoMcp(todoService, addonsService, guards),
new PackingMcp(packingService, addonsService, guards),
new BudgetMcp(budgetService, exchangeRatesService, new RuntimeEnvService(), new TripMembershipService(await createTestTripsRepo(db), await createTestTripMembersRepo(db)), addonsService, guards, await createTestUnitOfWork(db), await createTestPlacesRepo(db), await createTestTripsRepo(db), await createTestTripMembersRepo(db)),
new ReservationsMcp(reservationsService, daysService, budgetService, assignmentsService, guards),
new DayNotesMcp(new DayNotesService(await createTestTripsRepo(db), permissionsService, realtimeService, await createTestDayNotesRepo(db), await createTestDaysRepo(db)), guards),
new DaysMcp(daysService, guards, dayRemovalService),
new RoadtripMcp(
new RoadtripService(
realtimeService,
@@ -437,17 +435,16 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
),
await createTestTripsRepo(db),
guards,
authService,
addonsService,
),
new RoadtripPreferencesMcp(
new RoadtripPreferencesService(realtimeService, await createTestUnitOfWork(db), await createTestRoadtripPreferencesRepo(db)),
authService, addonsService, await createTestTripsRepo(db), guards,
addonsService, await createTestTripsRepo(db), guards,
),
new FilesMcp(filesService, authService, guards, new AllowedFileTypesService(appSettings)),
new AccommodationsMcp(accommodationsService, placesService, authService, guards, await createTestUnitOfWork(db)),
new AssignmentsMcp(assignmentsService, daysService, authService, guards),
new CollabMcp(collabService, authService, addonsService, guards),
new FilesMcp(filesService, guards, new AllowedFileTypesService(appSettings)),
new AccommodationsMcp(accommodationsService, placesService, guards, await createTestUnitOfWork(db)),
new AssignmentsMcp(assignmentsService, daysService, guards),
new CollabMcp(collabService, addonsService, guards),
new VacayMcp(new VacayService(
await createTestVacayPlansRepo(db), await createTestVacayPlanMembersRepo(db),
await createTestVacayYearsRepo(db), await createTestVacayUserYearsRepo(db),
@@ -467,13 +464,13 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
),
guards,
),
new TripsMcp(tripsService, todoService, collabService, authService, calendarService, membersService, readModelService, addonsService, guards),
new TripsMcp(tripsService, todoService, collabService, calendarService, membersService, readModelService, addonsService, guards),
new TripPromptsMcp(tripsService, readModelService, packingService, addonsService),
new ShareMcp(new ShareService(new SettingsService(await createTestUnitOfWork(db), appSettings, await createTestSettingsRepo(db)), permissionsService, queryHelpersService, placePhotoCache, await createTestUnitOfWork(db), ...(await shareServiceRepoArgs(db))), authService, guards),
new FeedsMcp(new FeedsService(await createTestTripsRepo(db), usersRepo, calendarService), await createTestTripsRepo(db), new RuntimeEnvService(), guards, demoService),
new TripInviteMcp(new TripInviteService(await createTestTripsRepo(db), permissionsService, new TripMembershipService(await createTestTripsRepo(db), await createTestTripMembersRepo(db)), await createTestUnitOfWork(db), await createTestTripInviteTokensRepo(db)), new RuntimeEnvService(), guards, new AuditService(auditLogRepo, usersRepo), demoService),
new ShareMcp(new ShareService(new SettingsService(await createTestUnitOfWork(db), appSettings, await createTestSettingsRepo(db)), permissionsService, queryHelpersService, placePhotoCache, await createTestUnitOfWork(db), ...(await shareServiceRepoArgs(db))), guards),
new FeedsMcp(new FeedsService(await createTestTripsRepo(db), usersRepo, calendarService), await createTestTripsRepo(db), new RuntimeEnvService(), guards),
new TripInviteMcp(new TripInviteService(await createTestTripsRepo(db), permissionsService, new TripMembershipService(await createTestTripsRepo(db), await createTestTripMembersRepo(db)), await createTestUnitOfWork(db), await createTestTripInviteTokensRepo(db)), new RuntimeEnvService(), guards, new AuditService(auditLogRepo, usersRepo)),
new MapsMcp(mapsService),
new PlacesMcp(placesService, mapsService, await createTestTripsRepo(db), authService, journeyDomain, assignmentsService, guards, await createTestUnitOfWork(db)),
new PlacesMcp(placesService, mapsService, await createTestTripsRepo(db), journeyDomain, assignmentsService, guards, await createTestUnitOfWork(db)),
new CollectionsMcp(
new CollectionsService(
permissionsService, realtimeService, notificationsStub(), generalStorage, await createTestUnitOfWork(db),
@@ -495,16 +492,16 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
// Plan 3h Task 1 — `CollectionsMcp`'s own constructor-ripple fix:
// `DatabaseService` dropped (CL89's only use), `UsersRepository`
// (`findUsernameEmail`, UM11's precedent) added in its place.
usersRepo, authService, addonsService,
usersRepo, addonsService,
),
new TransitMcp(new TransitService(new GoogleTransitProvider(appSettings, usersRepo, noGoogleQuota), transitConfig()), daysService, reservationsService, await createTestTripsRepo(db), authService, guards),
new TransitMcp(new TransitService(new GoogleTransitProvider(appSettings, usersRepo, noGoogleQuota), transitConfig()), daysService, reservationsService, await createTestTripsRepo(db), guards),
new AtlasMcp(new AtlasService(
await createTestBucketListRepo(db), await createTestHiddenCountriesRepo(db),
await createTestHiddenRegionsRepo(db), await createTestVisitedCountriesRepo(db),
await createTestVisitedRegionsRepo(db), await createTestPlaceRegionsRepo(db),
await createTestTripsRepo(db), await createTestPlacesRepo(db),
await createTestReservationEndpointsRepo(db), await createTestUnitOfWork(db), (await sharedTestOrm(db)).orm,
), addonsService, authService),
), addonsService),
new JourneyMcp(journeyDomain, new JourneyShareService(
journeyDomain,
new SettingsService(await createTestUnitOfWork(db), appSettings, await createTestSettingsRepo(db)),
@@ -517,21 +514,26 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
// + `JourneyEntryPhotosRepository` (JS14), relocated off
// `JourneyShareTokensRepository`'s own fallback stub.
await createTestJourneyEntriesRepo(db), await createTestJourneyEntryPhotosRepo(db),
), addonsService, authService, new JourneyPhotoCaptureService(captureBackfill, journeyDomain, { reverseGeocode: async () => ({ name: null, address: null }) } as never, mcpOrm.orm)),
), addonsService, new JourneyPhotoCaptureService(captureBackfill, journeyDomain, { reverseGeocode: async () => ({ name: null, address: null }) } as never, mcpOrm.orm)),
new MemoriesMcp(immichService, synologyService, addonsService, mcpOrm.repo(PhotoProviders)),
new NotificationsMcp(await makeNotificationsService(db, realtimeService), authService),
new NotificationsMcp(await makeNotificationsService(db, realtimeService)),
new AirtrailMcp(new AirtrailService(await createTestUserAirtrailRepo(db), new AuditService(auditLogRepo, usersRepo), new AirtrailClient()), addonsService),
new ReservationImportMcp(new AirtrailImportService(
await createTestReservationsRepo(db), await createTestReservationEndpointsRepo(db), await createTestDaysRepo(db),
realtimeService, reservationsService, new AirtrailClient(), new AirtrailService(await createTestUserAirtrailRepo(db), new AuditService(auditLogRepo, usersRepo), new AirtrailClient()),
await createTestUnitOfWork(db),
), await createTestTripsRepo(db), authService, guards, addonsService),
new SettingsMcp(new SettingsService(await createTestUnitOfWork(db), appSettings, await createTestSettingsRepo(db)), authService),
), await createTestTripsRepo(db), guards, addonsService),
new SettingsMcp(new SettingsService(await createTestUnitOfWork(db), appSettings, await createTestSettingsRepo(db))),
new HelpMcp(), new AddonsMcp(addonsService),
new TripWarningsMcp(new PluginHooks({ providersOf: () => [], invokeHook: async () => [] } as unknown as PluginRuntimeService), await createTestTripsRepo(db)),
new PluginSearchMcp(new PluginHooks({ providersOf: () => [], invokeHook: async () => [] } as unknown as PluginRuntimeService)),
new PluginPoisMcp(new PluginPoisService(new PluginHooks({ providersOf: () => [], invokeHook: async () => [] } as unknown as PluginRuntimeService), await createTestPluginsRepo(db))),
],
{ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess },
{
accessPolicy: trekMcpAccessPolicy,
validateAccess: trekMcpValidateAccess,
// The same demo gate AppModule hands McpModule.forRoot.
toolGate: trekDemoToolGate((id) => demoService.isDemoUserId(id)),
},
);
}
@@ -243,3 +243,32 @@ describe('app setting selectors', () => {
expect(messages(service, `${PROBE}void repo.getValue(\`pref_\${key}\`);`)).toEqual([]);
});
});
describe('MCP demo gate selectors', () => {
const DEMO = 'MCP tools do not check demo mode themselves';
const demoHits = (options: unknown[], code: string) => messages(options, code).filter((m) => m.startsWith(DEMO));
const CODE = `
declare const auth: { isDemoUser(id: number): Promise<boolean> };
declare const demo: { isDemoUserId(id: number): Promise<boolean> };
declare function demoDenied(): unknown;
`;
it('refuses a demo check of its own in a *.mcp.ts file, and keeps the env and fetch guards there', async () => {
const options = await selectorsFor('src/nest/memories/probe.mcp.ts');
expect(demoHits(options, `${CODE}
void auth.isDemoUser(1);`)).toHaveLength(1);
expect(demoHits(options, `${CODE}
void demo.isDemoUserId(1);`)).toHaveLength(1);
expect(demoHits(options, `${CODE}
void demoDenied();`)).toHaveLength(1);
expect(messages(options, 'void process.env.X;')).not.toEqual([]);
expect(messages(options, `${PRELUDE}
void fetch(url);`)).not.toEqual([]);
});
it('leaves the same call alone outside the tool files', async () => {
const options = await selectorsFor('src/nest/memories/probe.service.ts');
expect(demoHits(options, `${CODE}
void demo.isDemoUserId(1);`)).toEqual([]);
});
});
@@ -0,0 +1,174 @@
/**
* The demo-mode write block, held for every registered MCP tool at once.
*
* The block used to be the first line of each write tool; it now runs in the
* registry (trekDemoToolGate, wired into McpModule.forRoot and into the MCP test
* registry). This suite attaches the real registry to a capturing server and
* calls every write tool as the demo account: each one has to answer with the
* canned refusal before its handler runs. Every read-only tool has to pass the
* gate. Besides that it pins the two markers the gate relies on to each other,
* so a tool declared `mode: 'write'` but annotated read-only (or the reverse)
* fails here instead of slipping past the gate.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { db as testDb } from '../../../src/db/database';
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
import { resetTestDb, setAddonEnabled } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createMcpTestRegistry } from '../../helpers/mcp-test-controllers';
import { ADDON_IDS } from '../../../src/addons';
import { isDemoGatedTool, trekDemoToolGate } from '../../../src/mcp/nest-mcp-policy';
import type { McpContext, McpRegistry, McpRegistryListing } from '../../../src/nest-mcp';
const DEMO_REFUSAL = { content: [{ type: 'text', text: 'Write operations are disabled in demo mode.' }], isError: true };
interface CapturedTool {
annotations?: Record<string, unknown>;
call: (args?: Record<string, unknown>) => Promise<unknown>;
}
/** Attaches the registry to a stand-in server that keeps every tool callback instead of serving it. */
async function captureTools(registry: McpRegistry, ctx: McpContext): Promise<Map<string, CapturedTool>> {
const tools = new Map<string, CapturedTool>();
const server = {
registerTool: (name: string, config: { annotations?: Record<string, unknown> }, cb: (...a: unknown[]) => unknown) => {
// Arguments as a client would send nothing: the gate answers before
// the handler ever reads them.
tools.set(name, { annotations: config.annotations, call: async (args = {}) => cb(args, {}) });
},
registerResource: () => undefined,
registerPrompt: () => undefined,
} as unknown as McpServer;
await registry.attach(server, ctx);
return tools;
}
let registry: McpRegistry;
let toolListings: McpRegistryListing[];
beforeAll(async () => {
registry = await createMcpTestRegistry();
toolListings = registry.list().filter((entry) => entry.kind === 'tool');
});
beforeEach(() => {
resetTestDb(testDb);
for (const id of Object.values(ADDON_IDS)) setAddonEnabled(testDb, id, true);
});
afterEach(() => {
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
describe('demo tool gate over the whole registry', () => {
it('DEMOGATE-001: every write tool refuses the demo account with the canned body, before its handler', async () => {
process.env.DEMO_MODE = 'true';
const { user: demo } = createUser(testDb, { email: 'demo@trek.app' });
const tools = await captureTools(registry, { userId: demo.id, scopes: null, isStaticToken: false });
const writes = [...tools].filter(([, t]) => t.annotations?.readOnlyHint !== true);
expect(writes.length).toBeGreaterThan(150);
for (const [name, tool] of writes) {
expect({ name, result: await tool.call() }).toEqual({ name, result: DEMO_REFUSAL });
}
});
it('DEMOGATE-002: no read-only tool is held back for the demo account', async () => {
process.env.DEMO_MODE = 'true';
const { user: demo } = createUser(testDb, { email: 'demo@trek.app' });
const ctx: McpContext = { userId: demo.id, scopes: null, isStaticToken: false };
const gate = trekDemoToolGate(async () => true);
const tools = await captureTools(registry, ctx);
const reads = [...tools].filter(([, t]) => t.annotations?.readOnlyHint === true);
expect(reads.length).toBeGreaterThan(80);
for (const [name, tool] of reads) {
expect({ name, refusal: await gate({ name, annotations: tool.annotations }, ctx) }).toEqual({ name, refusal: undefined });
}
});
it('DEMOGATE-003: an ordinary account reaches the write handlers in demo mode', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'alice@example.com' });
const tools = await captureTools(registry, { userId: user.id, scopes: null, isStaticToken: false });
// create_tag has no trip to check and no addon gate: it writes and answers.
const result = (await tools.get('create_tag')!.call({ name: 'Mine' })) as { isError?: boolean; content: { text: string }[] };
expect(result.isError).toBeUndefined();
expect(result.content[0].text).toContain('Mine');
});
it('DEMOGATE-004: outside demo mode the demo account is an ordinary account', async () => {
const { user: demo } = createUser(testDb, { email: 'demo@trek.app' });
const tools = await captureTools(registry, { userId: demo.id, scopes: null, isStaticToken: false });
const result = (await tools.get('create_tag')!.call({ name: 'Mine' })) as { isError?: boolean };
expect(result.isError).toBeUndefined();
});
it('DEMOGATE-005: with every addon on, only the photo-library tools stay detached, so the sweep above covers the rest', async () => {
const { user } = createUser(testDb);
const tools = await captureTools(registry, { userId: user.id, scopes: null, isStaticToken: false });
const missing = toolListings.map((t) => t.name).filter((name) => !tools.has(name));
// These three wait for a connected Immich or Synology provider
// (anyPhotoProviderEnabled), and all three are reads.
expect(missing.sort()).toEqual(['list_provider_album_photos', 'list_provider_albums', 'search_provider_photos']);
});
it('DEMOGATE-006: the scope mode and the read-only annotation agree on every declarative tool', async () => {
const { user } = createUser(testDb);
const tools = await captureTools(registry, { userId: user.id, scopes: null, isStaticToken: false });
const mismatched: string[] = [];
for (const listing of toolListings) {
const access = listing.access;
if (access === undefined || typeof access === 'function') continue;
const tool = tools.get(listing.name);
if (!tool) continue; // the detached photo-library reads of DEMOGATE-005
const readOnly = tool.annotations?.readOnlyHint === true;
if (access.mode === 'read' && !readOnly) mismatched.push(`${listing.name}: mode read, not annotated read-only`);
if ((access.mode === 'write' || access.mode === 'delete') && readOnly) {
mismatched.push(`${listing.name}: mode ${access.mode}, annotated read-only`);
}
}
expect(mismatched).toEqual([]);
});
});
describe('trekDemoToolGate', () => {
const ctx: McpContext = { userId: 7, scopes: null, isStaticToken: false };
it('DEMOGATE-010: refuses a write tool for a demo account and asks with the caller id', async () => {
const isDemo = vi.fn(async () => true);
expect(await trekDemoToolGate(isDemo)({ name: 'w', annotations: { readOnlyHint: false } }, ctx)).toEqual(DEMO_REFUSAL);
expect(isDemo).toHaveBeenCalledWith(7);
});
it('DEMOGATE-011: lets a write tool through for any other account', async () => {
expect(await trekDemoToolGate(async () => false)({ name: 'w' }, ctx)).toBeUndefined();
});
it('DEMOGATE-012: never asks about the caller for a read-only tool', async () => {
const isDemo = vi.fn(async () => true);
expect(await trekDemoToolGate(isDemo)({ name: 'r', annotations: { readOnlyHint: true } }, ctx)).toBeUndefined();
expect(isDemo).not.toHaveBeenCalled();
});
it('DEMOGATE-013: a tool without annotations counts as a write', () => {
expect(isDemoGatedTool({ name: 'x' })).toBe(true);
expect(isDemoGatedTool({ name: 'x', annotations: { readOnlyHint: true } })).toBe(false);
});
});
@@ -28,7 +28,7 @@ import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { createTestRegistry } from '../../../src/nest-mcp';
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../../src/mcp/nest-mcp-policy';
import { trekDemoToolGate, trekMcpAccessPolicy, trekMcpValidateAccess } from '../../../src/mcp/nest-mcp-policy';
import { CategoriesMcp } from '../../../src/nest/categories/categories.mcp';
import { CategoriesService } from '../../../src/nest/categories/categories.service';
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
@@ -78,19 +78,20 @@ async function withHarness(
// file's DB, which is what lets the admin gate and the demo gate be driven from
// the users table instead of from a stub.
let categoriesMcp: CategoriesMcp;
let categoriesDemo: DemoService;
beforeAll(async () => {
const categoriesEm = (await sharedTestOrm(testDb)).em;
categoriesMcp = new CategoriesMcp(
new CategoriesService(await createTestCategoriesRepo(testDb)),
new RuntimeEnvService(),
new McpToolGuardsService(await createTestTripsRepo(testDb), await createTestUsersRepo(testDb), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new RealtimeService()),
new DemoService(new RuntimeEnvService(), categoriesEm),
);
categoriesDemo = new DemoService(new RuntimeEnvService(), categoriesEm);
});
async function withWriteHarness(userId: number, fn: (client: Client) => Promise<void>) {
const server = new McpServer({ name: 'trek-test', version: '1.0.0' });
await createTestRegistry([categoriesMcp], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess })
await createTestRegistry([categoriesMcp], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess, toolGate: trekDemoToolGate((id) => categoriesDemo.isDemoUserId(id)) })
.attach(server, { userId, scopes: null, isStaticToken: false });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'test-client', version: '1.0.0' });
+2 -18
View File
@@ -69,17 +69,15 @@ import { BudgetService } from '../../../src/nest/budget/budget.service';
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
import { AuthMcp } from '../../../src/nest/auth/auth.mcp';
import { DemoService } from '../../../src/nest/common/demo.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
import type { AuthService } from '../../../src/nest/auth/auth.service';
import type { TripsService } from '../../../src/nest/trips/trips.service';
import type { TodoService } from '../../../src/nest/todo/todo.service';
import type { CollabService } from '../../../src/nest/collab/collab.service';
import { AddonsService } from '../../../src/nest/addons/addons.service';
import { notificationsStub } from '../../helpers/notifications';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, createTestTripMembersRepo, createTestUsersRepo, sharedTestOrm, createTestPlacesRepo } from '../../helpers/test-uow';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, createTestTripMembersRepo, createTestUsersRepo, createTestPlacesRepo } from '../../helpers/test-uow';
import { budgetRepoArgs } from '../../helpers/budget-repos';
import {
createTestPackingItemsRepo,
@@ -90,7 +88,6 @@ import {
createTestPackingTemplateCategoriesRepo,
createTestPackingTemplateItemsRepo,
} from '../../helpers/packing-repos';
import type { EntityManager } from '@mikro-orm/core';
// The trip-summary prompt moved to the DI-discovered TripsMcp — its cases below
// exercise it through a hand-built registry over a stub TripsService whose
@@ -112,16 +109,6 @@ const readModelStub = {
// The three remaining prompts moved to their domains' @McpController classes:
// packing-list, budget-overview and the static-token notice. Built over the same
// in-memory DB so the cases below keep asserting real rows.
//
// Plan 3c Task 0b: `promptEm` is resolved once in the first `beforeAll` below —
// `canAccessTrip`/`isOwner`/`rosterUserIds`/
// `getPlaceWithTags` resolve `TripsRepository`/`TripMembersRepository`/
// `PlacesRepository` through it now, not through `db/database.ts`'s deleted
// free functions this file's `dbMock` used to stand in for.
let promptEm: EntityManager | undefined;
const authStub = { isDemoUser: () => false } as unknown as AuthService;
// The packing-list / budget-overview prompts live here since the trips.bridge
// fold; the summary rides the same readModelStub the trip-summary prompt uses.
@@ -134,7 +121,6 @@ let budgetMcp: BudgetMcp;
let tripPromptsMcp: TripPromptsMcp;
beforeAll(async () => {
orm = await createTestOrm(testDb);
promptEm = (await sharedTestOrm(testDb)).em;
promptGuards = new McpToolGuardsService(await createTestTripsRepo(testDb), await createTestUsersRepo(testDb), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new RealtimeService());
tripsMcp = new TripsMcp(
tripsStub,
@@ -142,7 +128,6 @@ beforeAll(async () => {
{ listPolls: () => [], countMessages: () => 0 } as unknown as CollabService,
undefined as never,
undefined as never,
undefined as never,
readModelStub,
addonsStub,
promptGuards,
@@ -162,7 +147,7 @@ beforeAll(async () => {
await createTestTripsRepo(testDb),
await createTestTripMembersRepo(testDb),
);
packingMcp = new PackingMcp(promptPackingService, authStub, addonsStub, promptGuards);
packingMcp = new PackingMcp(promptPackingService, addonsStub, promptGuards);
promptBudget = new BudgetService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb)));
budgetMcp = new BudgetMcp(
promptBudget,
@@ -174,7 +159,6 @@ beforeAll(async () => {
await createTestUnitOfWork(testDb),
await createTestPlacesRepo(testDb),
await createTestTripsRepo(testDb),
new DemoService(new RuntimeEnvService(), promptEm),
await createTestTripMembersRepo(testDb),
);
tripPromptsMcp = new TripPromptsMcp(tripsStub, readModelStub, promptPackingService, addonsStub);
@@ -0,0 +1,165 @@
import {
createTestRegistry,
McpController,
McpModule,
McpRegistryService,
Resource,
Tool,
type McpDynamicTool,
type McpToolGate,
} from '../../../src/nest-mcp';
import { asCtx, createAttachHarness, type AttachHarness, type TestCtx } from './harness';
import { Injectable, Module } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import 'reflect-metadata';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
const REFUSAL = { content: [{ type: 'text', text: 'refused' }], isError: true };
@McpController()
class GatedMcp {
calls = 0;
@Tool({ name: 'write_it', inputSchema: { n: z.number() }, annotations: { readOnlyHint: false } })
async writeIt({ n }: { n: number }, ctx: TestCtx) {
this.calls++;
return { content: [{ type: 'text', text: JSON.stringify({ n, userId: ctx.userId }) }] };
}
@Tool({ name: 'no_schema', annotations: { readOnlyHint: true } })
async noSchema(args: Record<string, never>) {
return { content: [{ type: 'text', text: JSON.stringify(args) }] };
}
@Resource({ name: 'res', uri: 'gate://res' })
async res(uri: URL) {
return { contents: [{ uri: uri.href, text: 'resource' }] };
}
}
const textOf = (result: unknown): string => (result as { content: { text: string }[] }).content[0].text;
describe('tool gate', () => {
let harness: AttachHarness | undefined;
afterEach(async () => {
await harness?.cleanup();
harness = undefined;
});
it('answers with the gate result and never runs the handler', async () => {
const mcp = new GatedMcp();
const gate = vi.fn<McpToolGate>(() => REFUSAL);
harness = await createAttachHarness(createTestRegistry([mcp], { toolGate: gate }), { userId: 3 });
const result = await harness.client.callTool({ name: 'write_it', arguments: { n: 1 } });
expect(result).toEqual(REFUSAL);
expect(mcp.calls).toBe(0);
expect(gate).toHaveBeenCalledWith(expect.objectContaining({ name: 'write_it', annotations: { readOnlyHint: false } }), asCtx({ userId: 3 }));
});
it('runs the handler with its arguments when the gate returns undefined', async () => {
const mcp = new GatedMcp();
harness = await createAttachHarness(createTestRegistry([mcp], { toolGate: async () => undefined }), { userId: 4 });
const result = await harness.client.callTool({ name: 'write_it', arguments: { n: 2 } });
expect(JSON.parse(textOf(result))).toEqual({ n: 2, userId: 4 });
expect(mcp.calls).toBe(1);
});
it('gates a tool without an input schema too', async () => {
harness = await createAttachHarness(createTestRegistry([new GatedMcp()], { toolGate: () => REFUSAL }), {});
expect(await harness.client.callTool({ name: 'no_schema', arguments: {} })).toEqual(REFUSAL);
});
it('leaves resources alone', async () => {
const gate = vi.fn<McpToolGate>(() => REFUSAL);
harness = await createAttachHarness(createTestRegistry([new GatedMcp()], { toolGate: gate }), {});
const read = await harness.client.readResource({ uri: 'gate://res' });
expect(read.contents[0]).toMatchObject({ text: 'resource' });
expect(gate).not.toHaveBeenCalled();
});
it('leaves dynamic tools to their own source', async () => {
const gate = vi.fn<McpToolGate>(() => REFUSAL);
const dynamic: McpDynamicTool = {
options: { name: 'dyn', inputSchema: {}, access: () => true },
handler: () => ({ content: [{ type: 'text', text: 'dynamic ran' }] }),
};
harness = await createAttachHarness(createTestRegistry([], { toolGate: gate }), {}, { dynamicTools: () => [dynamic] });
expect(textOf(await harness.client.callTool({ name: 'dyn', arguments: {} }))).toBe('dynamic ran');
expect(gate).not.toHaveBeenCalled();
});
it('runs after onInvoke and inside around, so the audit and the trace still see a refused call', async () => {
const order: string[] = [];
const registry = createTestRegistry([new GatedMcp()], {
toolGate: () => {
order.push('gate');
return REFUSAL;
},
});
harness = await createAttachHarness(registry, {}, {
onInvoke: () => order.push('onInvoke'),
around: async (_info, call) => {
order.push('around:in');
const out = await call();
order.push('around:out');
return out;
},
});
await harness.client.callTool({ name: 'write_it', arguments: { n: 1 } });
expect(order).toEqual(['onInvoke', 'around:in', 'gate', 'around:out']);
});
it('is skipped entirely without a configured gate', async () => {
const mcp = new GatedMcp();
harness = await createAttachHarness(createTestRegistry([mcp]), { userId: 1 });
await harness.client.callTool({ name: 'write_it', arguments: { n: 5 } });
expect(mcp.calls).toBe(1);
});
});
@Injectable()
class Verdicts {
refuse = true;
}
@Module({ providers: [Verdicts, GatedMcp], exports: [Verdicts] })
class GatedModule {}
describe('McpModule.forRoot toolGate', () => {
let harness: AttachHarness | undefined;
afterEach(async () => {
await harness?.cleanup();
harness = undefined;
});
it('builds the gate from the container with its injected providers', async () => {
const moduleRef = await Test.createTestingModule({
imports: [
GatedModule,
McpModule.forRoot({
toolGate: {
imports: [GatedModule],
inject: [Verdicts],
useFactory: (verdicts: Verdicts) => () => (verdicts.refuse ? REFUSAL : undefined),
},
}),
],
}).compile();
await moduleRef.init();
harness = await createAttachHarness(moduleRef.get(McpRegistryService), { userId: 1 });
expect(await harness.client.callTool({ name: 'write_it', arguments: { n: 1 } })).toEqual(REFUSAL);
moduleRef.get(Verdicts).refuse = false;
expect(JSON.parse(textOf(await harness.client.callTool({ name: 'write_it', arguments: { n: 1 } })))).toEqual({ n: 1, userId: 1 });
await moduleRef.close();
});
it('boots without a gate and runs every handler', async () => {
const moduleRef = await Test.createTestingModule({ imports: [GatedModule, McpModule.forRoot()] }).compile();
await moduleRef.init();
harness = await createAttachHarness(moduleRef.get(McpRegistryService), { userId: 2 });
expect(JSON.parse(textOf(await harness.client.callTool({ name: 'write_it', arguments: { n: 3 } })))).toEqual({ n: 3, userId: 2 });
await moduleRef.close();
});
});
@@ -1189,17 +1189,6 @@ describe('resetPassword', () => {
});
});
describe('ephemeral + demo helpers', () => {
it('AUTH-DB-088: isDemoUser is true only for the demo email in demo mode', async () => {
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const { user: other } = createUser(testDb);
expect(await svc.isDemoUser(user.id)).toBe(false); // demo mode off
vi.stubEnv('DEMO_MODE', 'true');
expect(await svc.isDemoUser(user.id)).toBe(true);
expect(await svc.isDemoUser(other.id)).toBe(false);
vi.unstubAllEnvs();
});
});
// ---------------------------------------------------------------------------
// Quirk fixes after the DI fold (trailing fix(server) commit): AUTH-DB-089+.
+7 -11
View File
@@ -43,7 +43,7 @@ import { DawarichMcp } from '../../../src/nest/integrations/dawarich.mcp';
import { AcceptError } from '../../../src/nest/integrations/dawarich-suggestions.service';
import type { DawarichSuggestionsService } from '../../../src/nest/integrations/dawarich-suggestions.service';
import type { DawarichTracksService } from '../../../src/nest/integrations/dawarich-tracks.service';
import type { AuthService } from '../../../src/nest/auth/auth.service';
import { callGatedTool } from '../../helpers/mcp-gate';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
import type { McpContext, McpTextResult, ToolOptions } from '../../../src/nest-mcp';
@@ -54,17 +54,14 @@ const ctx = { userId: 7, scopes: null, isStaticToken: false } as McpContext;
interface Mocks {
suggestions?: Partial<DawarichSuggestionsService>;
tracks?: Partial<DawarichTracksService>;
auth?: Partial<AuthService>;
addons?: Partial<AddonsService>;
}
/** The demo gate is off unless a case turns it on; every write tool asks it. */
/** The demo gate runs in the registry; the cases about it go through callGatedTool. */
function makeMcp(m: Mocks = {}) {
const auth: Partial<AuthService> = m.auth ?? { isDemoUser: async () => false };
return new DawarichMcp(
(m.suggestions ?? {}) as DawarichSuggestionsService,
(m.tracks ?? {}) as DawarichTracksService,
auth as AuthService,
(m.addons ?? {}) as AddonsService,
);
}
@@ -226,10 +223,10 @@ describe('DawarichMcp surface', () => {
const forTrip = vi.fn().mockResolvedValue({
days: [], source: 'tracks', fetchedAt: '2026-05-02T09:00:00.000Z', pointCount: 0, truncated: false,
});
const mcp = makeMcp({ suggestions: { list: listFn }, tracks: { forTrip }, auth: { isDemoUser } });
const mcp = makeMcp({ suggestions: { list: listFn }, tracks: { forTrip } });
payload(await mcp.listSuggestions({}, ctx));
payload(await mcp.tripTrack({ tripId: 5 }, ctx));
payload((await callGatedTool(mcp, 'listSuggestions', {}, ctx, isDemoUser)) as McpTextResult);
payload((await callGatedTool(mcp, 'tripTrack', { tripId: 5 }, ctx, isDemoUser)) as McpTextResult);
expect(isDemoUser).not.toHaveBeenCalled();
});
});
@@ -439,10 +436,9 @@ describe('DawarichMcp accept tools', () => {
const accept = vi.fn();
const setState = vi.fn();
const isDemoUser = vi.fn().mockResolvedValue(true);
const mcp = makeMcp({ suggestions: { accept, setState }, auth: { isDemoUser } });
const mcp = makeMcp({ suggestions: { accept, setState } });
const call = (mcp as unknown as Record<string, (a: unknown, c: McpContext) => Promise<McpTextResult>>)[method];
expect(refusal(await call.call(mcp, args, ctx))).toBe('Write operations are disabled in demo mode.');
expect(refusal((await callGatedTool(mcp, method, args, ctx, isDemoUser)) as McpTextResult)).toBe('Write operations are disabled in demo mode.');
expect(isDemoUser).toHaveBeenCalledWith(7);
expect(accept).not.toHaveBeenCalled();
expect(setState).not.toHaveBeenCalled();
@@ -18,6 +18,7 @@ import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
import { RoadtripDayBoundaries } from '../../../src/db/entities/RoadtripDayBoundaries.entity';
import { deleteRows } from '../../helpers/factories/rows';
import type { McpContext } from '../../../src/nest-mcp';
import { callGatedTool } from '../../helpers/mcp-gate';
const testDb = createSnapshotTestDb();
let t: TestOrm;
@@ -41,16 +42,16 @@ function setup() {
const boundariesRepo = t.repo(RoadtripDayBoundaries);
const service = new DayBoundariesService(dayAssignmentsRepo, boundariesRepo);
const realtime = { broadcast: vi.fn() };
const auth = { isDemoUser: vi.fn(() => false) };
const demo = { isDemoUser: vi.fn(async () => false) };
const guards = { hasTripPermission: vi.fn(() => true) };
const tripsRepo = { findAccessible: vi.fn(async () => ({ id: tripA.id, user_id: user.id })) };
const mcp = new DayBoundariesMcp(service, tripsRepo as never, auth as never, guards as never, realtime as never, {} as never);
const mcp = new DayBoundariesMcp(service, tripsRepo as never, guards as never, realtime as never, {} as never);
return {
service,
controller: new DayBoundariesController(service, realtime as never),
mcp,
tripsRepo,
auth,
demo,
guards,
realtime,
tripA,
@@ -134,9 +135,10 @@ it('MCP checks demo, trip access and edit permission before saving', async () =>
const ctx = { userId: 1 } as McpContext;
const boundary = { day_number: 1, from_assignment_id: s.fromId, to_assignment_id: s.toId, fraction: 0.4 };
const request = { tripId: s.tripA.id, dayNumber: 1, boundary };
s.auth.isDemoUser.mockReturnValue(true);
await s.mcp.save(request, ctx);
s.auth.isDemoUser.mockReturnValue(false);
const save = (req: typeof request) => callGatedTool(s.mcp, 'save', req, ctx, s.demo.isDemoUser);
s.demo.isDemoUser.mockResolvedValue(true);
await save(request);
s.demo.isDemoUser.mockResolvedValue(false);
s.tripsRepo.findAccessible.mockResolvedValue(undefined);
await s.mcp.save(request, ctx);
s.tripsRepo.findAccessible.mockResolvedValue({ id: s.tripA.id, user_id: 1 });
@@ -1,21 +1,15 @@
/**
* Plan 3i Task 3 — named regression tests for 4 of the 5 `isDemoUserId`
* survivor sites (the 5th, `PluginMcpToolsService`, is MCPTOOLS-014 in
* `tests/unit/plugins/plugin-mcp-tools.test.ts`, strengthened in this same
* task to assert the exact refusal body too).
*
* Each of `TripInviteMcp`/`FeedsMcp`/`CategoriesMcp`/`BudgetMcp` checks
* `this.isDemoUser(ctx.userId)` — now `this.demo.isDemoUserId(userId)`,
* the injected `DemoService` (`common/demo.service.ts`) — as the FIRST
* statement of the write tool exercised below, before touching any other
* collaborator, so every other constructor dependency can stay an untyped
* stub: the mutation this proves (Task 3's report: "mutation proofs for the
* demo gates — loosen isDemoUserId → red") is that a `DemoService` whose
* `isDemoUserId` resolves `true` reaches the exact canned `demoDenied()`
* body byte-for-byte, not a domain-specific string.
* The demo refusal for the four write tools that used to ask DemoService
* themselves (trip invites, calendar feeds, categories, budget). The check now
* sits in the registry's tool gate (trekDemoToolGate), so each tool is called
* the way a session reaches it, through tests/helpers/mcp-gate.ts, with every
* other collaborator a bare stub: a refusal that reached any of them would
* throw instead. The fifth site, PluginMcpToolsService, keeps its own gate and
* is MCPTOOLS-014 in tests/unit/plugins/plugin-mcp-tools.test.ts.
*/
import { describe, expect, it } from 'vitest';
import type { McpContext } from '../../../src/nest-mcp';
import { callGatedTool } from '../../helpers/mcp-gate';
import { TripInviteMcp } from '../../../src/nest/trip-invite/trip-invite.mcp';
import { FeedsMcp } from '../../../src/nest/feeds/feeds.mcp';
import { CategoriesMcp } from '../../../src/nest/categories/categories.mcp';
@@ -25,32 +19,32 @@ const DEMO_REFUSAL = { content: [{ type: 'text', text: 'Write operations are dis
const ctx = { userId: 5, scopes: null, isStaticToken: false } as McpContext;
/** A DemoService stub whose isDemoUserId always resolves `true`. */
const demoBlocked = { isDemoUserId: () => Promise.resolve(true) } as never;
/** The demo check, answering yes for everyone. */
const demoBlocked = () => Promise.resolve(true);
const stub = {} as never;
describe('Plan 3i Task 3 — isDemoUserId survivor sites: exact refusal body per domain', () => {
describe('demo refusal at the former survivor sites: exact refusal body per domain', () => {
it('DEMO-SURVIVOR-001 (trip-invite.mcp.ts): create_trip_invite_link refuses byte-for-byte in demo mode', async () => {
const mcp = new TripInviteMcp(stub, stub, stub, stub, demoBlocked);
const res = await mcp.createTripInviteLink({ tripId: 1, expires_in_days: null }, ctx);
const mcp = new TripInviteMcp(stub, stub, stub, stub);
const res = await callGatedTool(mcp, 'createTripInviteLink', { tripId: 1, expires_in_days: null }, ctx, demoBlocked);
expect(res).toEqual(DEMO_REFUSAL);
});
it('DEMO-SURVIVOR-002 (feeds.mcp.ts): enable_trip_calendar_feed refuses byte-for-byte in demo mode', async () => {
const mcp = new FeedsMcp(stub, stub, stub, stub, demoBlocked);
const res = await mcp.enableTripCalendarFeed({ tripId: 1 }, ctx);
const mcp = new FeedsMcp(stub, stub, stub, stub);
const res = await callGatedTool(mcp, 'enableTripCalendarFeed', { tripId: 1 }, ctx, demoBlocked);
expect(res).toEqual(DEMO_REFUSAL);
});
it('DEMO-SURVIVOR-003 (categories.mcp.ts): create_category refuses byte-for-byte in demo mode', async () => {
const mcp = new CategoriesMcp(stub, stub, stub, demoBlocked);
const res = await mcp.createCategory({ name: 'Test' }, ctx);
const mcp = new CategoriesMcp(stub, stub, stub);
const res = await callGatedTool(mcp, 'createCategory', { name: 'Test' }, ctx, demoBlocked);
expect(res).toEqual(DEMO_REFUSAL);
});
it('DEMO-SURVIVOR-004 (budget.mcp.ts): create_budget_item refuses byte-for-byte in demo mode', async () => {
const mcp = new BudgetMcp(stub, stub, stub, stub, stub, stub, stub, stub, stub, demoBlocked, stub);
const res = await mcp.createBudgetItem({ tripId: 1, name: 'Test', total_price: 10 }, ctx);
const mcp = new BudgetMcp(stub, stub, stub, stub, stub, stub, stub, stub, stub, stub);
const res = await callGatedTool(mcp, 'createBudgetItem', { tripId: 1, name: 'Test', total_price: 10 }, ctx, demoBlocked);
expect(res).toEqual(DEMO_REFUSAL);
});
});
@@ -1,6 +1,7 @@
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../../src/mcp/nest-mcp-policy';
import type { McpContext } from '../../../src/nest-mcp';
import { createTestRegistry } from '../../../src/nest-mcp';
import { callGatedTool } from '../../helpers/mcp-gate';
import { PlacesMcp } from '../../../src/nest/places/places.mcp';
import { RoadtripPlanService } from '../../../src/nest/roadtrip/roadtrip-plan.service';
import { RoadtripPlanningMcp } from '../../../src/nest/roadtrip/roadtrip-planning.mcp';
@@ -160,13 +161,12 @@ describe('roadtrip preferences', () => {
expect((await s.preferences.read(10)).roadtrip_day_start).toBe('');
const mcp = new RoadtripPreferencesMcp(
s.preferences,
{ isDemoUser: () => true } as never,
{} as never,
s.tripsRepo as never,
{} as never,
);
s.preferenceDb.upsertValue.mockClear();
await mcp.update({ tripId: 10, settings: { roadtrip_range_km: 300 } }, ctx);
await callGatedTool(mcp, 'update', { tripId: 10, settings: { roadtrip_range_km: 300 } }, ctx, async () => true);
expect(s.preferenceDb.upsertValue).not.toHaveBeenCalled();
});
it('saves the switch that starts and ends each day at the stay, through the tool too, and reads it back', async () => {
@@ -174,7 +174,6 @@ describe('roadtrip preferences', () => {
expect((await s.preferences.read(10)).roadtrip_hotel_bookends).toBeUndefined();
const mcp = new RoadtripPreferencesMcp(
s.preferences,
{ isDemoUser: () => false } as never,
{} as never,
s.tripsRepo as never,
{ hasTripPermission: () => true } as never,
@@ -192,7 +191,6 @@ describe('roadtrip preferences', () => {
const s = setup();
const mcp = new RoadtripPreferencesMcp(
s.preferences,
{ isDemoUser: () => false } as never,
{} as never,
s.tripsRepo as never,
{ hasTripPermission: () => true } as never,
@@ -355,13 +353,11 @@ describe('browser-independent roadtrip calculation', () => {
describe('Roadtrip MCP registration and search', () => {
it('imports GPX through the existing service and broadcasts the imported places', async () => {
const places = { importGpx: vi.fn(() => ({ places: [{ id: 12 }], count: 1, skipped: 0 })) };
const auth = { isDemoUser: vi.fn(() => false) };
const guards = { hasTripPermission: () => true, safeBroadcast: vi.fn() };
const mcp = new PlacesMcp(
places as never,
{} as never,
{ findAccessible: () => ({ id: 1, user_id: 1, currency: null }) } as never,
auth as never,
{} as never,
{} as never,
guards as never,
@@ -384,8 +380,7 @@ describe('Roadtrip MCP registration and search', () => {
});
expect(guards.safeBroadcast).toHaveBeenCalledWith(1, 'place:created', { place: { id: 12 } });
places.importGpx.mockClear();
auth.isDemoUser.mockReturnValue(true);
await mcp.importGpx(input, ctx);
await callGatedTool(mcp, 'importGpx', input, ctx, async () => true);
expect(places.importGpx).not.toHaveBeenCalled();
});
it('hides addon tools when disabled and separates settings reads from writes', async () => {
@@ -393,7 +388,7 @@ describe('Roadtrip MCP registration and search', () => {
const addons = { isAddonEnabled: vi.fn(() => true) };
const registry = createTestRegistry(
[
new RoadtripPreferencesMcp(s.preferences, {} as never, addons as never, s.tripsRepo as never, {} as never),
new RoadtripPreferencesMcp(s.preferences, addons as never, s.tripsRepo as never, {} as never),
new RoadtripPlanningMcp(s.plans, {} as never, addons as never),
],
{ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess },
@@ -416,7 +411,7 @@ describe('Roadtrip MCP registration and search', () => {
const s = setup();
const registry = createTestRegistry(
[
new RoadtripPreferencesMcp(s.preferences, {} as never, { isAddonEnabled: () => true } as never, s.tripsRepo as never, {} as never),
new RoadtripPreferencesMcp(s.preferences, { isAddonEnabled: () => true } as never, s.tripsRepo as never, {} as never),
new RoadtripPlanningMcp(s.plans, {} as never, { isAddonEnabled: () => true } as never),
],
{ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess },
@@ -492,7 +487,6 @@ describe('Roadtrip MCP registration and search', () => {
service as never,
{ findAccessible: () => true } as never,
guards as never,
{ isDemoUser: () => false } as never,
{} as never,
);
const input = { tripId: 1, dayId: 2, viaId: 4, lat: 48, lng: 10 };
@@ -531,8 +525,7 @@ describe('RoadtripMcp — non-member refusal, one case per tool (Plan 3d Task 7
};
const tripsRepo = { findAccessible: vi.fn(async () => findAccessible) };
const guards = { hasTripPermission: vi.fn(async () => true) };
const auth = { isDemoUser: vi.fn(async () => false) };
const mcp = new RoadtripMcp(service as never, tripsRepo as never, guards as never, auth as never, {} as never);
const mcp = new RoadtripMcp(service as never, tripsRepo as never, guards as never, {} as never);
return { mcp, service, tripsRepo };
}
@@ -576,7 +569,7 @@ describe('RoadtripMcp — non-member refusal, one case per tool (Plan 3d Task 7
describe('MCP trip preferences authorization', () => {
it('reads shared preferences and refuses inaccessible trips', async () => {
const s = setup();
const tool = new RoadtripPreferencesMcp(s.preferences, { isDemoUser: () => false } as never, {} as never, s.tripsRepo as never, { hasTripPermission: () => true } as never);
const tool = new RoadtripPreferencesMcp(s.preferences, {} as never, s.tripsRepo as never, { hasTripPermission: () => true } as never);
expect(JSON.stringify(await tool.read({ tripId: 10 }, ctx))).toContain('100');
s.tripsRepo.findAccessible.mockResolvedValue(false);
expect((await tool.read({ tripId: 10 }, ctx)).isError).toBe(true);
@@ -589,18 +582,16 @@ describe('MCP trip preferences authorization', () => {
it('update_roadtrip_settings refuses a non-member trip on its own, without a prior read call', async () => {
const s = setup();
s.tripsRepo.findAccessible.mockResolvedValue(false);
const tool = new RoadtripPreferencesMcp(s.preferences, { isDemoUser: () => false } as never, {} as never, s.tripsRepo as never, { hasTripPermission: () => true } as never);
const tool = new RoadtripPreferencesMcp(s.preferences, {} as never, s.tripsRepo as never, { hasTripPermission: () => true } as never);
const res = await tool.update({ tripId: 10, settings: { roadtrip_range_km: 50 } }, ctx);
expect(res.isError).toBe(true);
expect(JSON.stringify(res)).toContain('access denied');
});
it('rejects demos and readers, but permits a trip editor', async () => {
const s = setup();
const auth = { isDemoUser: vi.fn(() => true) };
const guards = { hasTripPermission: vi.fn(() => false) };
const tool = new RoadtripPreferencesMcp(s.preferences, auth as never, {} as never, s.tripsRepo as never, guards as never);
expect((await tool.update({ tripId: 10, settings: {} }, ctx)).isError).toBe(true);
auth.isDemoUser.mockReturnValue(false);
const tool = new RoadtripPreferencesMcp(s.preferences, {} as never, s.tripsRepo as never, guards as never);
expect(await callGatedTool(tool, 'update', { tripId: 10, settings: {} }, ctx, async () => true)).toMatchObject({ isError: true });
expect((await tool.update({ tripId: 10, settings: {} }, ctx)).isError).toBe(true);
guards.hasTripPermission.mockReturnValue(true);
expect(JSON.stringify(await tool.update({ tripId: 10, settings: { roadtrip_range_km: 120 } }, ctx))).toContain('120');
@@ -6,6 +6,7 @@ import { RoadtripHazardsMcp } from '../../../src/nest/roadtrip/roadtrip-hazards.
import { GoogleRouteMcp } from '../../../src/nest/roadtrip/google-route.mcp';
import { DayBoundariesMcp } from '../../../src/nest/roadtrip/day-boundaries.mcp';
import { createTestRegistry } from '../../../src/nest-mcp';
import { callGatedTool } from '../../helpers/mcp-gate';
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../../src/mcp/nest-mcp-policy';
const ctx = { userId: 5 } as McpContext;
@@ -25,8 +26,7 @@ describe('roadtrip read tools', () => {
});
it('passes reviewed Google stops and the authenticated user to the importer', async () => {
const routes = { preview: vi.fn(async () => ({ stops: [] })), import: vi.fn(() => ({ imported: 2 })) };
const auth = { isDemoUser: vi.fn(async () => false) };
const tool = new GoogleRouteMcp(routes as never, auth as never, {} as never);
const tool = new GoogleRouteMcp(routes as never, {} as never);
await tool.preview({ url: 'https://www.google.com/maps/dir/A/B' });
expect(routes.preview).toHaveBeenCalledWith('https://www.google.com/maps/dir/A/B');
const input = { tripId: 10, dayId: 1, stops: [{ name: 'A', lat: 1, lng: 2 }] };
@@ -38,12 +38,11 @@ describe('roadtrip read tools', () => {
// The one non-admin write tool that had no gate: a demo session could write
// thirty places and their assignments onto the shared demo trip.
const routes = { preview: vi.fn(), import: vi.fn() };
const auth = { isDemoUser: vi.fn(async () => true) };
const tool = new GoogleRouteMcp(routes as never, auth as never, {} as never);
const tool = new GoogleRouteMcp(routes as never, {} as never);
const res = await tool.import({ tripId: 10, dayId: 1, stops: [{ name: 'A', lat: 1, lng: 2 }] } as never, ctx);
const res = await callGatedTool(tool, 'import', { tripId: 10, dayId: 1, stops: [{ name: 'A', lat: 1, lng: 2 }] }, ctx, async () => true);
expect(res.isError).toBe(true);
expect(res).toMatchObject({ isError: true });
expect(routes.import).not.toHaveBeenCalled();
});
it('answers a refusal from the service with its reason, not with the exception class name', async () => {
@@ -57,22 +56,21 @@ describe('roadtrip read tools', () => {
const charging = new ChargingMcp({ read: refuse('Place not found', 404) } as never, tripsRepo as never, {} as never);
expect(text(await charging.read({ tripId: 10, placeId: 2 }, ctx))).toEqual([true, 'Place not found']);
const auth = { isDemoUser: vi.fn(async () => false) };
const routes = { preview: refuse('Use a Google Maps directions link.', 400), import: refuse('Permission denied', 403) };
const google = new GoogleRouteMcp(routes as never, auth as never, {} as never);
const google = new GoogleRouteMcp(routes as never, {} as never);
expect(text(await google.preview({ url: 'https://www.google.com/maps/place/A' }))).toEqual([true, 'Use a Google Maps directions link.']);
expect(text(await google.import({ tripId: 10, dayId: 1, stops: [{ name: 'A', lat: 1, lng: 2 }] } as never, ctx))).toEqual([true, 'Permission denied']);
const realtime = { broadcast: vi.fn() };
const guards = { hasTripPermission: vi.fn(() => true) };
const boundaries = new DayBoundariesMcp({ save: refuse('Stop not found', 404) } as never, tripsRepo as never, auth as never, guards as never, realtime as never, {} as never);
const boundaries = new DayBoundariesMcp({ save: refuse('Stop not found', 404) } as never, tripsRepo as never, guards as never, realtime as never, {} as never);
const boundary = { day_number: 1, from_assignment_id: 11, to_assignment_id: 12, fraction: 0.4 };
expect(text(await boundaries.save({ tripId: 10, dayNumber: 1, boundary }, ctx))).toEqual([true, 'Stop not found']);
expect(realtime.broadcast).not.toHaveBeenCalled();
});
it('tells the assistant that a boundary over a booked night is ignored while the stay switch is on', async () => {
const tool = new DayBoundariesMcp({} as never, {} as never, {} as never, {} as never, {} as never, { isAddonEnabled: () => true } as never);
const tool = new DayBoundariesMcp({} as never, {} as never, {} as never, {} as never, { isAddonEnabled: () => true } as never);
const registry = createTestRegistry([tool], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess });
const descriptions: Record<string, string> = {};
await registry.attach(
@@ -86,7 +84,7 @@ describe('roadtrip read tools', () => {
it('does not expose manual boundaries to nonmembers', async () => {
const boundaries = { list: vi.fn(() => [{ day_number: 1 }]) };
const tripsRepo = { findAccessible: vi.fn(async () => false) };
const tool = new DayBoundariesMcp(boundaries as never, tripsRepo as never, {} as never, {} as never, {} as never, {} as never);
const tool = new DayBoundariesMcp(boundaries as never, tripsRepo as never, {} as never, {} as never, {} as never);
expect((await tool.list({ tripId: 10 }, ctx)).isError).toBe(true);
expect(boundaries.list).not.toHaveBeenCalled();
tripsRepo.findAccessible.mockResolvedValue(true);