mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(mcp): read and organise the documents on a trip
Often the travel history is the uploaded PDFs, and an assistant could not learn that a trip had any: there was no files.mcp.ts, no files scope, and no files section in the trip summary. Six tools: list, read contents, set description and primary link, add a link, remove one, list them. Reading contents rides files:content rather than files:read, because listing what a trip carries and reading what is inside a booking PDF are different privileges. The content read is not a second copy of the plugin path. readContent moved out of files.rpc.ts into FilesService and both callers use it, so the 10 MB cap, the storage-layer read and the off-thread decode stay in one place. The RPC keeps its own error wording, which its tests still pin.
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
McpController, Tool, type McpContext,
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
demoDenied, 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';
|
||||
|
||||
const CONTENT_MAX_MB = Math.round(FILE_CONTENT_MAX / (1024 * 1024));
|
||||
|
||||
/**
|
||||
* Bytes worth handing over as text. Everything else goes back base64: a model
|
||||
* can pass those on to a converter, but it cannot read them, and decoding a PDF
|
||||
* as UTF-8 would spend the whole payload on replacement characters.
|
||||
*/
|
||||
function isTextual(mimetype: string): boolean {
|
||||
return mimetype.startsWith('text/') || mimetype === 'application/json';
|
||||
}
|
||||
|
||||
/** The MCP wording for a refused content read; the plugin RPC has its own for the same reasons. */
|
||||
function contentRefusal(err: FileContentError): string {
|
||||
if (err.reason === 'too-large') {
|
||||
return `File is too large to read here (over ${CONTENT_MAX_MB} MB). Ask the user to open it in TREK instead.`;
|
||||
}
|
||||
if (err.reason === 'not-accessible') return 'File contents are not available.';
|
||||
return 'File not found.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Trip-file MCP surface, mirroring /api/trips/:tripId/files. New rather than
|
||||
* ported: the domain had no MCP tools at all, so an assistant could see a trip's
|
||||
* bookings but not the confirmation PDF attached to one.
|
||||
*
|
||||
* Each tool reproduces its REST route's gates in the route's own order: trip
|
||||
* access first (the 404 that keeps a stranger from learning a trip exists), then
|
||||
* the file_edit right for the writes, then the row lookup scoped by :tripId. The
|
||||
* two listing tools carry no file_* right because the routes they mirror carry
|
||||
* none either; trip access is the whole gate there.
|
||||
*
|
||||
* There is no upload tool on purpose. MCP has no file transport, and an upload
|
||||
* faked through base64 would be a second, unpoliced ingestion path beside the
|
||||
* multipart route with its extension filter and per-type size caps.
|
||||
*/
|
||||
@McpController()
|
||||
export class FilesMcp {
|
||||
constructor(
|
||||
private readonly files: FilesService,
|
||||
private readonly auth: AuthService,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'list_trip_files',
|
||||
description: 'List the documents on a trip: name, type, size, who uploaded it, what it is attached to, whether it is starred, and when it was moved to the trash. This is the way to find a file ID; to read what is inside one, follow up with read_trip_file. Set trash to true to list the trip\'s deleted files instead of its live ones.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
trash: z.boolean().optional().default(false).describe('List the trash instead of the live files'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'files', mode: 'read' },
|
||||
})
|
||||
async listTripFiles({ tripId, trash }: { tripId: number; trash?: boolean }, ctx: McpContext) {
|
||||
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
return ok({ files: this.files.listFiles(tripId, trash === true) });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'read_trip_file',
|
||||
description: `Read what is inside one uploaded document, e.g. a booking confirmation or a ticket. Text files come back as readable text, anything else base64-encoded, with "encoding" saying which. Files over ${CONTENT_MAX_MB} MB are refused outright, so point the user at the file in TREK rather than retrying. Reading contents is a separate permission from listing files, so this can be refused on a trip where list_trip_files works.`,
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
fileId: z.number().int().positive().describe('File ID from list_trip_files'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
// Not files:read. Listing what a trip carries and reading the bytes of a
|
||||
// passport scan are different privileges, and the plugin host draws the same
|
||||
// line (db:read:files vs db:read:files:content).
|
||||
access: { group: 'files', mode: 'content' },
|
||||
})
|
||||
async readTripFile({ tripId, fileId }: { tripId: number; fileId: number }, ctx: McpContext) {
|
||||
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
try {
|
||||
const { name, mimetype, bytes } = await this.files.readContent(tripId, fileId);
|
||||
const asText = isTextual(mimetype);
|
||||
return ok({
|
||||
file: {
|
||||
id: fileId,
|
||||
name,
|
||||
mimetype,
|
||||
size: bytes.length,
|
||||
encoding: asText ? 'utf8' : 'base64',
|
||||
content: bytes.toString(asText ? 'utf8' : 'base64'),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof FileContentError) return errorResult(contentRefusal(err));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'update_trip_file',
|
||||
description: 'Set a file\'s description and attach it to a booking or a place. Fields left out keep their current value, null detaches. place_id and reservation_id are the file\'s primary attachment, the one the file manager shows next to it. To attach one document to several bookings or places at once, use link_trip_file instead.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
fileId: z.number().int().positive(),
|
||||
description: fileUpdateRequestSchema.shape.description.describe('Free-text description, or an empty string to clear it'),
|
||||
place_id: fileUpdateRequestSchema.shape.place_id.describe('Place on the same trip to attach the file to, or null to detach it'),
|
||||
reservation_id: fileUpdateRequestSchema.shape.reservation_id.describe('Booking on the same trip to attach the file to, or null to detach it'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'files', mode: 'write' },
|
||||
})
|
||||
async updateTripFile(
|
||||
{ tripId, fileId, ...fields }: { tripId: number; fileId: number } & FileUpdateRequest,
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('file_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const current = this.files.getFileById(fileId, tripId);
|
||||
if (!current) return errorResult('File not found.');
|
||||
// Same enforcement as the REST route's assertLinkTargets, through the same
|
||||
// service method: a foreign reservation id would otherwise come back with its
|
||||
// title attached the next time this file is listed.
|
||||
const foreign = this.files.findForeignLinkTarget(tripId, {
|
||||
reservation_id: fields.reservation_id,
|
||||
place_id: fields.place_id,
|
||||
});
|
||||
if (foreign) return errorResult(`Linked item does not belong to this trip (${foreign}).`);
|
||||
// The rest spread carries only the keys the caller actually sent, which is what
|
||||
// updateFile's presence sentinels need: naming the fields here would hand it an
|
||||
// undefined description on a link-only call and wipe the description.
|
||||
const file = this.files.updateFile(fileId, current, fields);
|
||||
this.guards.safeBroadcast(tripId, 'file:updated', { file });
|
||||
return ok({ file });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'link_trip_file',
|
||||
description: 'Attach a file to one more booking, place or day assignment on the same trip, keeping every attachment it already has. Prefer update_trip_file when the document belongs to a single booking or place; use this one for a document that covers several, such as one group ticket or one rental agreement. Returns every link the file now carries.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
fileId: z.number().int().positive(),
|
||||
reservation_id: fileLinkRequestSchema.shape.reservation_id.describe('Booking on the same trip'),
|
||||
assignment_id: fileLinkRequestSchema.shape.assignment_id.describe('Day assignment (a place scheduled on a specific day) on the same trip'),
|
||||
place_id: fileLinkRequestSchema.shape.place_id.describe('Place on the same trip'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'files', mode: 'write' },
|
||||
})
|
||||
async linkTripFile(
|
||||
{ tripId, fileId, ...targets }: { tripId: number; fileId: number } & FileLinkRequest,
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('file_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
if (!this.files.getFileById(fileId, tripId)) return errorResult('File not found.');
|
||||
// The REST body allows all three to be absent and stores a link row pointing at
|
||||
// nothing. A tool caller that gets here with no target made a mistake, and saying
|
||||
// so is more useful than a success that attached the file to nothing.
|
||||
if (!targets.reservation_id && !targets.assignment_id && !targets.place_id) {
|
||||
return errorResult('Pass at least one of reservation_id, assignment_id or place_id.');
|
||||
}
|
||||
const foreign = this.files.findForeignLinkTarget(tripId, targets);
|
||||
if (foreign) return errorResult(`Linked item does not belong to this trip (${foreign}).`);
|
||||
const links = this.files.createFileLink(fileId, targets);
|
||||
return ok({ success: true, links });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'unlink_trip_file',
|
||||
description: 'Remove one attachment between a file and a booking, place or day assignment. The file itself stays on the trip and its other attachments are untouched. Take linkId from list_trip_file_links, it is not the booking or place ID.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
fileId: z.number().int().positive(),
|
||||
linkId: z.number().int().positive().describe('Link ID from list_trip_file_links'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
access: { group: 'files', mode: 'write' },
|
||||
})
|
||||
async unlinkTripFile(
|
||||
{ tripId, fileId, linkId }: { tripId: number; fileId: number; linkId: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('file_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
// deleteFileLink scopes by (linkId, fileId) only, so the file has to be resolved
|
||||
// against :tripId first, exactly as the REST route does it. Otherwise a member of
|
||||
// any trip could drop a link row belonging to a foreign trip's file.
|
||||
if (!this.files.getFileById(fileId, tripId)) return errorResult('File not found.');
|
||||
this.files.deleteFileLink(linkId, fileId);
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'list_trip_file_links',
|
||||
description: 'List everything one file is attached to: bookings (with their title), places and day assignments, each with the linkId that unlink_trip_file needs. list_trip_files already reports the linked booking and place IDs, so reach for this one when you need the link IDs themselves.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
fileId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'files', mode: 'read' },
|
||||
})
|
||||
async listTripFileLinks({ tripId, fileId }: { tripId: number; fileId: number }, ctx: McpContext) {
|
||||
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.files.getFileById(fileId, tripId)) return errorResult('File not found.');
|
||||
return ok({ links: this.files.getFileLinks(fileId) });
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { FilesController } from './files.controller';
|
||||
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';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
@@ -33,9 +36,12 @@ import { MAX_VIDEO_SIZE } from './files.constants';
|
||||
}),
|
||||
}),
|
||||
StorageModule,
|
||||
EphemeralTokenModule, PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule],
|
||||
// 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],
|
||||
controllers: [FilesController, FilesDownloadController],
|
||||
providers: [FilesService, FilesRpc],
|
||||
providers: [FilesService, FilesRpc, FilesMcp],
|
||||
exports: [FilesService],
|
||||
})
|
||||
export class FilesModule {}
|
||||
|
||||
@@ -11,26 +11,16 @@ import { DatabaseService } from '../database/database.service';
|
||||
import { readEnv } from '../../app-config';
|
||||
import { isDemoEmail } from '../common/demo';
|
||||
import { BLOCKED_EXTENSIONS } from './files.constants';
|
||||
import { FilesService } from './files.service';
|
||||
// The read cap is the same number on both byte paths, so the upload side of
|
||||
// this surface reads it off the service that owns the read side.
|
||||
import { FilesService, FileContentError, FILE_CONTENT_MAX as CONTENT_MAX } from './files.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { StorageNotFoundError, StorageInvalidKeyError, type ObjectStat } from '../storage/storage.types';
|
||||
|
||||
/** Files use three separate rights, one per operation, unlike every other domain. */
|
||||
const UPLOAD_ACTION = 'file_upload';
|
||||
const EDIT_ACTION = 'file_edit';
|
||||
const DELETE_ACTION = 'file_delete';
|
||||
|
||||
/** Decoded bytes a plugin may read or write in one call. */
|
||||
const CONTENT_MAX = 10 * 1024 * 1024;
|
||||
|
||||
type FileRow = {
|
||||
filename: string;
|
||||
original_name: string;
|
||||
mime_type: string | null;
|
||||
file_size: number | null;
|
||||
deleted_at: string | null;
|
||||
};
|
||||
|
||||
type CreateInput = {
|
||||
name: string;
|
||||
content_base64: string;
|
||||
@@ -77,53 +67,22 @@ export class FilesRpc {
|
||||
}
|
||||
|
||||
/**
|
||||
* Size-capped BEFORE the read, so a 500MB video cannot be pulled through the IPC
|
||||
* pipe as ~667MB of base64. The bytes come from the storage layer, so this read
|
||||
* works on remote backends too. The read itself runs off the event loop: 10MB of
|
||||
* readFile plus base64 on the host thread would stall every other plugin RPC and
|
||||
* every HTTP request for its duration.
|
||||
* The cap, the storage read and the off-thread buffering live on FilesService,
|
||||
* because the MCP read tool has to obey exactly the same rules and a second
|
||||
* copy of them would drift. What stays here is the IPC wire shape and the two
|
||||
* error types the plugin boundary knows how to encode: a refusal the caller
|
||||
* caused (too large) is BAD_PARAMS, everything else RESOURCE_FORBIDDEN.
|
||||
*/
|
||||
private async readContent(tripId: number, fileId: number): Promise<unknown> {
|
||||
const file = this.files.getFileById(fileId, tripId) as FileRow | undefined;
|
||||
if (!file || file.deleted_at) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`);
|
||||
if ((file.file_size ?? 0) > CONTENT_MAX) {
|
||||
throw new BadParams(`file too large to read (>${CONTENT_MAX} bytes); use the download UI`);
|
||||
}
|
||||
let stream: Readable;
|
||||
let stat: ObjectStat;
|
||||
try {
|
||||
({ stream, stat } = await this.storage.getStream('files', pathMod.basename(file.filename)));
|
||||
const { name, mimetype, bytes } = await this.files.readContent(tripId, fileId);
|
||||
return { name, mimetype, size: bytes.length, content_base64: bytes.toString('base64') };
|
||||
} catch (err) {
|
||||
if (err instanceof StorageNotFoundError || err instanceof StorageInvalidKeyError) {
|
||||
throw new ForbiddenResource('file path is not accessible');
|
||||
if (err instanceof FileContentError) {
|
||||
throw err.reason === 'too-large' ? new BadParams(err.message) : new ForbiddenResource(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// Re-checked against the OBJECT, not the DB row: file_size can drift.
|
||||
if (stat.size > CONTENT_MAX) {
|
||||
stream.destroy();
|
||||
throw new BadParams(`file too large to read (>${CONTENT_MAX} bytes); use the download UI`);
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
for await (const chunk of stream) {
|
||||
const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
|
||||
total += part.length;
|
||||
// A driver whose stat under-reports must not push an oversized payload
|
||||
// through the IPC pipe: abort as soon as the running total crosses the cap.
|
||||
if (total > CONTENT_MAX) {
|
||||
stream.destroy();
|
||||
throw new BadParams('file too large to read');
|
||||
}
|
||||
chunks.push(part);
|
||||
}
|
||||
const buf = Buffer.concat(chunks);
|
||||
return {
|
||||
name: file.original_name,
|
||||
mimetype: file.mime_type ?? 'application/octet-stream',
|
||||
size: buf.length,
|
||||
content_base64: buf.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
@PluginMethod('files.create', { permission: 'db:write:files' })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import path from 'path';
|
||||
import type { Readable } from 'node:stream';
|
||||
import type { Request } from 'express';
|
||||
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
@@ -11,6 +12,7 @@ import type { User, TripFile } from '../../types';
|
||||
import { DatabaseService, type TripAccess } from '../database/database.service';
|
||||
import { DEFAULT_ALLOWED_EXTENSIONS } from './files.constants';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { StorageNotFoundError, StorageInvalidKeyError, type ObjectStat } from '../storage/storage.types';
|
||||
|
||||
type Trip = TripAccess;
|
||||
type FilePermission = 'file_upload' | 'file_edit' | 'file_delete';
|
||||
@@ -37,6 +39,21 @@ export interface FileLink {
|
||||
place_id: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoded bytes one non-HTTP caller may pull out of a file in a single read.
|
||||
* The browser download streams instead and is not bound by this.
|
||||
*/
|
||||
export const FILE_CONTENT_MAX = 10 * 1024 * 1024;
|
||||
|
||||
/** Why a content read was refused. Each caller maps it onto its own error shape. */
|
||||
export type FileContentRefusal = 'not-found' | 'too-large' | 'not-accessible';
|
||||
|
||||
export class FileContentError extends Error {
|
||||
constructor(readonly reason: FileContentRefusal, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File domain service — owns the file SQL (moved 1:1 from the legacy
|
||||
* services/fileService.ts: identical statements, the `||` falsy-coercion
|
||||
@@ -146,6 +163,64 @@ export class FilesService {
|
||||
return this.db.get<TripFile>('SELECT * FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NOT NULL', id, tripId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A file's bytes for a caller that is not the browser download.
|
||||
*
|
||||
* Shared by the plugin RPC (ctx.files.getContent) and the MCP read tool so the
|
||||
* rules exist once. Three of them matter. The size is capped BEFORE the read,
|
||||
* so a 500MB video is never pulled into memory just to be refused afterwards.
|
||||
* The bytes come from the storage layer rather than from disk, so this keeps
|
||||
* working on S3 or a mirrored pair. And the read runs off the event loop:
|
||||
* 10MB of readFile on the host thread stalls every other request for its
|
||||
* duration.
|
||||
*
|
||||
* Access is the caller's job. REST-side that is trip access plus the file
|
||||
* row being on the trip, which the getFileById lookup below re-checks.
|
||||
*/
|
||||
async readContent(
|
||||
tripId: string | number,
|
||||
fileId: string | number,
|
||||
): Promise<{ name: string; mimetype: string; bytes: Buffer }> {
|
||||
const file = this.getFileById(fileId, tripId);
|
||||
if (!file || file.deleted_at) throw new FileContentError('not-found', `no file ${fileId} on trip ${tripId}`);
|
||||
if ((file.file_size ?? 0) > FILE_CONTENT_MAX) {
|
||||
throw new FileContentError('too-large', `file too large to read (>${FILE_CONTENT_MAX} bytes); use the download UI`);
|
||||
}
|
||||
let stream: Readable;
|
||||
let stat: ObjectStat;
|
||||
try {
|
||||
({ stream, stat } = await this.storage.getStream('files', path.basename(file.filename)));
|
||||
} catch (err) {
|
||||
if (err instanceof StorageNotFoundError || err instanceof StorageInvalidKeyError) {
|
||||
throw new FileContentError('not-accessible', 'file path is not accessible');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// Re-checked against the OBJECT, not the DB row: file_size can drift.
|
||||
if (stat.size > FILE_CONTENT_MAX) {
|
||||
stream.destroy();
|
||||
throw new FileContentError('too-large', `file too large to read (>${FILE_CONTENT_MAX} bytes); use the download UI`);
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
for await (const chunk of stream) {
|
||||
const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
|
||||
total += part.length;
|
||||
// A driver whose stat under-reports must not hand back an oversized
|
||||
// payload: abort as soon as the running total crosses the cap.
|
||||
if (total > FILE_CONTENT_MAX) {
|
||||
stream.destroy();
|
||||
throw new FileContentError('too-large', 'file too large to read');
|
||||
}
|
||||
chunks.push(part);
|
||||
}
|
||||
return {
|
||||
name: file.original_name,
|
||||
mimetype: file.mime_type ?? 'application/octet-stream',
|
||||
bytes: Buffer.concat(chunks),
|
||||
};
|
||||
}
|
||||
|
||||
listFiles(tripId: string | number, showTrash: boolean) {
|
||||
const where = showTrash ? 'f.trip_id = ? AND f.deleted_at IS NOT NULL' : 'f.trip_id = ? AND f.deleted_at IS NULL';
|
||||
const files = this.db.all<TripFile>(`${FILE_SELECT} WHERE ${where} ORDER BY f.starred DESC, f.created_at DESC`, tripId);
|
||||
|
||||
@@ -17,7 +17,7 @@ import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit
|
||||
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
|
||||
import { FilesRpc } from '../../../src/nest/files/files.rpc';
|
||||
import { FilesModule } from '../../../src/nest/files/files.module';
|
||||
import type { FilesService } from '../../../src/nest/files/files.service';
|
||||
import { FilesService } from '../../../src/nest/files/files.service';
|
||||
import type { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
@@ -60,6 +60,12 @@ function build(opts: { file?: Record<string, unknown> | undefined; foreign?: str
|
||||
})),
|
||||
put: vi.fn(async () => undefined),
|
||||
} as unknown as StorageService & Record<string, ReturnType<typeof vi.fn>>;
|
||||
// readContent moved onto FilesService (the MCP read tool obeys the same cap and
|
||||
// the same storage rules), and the RPC only maps its refusals onto the two wire
|
||||
// error types. Bind the real implementation onto this double so the cases below
|
||||
// keep exercising that code rather than a stub of it.
|
||||
(files as unknown as { readContent: FilesService['readContent'] }).readContent =
|
||||
FilesService.prototype.readContent.bind({ getFileById: files.getFileById, storage } as unknown as FilesService);
|
||||
const rpc = new FilesRpc(files, realtime, db, guards, storage);
|
||||
const host = (...grants: string[]) => new PluginRpcHost('p', new Set(grants), makeDeps(), createTestPluginRegistry([rpc]));
|
||||
return { files, realtime, permissions, storage, host };
|
||||
@@ -112,6 +118,15 @@ describe('FilesRpc reads', () => {
|
||||
expect(res.error.message).toBe('file path is not accessible');
|
||||
});
|
||||
|
||||
it('FILES-RPC-005e an unexpected storage failure keeps its own taxonomy', async () => {
|
||||
const f = build();
|
||||
// Only the two storage errors become an accessibility refusal; a backend
|
||||
// outage must not be reported to the plugin as a missing file.
|
||||
(f.storage.getStream as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('backend down'));
|
||||
const res = (await f.host('db:read:files:content').dispatch(req('files.getContent', { tripId: 1, fileId: 2 }), 42)) as RpcError;
|
||||
expect(res.error.code).toBe('HOST_ERROR');
|
||||
});
|
||||
|
||||
it('FILES-RPC-005b an object whose stat exceeds the cap is refused before buffering', async () => {
|
||||
const f = build();
|
||||
const stream = Readable.from(Buffer.from('hi'));
|
||||
|
||||
@@ -0,0 +1,870 @@
|
||||
/**
|
||||
* The trip-file MCP surface (FilesMcp): listing a trip's documents, reading one
|
||||
* document's contents under its own scope, and the attach/describe writes.
|
||||
*
|
||||
* The gates are what this file is really about. Every tool has to answer exactly
|
||||
* what its REST route answers: trip access before anything else, file_edit on
|
||||
* the writes, and a file id that is only resolvable inside its own trip. And
|
||||
* read_trip_file has to stay behind files:content while list_trip_files answers
|
||||
* on files:read alone.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import nodePath from 'node:path';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
const { fixture } = vi.hoisted(() => ({ fixture: {} as { root: string } }));
|
||||
// Each createMcpHarness() builds a fresh registry, and the helper hands every one
|
||||
// of those its own mkdtemp root, so bytes written by a test would land in a
|
||||
// directory the next registry never looks at. Pin one shared fixture instead,
|
||||
// which is what lets read_trip_file return a file this suite wrote.
|
||||
vi.mock('../../helpers/storage-fixture', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../helpers/storage-fixture')>();
|
||||
const shared = actual.makeStorageFixture('');
|
||||
fixture.root = shared.root;
|
||||
return { makeStorageFixture: () => shared };
|
||||
});
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions-cache';
|
||||
import { createUser, createTrip, createDay, createPlace, createDayAssignment, createReservation, addTripMember } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
import { expectRegisteredProvider } from '../../helpers/module-providers';
|
||||
import { FilesModule } from '../../../src/nest/files/files.module';
|
||||
import { FilesMcp } from '../../../src/nest/files/files.mcp';
|
||||
import { FILE_CONTENT_MAX } from '../../../src/nest/files/files.service';
|
||||
import { StorageService } from '../../../src/nest/storage/storage.service';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.mockClear();
|
||||
delete process.env.DEMO_MODE;
|
||||
// resetTestDb truncates app_settings, but the permission cache is module-scoped
|
||||
// and would keep serving whatever the previous case configured.
|
||||
invalidatePermissionsCache();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withResources: false });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
/** Lower a configurable action the way the admin permission panel does. */
|
||||
function setPermission(action: string, level: string) {
|
||||
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(`perm_${action}`, level);
|
||||
invalidatePermissionsCache();
|
||||
}
|
||||
|
||||
interface FileRowOverrides {
|
||||
filename: string;
|
||||
original_name: string;
|
||||
file_size: number | null;
|
||||
mime_type: string | null;
|
||||
description: string | null;
|
||||
uploaded_by: number | null;
|
||||
place_id: number | null;
|
||||
reservation_id: number | null;
|
||||
starred: number;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
/** A trip_files row, straight in: there is no upload path through MCP to make one. */
|
||||
function insertFile(tripId: number, overrides: Partial<FileRowOverrides> = {}) {
|
||||
const info = testDb.prepare(`
|
||||
INSERT INTO trip_files (trip_id, filename, original_name, file_size, mime_type, description, uploaded_by, place_id, reservation_id, starred, deleted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
tripId,
|
||||
overrides.filename ?? 'stored-visa.pdf',
|
||||
overrides.original_name ?? 'visa.pdf',
|
||||
// `??` would swallow an explicit null here, and a row with no recorded size
|
||||
// or type is exactly what the fallback cases need.
|
||||
overrides.file_size === undefined ? 2 : overrides.file_size,
|
||||
overrides.mime_type === undefined ? 'application/pdf' : overrides.mime_type,
|
||||
overrides.description ?? null,
|
||||
overrides.uploaded_by ?? null,
|
||||
overrides.place_id ?? null,
|
||||
overrides.reservation_id ?? null,
|
||||
overrides.starred ?? 0,
|
||||
overrides.deleted_at ?? null,
|
||||
);
|
||||
return testDb.prepare('SELECT * FROM trip_files WHERE id = ?').get(info.lastInsertRowid) as { id: number; description: string | null; place_id: number | null; reservation_id: number | null };
|
||||
}
|
||||
|
||||
/** Put real bytes where the storage layer will look for `filename`. */
|
||||
function storeBytes(filename: string, body: Buffer | string) {
|
||||
fs.writeFileSync(nodePath.join(fixture.root, filename), body);
|
||||
}
|
||||
|
||||
function fileRow(id: number) {
|
||||
return testDb.prepare('SELECT description, place_id, reservation_id FROM trip_files WHERE id = ?').get(id) as
|
||||
{ description: string | null; place_id: number | null; reservation_id: number | null };
|
||||
}
|
||||
|
||||
function linkRows(fileId: number) {
|
||||
return testDb.prepare('SELECT * FROM file_links WHERE file_id = ?').all(fileId) as
|
||||
{ id: number; reservation_id: number | null; assignment_id: number | null; place_id: number | null }[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_trip_files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_trip_files', () => {
|
||||
it('reports name, size, uploader, links and starred state', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const reservation = createReservation(testDb, trip.id, { title: 'Hotel Ritz' });
|
||||
const file = insertFile(trip.id, {
|
||||
original_name: 'boarding-pass.pdf', file_size: 4242, uploaded_by: user.id,
|
||||
place_id: place.id, reservation_id: reservation.id, starred: 1, description: 'AF1234',
|
||||
});
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_trip_files', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.files).toHaveLength(1);
|
||||
expect(data.files[0]).toMatchObject({
|
||||
id: file.id,
|
||||
original_name: 'boarding-pass.pdf',
|
||||
file_size: 4242,
|
||||
mime_type: 'application/pdf',
|
||||
description: 'AF1234',
|
||||
starred: 1,
|
||||
deleted_at: null,
|
||||
reservation_title: 'Hotel Ritz',
|
||||
uploaded_by_name: user.username,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('lists the trash only when asked for it', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
insertFile(trip.id, { original_name: 'live.pdf' });
|
||||
insertFile(trip.id, { original_name: 'trashed.pdf', deleted_at: '2027-01-01 10:00:00' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const live = parseToolResult(await h.client.callTool({ name: 'list_trip_files', arguments: { tripId: trip.id } })) as any;
|
||||
expect(live.files.map((f: any) => f.original_name)).toEqual(['live.pdf']);
|
||||
|
||||
const trashed = parseToolResult(await h.client.callTool({ name: 'list_trip_files', arguments: { tripId: trip.id, trash: true } })) as any;
|
||||
expect(trashed.files.map((f: any) => f.original_name)).toEqual(['trashed.pdf']);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the bookings and places a file is linked to', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const reservation = createReservation(testDb, trip.id);
|
||||
const file = insertFile(trip.id);
|
||||
testDb.prepare('INSERT INTO file_links (file_id, reservation_id) VALUES (?, ?)').run(file.id, reservation.id);
|
||||
testDb.prepare('INSERT INTO file_links (file_id, place_id) VALUES (?, ?)').run(file.id, place.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({ name: 'list_trip_files', arguments: { tripId: trip.id } })) as any;
|
||||
expect(data.files[0].linked_reservation_ids).toEqual([reservation.id]);
|
||||
expect(data.files[0].linked_place_ids).toEqual([place.id]);
|
||||
});
|
||||
});
|
||||
|
||||
it('denies a non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
insertFile(trip.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_trip_files', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// read_trip_file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: read_trip_file', () => {
|
||||
it('returns a text document as text', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = insertFile(trip.id, { filename: 'notes.txt', original_name: 'packing-notes.txt', mime_type: 'text/plain', file_size: 11 });
|
||||
storeBytes('notes.txt', 'Bring socks');
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({
|
||||
name: 'read_trip_file', arguments: { tripId: trip.id, fileId: file.id },
|
||||
})) as any;
|
||||
expect(data.file).toMatchObject({
|
||||
id: file.id, name: 'packing-notes.txt', mimetype: 'text/plain', size: 11, encoding: 'utf8', content: 'Bring socks',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a binary document base64-encoded', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const bytes = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x00, 0xff]);
|
||||
const file = insertFile(trip.id, { filename: 'stored.pdf', mime_type: 'application/pdf', file_size: bytes.length });
|
||||
storeBytes('stored.pdf', bytes);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({
|
||||
name: 'read_trip_file', arguments: { tripId: trip.id, fileId: file.id },
|
||||
})) as any;
|
||||
expect(data.file.encoding).toBe('base64');
|
||||
expect(Buffer.from(data.file.content, 'base64').equals(bytes)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a binary mimetype when the row has none', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = insertFile(trip.id, { filename: 'unknown.bin', mime_type: null, file_size: null });
|
||||
storeBytes('unknown.bin', 'hi');
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({
|
||||
name: 'read_trip_file', arguments: { tripId: trip.id, fileId: file.id },
|
||||
})) as any;
|
||||
expect(data.file.mimetype).toBe('application/octet-stream');
|
||||
expect(data.file.encoding).toBe('base64');
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a file that belongs to another trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const otherTrip = createTrip(testDb, user.id);
|
||||
const foreign = insertFile(otherTrip.id, { filename: 'foreign.txt', mime_type: 'text/plain' });
|
||||
storeBytes('foreign.txt', 'secret');
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'read_trip_file', arguments: { tripId: trip.id, fileId: foreign.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).toContain('File not found.');
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a trashed file', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = insertFile(trip.id, { filename: 'trashed.txt', mime_type: 'text/plain', deleted_at: '2027-01-01 10:00:00' });
|
||||
storeBytes('trashed.txt', 'gone');
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'read_trip_file', arguments: { tripId: trip.id, fileId: file.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).toContain('File not found.');
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a file over the 10 MB cap without touching the bytes', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// Recorded size only: the refusal happens before the storage layer is asked,
|
||||
// which is the whole point of capping on the row.
|
||||
const file = insertFile(trip.id, { filename: 'huge.mp4', mime_type: 'video/mp4', file_size: FILE_CONTENT_MAX + 1 });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'read_trip_file', arguments: { tripId: trip.id, fileId: file.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).toContain('too large to read here (over 10 MB)');
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a row whose bytes are gone as unavailable, not as a crash', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = insertFile(trip.id, { filename: 'never-stored.pdf' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'read_trip_file', arguments: { tripId: trip.id, fileId: file.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).toContain('File contents are not available.');
|
||||
});
|
||||
});
|
||||
|
||||
it('lets an unexpected storage failure surface instead of calling the file missing', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = insertFile(trip.id, { filename: 'flaky.txt', mime_type: 'text/plain' });
|
||||
storeBytes('flaky.txt', 'hi');
|
||||
// A backend outage is not one of the three refusals: reporting it as "not
|
||||
// found" would send the caller off to fix a file that is perfectly fine.
|
||||
const outage = vi.spyOn(StorageService.prototype, 'getStream').mockRejectedValueOnce(new Error('backend down'));
|
||||
|
||||
try {
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'read_trip_file', arguments: { tripId: trip.id, fileId: file.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
const text = JSON.stringify(result.content);
|
||||
expect(text).not.toContain('File not found.');
|
||||
expect(text).not.toContain('File contents are not available.');
|
||||
});
|
||||
} finally {
|
||||
outage.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('denies a non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
const file = insertFile(trip.id, { filename: 'members-only.txt', mime_type: 'text/plain' });
|
||||
storeBytes('members-only.txt', 'secret');
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'read_trip_file', arguments: { tripId: trip.id, fileId: file.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).toContain('Trip not found or access denied.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_trip_file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_trip_file', () => {
|
||||
it('sets the description and attaches the file to a place', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const file = insertFile(trip.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({
|
||||
name: 'update_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: file.id, description: 'Museum ticket', place_id: place.id },
|
||||
})) as any;
|
||||
expect(data.file.description).toBe('Museum ticket');
|
||||
expect(fileRow(file.id)).toMatchObject({ description: 'Museum ticket', place_id: place.id });
|
||||
});
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'file:updated', expect.objectContaining({ _source: 'mcp' }));
|
||||
});
|
||||
|
||||
it('leaves out fields the caller did not send', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const reservation = createReservation(testDb, trip.id);
|
||||
const file = insertFile(trip.id, { description: 'Keep me' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({
|
||||
name: 'update_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: file.id, reservation_id: reservation.id },
|
||||
});
|
||||
expect(fileRow(file.id)).toMatchObject({ description: 'Keep me', reservation_id: reservation.id });
|
||||
});
|
||||
});
|
||||
|
||||
it('detaches a booking when reservation_id is null', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const reservation = createReservation(testDb, trip.id);
|
||||
const file = insertFile(trip.id, { reservation_id: reservation.id });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({
|
||||
name: 'update_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: file.id, reservation_id: null },
|
||||
});
|
||||
expect(fileRow(file.id).reservation_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a place that belongs to another trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const otherTrip = createTrip(testDb, user.id);
|
||||
const foreignPlace = createPlace(testDb, otherTrip.id);
|
||||
const file = insertFile(trip.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: file.id, place_id: foreignPlace.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).toContain('does not belong to this trip (place_id)');
|
||||
expect(fileRow(file.id).place_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a file id from another trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const otherTrip = createTrip(testDb, user.id);
|
||||
const foreign = insertFile(otherTrip.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: foreign.id, description: 'x' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).toContain('File not found.');
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a member once file_edit is owner-only', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const file = insertFile(trip.id);
|
||||
setPermission('file_edit', 'trip_owner');
|
||||
|
||||
await withHarness(member.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: file.id, description: 'nope' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(fileRow(file.id).description).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('denies a non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
const file = insertFile(trip.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: file.id, description: 'nope' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).toContain('Trip not found or access denied.');
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks a demo user before it looks at the trip', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@trek.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = insertFile(trip.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: file.id, description: 'nope' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(fileRow(file.id).description).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// link_trip_file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: link_trip_file', () => {
|
||||
it('attaches one file to a booking, a place and a day assignment', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const reservation = createReservation(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day.id, place.id);
|
||||
const file = insertFile(trip.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const first = parseToolResult(await h.client.callTool({
|
||||
name: 'link_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: file.id, reservation_id: reservation.id },
|
||||
})) as any;
|
||||
expect(first.success).toBe(true);
|
||||
expect(first.links).toHaveLength(1);
|
||||
|
||||
await h.client.callTool({
|
||||
name: 'link_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: file.id, place_id: place.id, assignment_id: assignment.id },
|
||||
});
|
||||
});
|
||||
|
||||
const links = linkRows(file.id);
|
||||
expect(links).toHaveLength(2);
|
||||
expect(links[0].reservation_id).toBe(reservation.id);
|
||||
expect(links[1]).toMatchObject({ place_id: place.id, assignment_id: assignment.id });
|
||||
});
|
||||
|
||||
it('refuses a call with no target instead of storing an empty link', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = insertFile(trip.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'link_trip_file', arguments: { tripId: trip.id, fileId: file.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).toContain('at least one of reservation_id');
|
||||
});
|
||||
expect(linkRows(file.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses a booking from another trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const otherTrip = createTrip(testDb, user.id);
|
||||
const foreignReservation = createReservation(testDb, otherTrip.id);
|
||||
const file = insertFile(trip.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'link_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: file.id, reservation_id: foreignReservation.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).toContain('does not belong to this trip (reservation_id)');
|
||||
});
|
||||
expect(linkRows(file.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses a day assignment from another trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const otherTrip = createTrip(testDb, user.id);
|
||||
const foreignDay = createDay(testDb, otherTrip.id);
|
||||
const foreignPlace = createPlace(testDb, otherTrip.id);
|
||||
const foreignAssignment = createDayAssignment(testDb, foreignDay.id, foreignPlace.id);
|
||||
const file = insertFile(trip.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'link_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: file.id, assignment_id: foreignAssignment.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).toContain('does not belong to this trip (assignment_id)');
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a file id from another trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const otherTrip = createTrip(testDb, user.id);
|
||||
const foreign = insertFile(otherTrip.id);
|
||||
const reservation = createReservation(testDb, trip.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'link_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: foreign.id, reservation_id: reservation.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).toContain('File not found.');
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a member once file_edit is owner-only', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const reservation = createReservation(testDb, trip.id);
|
||||
const file = insertFile(trip.id);
|
||||
setPermission('file_edit', 'trip_owner');
|
||||
|
||||
await withHarness(member.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'link_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: file.id, reservation_id: reservation.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
expect(linkRows(file.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('denies a non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
const reservation = createReservation(testDb, trip.id);
|
||||
const file = insertFile(trip.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'link_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: file.id, reservation_id: reservation.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).toContain('Trip not found or access denied.');
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks a demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@trek.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const reservation = createReservation(testDb, trip.id);
|
||||
const file = insertFile(trip.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'link_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: file.id, reservation_id: reservation.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
expect(linkRows(file.id)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// unlink_trip_file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: unlink_trip_file', () => {
|
||||
it('removes one link and leaves the others alone', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const reservation = createReservation(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const file = insertFile(trip.id);
|
||||
testDb.prepare('INSERT INTO file_links (file_id, reservation_id) VALUES (?, ?)').run(file.id, reservation.id);
|
||||
testDb.prepare('INSERT INTO file_links (file_id, place_id) VALUES (?, ?)').run(file.id, place.id);
|
||||
const doomed = linkRows(file.id)[0].id;
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({
|
||||
name: 'unlink_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: file.id, linkId: doomed },
|
||||
})) as any;
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
const left = linkRows(file.id);
|
||||
expect(left).toHaveLength(1);
|
||||
expect(left[0].place_id).toBe(place.id);
|
||||
});
|
||||
|
||||
it('refuses a file id from another trip, so a foreign link survives', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const otherTrip = createTrip(testDb, user.id);
|
||||
const foreignFile = insertFile(otherTrip.id);
|
||||
const foreignReservation = createReservation(testDb, otherTrip.id);
|
||||
testDb.prepare('INSERT INTO file_links (file_id, reservation_id) VALUES (?, ?)').run(foreignFile.id, foreignReservation.id);
|
||||
const linkId = linkRows(foreignFile.id)[0].id;
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'unlink_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: foreignFile.id, linkId },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).toContain('File not found.');
|
||||
});
|
||||
expect(linkRows(foreignFile.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refuses a member once file_edit is owner-only', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const reservation = createReservation(testDb, trip.id);
|
||||
const file = insertFile(trip.id);
|
||||
testDb.prepare('INSERT INTO file_links (file_id, reservation_id) VALUES (?, ?)').run(file.id, reservation.id);
|
||||
const linkId = linkRows(file.id)[0].id;
|
||||
setPermission('file_edit', 'trip_owner');
|
||||
|
||||
await withHarness(member.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'unlink_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: file.id, linkId },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
expect(linkRows(file.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('denies a non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
const file = insertFile(trip.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'unlink_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: file.id, linkId: 1 },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).toContain('Trip not found or access denied.');
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks a demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const reservation = createReservation(testDb, trip.id);
|
||||
const file = insertFile(trip.id);
|
||||
testDb.prepare('INSERT INTO file_links (file_id, reservation_id) VALUES (?, ?)').run(file.id, reservation.id);
|
||||
const linkId = linkRows(file.id)[0].id;
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'unlink_trip_file',
|
||||
arguments: { tripId: trip.id, fileId: file.id, linkId },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
expect(linkRows(file.id)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_trip_file_links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_trip_file_links', () => {
|
||||
it('returns every link with its id and the booking title', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const reservation = createReservation(testDb, trip.id, { title: 'Nightjet 421' });
|
||||
const file = insertFile(trip.id);
|
||||
testDb.prepare('INSERT INTO file_links (file_id, reservation_id) VALUES (?, ?)').run(file.id, reservation.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({
|
||||
name: 'list_trip_file_links', arguments: { tripId: trip.id, fileId: file.id },
|
||||
})) as any;
|
||||
expect(data.links).toHaveLength(1);
|
||||
expect(data.links[0]).toMatchObject({ file_id: file.id, reservation_id: reservation.id, reservation_title: 'Nightjet 421' });
|
||||
expect(typeof data.links[0].id).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a file id from another trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const otherTrip = createTrip(testDb, user.id);
|
||||
const foreign = insertFile(otherTrip.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'list_trip_file_links', arguments: { tripId: trip.id, fileId: foreign.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(JSON.stringify(result.content)).toContain('File not found.');
|
||||
});
|
||||
});
|
||||
|
||||
it('denies a non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
const file = insertFile(trip.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'list_trip_file_links', arguments: { tripId: trip.id, fileId: file.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scope gating
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('File tools: scope gating', () => {
|
||||
const READ_TOOLS = ['list_trip_files', 'list_trip_file_links'];
|
||||
const WRITE_TOOLS = ['update_trip_file', 'link_trip_file', 'unlink_trip_file'];
|
||||
|
||||
async function listToolNames(userId: number, scopes: string[] | null): Promise<string[]> {
|
||||
const h = await createMcpHarness({ userId, withResources: false, scopes });
|
||||
try {
|
||||
return (await h.client.listTools()).tools.map((t) => t.name);
|
||||
} finally {
|
||||
await h.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
it('registers all six tools for a full-access session', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const names = await listToolNames(user.id, null);
|
||||
for (const tool of [...READ_TOOLS, ...WRITE_TOOLS, 'read_trip_file']) expect(names).toContain(tool);
|
||||
});
|
||||
|
||||
it('files:read lists the documents but does not open them', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const names = await listToolNames(user.id, ['files:read']);
|
||||
for (const tool of READ_TOOLS) expect(names).toContain(tool);
|
||||
for (const tool of [...WRITE_TOOLS, 'read_trip_file']) expect(names).not.toContain(tool);
|
||||
});
|
||||
|
||||
it('files:content opens a document without granting the listing', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const names = await listToolNames(user.id, ['files:content']);
|
||||
expect(names).toContain('read_trip_file');
|
||||
for (const tool of [...READ_TOOLS, ...WRITE_TOOLS]) expect(names).not.toContain(tool);
|
||||
});
|
||||
|
||||
it('files:write carries the reads, and still not the contents', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const names = await listToolNames(user.id, ['files:write']);
|
||||
for (const tool of [...READ_TOOLS, ...WRITE_TOOLS]) expect(names).toContain(tool);
|
||||
expect(names).not.toContain('read_trip_file');
|
||||
});
|
||||
|
||||
it('a token without any files scope gets none of them', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const names = await listToolNames(user.id, ['trips:read']);
|
||||
for (const tool of [...READ_TOOLS, ...WRITE_TOOLS, 'read_trip_file']) expect(names).not.toContain(tool);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FilesMcp wiring', () => {
|
||||
it('is listed in its module providers', () => {
|
||||
expectRegisteredProvider(FilesModule, FilesMcp);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user