feat(server): give the memories endpoints their Zod contracts

The last nine memories entries in the body-contract allow list are gone: three
each on the immich, synology and unified controllers. They read @Body() as
Record<string, unknown> and coerced by hand.

The schemas are deliberately permissive and loose, and that is the point rather
than a shortcut. Paging arrives as `2` or `'2'` because the controller does
`Number(page) || 1`; synology_skip_ssl arrives as a boolean or the string
'true'; auto_upload is accepted in any shape because only a real boolean is
acted on. A schema that described the ideal body instead of the real one would
turn working clients into 400s, and parity outranks tightening. Narrowing them
is a separate, client-visible change.

Loose objects for the same reason: a strict object strips unknown keys, and
`selections[]` goes straight to the services as `Selection[]` - stripping there
would be a silent data loss the day a provider adds a field.

This unblocks be-memories-fold, which could not move a controller whose body had
no contract to move with it.
This commit is contained in:
Maurice
2026-08-08 11:58:18 +02:00
committed by Maurice
parent ac96644508
commit 97f524cdfd
8 changed files with 250 additions and 18 deletions
@@ -12,9 +12,6 @@ export const BODY_CONTRACT_ALLOW_LIST: string[] = [
'BookingImportController.confirm',
'BookingImportController.preview',
'BookingImportController.previewAsync',
'ImmichMemoriesController.putSettings',
'ImmichMemoriesController.search',
'ImmichMemoriesController.test',
'JourneyController.addContributor',
'JourneyController.addTrip',
'JourneyController.create',
@@ -43,12 +40,6 @@ export const BODY_CONTRACT_ALLOW_LIST: string[] = [
'PluginsController.setEgressHosts',
'PluginsController.uninstall',
'PluginsController.updateConfig',
'SynologyMemoriesController.putSettings',
'SynologyMemoriesController.search',
'SynologyMemoriesController.test',
'TagsController.create',
'TagsController.update',
'UnifiedMemoriesController.addPhotos',
'UnifiedMemoriesController.createAlbumLink',
'UnifiedMemoriesController.setSharing',
];
@@ -5,6 +5,7 @@ import { MemoriesService } from './memories.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { getClientIp } from '../audit/client-ip';
import { ImmichSearchDto, ImmichSettingsDto, ImmichTestDto } from './memories.dto';
/**
* /api/integrations/memories/immich — Immich connection, browse/search, asset
@@ -30,7 +31,7 @@ export class ImmichMemoriesController {
@Put('settings')
async putSettings(
@CurrentUser() user: User,
@Body() body: { immich_url?: string; immich_api_key?: string; auto_upload?: unknown },
@Body() body: ImmichSettingsDto,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
@@ -57,7 +58,7 @@ export class ImmichMemoriesController {
@Post('test')
@HttpCode(200)
async test(@Body() body: { immich_url?: string; immich_api_key?: string }) {
async test(@Body() body: ImmichTestDto) {
const { immich_url, immich_api_key } = body;
if (!immich_url || !immich_api_key) {
return { connected: false, error: 'URL and API key required' };
@@ -77,7 +78,7 @@ export class ImmichMemoriesController {
@Post('search')
@HttpCode(200)
async search(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
async search(@CurrentUser() user: User, @Body() body: ImmichSearchDto, @Res() res: Response): Promise<void> {
const { from, to, size, page } = body as { from?: string; to?: string; size?: unknown; page?: unknown };
const pageNum = Math.max(1, Number(page) || 1);
const pageSize = Math.min(Number(size) || 50, 200);
+34
View File
@@ -0,0 +1,34 @@
import { createZodDto } from 'nestjs-zod';
import {
addTripPhotosSchema,
createAlbumLinkSchema,
immichSearchSchema,
immichSettingsSchema,
immichTestSchema,
setTripPhotoSharingSchema,
synologySearchSchema,
synologySettingsSchema,
synologyTestSchema,
} from '@trek/shared';
/**
* Server-side createZodDto wrappers over the @trek/shared memories contracts.
* The global ZodValidationPipe (APP_PIPE in app.module.ts) validates any @Body()
* parameter typed with one of these classes by metatype — the Zod schemas in
* shared/ remain the single source of truth for the wire contract.
*
* These nine routes were the last memories entries in
* common/body-contract-allow-list.ts; the boot gate throws on stale entries, so
* they were removed in the same change.
*/
export class ImmichSettingsDto extends createZodDto(immichSettingsSchema) {}
export class ImmichTestDto extends createZodDto(immichTestSchema) {}
export class ImmichSearchDto extends createZodDto(immichSearchSchema) {}
export class SynologySettingsDto extends createZodDto(synologySettingsSchema) {}
export class SynologyTestDto extends createZodDto(synologyTestSchema) {}
export class SynologySearchDto extends createZodDto(synologySearchSchema) {}
export class AddTripPhotosDto extends createZodDto(addTripPhotosSchema) {}
export class SetTripPhotoSharingDto extends createZodDto(setTripPhotoSharingSchema) {}
export class CreateAlbumLinkDto extends createZodDto(createAlbumLinkSchema) {}
@@ -6,6 +6,7 @@ import { fail, success } from '../../services/memories/helpersService';
import { MemoriesService } from './memories.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { SynologySearchDto, SynologySettingsDto, SynologyTestDto } from './memories.dto';
function _parseStringBodyField(value: unknown): string {
return String(value ?? '').trim();
@@ -47,7 +48,7 @@ export class SynologyMemoriesController {
}
@Put('settings')
async putSettings(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
async putSettings(@CurrentUser() user: User, @Body() body: SynologySettingsDto, @Res() res: Response): Promise<void> {
const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password);
@@ -67,7 +68,7 @@ export class SynologyMemoriesController {
@Post('test')
@HttpCode(200)
async test(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
async test(@CurrentUser() user: User, @Body() body: SynologyTestDto, @Res() res: Response): Promise<void> {
const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password);
@@ -110,7 +111,7 @@ export class SynologyMemoriesController {
@Post('search')
@HttpCode(200)
async search(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Res() res: Response): Promise<void> {
async search(@CurrentUser() user: User, @Body() body: SynologySearchDto, @Res() res: Response): Promise<void> {
const from = _parseStringBodyField(body.from);
const to = _parseStringBodyField(body.to);
let offset = _parseNumberBodyField(body.offset, 0);
@@ -5,6 +5,7 @@ import type { Selection } from '../../services/memories/helpersService';
import { MemoriesService } from './memories.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { AddTripPhotosDto, CreateAlbumLinkDto, SetTripPhotoSharingDto } from './memories.dto';
/**
* /api/integrations/memories/unified — provider-agnostic trip photo + album-link
@@ -35,7 +36,7 @@ export class UnifiedMemoriesController {
async addPhotos(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: Record<string, unknown>,
@Body() body: AddTripPhotosDto,
@Headers('x-socket-id') sid: string,
@Res() res: Response,
): Promise<void> {
@@ -53,7 +54,7 @@ export class UnifiedMemoriesController {
async setSharing(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: Record<string, unknown>,
@Body() body: SetTripPhotoSharingDto,
@Res() res: Response,
): Promise<void> {
const result = await this.memories.setTripPhotoSharing(tripId, user.id, Number(body?.photo_id), body?.shared as boolean);
@@ -94,7 +95,7 @@ export class UnifiedMemoriesController {
createAlbumLink(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: Record<string, unknown>,
@Body() body: CreateAlbumLinkDto,
@Res() res: Response,
): void {
const passphrase = body?.passphrase ? String(body.passphrase) : undefined;
+1
View File
@@ -20,6 +20,7 @@ export * from './maps/maps.schema';
export * from './category/category.schema';
export * from './tag/tag.schema';
export * from './notification/notification.schema';
export * from './memories/memories.schema';
export * from './atlas/atlas.schema';
export * from './vacay/vacay.schema';
export * from './packing/packing.schema';
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import {
addTripPhotosSchema,
createAlbumLinkSchema,
immichSearchSchema,
immichSettingsSchema,
setTripPhotoSharingSchema,
synologySearchSchema,
synologySettingsSchema,
synologyTestSchema,
} from './memories.schema';
/**
* These contracts exist to describe what the endpoints already accept, not to
* tighten them — the nine routes were read as `Record<string, unknown>` and
* coerced by hand before. The cases below pin that tolerance, because a
* well-meaning narrowing here turns working clients into 400s.
*/
describe('immich contracts', () => {
it('accepts the settings body with an empty payload — every field is optional', () => {
expect(immichSettingsSchema.safeParse({}).success).toBe(true);
});
it('accepts auto_upload in any shape, since only a real boolean is acted on', () => {
for (const auto_upload of [true, false, 'yes', 1, null]) {
expect(immichSettingsSchema.safeParse({ auto_upload }).success).toBe(true);
}
});
it('accepts search paging as numbers OR strings (the controller does Number(x) || fallback)', () => {
expect(immichSearchSchema.safeParse({ page: 2, size: 50 }).success).toBe(true);
expect(immichSearchSchema.safeParse({ page: '2', size: '50' }).success).toBe(true);
});
it('rejects a page that is neither', () => {
expect(immichSearchSchema.safeParse({ page: { nope: true } }).success).toBe(false);
});
});
describe('synology contracts', () => {
it("accepts synology_skip_ssl as a boolean or the string 'true'/'false'", () => {
for (const synology_skip_ssl of [true, false, 'true', 'false']) {
expect(synologySettingsSchema.safeParse({ synology_skip_ssl }).success).toBe(true);
}
});
it('lets the test body carry an OTP on top of the settings fields', () => {
const parsed = synologyTestSchema.safeParse({
synology_url: 'https://nas.example.org',
synology_username: 'ada',
synology_password: 'secret',
synology_otp: '123456',
});
expect(parsed.success).toBe(true);
});
it('accepts every paging field of the search body, in either type', () => {
expect(synologySearchSchema.safeParse({ offset: '10', page: 2, limit: '100', size: 0 }).success).toBe(true);
});
});
describe('unified contracts', () => {
it('accepts a selection list and keeps unknown keys on the items', () => {
const parsed = addTripPhotosSchema.safeParse({
selections: [{ provider: 'immich', asset_ids: ['a', 'b'], passphrase: 'p', future_field: 1 }],
shared: false,
});
expect(parsed.success).toBe(true);
// Loose on purpose: `selections` is handed to the services as `Selection[]`,
// so a stripped key would be a silent data loss the day a provider adds one.
expect(parsed.success && (parsed.data.selections![0] as Record<string, unknown>).future_field).toBe(1);
});
it('accepts a body with no selections at all (the controller falls back to [])', () => {
expect(addTripPhotosSchema.safeParse({}).success).toBe(true);
});
it('rejects a selection missing its asset ids', () => {
expect(addTripPhotosSchema.safeParse({ selections: [{ provider: 'immich' }] }).success).toBe(false);
});
it('accepts photo_id as a number or a string', () => {
expect(setTripPhotoSharingSchema.safeParse({ photo_id: 7, shared: true }).success).toBe(true);
expect(setTripPhotoSharingSchema.safeParse({ photo_id: '7', shared: true }).success).toBe(true);
});
it('leaves the album-link fields untyped — the service validates them, and it answers 400 with its own wording', () => {
expect(createAlbumLinkSchema.safeParse({}).success).toBe(true);
expect(createAlbumLinkSchema.safeParse({ provider: 'synology', album_id: 12, album_name: null }).success).toBe(true);
});
});
+111
View File
@@ -0,0 +1,111 @@
import { z } from 'zod';
/**
* Contracts for the memories (photo provider) mutation endpoints.
*
* Deliberately permissive. Every one of these bodies was read as
* `Record<string, unknown>` and coerced by hand in the controller —
* `Number(page) || 1`, `body.synology_skip_ssl === 'true'`,
* `body?.shared === undefined ? true : !!body?.shared`. A strict schema would
* turn requests the client sends today into 400s, and the parity rule outranks
* tightening: these schemas describe the shape that already works, so the
* boot-time contract gate has something to point at and the client has a typed
* surface. Narrowing them is a separate, client-visible change.
*
* They are LOOSE objects for the same reason: a strict object strips unknown
* keys, and `selections[]` is handed straight to the services as `Selection[]`.
* Stripping there would silently drop a field a provider adds later.
*/
/** Accepts what `_parseStringBodyField` accepted: a string, or nothing. */
const optionalText = z.string().optional().nullable();
/** `x === true || x === 'true'` — the checkbox arrives either way. */
const looseBoolean = z.union([z.boolean(), z.literal('true'), z.literal('false')]).optional();
/** `Number(x) || fallback` — the client sends these as strings from query-ish forms. */
const looseNumber = z.union([z.number(), z.string()]).optional();
// ── Immich ────────────────────────────────────────────────────────────────
export const immichSettingsSchema = z.looseObject({
immich_url: optionalText,
immich_api_key: optionalText,
// Applied only when it is a real boolean (`typeof auto_upload === 'boolean'`),
// so anything else is accepted and ignored, exactly as before.
auto_upload: z.unknown().optional(),
});
export const immichTestSchema = z.looseObject({
immich_url: optionalText,
immich_api_key: optionalText,
});
export const immichSearchSchema = z.looseObject({
from: optionalText,
to: optionalText,
size: looseNumber,
page: looseNumber,
});
// ── Synology ──────────────────────────────────────────────────────────────
export const synologySettingsSchema = z.looseObject({
synology_url: optionalText,
synology_username: optionalText,
synology_password: optionalText,
synology_skip_ssl: looseBoolean,
});
export const synologyTestSchema = synologySettingsSchema.extend({
synology_otp: optionalText,
});
export const synologySearchSchema = z.looseObject({
from: optionalText,
to: optionalText,
offset: looseNumber,
page: looseNumber,
limit: looseNumber,
size: looseNumber,
});
// ── Unified (provider-agnostic trip photo surface) ────────────────────────
/** One provider's picked assets. Mirrors the `Selection` type the services take. */
export const photoSelectionSchema = z.looseObject({
provider: z.string(),
asset_ids: z.array(z.string()),
owner_id: z.number().optional(),
passphrase: z.string().optional(),
});
export const addTripPhotosSchema = z.looseObject({
// Non-arrays fall back to [] rather than 400 (`Array.isArray(body?.selections) ? … : []`).
selections: z.array(photoSelectionSchema).optional(),
// Absent means shared; anything else is read for truthiness.
shared: z.unknown().optional(),
});
export const setTripPhotoSharingSchema = z.looseObject({
photo_id: looseNumber,
shared: z.unknown().optional(),
});
export const createAlbumLinkSchema = z.looseObject({
provider: z.unknown().optional(),
album_id: z.unknown().optional(),
album_name: z.unknown().optional(),
passphrase: z.unknown().optional(),
});
export type ImmichSettingsInput = z.infer<typeof immichSettingsSchema>;
export type ImmichTestInput = z.infer<typeof immichTestSchema>;
export type ImmichSearchInput = z.infer<typeof immichSearchSchema>;
export type SynologySettingsInput = z.infer<typeof synologySettingsSchema>;
export type SynologyTestInput = z.infer<typeof synologyTestSchema>;
export type SynologySearchInput = z.infer<typeof synologySearchSchema>;
export type PhotoSelectionInput = z.infer<typeof photoSelectionSchema>;
export type AddTripPhotosInput = z.infer<typeof addTripPhotosSchema>;
export type SetTripPhotoSharingInput = z.infer<typeof setTripPhotoSharingSchema>;
export type CreateAlbumLinkInput = z.infer<typeof createAlbumLinkSchema>;