Move the file surface onto the decorators

Six more methods, so the registry owns 30 of 113, and the deps factory is down to
660 lines from 944.

Files are the awkward corner of the plugin surface and stay that way on purpose.
Reading a file's bytes is a separate grant from reading its metadata, because a
passport scan is more sensitive than its filename, and the three writes use three
distinct rights instead of one domain action. FILES-RPC-006 pins that by watching
which permission each operation actually asks for.

This is also the only plugin path that touches disk, so the tests lean on it: the
extension blocklist, the size cap checked before the read rather than after, a
path that resolves outside the uploads root, and the demo-account upload refusal
that mirrors the REST guard.
This commit is contained in:
Maurice
2026-08-09 12:49:01 +02:00
committed by Maurice
parent ca88676858
commit dbb0016cac
9 changed files with 470 additions and 209 deletions
+5 -2
View File
@@ -2,13 +2,16 @@ import { Module } from '@nestjs/common';
import { FilesController } from './files.controller';
import { FilesDownloadController } from './files-download.controller';
import { FilesService } from './files.service';
import { FilesRpc } from './files.rpc';
import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { AppConfigModule } from '../app-config/app-config.module';
@Module({
imports: [PermissionsModule, AppConfigModule],
imports: [PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule],
controllers: [FilesController, FilesDownloadController],
providers: [FilesService],
providers: [FilesService, FilesRpc],
exports: [FilesService],
})
export class FilesModule {}
+210
View File
@@ -0,0 +1,210 @@
import fsMod from 'node:fs';
import pathMod from 'node:path';
import { randomUUID } from 'node:crypto';
import { PluginController, PluginMethod } from '../plugins/host/rpc-kit/decorators';
import { PluginGuards } from '../plugins/host/plugin-guards.service';
import { BadParams, ForbiddenResource } from '../plugins/host/rpc-errors';
import { asPayload, num } from '../plugins/host/rpc-params';
import type { PluginRpcContext } from '../plugins/host/rpc-kit/types';
import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService } from '../database/database.service';
import { readEnv } from '../../app-config';
import { isDemoEmail } from '../common/demo';
import { BLOCKED_EXTENSIONS, filesDir } from './files.constants';
import { FilesService } from './files.service';
/** 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;
mimetype?: string;
description?: string;
place_id?: number;
reservation_id?: number;
};
/**
* The file surface a plugin may reach (#plugins).
*
* Two things here are unlike the rest of the plugin surface. Reading a file's BYTES
* is a separate grant from reading its metadata, because a passport scan is more
* sensitive than its filename. And the three write operations use three distinct
* rights (file_upload / file_edit / file_delete) rather than one domain action.
*
* The upload path is the only one in the plugin surface that touches disk, so the
* extension is checked against the central blocklist and the size is capped before
* anything is written, and a demo user is refused outright.
*/
@PluginController()
export class FilesRpc {
constructor(
private readonly files: FilesService,
private readonly realtime: RealtimeService,
private readonly db: DatabaseService,
private readonly guards: PluginGuards,
) {}
@PluginMethod('files.list', { permission: 'db:read:files' })
list(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
// Trash excluded, the same view the files tab shows.
return this.guards.tripRead(params, ctx, () => this.files.listFiles(num(params.tripId, 'tripId'), false));
}
@PluginMethod('files.getContent', { permission: 'db:read:files:content' })
getContent(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
return this.guards.tripRead(params, ctx, () =>
this.readContent(num(params.tripId, 'tripId'), num(params.fileId, 'fileId')),
);
}
/**
* Size-capped BEFORE the read, so a 500MB video cannot be pulled through the IPC
* pipe as ~667MB of base64. 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.
*/
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`);
}
const { resolved, safe } = this.files.resolveFilePath(file.filename);
if (!safe) throw new ForbiddenResource('file path is not accessible');
const buf = await fsMod.promises.readFile(resolved);
if (buf.length > CONTENT_MAX) throw new BadParams('file too large to read');
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' })
create(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
const tripId = num(params.tripId, 'tripId');
const actor = this.guards.requireActor(ctx, 'file');
const input = asPayload(params.input);
if (typeof input.name !== 'string' || input.name.trim() === '' || input.name.length > 255) {
throw new BadParams('file name is required (max 255 chars)');
}
if (typeof input.content_base64 !== 'string' || input.content_base64 === '') {
throw new BadParams('content_base64 is required');
}
// Bound the ENCODED length first: 14MB of base64 is roughly the 10MB cap, and
// rejecting here avoids decoding an oversized payload just to measure it.
if (input.content_base64.length > 14 * 1024 * 1024) {
throw new BadParams('file exceeds the 10MB plugin upload cap');
}
this.guards.requireTripEdit(tripId, actor, UPLOAD_ACTION);
return this.writeFile(tripId, input as unknown as CreateInput, actor);
}
private writeFile(tripId: number, input: CreateInput, actingUserId: number): unknown {
// Mirrors the REST upload guard: a demo user must not write bytes to the shared
// demo instance, not even through a plugin's db:write:files. The email is only
// resolved when demo mode is actually on, so self-hosted installs pay nothing.
if (readEnv().demo.enabled) {
const uploader = this.db.prepare('SELECT email FROM users WHERE id = ?').get(actingUserId) as { email?: string } | undefined;
if (isDemoEmail(uploader?.email)) throw new ForbiddenResource('Uploads are disabled in demo mode.');
}
const original = pathMod.basename(input.name);
const ext = pathMod.extname(original).toLowerCase();
if (!ext || BLOCKED_EXTENSIONS.includes(ext)) {
throw new BadParams(`file extension '${ext || '(none)'}' is not allowed`);
}
const buf = Buffer.from(input.content_base64, 'base64');
if (buf.length === 0) throw new BadParams('file content is empty');
if (buf.length > CONTENT_MAX) throw new BadParams('file exceeds the 10MB plugin upload cap');
const foreign = this.files.findForeignLinkTarget(tripId, {
reservation_id: input.reservation_id ?? null,
place_id: input.place_id ?? null,
});
if (foreign) throw new ForbiddenResource(`${foreign} does not belong to trip ${tripId}`);
const filename = `${randomUUID()}${ext}`;
fsMod.mkdirSync(filesDir, { recursive: true });
fsMod.writeFileSync(pathMod.join(filesDir, filename), buf);
const file = this.files.createFile(
tripId,
{ filename, originalname: original, size: buf.length, mimetype: input.mimetype || 'application/octet-stream' },
actingUserId,
{
place_id: input.place_id != null ? String(input.place_id) : null,
reservation_id: input.reservation_id != null ? String(input.reservation_id) : null,
description: input.description ?? null,
},
);
this.realtime.broadcast(tripId, 'file:created', { file }, undefined);
return file;
}
@PluginMethod('files.createLink', { permission: 'db:write:files' })
createLink(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
const tripId = num(params.tripId, 'tripId');
const fileId = num(params.fileId, 'fileId');
const actor = this.guards.requireActor(ctx, 'file link');
this.guards.requireTripEdit(tripId, actor, EDIT_ACTION);
if (!this.files.getFileById(fileId, tripId)) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`);
const opts = asPayload(params.opts) as { reservation_id?: number; assignment_id?: number; place_id?: number };
// A link target on another trip would otherwise attach this trip's file to it.
const foreign = this.files.findForeignLinkTarget(tripId, opts);
if (foreign) throw new ForbiddenResource(`${foreign} does not belong to trip ${tripId}`);
return this.files.createFileLink(fileId, {
reservation_id: opts.reservation_id != null ? String(opts.reservation_id) : null,
assignment_id: opts.assignment_id != null ? String(opts.assignment_id) : null,
place_id: opts.place_id != null ? String(opts.place_id) : null,
});
}
@PluginMethod('files.update', { permission: 'db:write:files' })
update(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
const tripId = num(params.tripId, 'tripId');
const fileId = num(params.fileId, 'fileId');
const actor = this.guards.requireActor(ctx, 'file');
this.guards.requireTripEdit(tripId, actor, EDIT_ACTION);
const current = this.files.getFileById(fileId, tripId);
if (!current) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`);
const input = asPayload(params.input) as { description?: string; place_id?: number | null; reservation_id?: number | null };
const foreign = this.files.findForeignLinkTarget(tripId, {
reservation_id: input.reservation_id ?? null,
place_id: input.place_id ?? null,
});
if (foreign) throw new ForbiddenResource(`${foreign} does not belong to trip ${tripId}`);
const file = this.files.updateFile(fileId, current, {
description: input.description,
// null clears the link, undefined leaves it alone: the two are distinct here.
place_id: input.place_id != null ? String(input.place_id) : input.place_id === null ? null : undefined,
reservation_id: input.reservation_id != null ? String(input.reservation_id) : input.reservation_id === null ? null : undefined,
});
this.realtime.broadcast(tripId, 'file:updated', { file }, undefined);
return file;
}
@PluginMethod('files.softDelete', { permission: 'db:write:files' })
softDelete(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
const tripId = num(params.tripId, 'tripId');
const fileId = num(params.fileId, 'fileId');
const actor = this.guards.requireActor(ctx, 'file');
this.guards.requireTripEdit(tripId, actor, DELETE_ACTION);
if (!this.files.getFileById(fileId, tripId)) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`);
this.files.softDeleteFile(fileId);
this.realtime.broadcast(tripId, 'file:deleted', { fileId }, undefined);
return { deleted: true };
}
}
@@ -196,82 +196,12 @@ export class PluginHostDepsFactory {
},
// --- Read scopes (packing/files). Membership is checked by the host (tripRead);
// these just delegate to the same services the REST paths use. ---
listTripFiles: (tripId) => this.files.listFiles(tripId, false),
// A file's bytes as base64, size-capped BEFORE the read so a 500MB video can't
// be pulled (~667MB base64) through the IPC pipe. 10MB matches the plugin upload
// cap; trashed files (deleted_at set) are refused like the download path.
getTripFileContent: async (tripId, fileId) => {
const CONTENT_MAX = 10 * 1024 * 1024;
const file = this.files.getFileById(fileId, tripId) as { filename: string; original_name: string; mime_type: string | null; file_size: number | null; deleted_at: string | null } | 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`);
const { resolved, safe } = this.files.resolveFilePath(file.filename);
if (!safe) throw new ForbiddenResource('file path is not accessible');
// Read off the event loop — a 10MB read + base64 on the host thread would
// otherwise stall every other plugin RPC and request for its duration.
const buf = await fsMod.promises.readFile(resolved);
if (buf.length > CONTENT_MAX) throw new BadParams('file too large to read');
return { name: file.original_name, mimetype: file.mime_type ?? 'application/octet-stream', size: buf.length, content_base64: buf.toString('base64') };
},
// --- Files write. Bytes arrive as bounded base64; the extension is validated
// against the central blocklist BEFORE anything touches disk, and link targets
// must live on the same trip (findForeignLinkTarget). Same events as the app. ---
canUploadFiles: (tripId, userId) => this.canEditTripAs('file_upload', tripId, userId),
canEditFiles: (tripId, userId) => this.canEditTripAs('file_edit', tripId, userId),
canDeleteFiles: (tripId, userId) => this.canEditTripAs('file_delete', tripId, userId),
createTripFile: (tripId, input, actingUserId) => {
// Mirror the REST upload guard (files.controller): a demo user must not write
// bytes to the shared demo instance, even through a plugin's db:write:files.
// Only resolve the email when demo mode is actually on — keeps the hot path
// (and the schema surface) untouched for self-hosted installs.
if (readEnv().demo.enabled) {
const uploader = this.db.prepare('SELECT email FROM users WHERE id = ?').get(actingUserId) as { email?: string } | undefined;
if (isDemoEmail(uploader?.email)) throw new ForbiddenResource('Uploads are disabled in demo mode.');
}
const i = input as { name: string; content_base64: string; mimetype?: string; description?: string; place_id?: number; reservation_id?: number };
const original = pathMod.basename(i.name);
const ext = pathMod.extname(original).toLowerCase();
if (!ext || BLOCKED_EXTENSIONS.includes(ext)) throw new BadParams(`file extension '${ext || '(none)'}' is not allowed`);
const buf = Buffer.from(i.content_base64, 'base64');
if (buf.length === 0) throw new BadParams('file content is empty');
if (buf.length > 10 * 1024 * 1024) throw new BadParams('file exceeds the 10MB plugin upload cap');
const foreign = this.files.findForeignLinkTarget(tripId, { reservation_id: i.reservation_id ?? null, place_id: i.place_id ?? null });
if (foreign) throw new ForbiddenResource(`${foreign} does not belong to trip ${tripId}`);
const filename = `${randomUUID()}${ext}`;
fsMod.mkdirSync(filesDir, { recursive: true });
fsMod.writeFileSync(pathMod.join(filesDir, filename), buf);
const file = this.files.createFile(
tripId,
{ filename, originalname: original, size: buf.length, mimetype: i.mimetype || 'application/octet-stream' },
actingUserId,
{ place_id: i.place_id != null ? String(i.place_id) : null, reservation_id: i.reservation_id != null ? String(i.reservation_id) : null, description: i.description ?? null },
);
this.realtime.broadcast(tripId, 'file:created', { file }, undefined);
return file;
},
createTripFileLink: (tripId, fileId, opts) => {
if (!this.files.getFileById(fileId, tripId)) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`);
const o = opts as { reservation_id?: number; assignment_id?: number; place_id?: number };
const foreign = this.files.findForeignLinkTarget(tripId, o);
if (foreign) throw new ForbiddenResource(`${foreign} does not belong to trip ${tripId}`);
return this.files.createFileLink(fileId, { reservation_id: o.reservation_id != null ? String(o.reservation_id) : null, assignment_id: o.assignment_id != null ? String(o.assignment_id) : null, place_id: o.place_id != null ? String(o.place_id) : null });
},
updateTripFile: (tripId, fileId, input) => {
const current = this.files.getFileById(fileId, tripId);
if (!current) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`);
const i = input as { description?: string; place_id?: number | null; reservation_id?: number | null };
const foreign = this.files.findForeignLinkTarget(tripId, { reservation_id: i.reservation_id ?? null, place_id: i.place_id ?? null });
if (foreign) throw new ForbiddenResource(`${foreign} does not belong to trip ${tripId}`);
const file = this.files.updateFile(fileId, current, { description: i.description, place_id: i.place_id != null ? String(i.place_id) : i.place_id === null ? null : undefined, reservation_id: i.reservation_id != null ? String(i.reservation_id) : i.reservation_id === null ? null : undefined });
this.realtime.broadcast(tripId, 'file:updated', { file }, undefined);
return file;
},
softDeleteTripFile: (tripId, fileId) => {
if (!this.files.getFileById(fileId, tripId)) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`);
this.files.softDeleteFile(fileId);
this.realtime.broadcast(tripId, 'file:deleted', { fileId }, undefined);
return { deleted: true };
},
// --- Collab reads (collab addon; membership checked by the host). Same hydrated
// shapes as the REST GETs, so a collab plugin can finally read what it writes. ---
listCollabNotes: (tripId) => { requireAddon(ADDON_IDS.COLLAB, 'collab'); return this.collab.listNotes(tripId) as unknown[]; },
+1 -61
View File
@@ -92,21 +92,12 @@ export interface HostDeps {
canEditCosts(tripId: number, userId: number): boolean;
/** A trip's packing items visible to `userId` (#858 private-item filter), for `packing.list`. */
/** A trip's files (trash excluded), for `files.list`. */
listTripFiles(tripId: number): unknown[];
/** One trip file's bytes as base64 (size-capped), for `files.getContent`. Throws if it's not on the trip or exceeds the cap. */
getTripFileContent(tripId: number, fileId: number): unknown;
// --- Files write (the app's file_upload / file_edit / file_delete permissions) ---
canUploadFiles(tripId: number, userId: number): boolean;
canEditFiles(tripId: number, userId: number): boolean;
canDeleteFiles(tripId: number, userId: number): boolean;
/** Store a bounded base64 payload as a trip file (extension + size validated); broadcasts file:created. */
createTripFile(tripId: number, input: Record<string, unknown>, actingUserId: number): unknown;
/** Link an existing trip file to a reservation/place/assignment on the SAME trip. */
createTripFileLink(tripId: number, fileId: number, opts: Record<string, unknown>): unknown;
/** Update a file's description/links (same-trip targets enforced); broadcasts file:updated. */
updateTripFile(tripId: number, fileId: number, input: Record<string, unknown>): unknown;
/** Move a trip file to the trash; broadcasts file:deleted. */
softDeleteTripFile(tripId: number, fileId: number): unknown;
// --- Collab reads (membership + collab addon; no separate right, like the REST GETs) ---
listCollabNotes(tripId: number): unknown[];
listCollabPolls(tripId: number): unknown[];
@@ -315,58 +306,7 @@ export class PluginRpcHost {
// The trip's member roster (ids + display fields only), membership-checked.
this.methods.set('trips.members', (p, uid) => this.tripRead(p, uid, () => deps.tripMembers(num(p.tripId, 'tripId'))));
}
if (has('db:read:files')) {
// Trip files, trash excluded — same view the files tab shows.
this.methods.set('files.list', (p, uid) =>
this.tripRead(p, uid, () => deps.listTripFiles(num(p.tripId, 'tripId'))),
);
}
if (has('db:read:files:content')) {
// Reading a file's BYTES is a step up from its metadata (a passport scan is
// more sensitive than its filename), so it's a separate grant. Membership-
// checked like files.list; the wiring caps the size before base64-ing it
// through the IPC pipe.
this.methods.set('files.getContent', (p, uid) =>
this.tripRead(p, uid, () => deps.getTripFileContent(num(p.tripId, 'tripId'), num(p.fileId, 'fileId'))),
);
}
if (has('db:write:files')) {
// Files write, under the app's separate file_upload / file_edit / file_delete
// rights. A created file arrives as bounded base64 (10MB decoded cap — well
// under the app's 50MB upload limit); the wiring validates the extension
// against the central blocklist before anything touches disk.
this.methods.set('files.create', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const actor = this.requireActor(uid, 'file');
const input = asPayload(p.input);
if (typeof input.name !== 'string' || input.name.trim() === '' || input.name.length > 255) throw new BadParams('file name is required (max 255 chars)');
if (typeof input.content_base64 !== 'string' || input.content_base64 === '') throw new BadParams('content_base64 is required');
if (input.content_base64.length > 14 * 1024 * 1024) throw new BadParams('file exceeds the 10MB plugin upload cap');
this.requireTripEdit(tripId, actor, deps.canUploadFiles);
return deps.createTripFile(tripId, input, actor);
});
this.methods.set('files.createLink', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const fileId = num(p.fileId, 'fileId');
const actor = this.requireActor(uid, 'file link');
this.requireTripEdit(tripId, actor, deps.canEditFiles);
return deps.createTripFileLink(tripId, fileId, asPayload(p.opts));
});
this.methods.set('files.update', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const fileId = num(p.fileId, 'fileId');
const actor = this.requireActor(uid, 'file');
this.requireTripEdit(tripId, actor, deps.canEditFiles);
return deps.updateTripFile(tripId, fileId, asPayload(p.input));
});
this.methods.set('files.softDelete', (p, uid) => {
const tripId = num(p.tripId, 'tripId');
const fileId = num(p.fileId, 'fileId');
const actor = this.requireActor(uid, 'file');
this.requireTripEdit(tripId, actor, deps.canDeleteFiles);
return deps.softDeleteTripFile(tripId, fileId);
});
}
// files.* now lives in src/nest/files/files.rpc.ts.
if (has('db:write:collab')) {
// Collab content (notes/polls/messages) under the app's collab_edit right.
this.methods.set('collab.createNote', (p, uid) => {
+2
View File
@@ -7,6 +7,7 @@ import { ExchangeRatesRpc } from '../../src/nest/budget/exchange-rates.rpc';
import { TodoRpc } from '../../src/nest/todo/todo.rpc';
import { DayNotesRpc } from '../../src/nest/days/day-notes.rpc';
import { PackingRpc } from '../../src/nest/packing/packing.rpc';
import { FilesRpc } from '../../src/nest/files/files.rpc';
/**
* The stubbed HostDeps every plugin router test builds on. It used to live inside
@@ -188,5 +189,6 @@ export function allRpcControllers(): object[] {
new TodoRpc(anyService(), anyService(), anyService()),
new DayNotesRpc(anyService(), anyService(), anyService()),
new PackingRpc(anyService(), anyService(), anyService()),
new FilesRpc(anyService(), anyService(), anyService(), anyService()),
];
}
+239
View File
@@ -0,0 +1,239 @@
/**
* The file plugin surface after it moved onto @PluginMethod.
*
* Files are the only plugin surface that touches disk and the only one that uses
* three separate rights, so the cases here lean on the upload path: the extension
* blocklist, the size cap, the demo-mode refusal, and that each operation asks for
* its own permission rather than one shared domain action.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { PluginRpcHost } from '../../../src/nest/plugins/host/rpc-host';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
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 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';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import type { RpcRequest, RpcError, RpcResponse } from '../../../src/nest/plugins/protocol/envelope';
import { makeDeps } from '../../helpers/rpc-host-deps';
const req = (method: string, params: Record<string, unknown> = {}): RpcRequest => ({ k: 'req', id: 'x', method, params });
const b64 = (s: string) => Buffer.from(s).toString('base64');
let tmpDir: string;
/** File 2 sits on trip 1, which belongs to user 42. */
function build(opts: { file?: Record<string, unknown> | undefined; foreign?: string | null; allow?: (a: string) => boolean } = {}) {
const realtime = { broadcast: vi.fn() } as unknown as RealtimeService & { broadcast: ReturnType<typeof vi.fn> };
const files = {
listFiles: vi.fn(() => [{ id: 2, filename: 'visa.pdf' }]),
getFileById: vi.fn((id: number) =>
id === 2 ? (opts.file ?? { filename: 'visa.pdf', original_name: 'visa.pdf', mime_type: 'application/pdf', file_size: 2, deleted_at: null }) : undefined,
),
resolveFilePath: vi.fn((name: string) => ({ resolved: path.join(tmpDir, name), safe: true })),
findForeignLinkTarget: vi.fn(() => opts.foreign ?? null),
createFile: vi.fn((_t: number, meta: Record<string, unknown>) => ({ id: 130, ...meta })),
createFileLink: vi.fn(() => [{ file_id: 2 }]),
updateFile: vi.fn((id: number) => ({ id })),
softDeleteFile: vi.fn(),
} as unknown as FilesService & Record<string, ReturnType<typeof vi.fn>>;
const db = {
canAccessTrip: vi.fn((tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined)),
prepare: vi.fn(() => ({ get: () => ({ role: 'user', email: 'real@example.test' }) })),
} as unknown as DatabaseService;
const permissions = {
checkPermission: vi.fn((action: string) => (opts.allow ? opts.allow(action) : true)),
} as unknown as PermissionsService;
const guards = new PluginGuards(db, permissions, { isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService);
const rpc = new FilesRpc(files, realtime, db, guards);
const host = (...grants: string[]) => new PluginRpcHost('p', new Set(grants), makeDeps(), createTestPluginRegistry([rpc]));
return { files, realtime, permissions, host };
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-files-rpc-'));
fs.writeFileSync(path.join(tmpDir, 'visa.pdf'), 'hi');
});
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
describe('FilesRpc reads', () => {
it('FILES-RPC-001 files.list is membership-checked and excludes the trash', async () => {
const f = build();
const host = f.host('db:read:files');
expect((await host.dispatch(req('files.list', { tripId: 1 }), 42)).ok).toBe(true);
expect(f.files.listFiles).toHaveBeenCalledWith(1, false);
expect(((await host.dispatch(req('files.list', { tripId: 2 }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
});
it('FILES-RPC-002 reading BYTES is a separate grant from reading metadata', async () => {
const f = build();
// db:read:files alone must not unlock the content.
const denied = (await f.host('db:read:files').dispatch(req('files.getContent', { tripId: 1, fileId: 2 }), 42)) as RpcError;
expect(denied.error.code).toBe('PERMISSION_DENIED');
const ok = (await f.host('db:read:files:content').dispatch(req('files.getContent', { tripId: 1, fileId: 2 }), 42)) as RpcResponse;
expect(ok.ok).toBe(true);
expect(ok.result).toMatchObject({ name: 'visa.pdf', mimetype: 'application/pdf', content_base64: b64('hi') });
});
it('FILES-RPC-003 a trashed file is refused like the download path', async () => {
const f = build({ file: { filename: 'visa.pdf', original_name: 'visa.pdf', mime_type: null, file_size: 2, deleted_at: '2027-01-01' } });
const res = (await f.host('db:read:files:content').dispatch(req('files.getContent', { tripId: 1, fileId: 2 }), 42)) as RpcError;
expect(res.error.message).toBe('no file 2 on trip 1');
});
it('FILES-RPC-004 the size is capped BEFORE the read, not after', async () => {
const f = build({ file: { filename: 'visa.pdf', original_name: 'visa.pdf', mime_type: null, file_size: 400 * 1024 * 1024, deleted_at: null } });
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('BAD_PARAMS');
// The path is never even resolved for an oversized file.
expect(f.files.resolveFilePath).not.toHaveBeenCalled();
});
it('FILES-RPC-005 a path outside the uploads root is refused', async () => {
const f = build();
(f.files.resolveFilePath as ReturnType<typeof vi.fn>).mockReturnValueOnce({ resolved: '/etc/passwd', safe: false });
const res = (await f.host('db:read:files:content').dispatch(req('files.getContent', { tripId: 1, fileId: 2 }), 42)) as RpcError;
expect(res.error.message).toBe('file path is not accessible');
});
});
describe('FilesRpc writes', () => {
it('FILES-RPC-006 each operation asks for its own right', async () => {
const seen: string[] = [];
const f = build({ allow: (a) => { seen.push(a); return true; } });
const host = f.host('db:write:files');
await host.dispatch(req('files.create', { tripId: 1, input: { name: 'a.pdf', content_base64: b64('x') } }), 42);
await host.dispatch(req('files.update', { tripId: 1, fileId: 2, input: {} }), 42);
await host.dispatch(req('files.softDelete', { tripId: 1, fileId: 2 }), 42);
expect(seen).toEqual(['file_upload', 'file_edit', 'file_delete']);
});
it('FILES-RPC-007 a blocked extension never reaches disk', async () => {
const f = build();
const res = (await f.host('db:write:files').dispatch(req('files.create', { tripId: 1, input: { name: 'evil.exe', content_base64: b64('MZ') } }), 42)) as RpcError;
expect(res.error.code).toBe('BAD_PARAMS');
expect(res.error.message).toBe("file extension '.exe' is not allowed");
expect(f.files.createFile).not.toHaveBeenCalled();
});
it('FILES-RPC-008 a file with no extension at all is refused', async () => {
const f = build();
const res = (await f.host('db:write:files').dispatch(req('files.create', { tripId: 1, input: { name: 'README', content_base64: b64('x') } }), 42)) as RpcError;
expect(res.error.message).toBe("file extension '(none)' is not allowed");
});
it('FILES-RPC-009 name and content are required, and the name is length-capped', async () => {
const f = build();
const host = f.host('db:write:files');
const bad = async (input: Record<string, unknown>) => ((await host.dispatch(req('files.create', { tripId: 1, input }), 42)) as RpcError).error.message;
expect(await bad({ content_base64: b64('x') })).toBe('file name is required (max 255 chars)');
expect(await bad({ name: 'x'.repeat(256), content_base64: b64('x') })).toBe('file name is required (max 255 chars)');
expect(await bad({ name: 'a.pdf' })).toBe('content_base64 is required');
expect(await bad({ name: 'a.pdf', content_base64: '' })).toBe('content_base64 is required');
});
it('FILES-RPC-010 an oversized payload is rejected on its ENCODED length', async () => {
const f = build();
const res = (await f.host('db:write:files').dispatch(
req('files.create', { tripId: 1, input: { name: 'big.pdf', content_base64: 'A'.repeat(15 * 1024 * 1024) } }), 42,
)) as RpcError;
expect(res.error.message).toBe('file exceeds the 10MB plugin upload cap');
});
it('FILES-RPC-011 empty decoded content is refused', async () => {
const f = build();
const res = (await f.host('db:write:files').dispatch(req('files.create', { tripId: 1, input: { name: 'a.pdf', content_base64: '====' } }), 42)) as RpcError;
expect(res.error.message).toBe('file content is empty');
});
it('FILES-RPC-012 a link target on another trip is refused, for create and update alike', async () => {
const f = build({ foreign: 'reservation 9' });
const host = f.host('db:write:files');
const created = (await host.dispatch(req('files.create', { tripId: 1, input: { name: 'a.pdf', content_base64: b64('x'), reservation_id: 9 } }), 42)) as RpcError;
expect(created.error.message).toBe('reservation 9 does not belong to trip 1');
const updated = (await host.dispatch(req('files.update', { tripId: 1, fileId: 2, input: { reservation_id: 9 } }), 42)) as RpcError;
expect(updated.error.message).toBe('reservation 9 does not belong to trip 1');
});
it('FILES-RPC-013 update tells null from undefined, so a link can be cleared', async () => {
const f = build();
const host = f.host('db:write:files');
await host.dispatch(req('files.update', { tripId: 1, fileId: 2, input: { place_id: null } }), 42);
expect(f.files.updateFile).toHaveBeenLastCalledWith(2, expect.anything(), expect.objectContaining({ place_id: null }));
await host.dispatch(req('files.update', { tripId: 1, fileId: 2, input: { description: 'x' } }), 42);
expect(f.files.updateFile).toHaveBeenLastCalledWith(2, expect.anything(), expect.objectContaining({ place_id: undefined }));
});
it('FILES-RPC-014 a missing file is RESOURCE_FORBIDDEN across every write', async () => {
const f = build();
const host = f.host('db:write:files');
for (const [method, params] of [
['files.createLink', { tripId: 1, fileId: 404, opts: {} }],
['files.update', { tripId: 1, fileId: 404, input: {} }],
['files.softDelete', { tripId: 1, fileId: 404 }],
] as const) {
const res = (await host.dispatch(req(method, params), 42)) as RpcError;
expect(res.error.message).toBe('no file 404 on trip 1');
}
});
it('FILES-RPC-015 a userless write is refused before anything else happens', async () => {
const f = build();
const res = (await f.host('db:write:files').dispatch(req('files.create', { tripId: 1, input: { name: 'a.pdf', content_base64: b64('x') } }), undefined)) as RpcError;
expect(res.error.message).toBe('file writes require an authenticated user context');
expect(f.files.createFile).not.toHaveBeenCalled();
});
it('FILES-RPC-016 a successful write broadcasts, a refused one does not', async () => {
const f = build();
const host = f.host('db:write:files');
await host.dispatch(req('files.softDelete', { tripId: 1, fileId: 2 }), 42);
expect(f.realtime.broadcast).toHaveBeenCalledWith(1, 'file:deleted', { fileId: 2 }, undefined);
f.realtime.broadcast.mockClear();
await host.dispatch(req('files.softDelete', { tripId: 1, fileId: 404 }), 42);
expect(f.realtime.broadcast).not.toHaveBeenCalled();
});
it('FILES-RPC-016b a demo account cannot upload while demo mode is on, other members can', async () => {
const prev = process.env.DEMO_MODE;
process.env.DEMO_MODE = 'true';
try {
const f = build();
// The demo guard resolves the uploader's email; user 9 is the demo account.
(f.files as unknown as { getFileById: unknown });
const db = {
canAccessTrip: vi.fn(() => ({ id: 1, user_id: 42 })),
prepare: vi.fn(() => ({ get: (id: number) => (id === 9 ? { role: 'user', email: 'demo@trek.app' } : { role: 'user', email: 'real@example.test' }) })),
} as unknown as DatabaseService;
const guards = new PluginGuards(
db,
{ checkPermission: vi.fn(() => true) } as unknown as PermissionsService,
{ isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService,
);
const files = {
findForeignLinkTarget: vi.fn(() => null),
createFile: vi.fn(() => ({ id: 130 })),
} as unknown as FilesService;
const rpc = new FilesRpc(files, { broadcast: vi.fn() } as unknown as RealtimeService, db, guards);
const host = new PluginRpcHost('p', new Set(['db:write:files']), makeDeps(), createTestPluginRegistry([rpc]));
const input = { name: 'a.pdf', content_base64: b64('x') };
const denied = (await host.dispatch(req('files.create', { tripId: 1, input }), 9)) as RpcError;
expect(denied.error.message).toBe('Uploads are disabled in demo mode.');
expect((await host.dispatch(req('files.create', { tripId: 1, input }), 42)).ok).toBe(true);
} finally {
if (prev === undefined) delete process.env.DEMO_MODE;
else process.env.DEMO_MODE = prev;
}
});
it('FILES-RPC-017 the class is listed in its module providers', () => {
expect(Reflect.getMetadata('providers', FilesModule) as unknown[]).toContain(FilesRpc);
});
});
@@ -626,7 +626,7 @@ describe('host-deps factory — reservations, day notes, cross-trip + addon read
expect(r.result).toHaveLength(1);
});
it('wave-13 wiring: collab reads, journal entries, atlas bucket, file content and trip create delegate correctly', async () => {
it('wave-13 wiring: collab reads, journal entries, atlas bucket and trip create delegate correctly', async () => {
// collab reads delegate to collabService and require the collab addon
const collab = host('db:read:collab');
expect((await call(collab, 'collab.listNotes', { tripId: 1 })).result).toEqual([{ id: 1, trip_id: 1, title: 'Note' }]);
@@ -640,14 +640,6 @@ describe('host-deps factory — reservations, day notes, cross-trip + addon read
// atlas.bucketList
const atlas = host('db:read:atlas');
expect((await call(atlas, 'atlas.bucketList', {})).result).toEqual([{ id: 5, user_id: 5, name: 'Kyoto' }]);
// files.getContent: size-capped (file 500 = 400MB -> BAD_PARAMS), 404 refused
fs.mkdirSync(testFilesDir, { recursive: true });
fs.writeFileSync(`${testFilesDir}/visa.pdf`, 'hi');
const files = host('db:read:files:content');
const content = await call(files, 'files.getContent', { tripId: 1, fileId: 2 });
expect(content.result).toMatchObject({ name: 'visa.pdf', mimetype: 'application/pdf', content_base64: Buffer.from('hi').toString('base64') });
expect((await call(files, 'files.getContent', { tripId: 1, fileId: 500 })).error.code).toBe('BAD_PARAMS');
expect((await call(files, 'files.getContent', { tripId: 1, fileId: 404 })).error.code).toBe('RESOURCE_FORBIDDEN');
// trips.create: owner = acting user, delegates to createTrip; validation error mapped
const create = host('db:create:trips');
expect((await call(create, 'trips.create', { input: { title: 'Japan' } })).result).toMatchObject({ id: 99, user_id: 5, title: 'Japan' });
@@ -795,44 +787,8 @@ describe('host-deps factory — Wave 3 wiring (files write / collab / member-add
beforeEach(() => { checkPermission.mockReset(); checkPermission.mockReturnValue(true); isAddonEnabled.mockReset(); isAddonEnabled.mockReturnValue(true); broadcast.mockClear() })
afterAll(() => closePluginDataDb('w3'))
it('files.create writes bytes, blocks bad extensions + foreign targets, broadcasts file:created', async () => {
const h = host('db:write:files')
const good = await call(h, 'files.create', { tripId: 1, input: { name: 'plan.pdf', content_base64: Buffer.from('hello').toString('base64') } })
expect(good.ok).toBe(true)
expect(broadcast.mock.calls.some((c) => c[1] === 'file:created')).toBe(true)
expect(((await call(h, 'files.create', { tripId: 1, input: { name: 'evil.exe', content_base64: 'aGk=' } })) as { error: { code: string } }).error.code).toBe('BAD_PARAMS')
expect(((await call(h, 'files.create', { tripId: 1, input: { name: 'noext', content_base64: 'aGk=' } })) as { error: { code: string } }).error.code).toBe('BAD_PARAMS')
expect(((await call(h, 'files.create', { tripId: 1, input: { name: 'a.pdf', content_base64: 'aGk=', reservation_id: 999 } })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
})
it('files.create blocks a demo user while DEMO_MODE is on, but not other members', async () => {
const prev = process.env.DEMO_MODE
process.env.DEMO_MODE = 'true'
try {
const h = host('db:write:files')
// user 77 is the demo account (demo@trek.app) → the plugin upload is refused
const denied = await call(h, 'files.create', { tripId: 1, input: { name: 'demo.pdf', content_base64: Buffer.from('x').toString('base64') } }, 77)
expect((denied as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
// a normal member (user 5, no demo email) is unaffected
const ok = await call(h, 'files.create', { tripId: 1, input: { name: 'ok.pdf', content_base64: Buffer.from('x').toString('base64') } }, 5)
expect(ok.ok).toBe(true)
} finally {
if (prev === undefined) delete process.env.DEMO_MODE; else process.env.DEMO_MODE = prev
}
})
it('files link/update/softDelete verify the file is on the trip + same-trip targets', async () => {
const h = host('db:write:files')
expect((await call(h, 'files.createLink', { tripId: 1, fileId: 130, opts: { place_id: 7 } })).ok).toBe(true)
expect(((await call(h, 'files.createLink', { tripId: 1, fileId: 404, opts: {} })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
expect(((await call(h, 'files.createLink', { tripId: 1, fileId: 130, opts: { place_id: 999 } })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
expect((await call(h, 'files.update', { tripId: 1, fileId: 130, input: { description: 'new' } })).ok).toBe(true)
expect(broadcast.mock.calls.some((c) => c[1] === 'file:updated')).toBe(true)
expect((await call(h, 'files.softDelete', { tripId: 1, fileId: 130 })).ok).toBe(true)
expect(broadcast.mock.calls.some((c) => c[1] === 'file:deleted')).toBe(true)
expect(((await call(h, 'files.softDelete', { tripId: 1, fileId: 404 })) as { error: { code: string } }).error.code).toBe('RESOURCE_FORBIDDEN')
})
// The files write deps left this factory with the decorator migration; the cases
// now run in tests/unit/files/files.rpc.test.ts against FilesRpc.
it('collab writes delegate + broadcast; service errors map to BAD_PARAMS; addon gated', async () => {
const h = host('db:write:collab')
expect((await call(h, 'collab.createNote', { tripId: 1, input: { title: 'Ideas' } })).ok).toBe(true)
@@ -54,8 +54,8 @@ describe('plugin RPC coverage ledger', () => {
// Update these two numbers in every rollout PR. They make the diff state the size
// of the move out loud, which is the number a reviewer wants to see.
const total = KNOWN_METHODS.length + UNCONDITIONAL_METHODS.length;
expect(fromRegistry.size).toBe(24);
expect(legacy.size).toBe(total - 24);
expect(fromRegistry.size).toBe(30);
expect(legacy.size).toBe(total - 30);
});
it('RPCLEDGER-006 the migrated methods are exactly the ones this PR claims', () => {
@@ -65,6 +65,12 @@ describe('plugin RPC coverage ledger', () => {
'daynotes.delete',
'daynotes.list',
'daynotes.update',
'files.create',
'files.createLink',
'files.getContent',
'files.list',
'files.softDelete',
'files.update',
'packing.create',
'packing.createBag',
'packing.delete',
+2 -27
View File
@@ -277,7 +277,7 @@ describe('PluginRpcHost — capability enforcement', () => {
}
});
it('wave-13 reads: collab / journal.getEntries / atlas.bucketList / files.getContent / rates / trips.create gated correctly', async () => {
it('wave-13 reads: collab / journal.getEntries / atlas.bucketList / trips.create gated correctly', async () => {
// collab reads: membership-checked, need db:read:collab
const collab = new PluginRpcHost('p', new Set(['db:read:collab']), deps);
expect(ok(await collab.dispatch(req('collab.listNotes', { tripId: 1 }), 42))).toBe(true);
@@ -291,13 +291,6 @@ describe('PluginRpcHost — capability enforcement', () => {
const atlas = new PluginRpcHost('p', new Set(['db:read:atlas']), deps);
expect(ok(await atlas.dispatch(req('atlas.bucketList'), 42))).toBe(true);
expect((await atlas.dispatch(req('atlas.bucketList'), undefined)).ok).toBe(false);
// files.getContent: separate grant from files.list, membership-checked
const files = new PluginRpcHost('p', new Set(['db:read:files:content']), deps);
expect(ok(await files.dispatch(req('files.getContent', { tripId: 1, fileId: 2 }), 42))).toBe(true);
expect((await files.dispatch(req('files.getContent', { tripId: 2, fileId: 2 }), 42)).ok).toBe(false);
// reading content is NOT unlocked by plain db:read:files
const filesList = new PluginRpcHost('p', new Set(['db:read:files']), deps);
expect((await filesList.dispatch(req('files.getContent', { tripId: 1, fileId: 2 }), 42) as RpcError).error.code).toBe('PERMISSION_DENIED');
// trips.create: needs db:create:trips + the acting user's trip_create + a bound user
const create = new PluginRpcHost('p', new Set(['db:create:trips']), deps);
expect(ok(await create.dispatch(req('trips.create', { input: { title: 'Japan' } }), 42))).toBe(true);
@@ -366,25 +359,7 @@ describe('PluginRpcHost — capability enforcement', () => {
expect((await noGrant.dispatch(req('collections.create', { input: { name: 'x' } }), 42)).ok).toBe(false);
});
it('files.create validates name/content/size and is gated by file_upload', async () => {
const host = new PluginRpcHost('p', new Set(['db:write:files']), deps);
const good = await host.dispatch(req('files.create', { tripId: 1, input: { name: 'itinerary.pdf', content_base64: 'aGk=' } }), 42);
expect(ok(good)).toBe(true);
expect(deps.createTripFile).toHaveBeenCalledWith(1, expect.objectContaining({ name: 'itinerary.pdf' }), 42);
expect((await host.dispatch(req('files.create', { tripId: 1, input: { name: '', content_base64: 'aGk=' } }), 42)).ok).toBe(false);
expect((await host.dispatch(req('files.create', { tripId: 1, input: { name: 'a.pdf' } }), 42)).ok).toBe(false); // no content
expect(((await host.dispatch(req('files.create', { tripId: 2, input: { name: 'a.pdf', content_base64: 'aGk=' } }), 42)) as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
expect((await host.dispatch(req('files.create', { tripId: 1, input: { name: 'a.pdf', content_base64: 'aGk=' } }), undefined)).ok).toBe(false);
});
it('files link/update/softDelete run under file_edit / file_delete', async () => {
const host = new PluginRpcHost('p', new Set(['db:write:files']), deps);
expect(ok(await host.dispatch(req('files.createLink', { tripId: 1, fileId: 130, opts: { place_id: 7 } }), 42))).toBe(true);
expect(ok(await host.dispatch(req('files.update', { tripId: 1, fileId: 130, input: { description: 'x' } }), 42))).toBe(true);
expect(ok(await host.dispatch(req('files.softDelete', { tripId: 1, fileId: 130 }), 42))).toBe(true);
expect(deps.canDeleteFiles).toHaveBeenCalled();
});
// The files cases moved to tests/unit/files/files.rpc.test.ts with the handlers.
it('collab writes are gated by collab_edit and validate their inputs', async () => {
const host = new PluginRpcHost('p', new Set(['db:write:collab']), deps);
expect(ok(await host.dispatch(req('collab.createNote', { tripId: 1, input: { title: 'Ideas' } }), 42))).toBe(true);