diff --git a/server/src/nest/files/files.module.ts b/server/src/nest/files/files.module.ts index a307bb3f7..fd0277209 100644 --- a/server/src/nest/files/files.module.ts +++ b/server/src/nest/files/files.module.ts @@ -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 {} diff --git a/server/src/nest/files/files.rpc.ts b/server/src/nest/files/files.rpc.ts new file mode 100644 index 000000000..de235b402 --- /dev/null +++ b/server/src/nest/files/files.rpc.ts @@ -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, 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, 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 { + 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, 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, 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, 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, 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 }; + } +} diff --git a/server/src/nest/plugins/host/plugin-host-deps.factory.ts b/server/src/nest/plugins/host/plugin-host-deps.factory.ts index a1a538ecf..c358fc55d 100644 --- a/server/src/nest/plugins/host/plugin-host-deps.factory.ts +++ b/server/src/nest/plugins/host/plugin-host-deps.factory.ts @@ -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[]; }, diff --git a/server/src/nest/plugins/host/rpc-host.ts b/server/src/nest/plugins/host/rpc-host.ts index 0c15c09b6..d88cbf8f8 100644 --- a/server/src/nest/plugins/host/rpc-host.ts +++ b/server/src/nest/plugins/host/rpc-host.ts @@ -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, actingUserId: number): unknown; /** Link an existing trip file to a reservation/place/assignment on the SAME trip. */ - createTripFileLink(tripId: number, fileId: number, opts: Record): unknown; /** Update a file's description/links (same-trip targets enforced); broadcasts file:updated. */ - updateTripFile(tripId: number, fileId: number, input: Record): 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) => { diff --git a/server/tests/helpers/rpc-host-deps.ts b/server/tests/helpers/rpc-host-deps.ts index cc2eed9be..9c4153886 100644 --- a/server/tests/helpers/rpc-host-deps.ts +++ b/server/tests/helpers/rpc-host-deps.ts @@ -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()), ]; } diff --git a/server/tests/unit/files/files.rpc.test.ts b/server/tests/unit/files/files.rpc.test.ts new file mode 100644 index 000000000..b61553f70 --- /dev/null +++ b/server/tests/unit/files/files.rpc.test.ts @@ -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 = {}): 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 | undefined; foreign?: string | null; allow?: (a: string) => boolean } = {}) { + const realtime = { broadcast: vi.fn() } as unknown as RealtimeService & { broadcast: ReturnType }; + 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) => ({ id: 130, ...meta })), + createFileLink: vi.fn(() => [{ file_id: 2 }]), + updateFile: vi.fn((id: number) => ({ id })), + softDeleteFile: vi.fn(), + } as unknown as FilesService & Record>; + 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).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) => ((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); + }); +}); diff --git a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts index d5c01d5ef..77d928f04 100644 --- a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts +++ b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts @@ -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) diff --git a/server/tests/unit/plugins/rpc-coverage.test.ts b/server/tests/unit/plugins/rpc-coverage.test.ts index 62b9d4c6f..d846d56de 100644 --- a/server/tests/unit/plugins/rpc-coverage.test.ts +++ b/server/tests/unit/plugins/rpc-coverage.test.ts @@ -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', diff --git a/server/tests/unit/plugins/rpc-host.test.ts b/server/tests/unit/plugins/rpc-host.test.ts index 1b47c2e3d..54c93639e 100644 --- a/server/tests/unit/plugins/rpc-host.test.ts +++ b/server/tests/unit/plugins/rpc-host.test.ts @@ -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);