Finish the contract ratchet, the upload bridge and the client leftovers

BODY_CONTRACT_ALLOW_LIST is empty. The last seventeen handlers type their
body with a createZodDto class, so the fail-closed boot gate has nothing left
to forgive; it still refuses a boot for a new handler that reads @Body()
without one.

Several of those contracts describe rather than constrain, and that is the
whole care in this change. The pipe runs before the handler, so a schema
stricter than the handler moves a user-visible answer:

- TagsController.create answers { error: 'Tag name is required' }, pinned
  byte-for-byte in two tests, and the pipe formats as `field: message`. The
  DTO leaves name optional and the handler keeps the check that owns the
  message. Same shape for the booking-import confirm body, which additionally
  gains per-item validation the handler never did.
- PluginsController.activate and install answer 503 when plugins are disabled,
  and check that BEFORE looking at the body. A schema that rejected first
  would turn that into a 400 for an operator whose server has plugins off.
- setEgressHosts reads a non-array `hosts` as "clear the list", which is the
  documented reset. Typing it as string[] would have removed a working admin
  action rather than tightened anything.
- PluginRoutesController.route answers { route: null } at 200 for everything
  it dislikes, so the client falls back to straight lines. A 400 there would
  turn a graceful degradation into a visible error.

files.bridge is gone. The multer options are built by
MulterModule.registerAsync from an AllowedFileTypesService leaf, so the
fileFilter reads the operator's extension list from the container instead of
from a second FilesService built outside it. Nest injects
MULTER_MODULE_OPTIONS with @Optional(), so a token that fails to resolve is
not a boot error: it silently falls back to defaults, with no filter and no
size cap. FILE-020 already proved that for trip files by uploading an .svg
over HTTP and expecting 400; journey uploads had no such case, and now do.
VIDEO_UPLOAD is untouched, including the filename callback that forces .jpg
regardless of what the client sends, which guards the public share proxy.

PluginFrame gets an onError. A frame whose document fails to load left a
blank element at full height, which reads as a hung app rather than as a
plugin that is gone.

typecheck:tests runs in CI now, with the test tree at zero errors. tz-lookup
is left as it is: the build puts it in a lazy helpers chunk, not the entry
chunk, and the remaining split would trade a synchronous render for an async
one to move 172 kB that is already off the critical path.
This commit is contained in:
Maurice
2026-08-10 19:35:47 +02:00
committed by Maurice
parent 7078ac7a4d
commit ba7d55f999
74 changed files with 620 additions and 196 deletions
+7
View File
@@ -154,6 +154,13 @@ jobs:
- name: Typecheck
run: cd server && npm run typecheck
- name: Typecheck tests
# tests/ is outside the build tsconfig, so a service that grows a
# constructor parameter does not break its hand-wired call sites at
# compile time: the argument arrives as undefined and throws at runtime,
# if anything reads it at all. This is the step that catches it.
run: cd server && npm run typecheck:tests
- name: Lint
run: cd server && npm run lint:check
+18 -3
View File
@@ -219,7 +219,7 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
// ids the plugin already had; never the httpOnly cookie); fully closing it
// would require not running plugin client JS at all.
const loadsRef = useRef(0)
const { locale } = useTranslation()
const { locale, t } = useTranslation()
const navigate = useNavigate()
const toast = useToast()
// useToast returns a fresh object every render; keep it out of the effect deps
@@ -243,6 +243,11 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
// The ref mirrors the state so the message handler can gate + answer without
// side effects inside a setState updater (StrictMode runs updaters twice).
const [confirmReq, setConfirmReq] = useState<ConfirmRequest | null>(null)
// The iframe document can fail to load on its own: a plugin uninstalled in
// another tab, a frame route the host no longer serves, a network blip on a
// lazily loaded frame. Without an onError the element just stays blank at full
// height, which reads as a hung app rather than as a plugin that is gone.
const [loadFailed, setLoadFailed] = useState(false)
const confirmReqRef = useRef<ConfirmRequest | null>(null)
// geolocation:read gate — the feed only flags plugins whose grant is recorded.
// Read via ref (like toastRef) so the bridge effect doesn't re-run on store churn.
@@ -593,14 +598,24 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
// Deliver the context as soon as the document is parsed (the plugin sets up its
// message listener during parse), closing the trek:ready race so the theme is
// right on first paint. A 2nd load is a self-navigation — don't bridge to it.
onLoad={() => { loadsRef.current += 1; if (loadsRef.current === 1) postFrame(buildContext()) }}
onLoad={() => { setLoadFailed(false); loadsRef.current += 1; if (loadsRef.current === 1) postFrame(buildContext()) }}
onError={() => setLoadFailed(true)}
sandbox="allow-scripts allow-forms"
referrerPolicy="no-referrer"
loading="lazy"
title={title || pluginId}
className={className}
style={{ width: '100%', height: fill ? '100%' : height ?? '100%', border: 0 }}
style={{ width: '100%', height: fill ? '100%' : height ?? '100%', border: 0, display: loadFailed ? 'none' : undefined }}
/>
{loadFailed && (
<div
role="alert"
className="flex items-center justify-center rounded-xl border border-edge bg-surface-2 p-6 text-caption text-content-2"
style={{ width: '100%', height: fill ? '100%' : height ?? '100%' }}
>
{t('plugins.frameLoadFailed')}
</div>
)}
<ConfirmDialog
isOpen={confirmReq != null}
onClose={() => answerConfirm(false)}
+1
View File
@@ -9,6 +9,7 @@
"start:prod": "node --require tsconfig-paths/register dist/index.js",
"reset-admin": "node reset-admin.js",
"typecheck": "tsc --noEmit",
"typecheck:tests": "tsc -p tsconfig.tests.json --noEmit",
"gen:plugin-facts": "node --import tsx scripts/gen-plugin-facts.ts",
"check:plugin-facts": "node --import tsx scripts/gen-plugin-facts.ts --check",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
@@ -3,26 +3,16 @@
* class — grandfathered when the fail-closed boot gate landed
* (validate-body-contracts.ts). Entries are `ControllerClass.methodName`.
*
* RATCHET ONLY: never add an entry. When a domain migrates, wrap its
* @trek/shared schema in a colocated <domain>.dto.ts and DELETE the routes
* here — the gate throws on stale entries, so removal is enforced too.
* EMPTY, and it stays that way. Every mutation handler in the tree now types
* its body with a createZodDto class, so the gate has nothing to forgive. It
* still runs: a new handler that reads @Body() without a contract refuses the
* boot rather than shipping unvalidated. Adding an entry here to get past that
* is not the fix.
*
* The last seventeen came off in the migration remainder. Several of their
* contracts describe rather than constrain, which is deliberate and documented
* at each DTO: those handlers own rejections the pipe would otherwise pre-empt,
* including two that answer 503 before ever looking at the body and one that
* answers `{ route: null }` at 200 for anything it dislikes.
*/
export const BODY_CONTRACT_ALLOW_LIST: string[] = [
'LlmLocalController.pull',
'OauthApiController.authorize',
'OauthApiController.createClient',
'PluginRoutesController.route',
'PluginUserSettingsController.update',
'PluginsController.activate',
'PluginsController.install',
'PluginsController.link',
'PluginsController.retrust',
'PluginsController.setEgressHosts',
'PluginsController.uninstall',
'PluginsController.updateConfig',
'ReservationImportController.confirm',
'ReservationImportController.preview',
'ReservationImportController.previewAsync',
'TagsController.create',
'TagsController.update',
];
export const BODY_CONTRACT_ALLOW_LIST: string[] = [];
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AllowedFileTypesService } from './allowed-file-types.service';
/**
* A leaf so both upload paths can reach the extension list without importing
* each other's domain. DatabaseModule is @Global, so this imports nothing.
*/
@Module({
providers: [AllowedFileTypesService],
exports: [AllowedFileTypesService],
})
export class AllowedFileTypesModule {}
@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { DEFAULT_ALLOWED_EXTENSIONS } from './files.constants';
/**
* The operator's allowed-extension list, on its own.
*
* Split out of FilesService so the multer factories can inject it without
* pulling the whole files domain into JourneyModule's injector. It is the only
* thing an upload's fileFilter needs from the container, and it is a live read:
* an admin changing the list in settings applies to the next upload, with no
* invalidation wiring.
*/
@Injectable()
export class AllowedFileTypesService {
constructor(private readonly db: DatabaseService) {}
/** Comma-separated, as the admin panel stores it. `*` means anything. */
get(): string {
try {
const row = this.db.get<{ value: string }>(
"SELECT value FROM app_settings WHERE key = 'allowed_file_types'",
);
return row?.value || DEFAULT_ALLOWED_EXTENSIONS;
} catch {
return DEFAULT_ALLOWED_EXTENSIONS;
}
}
}
-24
View File
@@ -1,24 +0,0 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import { FilesService } from './files.service';
import { PermissionsService } from '../permissions/permissions.service';
import { EphemeralTokenService } from '../auth/ephemeral-token.service';
/**
* Non-Nest entry point for the files domain. Unlike todo.bridge.ts this does
* NOT serve a legacy MCP registrar (files has no MCP surface) — it exists only
* because multer interceptor options are module-scope object literals (the
* UPLOAD configs in files.controller.ts and journey.controller.ts are built
* before any DI container exists), yet their fileFilter closures need the
* request-time app_settings read. Everything else injects FilesService.
* Delete this file if uploads ever move to MulterModule.registerAsync.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
*/
const files = new FilesService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService(), new EphemeralTokenService());
export function getAllowedExtensions(): string {
return files.getAllowedExtensions();
}
+38 -27
View File
@@ -30,34 +30,45 @@ import { TripAccessGuard } from '../permissions/trip-access.guard';
import type { TripAccess } from '../database/database.service';
import { Trip } from '../permissions/trip.decorator';
import { MAX_FILE_SIZE, MAX_VIDEO_SIZE, BLOCKED_EXTENSIONS, filesDir, isVideoExtension } from './files.constants';
import { getAllowedExtensions } from './files.bridge';
import { FileUploadDto, FileUpdateDto, FileLinkDto } from './files.dto';
import { AllowedFileTypesService } from './allowed-file-types.service';
const UPLOAD = {
storage: diskStorage({
destination: (_req, _file, cb) => { if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true }); cb(null, filesDir); },
filename: (_req, file, cb) => cb(null, `${uuidv4()}${path.extname(file.originalname)}`),
}),
// Allow up to the video cap; non-video files are still held to MAX_FILE_SIZE by
// the per-type guard in the upload handler (#823).
limits: { fileSize: MAX_VIDEO_SIZE },
defParamCharset: 'utf8', // parity with legacy routes/files.ts — preserve non-ASCII original filenames
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
const ext = path.extname(file.originalname).toLowerCase();
const reject = () => {
// i18n key — the client resolves it via t() (see translateApiError).
const err: Error & { statusCode?: number } = new Error('files.uploadErrorType');
err.statusCode = 400;
cb(err, false);
};
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) return reject();
const allowed = getAllowedExtensions().split(',').map((e) => e.trim().toLowerCase());
const fileExt = ext.replace('.', '');
// Video is accepted as media regardless of the admin doc-types allowlist (#823).
if (allowed.includes(fileExt) || isVideoExtension(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) return cb(null, true);
reject();
},
};
/**
* Trip-file upload options, built from the container.
*
* A factory rather than a module-scope literal because the fileFilter reads the
* operator's allowed-extension list at request time, and that list lives in the
* database. It used to reach it through files.bridge.ts, which built a second
* FilesService outside the injector; MulterModule.registerAsync gives the
* closure the real one.
*/
export function buildFilesUploadOptions(allowedTypes: AllowedFileTypesService) {
return {
storage: diskStorage({
destination: (_req, _file, cb) => { if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true }); cb(null, filesDir); },
filename: (_req, file, cb) => cb(null, `${uuidv4()}${path.extname(file.originalname)}`),
}),
// Allow up to the video cap; non-video files are still held to MAX_FILE_SIZE by
// the per-type guard in the upload handler (#823).
limits: { fileSize: MAX_VIDEO_SIZE },
defParamCharset: 'utf8', // parity with legacy routes/files.ts — preserve non-ASCII original filenames
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
const ext = path.extname(file.originalname).toLowerCase();
const reject = () => {
// i18n key — the client resolves it via t() (see translateApiError).
const err: Error & { statusCode?: number } = new Error('files.uploadErrorType');
err.statusCode = 400;
cb(err, false);
};
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) return reject();
const allowed = allowedTypes.get().split(',').map((e) => e.trim().toLowerCase());
const fileExt = ext.replace('.', '');
// Video is accepted as media regardless of the admin doc-types allowlist (#823).
if (allowed.includes(fileExt) || isVideoExtension(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) return cb(null, true);
reject();
},
};
}
/**
* /api/trips/:tripId/files — trip file manager (upload, metadata, starring,
@@ -105,7 +116,7 @@ export class FilesController {
}
@Post()
@UseInterceptors(FileInterceptor('file', UPLOAD))
@UseInterceptors(FileInterceptor('file'))
upload(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
+11 -1
View File
@@ -8,9 +8,19 @@ import { RealtimeModule } from '../realtime/realtime.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { AppConfigModule } from '../app-config/app-config.module';
import { EphemeralTokenModule } from '../auth/ephemeral-token.module';
import { MulterModule } from '@nestjs/platform-express';
import { AllowedFileTypesModule } from './allowed-file-types.module';
import { AllowedFileTypesService } from './allowed-file-types.service';
import { buildFilesUploadOptions } from './files.controller';
@Module({
imports: [EphemeralTokenModule, PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule],
imports: [
MulterModule.registerAsync({
imports: [AllowedFileTypesModule],
inject: [AllowedFileTypesService],
useFactory: (allowedTypes: AllowedFileTypesService) => buildFilesUploadOptions(allowedTypes),
}),
EphemeralTokenModule, PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule],
controllers: [FilesController, FilesDownloadController],
providers: [FilesService, FilesRpc],
exports: [FilesService],
+36 -26
View File
@@ -34,31 +34,41 @@ import {
JourneyReorderEntriesDto, JourneyShareLinkDto, JourneyUpdateDto,
} from './journey.dto';
import { isVideoMime, isVideoExtension, MAX_VIDEO_SIZE } from '../files/files.constants';
import { getAllowedExtensions } from '../files/files.bridge';
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
const uploadsBase = path.join(__dirname, '../../../uploads/journey');
const IMAGE_UPLOAD = {
storage: diskStorage({
destination: (_req, _file, cb) => { if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true }); cb(null, uploadsBase); },
filename: (_req, file, cb) => cb(null, `${crypto.randomUUID()}${path.extname(file.originalname).toLowerCase() || '.jpg'}`),
}),
limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) {
const err: Error & { statusCode?: number } = new Error('Only image files are allowed');
err.statusCode = 400;
return cb(err, false);
}
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
const allowed = getAllowedExtensions().split(',').map((e) => e.trim().toLowerCase());
if (!allowed.includes('*') && !allowed.includes(ext)) {
const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`);
err.statusCode = 400;
return cb(err, false);
}
cb(null, true);
},
};
/**
* Journey image upload options, built from the container.
*
* Same reason as the trip-file factory: the fileFilter reads the operator's
* allowed-extension list at request time. Note this config carries NO
* defParamCharset, unlike the trip-file one; that difference is deliberate and
* predates the move, so the two are not folded into one shared object.
*/
export function buildJourneyImageUploadOptions(allowedTypes: AllowedFileTypesService) {
return {
storage: diskStorage({
destination: (_req, _file, cb) => { if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true }); cb(null, uploadsBase); },
filename: (_req, file, cb) => cb(null, `${crypto.randomUUID()}${path.extname(file.originalname).toLowerCase() || '.jpg'}`),
}),
limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) {
const err: Error & { statusCode?: number } = new Error('Only image files are allowed');
err.statusCode = 400;
return cb(err, false);
}
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
const allowed = allowedTypes.get().split(',').map((e) => e.trim().toLowerCase());
if (!allowed.includes('*') && !allowed.includes(ext)) {
const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`);
err.statusCode = 400;
return cb(err, false);
}
cb(null, true);
},
};
}
// Gallery video upload (#823): one video plus an optional client-captured poster
// image, written to the same uploads/journey store. Larger cap than images since
@@ -161,7 +171,7 @@ export class JourneyController {
}
@Post('entries/:entryId/photos')
@UseInterceptors(FilesInterceptor('photos', undefined, IMAGE_UPLOAD))
@UseInterceptors(FilesInterceptor('photos'))
async uploadEntryPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @UploadedFiles() files: Express.Multer.File[] | undefined, @Body() body: JourneyEntryPhotoUploadDto) {
if (!files?.length) {
throw new HttpException({ error: 'No files uploaded' }, 400);
@@ -257,7 +267,7 @@ export class JourneyController {
// ── Gallery (prefix /:id/gallery — before /:id) ─────────────────────────
@Post(':id/gallery/photos')
@UseInterceptors(FilesInterceptor('photos', undefined, IMAGE_UPLOAD))
@UseInterceptors(FilesInterceptor('photos'))
uploadGalleryPhotos(@CurrentUser() user: User, @Param('id') id: string, @UploadedFiles() files: Express.Multer.File[] | undefined) {
if (!files?.length) {
throw new HttpException({ error: 'No files uploaded' }, 400);
@@ -360,7 +370,7 @@ export class JourneyController {
@Post(':id/cover')
@HttpCode(200) // Express answers cover with res.json (200).
@UseInterceptors(FileInterceptor('cover', IMAGE_UPLOAD))
@UseInterceptors(FileInterceptor('cover'))
cover(@CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined) {
if (!file) {
throw new HttpException({ error: 'No file uploaded' }, 400);
+11 -1
View File
@@ -7,10 +7,20 @@ import { MemoriesModule } from '../memories/memories.module';
import { JourneyDomainModule } from './journey-domain.module';
import { JourneyMcp } from './journey.mcp';
import { AuthModule } from '../auth/auth.module';
import { MulterModule } from '@nestjs/platform-express';
import { AllowedFileTypesModule } from '../files/allowed-file-types.module';
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
import { buildJourneyImageUploadOptions } from './journey.controller';
@Module({
// MemoriesModule: the journey gallery streams provider assets and uploads to Immich.
imports: [AuthModule, AddonsModule, MemoriesModule, JourneyDomainModule],
imports: [
MulterModule.registerAsync({
imports: [AllowedFileTypesModule],
inject: [AllowedFileTypesService],
useFactory: (allowedTypes: AllowedFileTypesService) => buildJourneyImageUploadOptions(allowedTypes),
}),
AuthModule, AddonsModule, MemoriesModule, JourneyDomainModule],
controllers: [JourneyController, JourneyPublicController],
providers: [JourneyService, JourneyMcp],
})
@@ -3,6 +3,7 @@ import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard';
import { LlmLocalService } from './llm-local.service';
import { LlmLocalPullDto } from './llm-local.dto';
/**
* Admin-only management of a local LLM server (Ollama): list installed models and
@@ -24,7 +25,7 @@ export class LlmLocalController {
* response body to render a progress bar. Uses @Res() to stream manually.
*/
@Post('pull')
async pull(@Body() body: { baseUrl?: string; model?: string }, @Res() res: Response): Promise<void> {
async pull(@Body() body: LlmLocalPullDto, @Res() res: Response): Promise<void> {
const stream = await this.local.pull(body?.baseUrl, body?.model ?? '');
res.status(200);
res.setHeader('Content-Type', 'application/x-ndjson');
@@ -0,0 +1,10 @@
import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';
/**
* The Ollama pull body. Both fields are optional because the service supplies
* its own defaults for a missing baseUrl and reports an empty model itself.
*/
export class LlmLocalPullDto extends createZodDto(
z.looseObject({ baseUrl: z.string().optional(), model: z.string().optional() }),
) {}
@@ -9,6 +9,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
import { getClientIp } from '../audit/client-ip';
import type { User } from '../../types';
import type { AuthorizeParams } from './oauth.service';
import { OauthClientCreateDto, OauthConsentDto } from './oauth.dto';
const MIN = 60_000;
@@ -67,10 +68,7 @@ export class OauthApiController {
@Post('authorize')
@HttpCode(200) // Express answers consent with res.json (200), not the POST-default 201.
@UseGuards(CookieAuthGuard)
authorize(@CurrentUser() user: User, @Body() body: {
client_id: string; redirect_uri: string; scope: string; state?: string;
code_challenge: string; code_challenge_method: string; approved: boolean; resource?: string;
}, @Req() req: Request) {
authorize(@CurrentUser() user: User, @Body() body: OauthConsentDto, @Req() req: Request) {
const ip = getClientIp(req);
if (!this.oauth.mcpEnabled()) {
throw new HttpException({ error: 'MCP is not enabled' }, 403);
@@ -126,7 +124,7 @@ export class OauthApiController {
@Post('clients')
@HttpCode(201)
@UseGuards(CookieAuthGuard)
createClient(@CurrentUser() user: User, @Body() body: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }, @Req() req: Request) {
createClient(@CurrentUser() user: User, @Body() body: OauthClientCreateDto, @Req() req: Request) {
this.requireMcp403();
const result = this.oauth.createOAuthClient(user.id, body.name, body.redirect_uris ?? [], body.allowed_scopes, getClientIp(req), { allowsClientCredentials: body.allows_client_credentials });
if (result.error) {
+17
View File
@@ -0,0 +1,17 @@
import { createZodDto } from 'nestjs-zod';
import { oauthClientCreateRequestSchema, oauthConsentRequestSchema } from '@trek/shared';
/**
* Server-side createZodDto wrappers over the @trek/shared OAuth contracts.
*
* Both schemas already described these two bodies exactly; they were written
* with the contracts and then never wired to the handlers, which declared the
* same shape inline as an anonymous type. The inline types are gone now, so the
* wire shape has one definition instead of two that could drift.
*
* Neither handler owns a bespoke required-field message: consent redirects with
* `error=access_denied` on a rejection, and client creation surfaces whatever
* the service returns. So nothing user-visible moves in front of the pipe here.
*/
export class OauthConsentDto extends createZodDto(oauthConsentRequestSchema) {}
export class OauthClientCreateDto extends createZodDto(oauthClientCreateRequestSchema) {}
@@ -6,6 +6,7 @@ import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { pluginsEnabled } from '../kill-switch';
import { PluginHooks } from '../plugin-hooks.service';
import { stripEmoji } from '../text-sanitize';
import { PluginRouteDto } from '../plugins.dto';
/**
* POST /api/plugin-routes/:pluginId/:profileId — ask ONE routeProvider plugin to
@@ -158,7 +159,7 @@ export class PluginRoutesController {
async route(
@Param('pluginId') pluginId: string,
@Param('profileId') profileId: string,
@Body() body: Record<string, unknown> | undefined,
@Body() body: PluginRouteDto,
@Req() req: Request & { user?: { id: number } },
): Promise<{ route: PluginRouteOut | null }> {
if (!pluginsEnabled()) return { route: null };
@@ -5,6 +5,7 @@ import { pluginsEnabled } from './kill-switch';
import { PluginsService } from './plugins.service';
import { PluginRuntimeService } from './plugin-runtime.service';
import { DatabaseService } from '../database/database.service';
import { PluginUserSettingsUpdateDto } from './plugins.dto';
/**
* GET/POST /api/plugin-settings/:id — a USER's own `scope:'user'` settings for a
@@ -74,7 +75,7 @@ export class PluginUserSettingsController {
@Post(':id')
update(
@Param('id') id: string,
@Body() body: { config?: Record<string, unknown> },
@Body() body: PluginUserSettingsUpdateDto,
@Req() req: Request & { user?: { id: number } },
): { config: Record<string, unknown> } {
const userId = req.user?.id;
@@ -11,6 +11,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
import { getClientIp } from '../audit/client-ip';
import { pluginsEnabled } from './kill-switch';
import { devLinkEnabled } from './dev-link';
import { PluginActivateDto, PluginConfigDto, PluginEgressHostsDto, PluginInstallDto, PluginLinkDto, PluginRetrustDto, PluginUninstallDto } from './plugins.dto';
/**
* Flatten a registry/install failure into the error envelope — CARRYING THE CODE.
@@ -70,7 +71,7 @@ export class PluginsController {
@Post('install')
@HttpCode(200)
async install(@Body() body: { id?: string; version?: string; constraint?: string; withDependencies?: boolean }) {
async install(@Body() body: PluginInstallDto) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
if (!body?.id) throw new HttpException({ error: 'id is required' }, 400);
try {
@@ -104,7 +105,7 @@ export class PluginsController {
*/
@Post('link')
@HttpCode(200)
async link(@Body() body: { path?: string }) {
async link(@Body() body: PluginLinkDto) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
if (!devLinkEnabled()) throw new HttpException({ error: 'Dev-link is disabled (set TREK_PLUGINS_DEV_LINK=1)' }, 403);
const dir = body?.path?.trim();
@@ -122,7 +123,7 @@ export class PluginsController {
}
@Put(':id/config')
updateConfig(@Param('id') id: string, @Body() body: Record<string, unknown>) {
updateConfig(@Param('id') id: string, @Body() body: PluginConfigDto) {
return { config: this.plugins.updateInstanceConfig(id, body || {}) };
}
@@ -138,7 +139,7 @@ export class PluginsController {
}
@Put(':id/egress-hosts')
async setEgressHosts(@Param('id') id: string, @Body() body: { hosts?: unknown } = {}) {
async setEgressHosts(@Param('id') id: string, @Body() body: PluginEgressHostsDto) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
const hosts = Array.isArray(body.hosts) ? body.hosts.map(String) : [];
try {
@@ -150,7 +151,7 @@ export class PluginsController {
@Post(':id/activate')
@HttpCode(200)
async activate(@Param('id') id: string, @Body() body: { consent?: boolean } = {}) {
async activate(@Param('id') id: string, @Body() body: PluginActivateDto) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
try {
await this.runtime.activate(id, !!body?.consent);
@@ -231,7 +232,7 @@ export class PluginsController {
@HttpCode(200)
async retrust(
@Param('id') id: string,
@Body() body: { version?: string; publicKey?: string },
@Body() body: PluginRetrustDto,
@CurrentUser() user: { id: number },
@Req() req: Request,
) {
@@ -247,7 +248,7 @@ export class PluginsController {
@Post(':id/uninstall')
@HttpCode(200)
async uninstall(@Param('id') id: string, @Body() body: { deleteData?: boolean }) {
async uninstall(@Param('id') id: string, @Body() body: PluginUninstallDto) {
await this.runtime.uninstall(id, !!body?.deleteData);
return { status: 'uninstalled' };
}
+73
View File
@@ -0,0 +1,73 @@
import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';
/**
* Body contracts for the plugin surfaces, written to DESCRIBE rather than to
* tighten (the journey-contract doctrine, #1842).
*
* Every one of these handlers already owns its own rejection, and several of
* them answer something other than 400 for a body they dislike: activate and
* install answer 503 when plugins are disabled, and they check that BEFORE
* looking at the body. A schema that rejected first would move a 503 to a 400
* for an operator whose server has plugins off. So the fields stay optional and
* the objects stay loose: the DTO pins the wire TYPES, and the handler keeps
* every decision it already made, in the order it already made it.
*/
/** `id is required` stays the handler's answer, so id is optional here. */
export class PluginInstallDto extends createZodDto(
z.looseObject({
id: z.string().optional(),
version: z.string().optional(),
constraint: z.string().optional(),
withDependencies: z.boolean().optional(),
}),
) {}
/** `path is required` likewise, and dev-link answers 403 before reading it. */
export class PluginLinkDto extends createZodDto(
z.looseObject({ path: z.string().optional() }),
) {}
export class PluginActivateDto extends createZodDto(
z.looseObject({ consent: z.boolean().optional() }),
) {}
export class PluginUninstallDto extends createZodDto(
z.looseObject({ deleteData: z.boolean().optional() }),
) {}
export class PluginRetrustDto extends createZodDto(
z.looseObject({ version: z.string().optional(), publicKey: z.string().optional() }),
) {}
/**
* `hosts` is deliberately unknown, not `string[]`.
*
* The handler reads `Array.isArray(body.hosts) ? body.hosts.map(String) : []`,
* which means an omitted or non-array `hosts` CLEARS the operator egress list.
* That is the documented way to reset it, so a schema demanding an array would
* not tighten validation, it would remove a working admin action.
*/
export class PluginEgressHostsDto extends createZodDto(
z.looseObject({ hosts: z.unknown().optional() }),
) {}
/**
* Plugin instance config is defined by the plugin's own manifest, not by TREK,
* so there is no shape to assert here beyond "a JSON object".
*/
export class PluginConfigDto extends createZodDto(z.looseObject({})) {}
export class PluginUserSettingsUpdateDto extends createZodDto(
z.looseObject({ config: z.record(z.string(), z.unknown()).optional() }),
) {}
/**
* The route hook answers `{ route: null }` at 200 for every input it dislikes:
* a missing trip, a bad profile, unusable waypoints, a slow provider. The client
* draws straight lines on a null and shows nothing on a 400, so a schema that
* rejected a malformed body would turn a graceful fallback into a visible error.
* Loose and fully optional on purpose.
*/
export class PluginRouteDto extends createZodDto(z.looseObject({})) {}
@@ -26,6 +26,7 @@ import { AirtrailImportDto } from '../integrations/airtrail.dto';
import type { AirtrailImportResult } from '@trek/shared';
import { bookingImportModeSchema } from '@trek/shared';
import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, BookingImportMode } from '@trek/shared';
import { BookingImportConfirmDto, BookingImportPreviewDto } from './reservation-import.dto';
const ACCEPTED_EXTS = new Set(['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt']);
const MAX_FILE_BYTES = 10 * 1024 * 1024;
@@ -127,9 +128,9 @@ export class ReservationImportController {
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@UploadedFiles() files: Express.Multer.File[] | undefined,
@Body('mode') rawMode?: string,
@Body() body: BookingImportPreviewDto,
): Promise<BookingImportPreviewResponse> {
const mode = this.validateImport(tripId, user, files, rawMode);
const mode = this.validateImport(tripId, user, files, body?.mode);
return this.bookingImport.preview(files!, mode, user.id);
}
@@ -147,9 +148,9 @@ export class ReservationImportController {
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@UploadedFiles() files: Express.Multer.File[] | undefined,
@Body('mode') rawMode?: string,
@Body() body: BookingImportPreviewDto,
): Promise<{ jobId: string }> {
const mode = this.validateImport(tripId, user, files, rawMode);
const mode = this.validateImport(tripId, user, files, body?.mode);
const jobId = this.importJobs.start(tripId, files!, mode, user.id);
return { jobId };
}
@@ -175,7 +176,7 @@ export class ReservationImportController {
async confirm(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: { items?: BookingImportPreviewItem[] },
@Body() body: BookingImportConfirmDto,
@Headers('x-socket-id') socketId?: string,
): Promise<BookingImportConfirmResponse> {
@@ -0,0 +1,29 @@
import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';
import { bookingImportPreviewItemSchema } from '@trek/shared';
/**
* Server-side createZodDto wrappers for the booking-import bodies.
*
* Confirm validates each item against the shared contract but leaves `items`
* itself optional, which is narrower than `bookingImportConfirmRequestSchema`
* on purpose. That schema declares `.min(1)`, and the pipe reports a rejection
* as `field: message` — so adopting it verbatim would replace the endpoint's
* `{ error: 'items must be a non-empty array' }`. The handler keeps the check
* that owns the message, and the DTO adds what the handler never did: per-item
* shape validation.
*
* Preview and its async twin carry a multipart body, so every field arrives as a
* string and the real payload is the files. `mode` is described here for the
* type, and deliberately not narrowed to the mode enum: `validateImport` parses
* it with `bookingImportModeSchema` and owns the `Invalid mode` response. The
* object is not strict, because a multipart form may carry fields the browser
* adds and the handler ignores.
*/
export class BookingImportConfirmDto extends createZodDto(
z.object({ items: z.array(bookingImportPreviewItemSchema).optional() }),
) {}
export class BookingImportPreviewDto extends createZodDto(
z.object({ mode: z.string().optional() }),
) {}
+5 -7
View File
@@ -4,6 +4,7 @@ import type { User } from '../../types';
import { TagsService } from './tags.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { TagCreateDto, TagUpdateDto } from './tags.dto';
/**
* /api/tags — per-user place-tag CRUD.
@@ -25,11 +26,8 @@ export class TagsController {
}
@Post()
create(
@CurrentUser() user: User,
@Body('name') name?: string,
@Body('color') color?: string,
): { tag: Tag } {
create(@CurrentUser() user: User, @Body() body: TagCreateDto): { tag: Tag } {
const { name, color } = body;
if (!name) {
throw new HttpException({ error: 'Tag name is required' }, 400);
}
@@ -40,9 +38,9 @@ export class TagsController {
update(
@CurrentUser() user: User,
@Param('id') id: string,
@Body('name') name?: string,
@Body('color') color?: string,
@Body() body: TagUpdateDto,
): { tag: Tag } {
const { name, color } = body;
if (!this.tags.getByIdAndUser(id, user.id)) {
throw new HttpException({ error: 'Tag not found' }, 404);
}
+24
View File
@@ -0,0 +1,24 @@
import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';
import { updateTagRequestSchema } from '@trek/shared';
/**
* Server-side createZodDto wrappers over the @trek/shared tag contracts.
*
* Create deliberately does NOT use `createTagRequestSchema` as written. That
* schema declares `name: z.string().min(1)`, and the pipe reports a rejection as
* `field: message` — so adopting it verbatim would replace the endpoint's
* `{ error: 'Tag name is required' }` with `name: ...`. Two tests pin that
* string, and more to the point it is what a client shows the user.
*
* So the DTO accepts the shape and leaves `name` optional, and the handler keeps
* the check that owns the message. The endpoint's actual contract is unchanged:
* a request without a name still gets 400, with the same body it always had.
* What the DTO does add is type and shape validation on everything else, which
* is what took this route off the allow-list.
*/
export class TagCreateDto extends createZodDto(
z.object({ name: z.string().optional(), color: z.string().optional() }),
) {}
export class TagUpdateDto extends createZodDto(updateTagRequestSchema) {}
+13
View File
@@ -132,6 +132,19 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
expect(res.body).toEqual({ id: 9 });
});
it('the image fileFilter is wired: a disallowed extension is rejected, not stored', async () => {
// The multer options are built by MulterModule.registerAsync now, and Nest
// injects MULTER_MODULE_OPTIONS with @Optional(): a token that fails to
// resolve is not a boot error, it silently falls back to defaults, which
// means no fileFilter and no size cap. This case is what turns that into a
// visible failure, since an unfiltered upload would answer 201.
const res = await request(server)
.post('/api/journeys/9/cover')
.set('Cookie', sessionCookie(1))
.attach('cover', Buffer.from('MZ'), { filename: 'payload.exe', contentType: 'application/octet-stream' });
expect(res.status).toBe(400);
});
it('public journey 404 for an unknown token', async () => {
sharesvc.getPublicJourney.mockReturnValueOnce(null);
const res = await request(server).get('/api/public/journey/bad');
+17 -3
View File
@@ -43,7 +43,9 @@ export async function createMcpHarness(options: McpHarnessOptions): Promise<McpH
const server = new McpServer({ name: 'trek-test', version: '1.0.0' });
if (withResources) registerResources(server, userId);
// registerResources gates the journey resources on the same scope list the
// tools use, so the harness has to hand it over as well.
if (withResources) registerResources(server, userId, scopes ?? null);
if (withTools) {
// In production bootstrap.ts hands the Nest-discovered registry to
// registerTools after app.init(); the harness has no Nest app, so it
@@ -68,9 +70,21 @@ export async function createMcpHarness(options: McpHarnessOptions): Promise<McpH
return { client, server, cleanup };
}
/**
* callTool is declared as a union of the current result shape and the legacy
* `toolResult` compatibility shape. Reading `content` off the union yields
* unknown, because the compatibility branch only has an index signature.
*/
type ToolCallResult = Awaited<ReturnType<Client['callTool']>>;
type ToolCallContentResult = Extract<ToolCallResult, { content: unknown[] }>;
type ToolTextContent = Extract<ToolCallContentResult['content'][number], { type: 'text' }>;
/** Parse JSON from a callTool result (first text content item). */
export function parseToolResult(result: Awaited<ReturnType<Client['callTool']>>): unknown {
const text = result.content.find((c: { type: string }) => c.type === 'text') as { type: 'text'; text: string } | undefined;
export function parseToolResult(result: ToolCallResult): unknown {
// Our own McpServer always answers with the content shape, never the
// compatibility one, so pick that branch of the union.
const { content } = result as ToolCallContentResult;
const text = content.find((c): c is ToolTextContent => c.type === 'text');
if (!text) throw new Error('No text content in tool result');
return JSON.parse(text.text);
}
+23 -4
View File
@@ -57,6 +57,9 @@ import { JourneyShareService } from '../../src/nest/journey/journey-share.servic
import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.repository';
import { UnsplashService } from '../../src/nest/unsplash/unsplash.service';
import { UserCleanupService } from '../../src/nest/auth/user-cleanup.service';
import { WebauthnConfigService } from '../../src/nest/auth/webauthn-config.service';
import { TripMembershipService } from '../../src/nest/trip-membership/trip-membership.service';
import { MailerService } from '../../src/nest/notifications/mailer/mailer.service';
import { CalendarService } from '../../src/nest/calendar/calendar.service';
import { AccommodationsService } from '../../src/nest/accommodations/accommodations.service';
import { AccommodationsMcp } from '../../src/nest/accommodations/accommodations.mcp';
@@ -79,7 +82,20 @@ import { EphemeralTokenService } from '../../src/nest/auth/ephemeral-token.servi
export function createMcpTestRegistry(): McpRegistry {
const dbService = new DatabaseService(db);
const permissionsService = new PermissionsService(dbService);
const authService = new AuthService(dbService, permissionsService, new AtlasService(dbService), new EphemeralTokenService());
// Same argument list as auth.bridge.ts. AtlasService used to sit in third
// place; when getTravelStats moved onto AtlasService itself the edge was
// dropped and four collaborators took its place, but this call site kept the
// old shape, so `membership` and `webauthn` held the wrong objects and
// userCleanup/mailer/tokens were undefined.
const authService = new AuthService(
dbService,
permissionsService,
new TripMembershipService(dbService),
new WebauthnConfigService(dbService),
new UserCleanupService(dbService),
new MailerService(dbService),
new EphemeralTokenService(),
);
const realtimeService = new RealtimeService();
const queryHelpersService = new QueryHelpersService(dbService);
const daysService = new DaysService(dbService, permissionsService, realtimeService, queryHelpersService);
@@ -88,7 +104,10 @@ export function createMcpTestRegistry(): McpRegistry {
const todoService = new TodoService(dbService, permissionsService, realtimeService);
const packingService = new PackingService(dbService, permissionsService, realtimeService, notificationsStub());
const collabService = new CollabService(dbService, permissionsService, realtimeService, notificationsStub());
const mapsService = new MapsService(dbService);
// Exactly one instance, shared by maps, places and share: its stampede guard
// and its on-disk set only work if all three readers see the same maps.
const placePhotoCache = new PlacePhotoCacheService(dbService, new RuntimeEnvService());
const mapsService = new MapsService(dbService, placePhotoCache);
const journeyDomain = new JourneyDomainService(dbService, realtimeService, new TrekPhotosRepository(dbService));
// The last three were previously omitted, which left them `undefined` at
// runtime — silently fine while nothing called them, a TypeError the moment
@@ -97,7 +116,7 @@ export function createMcpTestRegistry(): McpRegistry {
const placesService = new PlacesService(
dbService, permissionsService, realtimeService, mapsService, queryHelpersService,
new UnsplashService(dbService, new RuntimeEnvService()),
new PlacePhotoCacheService(dbService, new RuntimeEnvService()),
placePhotoCache,
journeyDomain,
);
const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService, notificationsStub());
@@ -146,7 +165,7 @@ export function createMcpTestRegistry(): McpRegistry {
new CollabMcp(collabService, authService, addonsService),
new VacayMcp(new VacayService(dbService, realtimeService, notificationsStub()), authService, addonsService),
new TripsMcp(tripsService, todoService, collabService, authService, calendarService, membersService, readModelService, addonsService),
new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService, queryHelpersService), authService),
new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService, queryHelpersService, placePhotoCache), authService),
new MapsMcp(mapsService),
new PlacesMcp(placesService, mapsService, dbService, authService, journeyDomain),
new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService, notificationsStub()), dbService, authService, addonsService),
+13 -8
View File
@@ -64,6 +64,11 @@ import { CollectionsRpc } from '../../src/nest/collections/collections.rpc';
import { makeNotificationsService } from './notifications';
import { notificationsStub } from './notifications';
import { EphemeralTokenService } from '../../src/nest/auth/ephemeral-token.service';
import { UserCleanupService } from '../../src/nest/auth/user-cleanup.service';
import { UnsplashService } from '../../src/nest/unsplash/unsplash.service';
import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service';
import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.repository';
import { RuntimeEnvService } from '../../src/nest/app-config/runtime-env.service';
/**
* Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests
@@ -89,21 +94,21 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF
const collab = new CollabService(dbs, permissions, realtime, notificationsStub());
const vacay = new VacayService(dbs, realtime, notificationsStub());
const days = new DaysService(dbs, permissions, realtime, queryHelpers);
const places = new PlacesService(dbs, permissions, realtime, new MapsService(dbs), queryHelpers);
const photoCache = new PlacePhotoCacheService(dbs, new RuntimeEnvService());
const unsplash = new UnsplashService(dbs, new RuntimeEnvService());
const journey = new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs));
const places = new PlacesService(dbs, permissions, realtime, new MapsService(dbs, photoCache), queryHelpers, unsplash, photoCache, journey);
const collections = new CollectionsService(dbs, permissions, realtime, notificationsStub());
const atlas = new AtlasService(dbs);
const dayNotes = new DayNotesService(dbs, permissions, realtime);
const assignments = new AssignmentsService(dbs, permissions, realtime, queryHelpers);
const journey = new JourneyDomainService(dbs);
const membership = new TripMembershipService(dbs, permissions, realtime);
const assignments = new AssignmentsService(dbs, permissions, realtime, queryHelpers, journey);
const membership = new TripMembershipService(dbs);
const notifications = makeNotificationsService(dbs, realtime);
const llmConfig = new LlmConfigResolver(new SettingsService(dbs), dbs, addons);
const oauth = new PluginOAuthService(dbs);
const accommodations = new AccommodationsService(dbs, permissions, realtime);
// Was 12 arguments for a 14-parameter constructor; unsplash and userCleanup were
// undefined here and tsconfig only typechecks src, so nothing said so.
const trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, undefined as never);
const members = new TripMembersService(dbs, budget, undefined as never, permissions, realtime, notificationsStub());
const trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, unsplash);
const members = new TripMembersService(dbs, budget, new UserCleanupService(dbs), permissions, realtime, notificationsStub());
const guards = new PluginGuards(dbs, permissions, addons);
const registry = createTestPluginRegistry([
@@ -8,7 +8,7 @@
*
* No real HTTP calls are made.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
@@ -16,6 +16,15 @@ import { PluginRpcHost, type HostDeps } from '../../../src/nest/plugins/host/rpc
import { PluginDataDb } from '../../../src/nest/plugins/host/plugin-data.service';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc';
import type { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
/**
* DbRpc also carries `settings.get`, which needs the per-user settings store. No
* fixture below calls it, and none of these plugins has a stored setting anyway, so
* the stub is the one read DbRpc makes, typed off the real signature and answering
* "nothing stored" exactly as the real service would.
*/
const stubUserSettings: Pick<PluginUserSettingsService, 'readOne'> = { readOne: () => undefined };
let codeRoot: string;
let dataRoot: string;
@@ -38,7 +47,7 @@ function makeSupervisor(events: Array<{ topic: string; data: unknown }>, tuning:
};
// Only db.* is exercised from a child here; the rest of the surface has its own
// unit suites and would drag every domain service into this integration test.
return new PluginRpcHost(id, granted, deps, createTestPluginRegistry([new DbRpc()]));
return new PluginRpcHost(id, granted, deps, createTestPluginRegistry([new DbRpc(stubUserSettings as PluginUserSettingsService)]));
};
const hooks: SupervisorHooks = {
onEvent: (_id, topic, data) => events.push({ topic, data }),
+14 -6
View File
@@ -3,6 +3,20 @@
// config.ts, database.ts, etc. pick them up at import time. (Importing from
// 'vitest' itself is safe: it is externalized and pulls in no app modules.)
import { afterEach } from 'vitest';
// The one app module imported here, and it is deliberately a static import even
// though import declarations are hoisted above the process.env writes below.
// The chain is nominatim.client -> maps.helpers -> app-config, and its only
// load-time read of the environment is the UA constant's getAppUrl(), which
// looks at APP_URL, ALLOWED_ORIGINS and PORT. This file sets none of those, so
// nothing in the chain observes a variable before it is written; config.ts and
// database.ts are not on it.
//
// It also has to be static. A setup file is imported for its side effects, not
// awaited as a hook, so a deferred `import()` would still be resolving while the
// test file registers its own mocks: booking-import's kitinerary suite replaces
// app-config with a factory that has no getAppUrl, and the late import then
// resolves maps.helpers against that mock and throws.
import { setGeoThrottleInterval } from '../src/nest/geo/nominatim.client';
// Fixed encryption key (64 hex chars = 32 bytes) for at-rest crypto in tests
process.env.ENCRYPTION_KEY = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2';
@@ -28,10 +42,4 @@ afterEach(async () => {
// under test. Nineteen maps cases stub fetch and drive those paths back to back;
// under the real 1.1s interval they would spend nineteen seconds asleep. The
// cases that are actually about the throttle set it back for themselves.
//
// Imported dynamically, and down here rather than at the top: the client pulls
// in maps.helpers, whose UA constant calls getAppUrl() at module load. A static
// import is hoisted above the assignments above it, so the URL would be read
// from an environment this file has not finished setting up yet.
const { setGeoThrottleInterval } = await import('../src/nest/geo/nominatim.client');
setGeoThrottleInterval(0);
@@ -17,12 +17,10 @@
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { resolve } from 'node:path';
import { createTestDb } from '../../helpers/test-db';
const here = dirname(fileURLToPath(import.meta.url));
const MIGRATIONS_PATH = resolve(here, '../../../src/db/migrations.ts');
const MIGRATIONS_PATH = resolve(__dirname, '../../../src/db/migrations.ts');
const migrationsSource = readFileSync(MIGRATIONS_PATH, 'utf8');
/**
+5 -1
View File
@@ -41,6 +41,7 @@ vi.mock('../../../src/config', () => ({
}));
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import type { TransitPlace } from '../../../src/nest/transit/transit.helpers';
import { TransitService } from '../../../src/nest/transit/transit.service';
// The transit tools live on the DI-discovered transit.mcp.ts since the transit
@@ -157,7 +158,10 @@ describe('MCP transit tools', () => {
it('forwards stop and route searches and replaces provider endpoint names', async () => {
const { user } = createUser(testDb);
geocodeMock.mockResolvedValue({ results: [from] });
// `from` is a tool-input place (name/lat/lng). A geocode result is a TransitPlace,
// which also carries `type` and `area`; the stop search only reads the name back out,
// so the fixture stays as it is instead of growing fields nothing here looks at.
geocodeMock.mockResolvedValue({ results: [from as TransitPlace] });
planMock.mockResolvedValue({
itineraries: [
itinerary,
@@ -35,6 +35,16 @@ function controller(over: Partial<PackingService> = {}) {
return { c: new AdminPackingTemplatesController(packing, { writeAudit } as unknown as AuditService), packing };
}
/**
* An override whose return value the real method signature cannot produce. Every error
* branch in PackingService sets a status, so an error envelope without one is not part
* of the service's return type, yet the controller still has a fallback for it and
* PACKTPL-003 is the case that covers that fallback.
*/
function offContract<K extends keyof PackingService>(name: K, impl: () => unknown) {
return { [name]: vi.fn(impl) } as unknown as Partial<PackingService>;
}
const thrown = (run: () => unknown) => {
try {
run();
@@ -54,12 +64,12 @@ describe('AdminPackingTemplatesController', () => {
});
it('PACKTPL-002 a service {error,status} becomes that HTTP status, not a 200 body', () => {
const { c } = controller({ getPackingTemplate: vi.fn(() => ({ error: 'not found', status: 404 })) } as Partial<PackingService>);
const { c } = controller({ getPackingTemplate: vi.fn(() => ({ error: 'not found', status: 404 })) });
expect(thrown(() => c.get('9'))).toEqual({ status: 404, body: { error: 'not found' } });
});
it('PACKTPL-003 an error without a status defaults to 400', () => {
const { c } = controller({ createTemplateCategory: vi.fn(() => ({ error: 'name required' })) } as Partial<PackingService>);
const { c } = controller(offContract('createTemplateCategory', () => ({ error: 'name required' })));
expect(thrown(() => c.createCategory('1', { name: '' }))).toEqual({ status: 400, body: { error: 'name required' } });
});
@@ -180,8 +180,8 @@ describe('AdminController read-only getters', () => {
describe('AdminController tokens + sessions', () => {
it('mcp token + oauth session deletes return success and map errors', () => {
expect(adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken('2')).toEqual({ success: true });
expect(thrown(() => adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) }).deleteMcpToken('9'))).toEqual({ status: 404, body: { error: 'no token' } });
expect(adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken(user, '2', req)).toEqual({ success: true });
expect(thrown(() => adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) }).deleteMcpToken(user, '9', req))).toEqual({ status: 404, body: { error: 'no token' } });
expect(thrown(() => adminCtl(svc(), undefined, undefined, {}, {}, { adminRevokeOAuthSession: vi.fn().mockReturnValue({ error: 'no session', status: 404 }) }).revokeOAuthSession(user, '9', req))).toEqual({ status: 404, body: { error: 'no session' } });
});
});
@@ -45,9 +45,9 @@ import { DatabaseService } from '../../../src/nest/database/database.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { AddonsService } from '../../../src/nest/addons/addons.service';
import { SettingsService } from '../../../src/nest/settings/settings.service';
import { AtlasService } from '../../../src/nest/atlas/atlas.service';
import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service';
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
import { WebauthnConfigService } from '../../../src/nest/auth/webauthn-config.service';
import { AuthService } from '../../../src/nest/auth/auth.service';
import { PasskeyService } from '../../../src/nest/auth/passkey.service';
@@ -63,7 +63,11 @@ const realtime = new RealtimeService();
const permissions = new PermissionsService(dbs);
const webauthn = new WebauthnConfigService(dbs);
const userCleanup = new UserCleanupService(dbs);
const auth = new AuthService(dbs, permissions, new AtlasService(dbs), new TripMembershipService(dbs), webauthn, userCleanup, new EphemeralTokenService());
// Positional and previously wrong: an AtlasService sat in the membership slot
// and the mailer was missing entirely, so `auth` was built with its last four
// collaborators shifted by one. Nothing failed, because the version-check path
// below never reaches them.
const auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, new MailerService(dbs), new EphemeralTokenService());
const svc = new AdminService(
dbs,
new AddonsService(dbs),
@@ -72,6 +76,7 @@ const svc = new AdminService(
permissions,
makeNotificationsService(dbs, realtime),
userCleanup,
realtime,
);
const checkAndNotifyVersion = () => svc.checkAndNotifyVersion();
+1 -1
View File
@@ -66,7 +66,7 @@ vi.mock('../../../src/scheduler', () => ({
// Imports (after mocks)
// ---------------------------------------------------------------------------
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from 'vitest';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
@@ -30,33 +30,33 @@ beforeEach(() => vi.clearAllMocks());
describe('ReservationImportController.preview', () => {
it('rejects an invalid mode with 400', async () => {
const { c } = make();
expect(await status(() => c.preview(user, 't1', [file()], 'bogus'))).toBe(400);
expect(await status(() => c.preview(user, 't1', [file()], { mode: 'bogus' }))).toBe(400);
});
it('returns 409 for force-ai when AI is not configured', async () => {
const { c } = make({ aiAvailable: vi.fn(() => false) as any });
expect(await status(() => c.preview(user, 't1', [file()], 'force-ai'))).toBe(409);
expect(await status(() => c.preview(user, 't1', [file()], { mode: 'force-ai' }))).toBe(409);
});
it('returns 503 for no-ai when the extractor is unavailable', async () => {
const { c } = make({ isAvailable: vi.fn(() => false) as any });
expect(await status(() => c.preview(user, 't1', [file()], 'no-ai'))).toBe(503);
expect(await status(() => c.preview(user, 't1', [file()], { mode: 'no-ai' }))).toBe(503);
});
it('returns 400 when no files are uploaded', async () => {
const { c } = make();
expect(await status(() => c.preview(user, 't1', [], 'no-ai'))).toBe(400);
expect(await status(() => c.preview(user, 't1', [], { mode: 'no-ai' }))).toBe(400);
});
it('passes the parsed mode and user id through to the service', async () => {
const { c, svc } = make();
await c.preview(user, 't1', [file()], 'fallback-on-empty');
await c.preview(user, 't1', [file()], { mode: 'fallback-on-empty' });
expect(svc.preview).toHaveBeenCalledWith([expect.anything()], 'fallback-on-empty', 1);
});
it('defaults the mode to no-ai when omitted', async () => {
const { c, svc } = make();
await c.preview(user, 't1', [file()], undefined);
await c.preview(user, 't1', [file()], {});
expect(svc.preview).toHaveBeenCalledWith([expect.anything()], 'no-ai', 1);
});
});
@@ -46,7 +46,7 @@ describe('CategoriesController (parity with the legacy /api/categories route)',
it('404 when the category does not exist', () => {
const getById = vi.fn().mockReturnValue(undefined);
const update = vi.fn();
expect(thrown(() => makeController({ getById, update }).update('9', 'X'))).toEqual({
expect(thrown(() => makeController({ getById, update }).update('9', { name: 'X' }))).toEqual({
status: 404, body: { error: 'Category not found' },
});
expect(update).not.toHaveBeenCalled();
+8 -3
View File
@@ -62,7 +62,7 @@ import { createUser, createTrip, addTripMember, createPlace, createReservation,
import { DatabaseService, type TripAccess } from '../../../src/nest/database/database.service';
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { FilesService } from '../../../src/nest/files/files.service';
import { getAllowedExtensions as bridgeGetAllowedExtensions } from '../../../src/nest/files/files.bridge';
import { AllowedFileTypesService } from '../../../src/nest/files/allowed-file-types.service';
import {
DEFAULT_ALLOWED_EXTENSIONS,
MAX_FILE_SIZE,
@@ -558,8 +558,13 @@ describe('verifyTripAccess / can / files.bridge', () => {
expect(checkPermission).toHaveBeenLastCalledWith('file_upload', 'user', user.id, user.id, false);
});
it('FILE-SVC-038: files.bridge getAllowedExtensions delegates to the service over the shared db', () => {
it('FILE-SVC-038: the allowed-extension list is a live read, not a boot snapshot', () => {
// Was asserted against files.bridge, which built its own FilesService. The
// list moved to a leaf service the multer factories inject, and the property
// that matters is unchanged: an admin editing the list in settings applies
// to the next upload, with no invalidation wiring.
const allowed = new AllowedFileTypesService(new DatabaseService(testDb));
setAppSetting(testDb, 'allowed_file_types', 'md,markdown');
expect(bridgeGetAllowedExtensions()).toBe('md,markdown');
expect(allowed.get()).toBe('md,markdown');
});
});
@@ -5,6 +5,7 @@ import { UnifiedMemoriesController } from '../../../src/nest/memories/unified.co
import { ImmichMemoriesController } from '../../../src/nest/memories/immich.controller';
import { SynologyMemoriesController } from '../../../src/nest/memories/synology.controller';
import type { MemoriesService } from '../../../src/nest/memories/memories.service';
import type { AddTripPhotosDto, SynologySettingsDto } from '../../../src/nest/memories/memories.dto';
import type { User } from '../../../src/types';
const { getClientIp } = vi.hoisted(() => ({ getClientIp: vi.fn(() => '1.2.3.4') }));
@@ -16,6 +17,16 @@ function makeService(overrides: Partial<MemoriesService> = {}): MemoriesService
return { ...overrides } as unknown as MemoriesService;
}
/**
* A DTO type describes a body that already passed the Zod pipe. The two cases
* that use this hand a controller a value the pipe would reject, because what
* they pin is the hand-rolled coercion running after validation — the leniency
* the Express routers had, which the controllers still carry.
*/
function offContractBody<T>(body: Record<string, unknown>): T {
return body as unknown as T;
}
type MockRes = Response & {
status: ReturnType<typeof vi.fn>;
json: ReturnType<typeof vi.fn>;
@@ -78,7 +89,13 @@ describe('UnifiedMemoriesController (parity with /api/integrations/memories/unif
it('ignores a non-array selections payload', async () => {
const addTripPhotos = vi.fn().mockResolvedValue({ data: { added: 0 } });
const svc = makeService({ addTripPhotos });
await new UnifiedMemoriesController(svc).addPhotos(user, '5', { selections: 'nope', shared: true }, 'sock', makeRes());
await new UnifiedMemoriesController(svc).addPhotos(
user,
'5',
offContractBody<AddTripPhotosDto>({ selections: 'nope', shared: true }),
'sock',
makeRes(),
);
expect(addTripPhotos).toHaveBeenCalledWith('5', 7, true, [], 'sock');
});
@@ -567,7 +584,11 @@ describe('SynologyMemoriesController (parity with /api/integrations/memories/syn
expect(synologyUpdateSettings).toHaveBeenCalledWith(7, 'u', 'a', '', true);
const svc2 = makeService({ synologyUpdateSettings: vi.fn().mockResolvedValue({ success: true, data: {} }) });
await new SynologyMemoriesController(svc2).putSettings(user, { synology_url: 'u', synology_username: 'a', synology_skip_ssl: 'no' }, makeRes());
await new SynologyMemoriesController(svc2).putSettings(
user,
offContractBody<SynologySettingsDto>({ synology_url: 'u', synology_username: 'a', synology_skip_ssl: 'no' }),
makeRes(),
);
expect(svc2.synologyUpdateSettings).toHaveBeenCalledWith(7, 'u', 'a', '', false);
});
});
@@ -12,6 +12,7 @@ import { OauthPublicController } from '../../../src/nest/oauth/oauth-public.cont
import { OauthApiController } from '../../../src/nest/oauth/oauth-api.controller';
import { RateLimitService } from '../../../src/nest/common/rate-limit.service';
import type { OauthService } from '../../../src/nest/oauth/oauth.service';
import type { AuditService } from '../../../src/nest/audit/audit.service';
import type { User } from '../../../src/types';
function osvc(o: Partial<OauthService> = {}): OauthService {
@@ -83,7 +84,9 @@ describe('OauthPublicController /token', () => {
it('authorization_code: maps client_id / redirect_uri / resource mismatches + pkce + client auth', () => {
const base = { grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' };
const mk = (pending: Record<string, unknown>, extra: Partial<OauthService> = {}, body = base) => {
// body is the loose form the controller actually reads off req.body, not the
// shape of `base`, so a case can add a field such as `resource`.
const mk = (pending: Record<string, unknown>, extra: Partial<OauthService> = {}, body: Record<string, string> = base) => {
const res = makeRes();
opc(osvc({ consumeAuthCode: vi.fn().mockReturnValue(pending), authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), verifyPKCE: vi.fn().mockReturnValue(true), ...extra }), rl()).token(reqWith(body), res);
return res;
@@ -12,7 +12,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockDbGet, mockDbRun } = vi.hoisted(() => ({
mockDbGet: vi.fn(() => undefined as any),
// Declared with the shape the fake statement below calls it with (the SQL plus
// whatever the caller bound), otherwise the spread has no rest parameter to land in.
mockDbGet: vi.fn((_sql: string, ..._params: unknown[]): unknown => undefined),
mockDbRun: vi.fn(),
}));
@@ -10,7 +10,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockDbGet, mockDbRun } = vi.hoisted(() => ({
mockDbGet: vi.fn(() => undefined as any),
// The prepare() stub below forwards the SQL and then the bound values, so the
// spy has to declare that shape. Inferred from a zero-arg implementation it
// would reject the spread at the call site.
mockDbGet: vi.fn((_sql: string, ..._params: unknown[]): unknown => undefined),
mockDbRun: vi.fn(),
}));
@@ -4,6 +4,7 @@ import { TagsController } from '../../../src/nest/tags/tags.controller';
import type { TagsService } from '../../../src/nest/tags/tags.service';
import type { User } from '../../../src/types';
import type { Tag } from '@trek/shared';
import { anyBody } from '../../helpers/dto';
const user = { id: 5 } as User;
@@ -34,7 +35,7 @@ describe('TagsController (parity with the legacy /api/tags route)', () => {
describe('POST /', () => {
it('400 when name is missing', () => {
const create = vi.fn();
expect(thrown(() => makeController({ create }).create(user, undefined))).toEqual({
expect(thrown(() => makeController({ create }).create(user, anyBody()))).toEqual({
status: 400, body: { error: 'Tag name is required' },
});
expect(create).not.toHaveBeenCalled();
@@ -42,7 +43,7 @@ describe('TagsController (parity with the legacy /api/tags route)', () => {
it('creates a tag for the caller', () => {
const create = vi.fn().mockReturnValue(tag);
expect(makeController({ create }).create(user, 'Beach', '#10b981')).toEqual({ tag });
expect(makeController({ create }).create(user, anyBody({ name: 'Beach', color: '#10b981' }))).toEqual({ tag });
expect(create).toHaveBeenCalledWith(5, 'Beach', '#10b981');
});
});
@@ -51,7 +52,7 @@ describe('TagsController (parity with the legacy /api/tags route)', () => {
it('404 when the tag is not owned by the caller', () => {
const getByIdAndUser = vi.fn().mockReturnValue(undefined);
const update = vi.fn();
expect(thrown(() => makeController({ getByIdAndUser, update }).update(user, '9', 'X'))).toEqual({
expect(thrown(() => makeController({ getByIdAndUser, update }).update(user, '9', anyBody({ name: 'X' })))).toEqual({
status: 404, body: { error: 'Tag not found' },
});
expect(getByIdAndUser).toHaveBeenCalledWith('9', 5);
@@ -61,7 +62,7 @@ describe('TagsController (parity with the legacy /api/tags route)', () => {
it('updates an owned tag', () => {
const getByIdAndUser = vi.fn().mockReturnValue(tag);
const update = vi.fn().mockReturnValue({ ...tag, name: 'Hike' });
expect(makeController({ getByIdAndUser, update }).update(user, '1', 'Hike')).toEqual({ tag: { ...tag, name: 'Hike' } });
expect(makeController({ getByIdAndUser, update }).update(user, '1', anyBody({ name: 'Hike' }))).toEqual({ tag: { ...tag, name: 'Hike' } });
expect(update).toHaveBeenCalledWith('1', 'Hike', undefined);
});
});
@@ -68,6 +68,11 @@ import { MapsService } from '../../../src/nest/maps/maps.service';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import { notificationsStub } from '../../helpers/notifications';
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
// Real sibling services over the same in-memory DB — the aggregation runs the
// actual SQL of every domain it fans out to, so a shape change downstream shows
@@ -75,7 +80,15 @@ import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.se
const dbs = () => new DatabaseService(testDb);
const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService());
const daysSvc = new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new QueryHelpersService(dbs()));
const placesSvc = new PlacesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new MapsService(dbs()), new QueryHelpersService(dbs()));
// One shared cache instance, as in trips.bridge.ts: the in-flight dedup in
// PlacePhotoCacheService only works while both consumers hold the same object.
const photoCache = new PlacePhotoCacheService(dbs(), new RuntimeEnvService());
const placesSvc = new PlacesService(
dbs(), new PermissionsService(dbs()), new RealtimeService(),
new MapsService(dbs(), photoCache), new QueryHelpersService(dbs()),
new UnsplashService(dbs(), new RuntimeEnvService()), photoCache,
new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs())),
);
const accommodationsSvc = new AccommodationsService(dbs(), new PermissionsService(dbs()), new RealtimeService());
const membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs()), new PermissionsService(dbs()), new RealtimeService(), notificationsStub());
+23 -1
View File
@@ -74,6 +74,11 @@ import { TripMembersService } from '../../../src/nest/trip-members/trip-members.
import { TripReadModelService } from '../../../src/nest/trip-read-model/trip-read-model.service';
import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service';
import { MapsService } from '../../../src/nest/maps/maps.service';
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { getTripOwner, listMembers as bridgeListMembers } from '../../../src/nest/trips/trips.bridge';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import fs from 'fs';
@@ -85,7 +90,24 @@ import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.se
const dbs = () => new DatabaseService(testDb);
const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService());
const daysSvc = new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new QueryHelpersService(dbs()));
const placesSvc = new PlacesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new MapsService(dbs()), new QueryHelpersService(dbs()));
// Same collaborator set the container hands PlacesService (see trips.bridge.ts).
// Only the read-model aggregation reaches into places here, but the photo cache,
// Unsplash and journey domain are real instances over the same in-memory DB
// rather than casts: the place hooks are fire-and-forget behind a catch, so a
// missing collaborator would look like a pass while swallowing a TypeError.
// One PlacePhotoCacheService for both PlacesService and MapsService, matching
// production, where the in-flight dedup only works on a shared instance.
const photoCache = new PlacePhotoCacheService(dbs(), new RuntimeEnvService());
const placesSvc = new PlacesService(
dbs(),
new PermissionsService(dbs()),
new RealtimeService(),
new MapsService(dbs(), photoCache),
new QueryHelpersService(dbs()),
new UnsplashService(dbs(), new RuntimeEnvService()),
photoCache,
new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs())),
);
const accommodationsSvc = new AccommodationsService(dbs(), new PermissionsService(dbs()), new RealtimeService());
const createAccommodation = accommodationsSvc.createAccommodation.bind(accommodationsSvc);
@@ -35,10 +35,13 @@ function build(opts: { canEdit?: boolean; before?: Item | undefined; updated?: I
const packing = new PackingService({} as never, {} as never, realtime, notificationsStub());
const data = {
listItems: vi.fn(() => [{ id: 70, name: 'Socks' }]),
createItem: vi.fn((_t: string, i: Record<string, unknown>) => ({ id: 70, ...i })),
// The bodies here are the boring fixture; the item writes are typed as Item so a
// case can hand back the privacy fields the real service selects (#858), which the
// fixture itself never bothers to set.
createItem: vi.fn((_t: string, i: Record<string, unknown>): Item => ({ id: 70, ...i })),
getItemPrivacy: vi.fn(() => opts.before),
updateItem: vi.fn(() => (opts.updated === undefined ? { id: 70, is_private: 0 } : opts.updated)),
deleteItem: vi.fn((_t: string, id: string) => (id === '70' ? { id: 70, is_private: 0 } : null)),
deleteItem: vi.fn((_t: string, id: string): Item | null => (id === '70' ? { id: 70, is_private: 0 } : null)),
listBags: vi.fn(() => [{ id: 80, name: 'Backpack' }]),
createBag: vi.fn((_t: string, b: Record<string, unknown>) => ({ id: 80, ...b })),
updateBag: vi.fn((_t: string, id: string) => (id === '80' ? { id: 80 } : null)),
@@ -215,19 +215,19 @@ describe('PluginsController M2 endpoints', () => {
it('activate spawns via the runtime when enabled', async () => {
const rt = { activate: vi.fn(async () => {}), isActive: vi.fn(() => true) } as never;
const out = await new PluginsController(svc, rt, {} as never).activate('x');
const out = await new PluginsController(svc, rt, {} as never).activate('x', {});
expect(out).toEqual({ status: 'active' });
});
it('activate is 503 when the runtime is disabled', async () => {
process.env.TREK_PLUGINS_ENABLED = 'false';
const rt = { activate: vi.fn(), isActive: vi.fn() } as never;
await expect(new PluginsController(svc, rt, {} as never).activate('x')).rejects.toMatchObject({ status: 503 });
await expect(new PluginsController(svc, rt, {} as never).activate('x', {})).rejects.toMatchObject({ status: 503 });
});
it('activate surfaces an activation error as 400', async () => {
const rt = { activate: vi.fn(async () => { throw new Error('bad code'); }), isActive: vi.fn(() => false) } as never;
await expect(new PluginsController(svc, rt, {} as never).activate('x')).rejects.toMatchObject({ status: 400 });
await expect(new PluginsController(svc, rt, {} as never).activate('x', {})).rejects.toMatchObject({ status: 400 });
});
it('deactivate stops the plugin (and cascades to dependents)', async () => {
@@ -18,7 +18,7 @@ import { ReservationsModule } from '../../../src/nest/reservations/reservations.
import { AccommodationsRpc } from '../../../src/nest/accommodations/accommodations.rpc';
import { AccommodationsModule } from '../../../src/nest/accommodations/accommodations.module';
import type { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import type { DaysService } from '../../../src/nest/days/days.service';
import type { AccommodationsService } from '../../../src/nest/accommodations/accommodations.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';
@@ -45,13 +45,15 @@ function build(opts: { canEdit?: boolean; cascade?: boolean; seenActions?: strin
syncBudgetOnUpdate: vi.fn(),
notifyBookingChange: vi.fn(),
} as unknown as ReservationsService & Record<string, ReturnType<typeof vi.fn>>;
// The lodging blocks live in AccommodationsService; AccommodationsRpc still calls its
// injected copy `days`, which is why the fixture keeps that name.
const days = {
validateAccommodationRefs: vi.fn((_t: number, placeId?: number) => (placeId === 404 ? [{ message: 'place 404 is not on this trip' }] : [])),
createAccommodation: vi.fn(() => ({ id: 60 })),
getAccommodation: vi.fn((id: number) => (id === 11 ? { id: 11 } : undefined)),
updateAccommodation: vi.fn(() => ({ id: 11 })),
deleteAccommodation: vi.fn(() => ({ linkedReservationId: opts.cascade ? 40 : null, deletedBudgetItemId: opts.cascade ? 7 : null })),
} as unknown as DaysService & Record<string, ReturnType<typeof vi.fn>>;
} as unknown as AccommodationsService & Record<string, ReturnType<typeof vi.fn>>;
const guards = new PluginGuards(
{
canAccessTrip: vi.fn((tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined)),
@@ -1,6 +1,13 @@
import { describe, it, expect } from 'vitest';
import { buildSavePayload } from '../../../src/nest/integrations/airtrail-sync.service';
import type { AirtrailAirport, AirtrailFlightRaw } from '../../../src/nest/integrations/airtrail.client';
import type { AirtrailAirport, AirtrailFlightRaw, AirtrailSavePayload } from '../../../src/nest/integrations/airtrail.client';
/**
* buildSavePayload spreads the raw flight, so the body also carries the
* AirTrail-owned keys the save type deliberately leaves unmodelled (terminal,
* gate, actual times, customFields, track). Read those through this view.
*/
type SavePayloadWithPassthrough = AirtrailSavePayload & Record<string, unknown>;
function airport(over: Partial<AirtrailAirport> = {}): AirtrailAirport {
return {
@@ -125,7 +132,7 @@ describe('airtrailSync.buildSavePayload', () => {
metadata: JSON.stringify({ airline: 'BAW', flight_number: 'BA999', seat: '3C' }),
}),
existingFlight(),
);
) as SavePayloadWithPassthrough | null;
expect(payload).toMatchObject({
id: 42,
from: 'JFK',
+9
View File
@@ -24,6 +24,15 @@ const ctx = (actingUserId: number | undefined): PluginRpcContext => ({
pluginId: 'p',
actingUserId,
data: {} as PluginRpcContext['data'],
// No tags handler reaches for a peer plugin, but the peers are a real collaborator on
// the context, so they get functions that throw rather than an object whose methods
// would silently be undefined if a handler ever did reach for one.
plugins: {
call: () => Promise.reject(new Error('ctx: no peer plugin router in these cases')),
emit: () => {
throw new Error('ctx: no peer plugin router in these cases');
},
},
});
/** Tag 1 belongs to user 42, nothing else does. */
+1
View File
@@ -237,6 +237,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'تفعيل الإضافة',
'plugins.notFound': 'الإضافة غير موجودة',
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
'admin.plugins.title': 'الإضافات الخارجية',
'admin.plugins.subtitle': 'ثبّت وأدر إضافات الجهات الخارجية على خادمك.',
'admin.plugins.disabledTitle': 'الإضافات الخارجية معطّلة',
+1
View File
@@ -217,6 +217,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Atualizar → v{version}',
'admin.plugins.enabledToggle': 'Ativar plugin',
'plugins.notFound': 'Plugin não encontrado',
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Instale e gerencie plugins de terceiros na sua instância.',
'admin.plugins.disabledTitle': 'Os plugins estão desativados',
+1
View File
@@ -430,6 +430,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Actualitzar → v{version}',
'admin.plugins.enabledToggle': 'Activar el connector',
'plugins.notFound': "No s'ha trobat el connector",
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
'admin.plugins.title': 'Connectors',
'admin.plugins.subtitle': 'Instal·la i gestiona connectors de tercers a la teva instància.',
'admin.plugins.disabledTitle': 'Els connectors estan desactivats',
+1
View File
@@ -218,6 +218,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Povolit plugin',
'plugins.notFound': 'Plugin nenalezen',
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
'admin.plugins.title': 'Pluginy',
'admin.plugins.subtitle': 'Instalujte a spravujte pluginy třetích stran na své instanci.',
'admin.plugins.disabledTitle': 'Pluginy jsou zakázány',
+1
View File
@@ -221,6 +221,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Plugin aktivieren',
'plugins.notFound': 'Plugin nicht gefunden',
'plugins.frameLoadFailed': 'Dieses Plugin konnte nicht geladen werden.',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Plugins von Drittanbietern auf deiner Instanz installieren und verwalten.',
'admin.plugins.disabledTitle': 'Plugins sind deaktiviert',
+1
View File
@@ -271,6 +271,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Enable plugin',
'plugins.notFound': 'Plugin not found',
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
'admin.plugins.disabledTitle': 'Plugins are disabled',
+1
View File
@@ -219,6 +219,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Actualizar → v{version}',
'admin.plugins.enabledToggle': 'Activar plugin',
'plugins.notFound': 'Plugin no encontrado',
'plugins.frameLoadFailed': 'No se ha podido cargar este plugin.',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Instala y gestiona plugins de terceros en tu instancia.',
'admin.plugins.disabledTitle': 'Los plugins están desactivados',
+1
View File
@@ -222,6 +222,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Activer le plugin',
'plugins.notFound': 'Plugin introuvable',
'plugins.frameLoadFailed': "Ce plugin n'a pas pu être chargé.",
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Installez et gérez des plugins tiers sur votre instance.',
'admin.plugins.disabledTitle': 'Les plugins sont désactivés',
+1
View File
@@ -266,6 +266,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Ενεργοποίηση plugin',
'plugins.notFound': 'Το plugin δεν βρέθηκε',
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Εγκαταστήστε και διαχειριστείτε plugins τρίτων στην εγκατάστασή σας.',
'admin.plugins.disabledTitle': 'Τα plugins είναι απενεργοποιημένα',
+1
View File
@@ -220,6 +220,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Frissítés → v{version}',
'admin.plugins.enabledToggle': 'Plugin engedélyezése',
'plugins.notFound': 'A plugin nem található',
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
'admin.plugins.title': 'Pluginok',
'admin.plugins.subtitle': 'Harmadik féltől származó pluginok telepítése és kezelése a példányodon.',
'admin.plugins.disabledTitle': 'A pluginok le vannak tiltva',
+1
View File
@@ -262,6 +262,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Aktifkan plugin',
'plugins.notFound': 'Plugin tidak ditemukan',
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Pasang dan kelola plugin pihak ketiga di instans kamu.',
'admin.plugins.disabledTitle': 'Plugin dinonaktifkan',
+1
View File
@@ -219,6 +219,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Aggiorna → v{version}',
'admin.plugins.enabledToggle': 'Abilita plugin',
'plugins.notFound': 'Plugin non trovato',
'plugins.frameLoadFailed': 'Impossibile caricare questo plugin.',
'admin.plugins.title': 'Plugin',
'admin.plugins.subtitle': 'Installa e gestisci plugin di terze parti sulla tua istanza.',
'admin.plugins.disabledTitle': 'I plugin sono disabilitati',
+1
View File
@@ -251,6 +251,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': '更新 → v{version}',
'admin.plugins.enabledToggle': 'プラグインを有効にする',
'plugins.notFound': 'プラグインが見つかりません',
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
'admin.plugins.title': 'プラグイン',
'admin.plugins.subtitle': 'サードパーティ製プラグインをインスタンスにインストール・管理します。',
'admin.plugins.disabledTitle': 'プラグインは無効になっています',
+1
View File
@@ -256,6 +256,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': '업데이트 → v{version}',
'admin.plugins.enabledToggle': '플러그인 활성화',
'plugins.notFound': '플러그인을 찾을 수 없습니다',
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
'admin.plugins.title': '플러그인',
'admin.plugins.subtitle': '인스턴스에 서드파티 플러그인을 설치하고 관리하세요.',
'admin.plugins.disabledTitle': '플러그인이 비활성화되어 있습니다',
+1
View File
@@ -220,6 +220,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Plugin inschakelen',
'plugins.notFound': 'Plugin niet gevonden',
'plugins.frameLoadFailed': 'Deze plug-in kon niet worden geladen.',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Installeer en beheer plugins van derden op je instantie.',
'admin.plugins.disabledTitle': 'Plugins zijn uitgeschakeld',
+1
View File
@@ -213,6 +213,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Update → v{version}',
'admin.plugins.enabledToggle': 'Włącz wtyczkę',
'plugins.notFound': 'Nie znaleziono wtyczki',
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
'admin.plugins.title': 'Wtyczki',
'admin.plugins.subtitle': 'Instaluj wtyczki innych autorów i zarządzaj nimi na swojej instancji.',
'admin.plugins.disabledTitle': 'Wtyczki są wyłączone',
+1
View File
@@ -220,6 +220,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Обновить → v{version}',
'admin.plugins.enabledToggle': 'Включить плагин',
'plugins.notFound': 'Плагин не найден',
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
'admin.plugins.title': 'Плагины',
'admin.plugins.subtitle': 'Устанавливайте сторонние плагины и управляйте ими на вашем экземпляре.',
'admin.plugins.disabledTitle': 'Плагины отключены',
+1
View File
@@ -276,6 +276,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Uppdatera → v{version}',
'admin.plugins.enabledToggle': 'Aktivera plugin',
'plugins.notFound': 'Pluginet hittades inte',
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Installera och hantera tredjepartsplugins på din instans.',
'admin.plugins.disabledTitle': 'Plugins är inaktiverade',
+1
View File
@@ -264,6 +264,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Güncelle → v{version}',
'admin.plugins.enabledToggle': 'Eklentiyi etkinleştir',
'plugins.notFound': 'Eklenti bulunamadı',
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Örneğinizde üçüncü taraf eklentileri yükleyin ve yönetin.',
'admin.plugins.disabledTitle': 'Eklentiler devre dışı',
+1
View File
@@ -219,6 +219,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Оновити → v{version}',
'admin.plugins.enabledToggle': 'Увімкнути плагін',
'plugins.notFound': 'Плагін не знайдено',
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
'admin.plugins.title': 'Плагіни',
'admin.plugins.subtitle': 'Встановлюйте сторонні плагіни та керуйте ними на вашому інстансі.',
'admin.plugins.disabledTitle': 'Плагіни вимкнено',
+1
View File
@@ -275,6 +275,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': 'Cập nhật → v{version}',
'admin.plugins.enabledToggle': 'Bật plugin',
'plugins.notFound': 'Không tìm thấy plugin',
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
'admin.plugins.title': 'Plugins',
'admin.plugins.subtitle': 'Cài đặt và quản lý các plugin bên thứ ba trên hệ thống của bạn.',
'admin.plugins.disabledTitle': 'Plugin đang bị tắt',
+1
View File
@@ -251,6 +251,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': '更新 → v{version}',
'admin.plugins.enabledToggle': '啟用外掛',
'plugins.notFound': '找不到外掛',
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
'admin.plugins.title': '外掛',
'admin.plugins.subtitle': '在你的執行個體上安裝並管理第三方外掛。',
'admin.plugins.disabledTitle': '外掛已停用',
+1
View File
@@ -211,6 +211,7 @@ const admin: TranslationStrings = {
'admin.plugins.updateTo': '更新 → v{version}',
'admin.plugins.enabledToggle': '启用插件',
'plugins.notFound': '未找到插件',
'plugins.frameLoadFailed': 'This plugin could not be loaded.',
'admin.plugins.title': '插件',
'admin.plugins.subtitle': '在你的实例上安装和管理第三方插件。',
'admin.plugins.disabledTitle': '插件功能已禁用',