fix(demo): journey and collab uploads are refused for the demo account too

Six upload routes refused the shared demo account in demo mode; journey photos, videos, gallery media and covers, collab note files and chat images did not, so any visitor could put images in front of every other one for the hour. They answer the same 403 now, checked in the handler after the upload is read, and a text-only chat message still goes through. The Demo Mode wiki page lists them, and says document sync is not covered.
This commit is contained in:
Maurice
2026-10-25 00:46:23 +00:00
parent 42ed858853
commit 2372dde716
11 changed files with 395 additions and 108 deletions
@@ -1,6 +1,8 @@
import type { User } from '../../types';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { DEMO_WRITE_ERROR, isDemoWriteBlocked } from '../common/demo-write';
import { ResponseContract } from '../common/response-contract';
import { SpoolCleanupInterceptor } from '../common/spool-cleanup.interceptor';
import { BLOCKED_EXTENSIONS, MAX_FILE_SIZE } from '../files/files.constants';
@@ -130,6 +132,8 @@ export class CollabController {
constructor(
private readonly collab: CollabService,
private readonly storage: StorageService,
// Whether DEMO_MODE is on, read on every upload: the demo account's are refused.
private readonly env: RuntimeEnvService,
) {}
private async requireTrip(tripId: string, user: User) {
@@ -242,6 +246,12 @@ export class CollabController {
};
try {
const trip = await this.requireTrip(tripId, user);
// After the 404, as on the trip-file upload, so the demo account cannot
// learn which trips exist; refused like every other upload of it
// (common/demo-write.ts).
if (isDemoWriteBlocked(this.env, user.email)) {
throw new HttpException(DEMO_WRITE_ERROR, 403);
}
if (!(await this.collab.canUploadFiles(trip, user))) {
throw new HttpException({ error: 'No permission to upload files' }, 403);
}
@@ -491,6 +501,11 @@ export class CollabController {
cleanupSpool();
throw err;
}
// Only the images are refused to the demo account; its text still posts.
if (uploaded.length && isDemoWriteBlocked(this.env, user.email)) {
cleanupSpool();
throw new HttpException(DEMO_WRITE_ERROR, 403);
}
if (uploaded.length && !(await this.collab.canUploadFiles(trip, user))) {
cleanupSpool();
throw new HttpException({ error: 'No permission to upload files' }, 403);
+3
View File
@@ -7,6 +7,7 @@ import { CollabPolls } from '../../db/entities/CollabPolls.entity';
import { Trips } from '../../db/entities/Trips.entity';
import { PluginGuardsModule } from '../../nest-rpc/plugin-guards.module';
import { AddonsModule } from '../addons/addons.module';
import { AppConfigModule } from '../app-config/app-config.module';
import { RateLimitModule } from '../common/rate-limit.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { NotificationsModule } from '../notifications/notifications.module';
@@ -57,6 +58,8 @@ import { MulterModule } from '@nestjs/platform-express';
PluginGuardsModule,
AddonsModule,
RateLimitModule,
// RuntimeEnvService: the controller refuses the demo account's uploads in demo mode.
AppConfigModule,
],
controllers: [CollabController],
providers: [CollabService, CollabMcp, CollabRpc],
+5 -3
View File
@@ -3,7 +3,7 @@ import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { DEMO_ACCOUNT_SETTINGS, isDemoEmail } from './demo';
import { DomainError } from './domain-error';
/** The exact 403 body the six upload endpoints have always returned. */
/** The exact 403 body the upload endpoints answer the demo account with. */
export const DEMO_WRITE_ERROR = {
error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.',
} as const;
@@ -12,9 +12,11 @@ export const DEMO_WRITE_ERROR = {
* Whether this user's writes are blocked because the instance runs in demo mode.
*
* The condition was copy-pasted into six upload handlers, each with its own copy
* of the message. It lives here once now, and every one of them calls it.
* of the message. It lives here once now, and every one of them calls it, as do
* the journey photo, video and cover uploads and the collab note file and chat
* image uploads that came without it.
*
* Deliberately a function and not a guard, though all six sites are handlers and
* Deliberately a function and not a guard, though every site is a handler and
* a guard is the obvious shape. Guards run before the multipart parser, so
* throwing from one leaves the request body unread and Node resets the
* connection: the client sees ECONNRESET instead of the 403. PROFILE-015 catches
+1 -1
View File
@@ -16,7 +16,7 @@ import { Injectable } from '@nestjs/common';
* hazard, confirmed by `task-0-report.md` §6), so an injected `EntityManager`
* resolving the current request's fork is exactly the right shape.
*
* `isDemoWriteBlocked`/`DEMO_WRITE_ERROR` (the six-upload-endpoint sibling
* `isDemoWriteBlocked`/`DEMO_WRITE_ERROR` (the upload endpoints' sibling
* check) stay exactly where they are, in `common/demo-write.ts` — a
* different, pure-function check over an already-known email string that
* this task does not touch.
@@ -22,6 +22,8 @@ import path from 'node:path';
import fs from 'node:fs';
import crypto from 'node:crypto';
import type { User } from '../../types';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write';
import { StorageService } from '../storage/storage.service';
import { journeyThumbName } from '../memories/thumbnail.service';
import { JourneyService } from './journey.service';
@@ -172,6 +174,8 @@ export class JourneyController {
private readonly photoCapture: JourneyPhotoCaptureService,
// Which entries the gallery's photos sit on, told to every tab once written.
private readonly photoLinks: JourneyPhotoLinksService,
// Whether DEMO_MODE is on, read on every upload (refuseDemoUpload).
private readonly env: RuntimeEnvService,
) {}
/**
@@ -208,6 +212,22 @@ export class JourneyController {
}
}
/**
* Refuse the shared demo account's upload in demo mode, with the 403 the
* other upload routes answer (common/demo-write.ts). Called in the handler,
* once multer has drained the body, so the 403 arrives instead of an
* ECONNRESET. It runs before the commit: the spooled parts are all there is
* to take back, and a committed object would stay, since a demo instance
* keeps what is deleted (StorageService.delete).
*/
private refuseDemoUpload(user: User, files: ReadonlyArray<Express.Multer.File | undefined>): void {
if (!isDemoWriteBlocked(this.env, user.email)) return;
for (const f of files) {
if (f?.path) { try { fs.unlinkSync(f.path); } catch { /* best-effort */ } }
}
throw new HttpException(DEMO_WRITE_ERROR, 403);
}
// ── Static prefix routes (before /:id) ──────────────────────────────────
@Get()
@ResponseContract(journeyListResponseSchema)
@@ -271,6 +291,7 @@ export class JourneyController {
@Post('entries/:entryId/photos')
@UseInterceptors(FilesInterceptor('photos'))
async uploadEntryPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @UploadedFiles() files: Express.Multer.File[] | undefined, @Body() body: JourneyEntryPhotoUploadDto) {
this.refuseDemoUpload(user, files ?? []);
if (!files?.length) {
throw new HttpException({ error: 'No files uploaded' }, 400);
}
@@ -337,6 +358,7 @@ export class JourneyController {
) {
const video = files?.video?.[0];
const poster = files?.poster?.[0];
this.refuseDemoUpload(user, [video, poster]);
const cleanup = () => {
for (const f of [video, poster]) {
if (f?.path) { try { fs.unlinkSync(f.path); } catch { /* best-effort */ } }
@@ -478,6 +500,7 @@ export class JourneyController {
@Post(':id/gallery/photos')
@UseInterceptors(FilesInterceptor('photos'))
async uploadGalleryPhotos(@CurrentUser() user: User, @Param('id') id: string, @UploadedFiles() files: Express.Multer.File[] | undefined) {
this.refuseDemoUpload(user, files ?? []);
if (!files?.length) {
throw new HttpException({ error: 'No files uploaded' }, 400);
}
@@ -510,6 +533,7 @@ export class JourneyController {
) {
const video = files?.video?.[0];
const poster = files?.poster?.[0];
this.refuseDemoUpload(user, [video, poster]);
// multer already spooled both parts; clean them up on a pre-commit rejection
// so a bad POST doesn't orphan a 500 MB clip (#823).
const cleanup = () => {
@@ -580,6 +604,7 @@ export class JourneyController {
@HttpCode(200) // Express answers cover with res.json (200).
@UseInterceptors(FileInterceptor('cover'))
async cover(@CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined, @Headers('x-socket-id') socketId?: string) {
this.refuseDemoUpload(user, [file]);
if (!file) {
throw new HttpException({ error: 'No file uploaded' }, 400);
}
@@ -1,5 +1,6 @@
import { Users } from '../../db/entities/Users.entity';
import { AddonsModule } from '../addons/addons.module';
import { AppConfigModule } from '../app-config/app-config.module';
import { UserConnectionRepositoriesModule } from '../database/user-connection-repositories.module';
import { AllowedFileTypesModule } from '../files/allowed-file-types.module';
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
@@ -42,6 +43,8 @@ import { MulterModule } from '@nestjs/platform-express';
}),
StorageModule,
AddonsModule,
// RuntimeEnvService: the controller refuses the demo account's uploads in demo mode.
AppConfigModule,
MemoriesModule,
JourneyDomainModule,
JourneyPhotoCaptureModule,
+48 -1
View File
@@ -32,8 +32,9 @@ import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import path from 'node:path';
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi, type MockInstance } from 'vitest';
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
@@ -329,4 +330,50 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => {
);
});
});
describe('demo mode', () => {
// Over the wire, because a refusal from a guard would reset the connection
// while the client is still sending (PROFILE-015): the 403 has to arrive with
// its body, and nothing of the upload may be written.
const refused = { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' };
beforeAll(async () => {
// Pinned id: sessionCookie(2) signs for exactly this user, the owner of trip 6.
await makeUser(orm, { id: 2, username: 'demo', email: 'demo@trek.app', role: 'user', password_version: 0 });
await insertRow(orm, Trips, { id: 6, title: 'Demo trip', user: 2 });
});
beforeEach(() => {
vi.stubEnv('DEMO_MODE', 'true');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('403 with the demo body for a note file, and no file row', async () => {
const res = await request(server)
.post('/api/trips/6/collab/notes/1/files')
.set('Cookie', sessionCookie(2))
.attach('file', path.join(__dirname, '../fixtures/test.pdf'));
expect(res.status).toBe(403);
expect(res.body).toEqual(refused);
expect(await countRows(orm, TripFiles)).toBe(0);
});
it('403 with the demo body for a chat image, and no message row; a text message still posts', async () => {
const res = await request(server)
.post('/api/trips/6/collab/messages')
.set('Cookie', sessionCookie(2))
.field('text', 'look')
.attach('images', path.join(__dirname, '../fixtures/small-image.jpg'));
expect(res.status).toBe(403);
expect(res.body).toEqual(refused);
expect(await countRows(orm, CollabMessages)).toBe(0);
const text = await request(server)
.post('/api/trips/6/collab/messages')
.set('Cookie', sessionCookie(2))
.send({ text: 'hi' });
expect(text.status).toBe(201);
expect(text.body.message).toMatchObject({ text: 'hi', trip_id: 6, user_id: 2 });
});
});
});
+36 -1
View File
@@ -28,7 +28,7 @@ import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import path from 'node:path';
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -749,4 +749,39 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
expect(jsvc.linkPhotoToEntry).not.toHaveBeenCalled();
});
});
describe('demo mode', () => {
// Over the wire, because a refusal from a guard would reset the connection
// while the client is still sending (PROFILE-015): the 403 has to arrive with
// its body, and nothing of the upload may reach the service.
const image = path.join(__dirname, '../fixtures/small-image.jpg');
const clip = { filename: 'clip.mp4', contentType: 'video/mp4' };
beforeAll(() => {
seedUser(db as never, { id: 2, username: 'demo', email: 'demo@trek.app' });
});
beforeEach(() => {
vi.stubEnv('DEMO_MODE', 'true');
jsvc.uploadGalleryPhotos.mockClear();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it.each([
['/api/journeys/entries/3/photos', 'photos'],
['/api/journeys/entries/3/video', 'video'],
['/api/journeys/9/gallery/photos', 'photos'],
['/api/journeys/9/gallery/video', 'video'],
['/api/journeys/9/cover', 'cover'],
])('JOURNEY-E2E-020: POST %s answers the demo account 403 with the demo body', async (url, field) => {
const req = request(server).post(url).set('Cookie', sessionCookie(2));
const res = await (field === 'video'
? req.attach(field, Buffer.from('not a clip'), clip)
: req.attach(field, image));
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' });
expect(jsvc.uploadGalleryPhotos).not.toHaveBeenCalled();
});
});
});
+182 -100
View File
@@ -1,3 +1,4 @@
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
import {
CollabController,
@@ -14,7 +15,7 @@ import { HttpException } from '@nestjs/common';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
@@ -49,6 +50,14 @@ const storageStub = {
delete: vi.fn().mockResolvedValue(undefined),
} as unknown as StorageService;
/**
* Build the controller over its stubs. The env is the live one, so a case about
* demo mode stubs DEMO_MODE and every other case runs outside it.
*/
function ctl(service: CollabService, storage: StorageService = storageStub): CollabController {
return new CollabController(service, storage, new RuntimeEnvService());
}
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
try {
await fn();
@@ -66,15 +75,15 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
describe('notes', () => {
it('GET lists', async () => {
const s = svc({ listNotes: vi.fn().mockResolvedValue([{ id: 1 }]) } as Partial<CollabService>);
expect(await new CollabController(s, storageStub).listNotes(user, '5')).toEqual({ notes: [{ id: 1 }] });
expect(await ctl(s, storageStub).listNotes(user, '5')).toEqual({ notes: [{ id: 1 }] });
});
it('POST creates through the posting use case, which broadcasts and notifies (empty title now 400s in the Zod pipe)', async () => {
const createNote = vi.fn().mockReturnValue({ id: 9 });
const s = svc({ createNote } as Partial<CollabService>);
expect(
await new CollabController(s, storageStub).createNote(user, '5', { title: 'T', content: 'c' }, 'sock'),
).toEqual({ note: { id: 9 } });
expect(await ctl(s, storageStub).createNote(user, '5', { title: 'T', content: 'c' }, 'sock')).toEqual({
note: { id: 9 },
});
const fields = { title: 'T', content: 'c', category: undefined, color: undefined, website: undefined };
expect(createNote).toHaveBeenCalledWith('5', 1, fields);
expect(s.restWriter).toHaveBeenCalledWith('5', user, 'sock');
@@ -86,15 +95,17 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
it('PUT 404 when missing, else updates + broadcasts', async () => {
expect(
await thrownAsync(() =>
new CollabController(
svc({ updateNote: vi.fn().mockResolvedValue(null) } as Partial<CollabService>),
storageStub,
).updateNote(user, '5', '9', {}),
ctl(svc({ updateNote: vi.fn().mockResolvedValue(null) } as Partial<CollabService>), storageStub).updateNote(
user,
'5',
'9',
{},
),
),
).toEqual({ status: 404, body: { error: 'Note not found' } });
const broadcast = vi.fn();
const s = svc({ updateNote: vi.fn().mockResolvedValue({ id: 9 }), broadcast } as Partial<CollabService>);
expect(await new CollabController(s, storageStub).updateNote(user, '5', '9', { title: 'b' }, 'sock')).toEqual({
expect(await ctl(s, storageStub).updateNote(user, '5', '9', { title: 'b' }, 'sock')).toEqual({
note: { id: 9 },
});
expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:updated', { note: { id: 9 } }, 'sock');
@@ -103,15 +114,16 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
it('DELETE 404 when missing, else success + broadcasts', async () => {
expect(
await thrownAsync(() =>
new CollabController(
svc({ deleteNote: vi.fn().mockResolvedValue(false) } as Partial<CollabService>),
storageStub,
).deleteNote(user, '5', '9'),
ctl(svc({ deleteNote: vi.fn().mockResolvedValue(false) } as Partial<CollabService>), storageStub).deleteNote(
user,
'5',
'9',
),
),
).toEqual({ status: 404, body: { error: 'Note not found' } });
const broadcast = vi.fn();
const s = svc({ deleteNote: vi.fn().mockResolvedValue(true), broadcast } as Partial<CollabService>);
expect(await new CollabController(s, storageStub).deleteNote(user, '5', '9', 'sock')).toEqual({ success: true });
expect(await ctl(s, storageStub).deleteNote(user, '5', '9', 'sock')).toEqual({ success: true });
expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:deleted', { noteId: 9 }, 'sock');
});
});
@@ -121,24 +133,22 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
it('403 without file_upload, 400 without file, 404 unknown note, else commits + returns result', async () => {
expect(
await thrownAsync(() =>
new CollabController(svc({ canUploadFiles: vi.fn().mockResolvedValue(false) }), storageStub).addNoteFile(
ctl(svc({ canUploadFiles: vi.fn().mockResolvedValue(false) }), storageStub).addNoteFile(user, '5', '9', file),
),
).toEqual({ status: 403, body: { error: 'No permission to upload files' } });
expect(await thrownAsync(() => ctl(svc(), storageStub).addNoteFile(user, '5', '9', undefined))).toEqual({
status: 400,
body: { error: 'No file uploaded' },
});
expect(
await thrownAsync(() =>
ctl(svc({ addNoteFile: vi.fn().mockResolvedValue(null) } as Partial<CollabService>), storageStub).addNoteFile(
user,
'5',
'9',
file,
),
),
).toEqual({ status: 403, body: { error: 'No permission to upload files' } });
expect(
await thrownAsync(() => new CollabController(svc(), storageStub).addNoteFile(user, '5', '9', undefined)),
).toEqual({ status: 400, body: { error: 'No file uploaded' } });
expect(
await thrownAsync(() =>
new CollabController(
svc({ addNoteFile: vi.fn().mockResolvedValue(null) } as Partial<CollabService>),
storageStub,
).addNoteFile(user, '5', '9', file),
),
).toEqual({ status: 404, body: { error: 'Note not found' } });
const broadcast = vi.fn();
const s = svc({
@@ -146,7 +156,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
getFormattedNoteById: vi.fn().mockResolvedValue({ id: 9 }),
broadcast,
} as Partial<CollabService>);
expect(await new CollabController(s, storageStub).addNoteFile(user, '5', '9', file, 'sock')).toEqual({
expect(await ctl(s, storageStub).addNoteFile(user, '5', '9', file, 'sock')).toEqual({
file: { id: 3 },
});
expect(storageStub.put).toHaveBeenCalledWith('files', 'a.pdf', { tmpPath: undefined });
@@ -161,7 +171,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
const spooled = { filename: 'a.pdf', path: spool } as Express.Multer.File;
expect(
await thrownAsync(() =>
new CollabController(svc({ canUploadFiles: vi.fn().mockResolvedValue(false) }), storageStub).addNoteFile(
ctl(svc({ canUploadFiles: vi.fn().mockResolvedValue(false) }), storageStub).addNoteFile(
user,
'5',
'9',
@@ -175,12 +185,10 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
fs.writeFileSync(spool2, 'x');
expect(
await thrownAsync(() =>
new CollabController(svc({ verifyTripAccess: vi.fn().mockResolvedValue(null) }), storageStub).addNoteFile(
user,
'5',
'9',
{ filename: 'b.pdf', path: spool2 } as Express.Multer.File,
),
ctl(svc({ verifyTripAccess: vi.fn().mockResolvedValue(null) }), storageStub).addNoteFile(user, '5', '9', {
filename: 'b.pdf',
path: spool2,
} as Express.Multer.File),
),
).toEqual({ status: 404, body: { error: 'Trip not found' } });
expect(fs.existsSync(spool2)).toBe(false);
@@ -188,10 +196,12 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
// Past the commit the spool file is gone, so the final object is what has
// to go instead.
await thrownAsync(() =>
new CollabController(
svc({ addNoteFile: vi.fn().mockResolvedValue(null) } as Partial<CollabService>),
storageStub,
).addNoteFile(user, '5', '9', file),
ctl(svc({ addNoteFile: vi.fn().mockResolvedValue(null) } as Partial<CollabService>), storageStub).addNoteFile(
user,
'5',
'9',
file,
),
);
expect(storageStub.delete).toHaveBeenCalledWith('files', 'a.pdf');
});
@@ -199,7 +209,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
it('DELETE file 404 when missing, else success', async () => {
expect(
await thrownAsync(() =>
new CollabController(
ctl(
svc({ deleteNoteFile: vi.fn().mockResolvedValue(false) } as Partial<CollabService>),
storageStub,
).deleteNoteFile(user, '5', '9', '3'),
@@ -210,22 +220,22 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
getFormattedNoteById: vi.fn().mockResolvedValue({ id: 9 }),
broadcast: vi.fn(),
} as Partial<CollabService>);
expect(await new CollabController(s, storageStub).deleteNoteFile(user, '5', '9', '3')).toEqual({ success: true });
expect(await ctl(s, storageStub).deleteNoteFile(user, '5', '9', '3')).toEqual({ success: true });
});
});
describe('polls', () => {
it('POST creates (missing question / <2 options now 400 in the Zod pipe)', async () => {
const s = svc({ createPoll: vi.fn().mockResolvedValue({ id: 7 }), broadcast: vi.fn() } as Partial<CollabService>);
expect(
await new CollabController(s, storageStub).createPoll(user, '5', { question: 'q', options: ['a', 'b'] }),
).toEqual({ poll: { id: 7 } });
expect(await ctl(s, storageStub).createPoll(user, '5', { question: 'q', options: ['a', 'b'] })).toEqual({
poll: { id: 7 },
});
});
it('vote maps not_found/closed/invalid_index, else broadcasts the poll', async () => {
expect(
await thrownAsync(() =>
new CollabController(
ctl(
svc({ votePoll: vi.fn().mockResolvedValue({ error: 'not_found' }) } as Partial<CollabService>),
storageStub,
).votePoll(user, '5', '7', { option_index: 0 }),
@@ -233,7 +243,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
).toEqual({ status: 404, body: { error: 'Poll not found' } });
expect(
await thrownAsync(() =>
new CollabController(
ctl(
svc({ votePoll: vi.fn().mockResolvedValue({ error: 'closed' }) } as Partial<CollabService>),
storageStub,
).votePoll(user, '5', '7', { option_index: 0 }),
@@ -241,7 +251,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
).toEqual({ status: 400, body: { error: 'Poll is closed' } });
expect(
await thrownAsync(() =>
new CollabController(
ctl(
svc({ votePoll: vi.fn().mockResolvedValue({ error: 'invalid_index' }) } as Partial<CollabService>),
storageStub,
).votePoll(user, '5', '7', { option_index: 9 }),
@@ -249,7 +259,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
).toEqual({ status: 400, body: { error: 'Invalid option index' } });
const broadcast = vi.fn();
const s = svc({ votePoll: vi.fn().mockResolvedValue({ poll: { id: 7 } }), broadcast } as Partial<CollabService>);
expect(await new CollabController(s, storageStub).votePoll(user, '5', '7', { option_index: 0 }, 'sock')).toEqual({
expect(await ctl(s, storageStub).votePoll(user, '5', '7', { option_index: 0 }, 'sock')).toEqual({
poll: { id: 7 },
});
expect(broadcast).toHaveBeenCalledWith('5', 'collab:poll:voted', { poll: { id: 7 } }, 'sock');
@@ -258,27 +268,29 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
it('close 404 when missing, else broadcasts', async () => {
expect(
await thrownAsync(() =>
new CollabController(
svc({ closePoll: vi.fn().mockResolvedValue(null) } as Partial<CollabService>),
storageStub,
).closePoll(user, '5', '7'),
ctl(svc({ closePoll: vi.fn().mockResolvedValue(null) } as Partial<CollabService>), storageStub).closePoll(
user,
'5',
'7',
),
),
).toEqual({ status: 404, body: { error: 'Poll not found' } });
const s = svc({ closePoll: vi.fn().mockResolvedValue({ id: 7 }), broadcast: vi.fn() } as Partial<CollabService>);
expect(await new CollabController(s, storageStub).closePoll(user, '5', '7')).toEqual({ poll: { id: 7 } });
expect(await ctl(s, storageStub).closePoll(user, '5', '7')).toEqual({ poll: { id: 7 } });
});
it('delete 404 when missing, else success', async () => {
expect(
await thrownAsync(() =>
new CollabController(
svc({ deletePoll: vi.fn().mockResolvedValue(false) } as Partial<CollabService>),
storageStub,
).deletePoll(user, '5', '7'),
ctl(svc({ deletePoll: vi.fn().mockResolvedValue(false) } as Partial<CollabService>), storageStub).deletePoll(
user,
'5',
'7',
),
),
).toEqual({ status: 404, body: { error: 'Poll not found' } });
const s = svc({ deletePoll: vi.fn().mockResolvedValue(true), broadcast: vi.fn() } as Partial<CollabService>);
expect(await new CollabController(s, storageStub).deletePoll(user, '5', '7')).toEqual({ success: true });
expect(await ctl(s, storageStub).deletePoll(user, '5', '7')).toEqual({ success: true });
});
});
@@ -288,25 +300,24 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
it('POST 400 whitespace-only, 400 reply_not_found, else creates + notifies (length checks now in the Zod pipe)', async () => {
// A request with no file part keeps the wording it always had.
expect(
await thrownAsync(() =>
new CollabController(svc(), storageStub).createMessage(user, '5', { text: ' ' }, undefined),
),
await thrownAsync(() => ctl(svc(), storageStub).createMessage(user, '5', { text: ' ' }, undefined)),
).toEqual({ status: 400, body: { error: 'Message text is required' } });
expect(
await thrownAsync(() => new CollabController(svc(), storageStub).createMessage(user, '5', { text: ' ' }, [])),
).toEqual({ status: 400, body: { error: 'Message text or image is required' } });
expect(await thrownAsync(() => ctl(svc(), storageStub).createMessage(user, '5', { text: ' ' }, []))).toEqual({
status: 400,
body: { error: 'Message text or image is required' },
});
expect(
await thrownAsync(() =>
new CollabController(
ctl(
svc({ createMessage: vi.fn().mockResolvedValue({ error: 'reply_not_found' }) } as Partial<CollabService>),
storageStub,
).createMessage(user, '5', { text: 'hi', reply_to: 99 }, undefined),
),
).toEqual({ status: 400, body: { error: 'Reply target message not found' } });
const s = svc({ createMessage: vi.fn().mockResolvedValue({ message: { id: 3 } }) } as Partial<CollabService>);
expect(
await new CollabController(s, storageStub).createMessage(user, '5', { text: 'hello' }, undefined, 'sock'),
).toEqual({ message: { id: 3 } });
expect(await ctl(s, storageStub).createMessage(user, '5', { text: 'hello' }, undefined, 'sock')).toEqual({
message: { id: 3 },
});
// The posting use case sends and notifies; the route hands it the trimmed text and its writer.
expect(s.postMessage).toHaveBeenCalledWith('5', { text: 'hello', replyTo: null, files: [] }, restWriter('sock'));
expect(s.broadcast).not.toHaveBeenCalled();
@@ -317,7 +328,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
// refusals are the only thing standing in front of the route now.
expect(
await thrownAsync(() =>
new CollabController(
ctl(
svc({ verifyTripAccess: vi.fn().mockResolvedValue(null) } as Partial<CollabService>),
storageStub,
).createMessage(user, '5', { text: 'hi' }, undefined),
@@ -325,10 +336,12 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
).toEqual({ status: 404, body: { error: 'Trip not found' } });
expect(
await thrownAsync(() =>
new CollabController(
svc({ canEdit: vi.fn().mockResolvedValue(false) } as Partial<CollabService>),
storageStub,
).createMessage(user, '5', { text: 'hi' }, undefined),
ctl(svc({ canEdit: vi.fn().mockResolvedValue(false) } as Partial<CollabService>), storageStub).createMessage(
user,
'5',
{ text: 'hi' },
undefined,
),
),
).toEqual({ status: 403, body: { error: 'No permission' } });
});
@@ -336,7 +349,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
it('react 404 unknown, else broadcasts reactions (empty emoji now 400s in the Zod pipe)', async () => {
expect(
await thrownAsync(() =>
new CollabController(
ctl(
svc({ reactMessage: vi.fn().mockResolvedValue({ found: false, reactions: [] }) } as Partial<CollabService>),
storageStub,
).react(user, '5', '3', { emoji: '👍' }),
@@ -347,7 +360,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
reactMessage: vi.fn().mockResolvedValue({ found: true, reactions: [{ emoji: '👍', count: 1 }] }),
broadcast,
} as Partial<CollabService>);
expect(await new CollabController(s, storageStub).react(user, '5', '3', { emoji: '👍' }, 'sock')).toEqual({
expect(await ctl(s, storageStub).react(user, '5', '3', { emoji: '👍' }, 'sock')).toEqual({
reactions: [{ emoji: '👍', count: 1 }],
});
expect(broadcast).toHaveBeenCalledWith(
@@ -361,7 +374,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
it('delete maps not_found (404) / not_owner (403), else success with username', async () => {
expect(
await thrownAsync(() =>
new CollabController(
ctl(
svc({ deleteMessage: vi.fn().mockResolvedValue({ error: 'not_found' }) } as Partial<CollabService>),
storageStub,
).deleteMessage(user, '5', '3'),
@@ -369,7 +382,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
).toEqual({ status: 404, body: { error: 'Message not found' } });
expect(
await thrownAsync(() =>
new CollabController(
ctl(
svc({ deleteMessage: vi.fn().mockResolvedValue({ error: 'not_owner' }) } as Partial<CollabService>),
storageStub,
).deleteMessage(user, '5', '3'),
@@ -380,7 +393,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
deleteMessage: vi.fn().mockResolvedValue({ username: 'bob' }),
broadcast,
} as Partial<CollabService>);
expect(await new CollabController(s, storageStub).deleteMessage(user, '5', '3', 'sock')).toEqual({
expect(await ctl(s, storageStub).deleteMessage(user, '5', '3', 'sock')).toEqual({
success: true,
});
expect(broadcast).toHaveBeenCalledWith('5', 'collab:message:deleted', { messageId: 3, username: 'bob' }, 'sock');
@@ -396,7 +409,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
deleteMessage: vi.fn().mockResolvedValue({ username: undefined }),
broadcast,
} as Partial<CollabService>);
expect(await new CollabController(s, storageStub).deleteMessage(user, '5', '3', 'sock')).toEqual({
expect(await ctl(s, storageStub).deleteMessage(user, '5', '3', 'sock')).toEqual({
success: true,
});
expect(broadcast).toHaveBeenCalledWith(
@@ -425,7 +438,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
const put = vi.fn().mockResolvedValue(undefined);
const createMessage = vi.fn().mockReturnValue({ message: { id: 3 } });
const s = svc({ createMessage, broadcast: vi.fn(), notifyCollab: vi.fn() } as Partial<CollabService>);
await new CollabController(s, { put, delete: vi.fn() } as never).createMessage(user, '5', { text: 'look' }, [
await ctl(s, { put, delete: vi.fn() } as never).createMessage(user, '5', { text: 'look' }, [
img('a.png'),
img('b.png'),
]);
@@ -444,10 +457,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
const s = svc({ createMessage } as Partial<CollabService>);
await expect(
new CollabController(s, { put, delete: del } as never).createMessage(user, '5', { text: 'x' }, [
img('a.png'),
img('b.png'),
]),
ctl(s, { put, delete: del } as never).createMessage(user, '5', { text: 'x' }, [img('a.png'), img('b.png')]),
).rejects.toThrow('disk full');
// The first one is already in storage and has to come back out; the
// message row was never written, so nothing would point at it.
@@ -463,7 +473,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
expect(
await thrownAsync(() =>
new CollabController(s, { put: vi.fn().mockResolvedValue(undefined), delete: del } as never).createMessage(
ctl(s, { put: vi.fn().mockResolvedValue(undefined), delete: del } as never).createMessage(
user,
'5',
{ text: 'x', reply_to: 99 },
@@ -479,15 +489,90 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
const s = svc({ canUploadFiles: vi.fn().mockResolvedValue(false) } as Partial<CollabService>);
expect(
await thrownAsync(() =>
new CollabController(s, { put, delete: vi.fn() } as never).createMessage(user, '5', { text: 'x' }, [
img('a.png'),
]),
ctl(s, { put, delete: vi.fn() } as never).createMessage(user, '5', { text: 'x' }, [img('a.png')]),
),
).toEqual({ status: 403, body: { error: 'No permission to upload files' } });
expect(put).not.toHaveBeenCalled();
});
});
describe('demo mode', () => {
// The shared demo account is refused note files and chat images with the
// 403 the other upload routes answer it (common/demo-write.ts), and what
// multer spooled is removed before anything is committed: a committed object
// would stay, since a demo instance keeps what is deleted (StorageService.delete).
const demo = { ...user, email: 'demo@trek.app' } as User;
const refused = {
status: 403,
body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' },
};
let spoolDir: string;
const spooled = (name: string): Express.Multer.File => {
const file = path.join(spoolDir, name);
fs.writeFileSync(file, 'x');
return { filename: name, originalname: name, size: 1, mimetype: 'image/png', path: file } as Express.Multer.File;
};
beforeEach(() => {
spoolDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-collab-demo-'));
});
afterEach(() => {
vi.unstubAllEnvs();
fs.rmSync(spoolDir, { recursive: true, force: true });
});
it('POST notes/:id/files: 403 for the demo account, its spooled file removed and nothing stored', async () => {
vi.stubEnv('DEMO_MODE', 'true');
const addNoteFile = vi.fn();
const s = svc({ addNoteFile } as Partial<CollabService>);
expect(await thrownAsync(() => ctl(s).addNoteFile(demo, '5', '9', spooled('a.pdf')))).toEqual(refused);
expect(fs.readdirSync(spoolDir)).toEqual([]);
expect(storageStub.put).not.toHaveBeenCalled();
expect(addNoteFile).not.toHaveBeenCalled();
});
it('POST notes/:id/files: still taken from any other account in demo mode, and from the demo account outside it', async () => {
const s = svc({
addNoteFile: vi.fn().mockResolvedValue({ file: { id: 3 } }),
getFormattedNoteById: vi.fn().mockResolvedValue({ id: 9 }),
} as Partial<CollabService>);
vi.stubEnv('DEMO_MODE', 'true');
expect(await ctl(s).addNoteFile(user, '5', '9', spooled('a.pdf'))).toEqual({ file: { id: 3 } });
vi.stubEnv('DEMO_MODE', 'false');
expect(await ctl(s).addNoteFile(demo, '5', '9', spooled('b.pdf'))).toEqual({ file: { id: 3 } });
expect(storageStub.put).toHaveBeenCalledTimes(2);
});
it('POST messages with images: 403 for the demo account, its spooled images removed and no message written', async () => {
vi.stubEnv('DEMO_MODE', 'true');
const createMessage = vi.fn();
const s = svc({ createMessage } as Partial<CollabService>);
expect(
await thrownAsync(() =>
ctl(s).createMessage(demo, '5', { text: 'look' }, [spooled('a.png'), spooled('b.png')]),
),
).toEqual(refused);
expect(fs.readdirSync(spoolDir)).toEqual([]);
expect(storageStub.put).not.toHaveBeenCalled();
expect(createMessage).not.toHaveBeenCalled();
});
it('POST messages: images still taken from any other account in demo mode and from the demo account outside it, text from the demo account always', async () => {
const s = svc({ createMessage: vi.fn().mockResolvedValue({ message: { id: 3 } }) } as Partial<CollabService>);
vi.stubEnv('DEMO_MODE', 'true');
expect(await ctl(s).createMessage(user, '5', { text: 'look' }, [spooled('a.png')])).toEqual({
message: { id: 3 },
});
// Text is not an upload: the chat stays open to the demo account.
expect(await ctl(s).createMessage(demo, '5', { text: 'hi' }, undefined)).toEqual({ message: { id: 3 } });
vi.stubEnv('DEMO_MODE', 'false');
expect(await ctl(s).createMessage(demo, '5', { text: 'look' }, [spooled('b.png')])).toEqual({
message: { id: 3 },
});
expect(storageStub.put).toHaveBeenCalledTimes(2);
});
});
describe('note file filter', () => {
const run = (originalname: string, mimetype: string) => {
let outcome: { err: Error | null; ok?: boolean } = { err: null };
@@ -588,12 +673,13 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
describe('link preview', () => {
it('400 without url, maps an error result to 400, else returns the preview', async () => {
expect(
await thrownAsync(() => new CollabController(svc(), storageStub).linkPreview(user, '5', undefined)),
).toEqual({ status: 400, body: { error: 'URL is required' } });
expect(await thrownAsync(() => ctl(svc(), storageStub).linkPreview(user, '5', undefined))).toEqual({
status: 400,
body: { error: 'URL is required' },
});
expect(
await thrownAsync(() =>
new CollabController(
ctl(
svc({ linkPreview: vi.fn().mockResolvedValue({ error: 'bad url' }) } as Partial<CollabService>),
storageStub,
).linkPreview(user, '5', 'http://x'),
@@ -602,7 +688,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
const s = svc({
linkPreview: vi.fn().mockResolvedValue({ title: 'T', description: null, image: null, url: 'http://x' }),
} as Partial<CollabService>);
expect(await new CollabController(s, storageStub).linkPreview(user, '5', 'http://x')).toEqual({
expect(await ctl(s, storageStub).linkPreview(user, '5', 'http://x')).toEqual({
title: 'T',
description: null,
image: null,
@@ -616,7 +702,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
.fn()
.mockResolvedValue({ title: null, description: null, image: null, url: 'http://x', rateLimited: true }),
} as Partial<CollabService>);
expect(await thrownAsync(() => new CollabController(s, storageStub).linkPreview(user, '5', 'http://x'))).toEqual({
expect(await thrownAsync(() => ctl(s, storageStub).linkPreview(user, '5', 'http://x'))).toEqual({
status: 429,
body: { error: 'Too many requests' },
});
@@ -624,17 +710,13 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
it('passes the caller through, so the budget is charged per user and not per instance', async () => {
const linkPreview = vi.fn().mockResolvedValue({ title: 'T', description: null, image: null, url: 'http://x' });
await new CollabController(svc({ linkPreview } as Partial<CollabService>), storageStub).linkPreview(
user,
'5',
'http://x',
);
await ctl(svc({ linkPreview } as Partial<CollabService>), storageStub).linkPreview(user, '5', 'http://x');
expect(linkPreview).toHaveBeenCalledWith('http://x', user.id);
});
it('falls back to a null preview when the service throws', async () => {
const s = svc({ linkPreview: vi.fn().mockRejectedValue(new Error('network')) } as Partial<CollabService>);
expect(await new CollabController(s, storageStub).linkPreview(user, '5', 'http://x')).toEqual({
expect(await ctl(s, storageStub).linkPreview(user, '5', 'http://x')).toEqual({
title: null,
description: null,
image: null,
@@ -1,6 +1,7 @@
import { ADDON_IDS } from '../../../src/addons';
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import type { JourneyBookService } from '../../../src/nest/journey/journey-book.service';
import type { JourneyPhotoCaptureService } from '../../../src/nest/journey/journey-photo-capture.service';
import type { JourneyPhotoLinksService } from '../../../src/nest/journey/journey-photo-links.service';
@@ -13,7 +14,10 @@ import type { User } from '../../../src/types';
import { HttpException } from '@nestjs/common';
import type { Response } from 'express';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
@@ -61,6 +65,8 @@ function ctl(
books as JourneyBookService,
captureBackfillStub,
photoLinks as JourneyPhotoLinksService,
// The live env: a case about demo mode stubs DEMO_MODE, every other runs outside it.
new RuntimeEnvService(),
);
}
@@ -1055,6 +1061,75 @@ describe('JourneyController', () => {
});
});
describe('JourneyController in demo mode', () => {
// The shared demo account is refused every journey upload with the 403 the
// other upload routes answer it (common/demo-write.ts), and what multer
// spooled is removed before anything is committed: a committed object would
// stay, since a demo instance keeps what is deleted (StorageService.delete).
const demo = { ...user, email: 'demo@trek.app' } as User;
let spoolDir: string;
const spooled = (name: string): Express.Multer.File => {
const file = path.join(spoolDir, name);
fs.writeFileSync(file, 'x');
return { filename: name, originalname: name, path: file } as Express.Multer.File;
};
/** A service that takes every upload, so a case shows what the demo check alone decides. */
const takesAll = () =>
svc({
addPhoto: vi.fn().mockReturnValue({ id: 5 }),
immichAutoUploadEnabled: vi.fn().mockReturnValue(false),
uploadGalleryPhotos: vi.fn().mockReturnValue([{ id: 7 }]),
updateJourney: vi.fn().mockReturnValue({ id: 9 }),
} as Partial<JourneyService>);
const routes: Array<[string, (c: JourneyController, u: User) => Promise<unknown>]> = [
['POST /entries/:entryId/photos', (c, u) => c.uploadEntryPhotos(u, '3', [spooled('a.jpg'), spooled('b.jpg')], {})],
[
'POST /entries/:entryId/video',
(c, u) => c.uploadEntryVideo(u, '3', { video: [spooled('v.mp4')], poster: [spooled('p.jpg')] }, {}),
],
['POST /:id/gallery/photos', (c, u) => c.uploadGalleryPhotos(u, '9', [spooled('g.jpg')])],
[
'POST /:id/gallery/video',
(c, u) => c.uploadGalleryVideo(u, '9', { video: [spooled('v.mp4')], poster: [spooled('p.jpg')] }, {}),
],
['POST /:id/cover', (c, u) => c.cover(u, '9', spooled('c.jpg'))],
];
beforeEach(() => {
spoolDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-journey-demo-'));
});
afterEach(() => {
vi.unstubAllEnvs();
fs.rmSync(spoolDir, { recursive: true, force: true });
});
it.each(routes)('%s: 403 for the demo account, its spooled parts removed and nothing stored', async (_, call) => {
vi.stubEnv('DEMO_MODE', 'true');
const service = takesAll();
expect(await thrownAsync(() => call(ctl(service), demo))).toEqual({
status: 403,
body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' },
});
expect(fs.readdirSync(spoolDir)).toEqual([]);
expect(storageStub.put).not.toHaveBeenCalled();
expect(service.addPhoto).not.toHaveBeenCalled();
expect(service.uploadGalleryPhotos).not.toHaveBeenCalled();
expect(service.updateJourney).not.toHaveBeenCalled();
});
it.each(routes)('%s: still taken from any other account in demo mode', async (_, call) => {
vi.stubEnv('DEMO_MODE', 'true');
await call(ctl(takesAll()), user);
expect(storageStub.put).toHaveBeenCalled();
});
it.each(routes)('%s: still taken from the demo account outside demo mode', async (_, call) => {
vi.stubEnv('DEMO_MODE', 'false');
await call(ctl(takesAll()), demo);
expect(storageStub.put).toHaveBeenCalled();
});
});
describe('JourneyPublicController', () => {
it('is addon-gated like the authenticated surface', () => {
// Turning the Journey addon off is meant to take the feature away; without
+1 -1
View File
@@ -40,7 +40,7 @@ The demo user can work with the shared example trips like any member, but the fo
- **Password change**: returns 403.
- **Account deletion**: returns 403.
- **MFA enrollment or removal**: returns 403. The 2FA card in Settings says it is not available in demo mode, and the Passkeys card is hidden.
- **File uploads**: avatar uploads, trip cover uploads, and document/photo file attachments are blocked and return 403.
- **File uploads**: avatars, trip and collection covers, place images, trip files, journey photos, videos and covers, and the files and images attached to collab notes and chat messages are blocked and return 403. A chat message without an image still posts. [Document-Sync](Document-Sync) is not covered by this block: all its stores ship switched off, but once an administrator switches one on, the demo account can bind a trip it owns in the trip's file manager and pull documents from that store into the trip files, also through the `sync_trip_documents` MCP tool.
- **All MCP write tools**: create, update, and delete operations via the MCP API are blocked for the demo user.
Registration is also disabled while demo mode is active; visitors cannot create new accounts.