diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 1d8f47bf2..c0222d812 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -19,10 +19,13 @@ node scripts/coverage-thresholds.mjs # after test:coverage — prints the ratch npm run db:call-graph -- --sync --tx [--domains nest/days,…] # sync DB-touching methods + transaction sites (exit 1 if any) npm run lint:size # no file in src/ or scripts/ past 1000 lines, longer ones only shrink (CI gate) npm run lint:boundaries # import cycles and cross-domain reach-ins may only shrink (CI gate) +npm run contracts:dto-open # open shapes in the createZodDto schemas may only shrink (runs in the unit tests) ``` **`lint:boundaries`** (`scripts/import-boundaries.mjs`) parses every import under `src/` and holds five lists in `scripts/import-boundaries-baseline.json`: load-time file cycles (type-only and lazy imports, and the MikroORM entity relations, do not count), dependencies between two `nest/` folders that already depend on each other, shared-kernel files (`nest/common`, `nest/database`, `nest/app-config`) importing a domain, a domain importing another domain's internals, and `src/db` importing `src/nest`. A domain's public surface is its `*.module`, `*.service`, `*.guard`, `*.decorator`, `*.types`, `*.interface`, `*.contract`, `*.constants` and `*.logger` files plus the short `PUBLIC_FILES` list in the script; anything else is an internal. A new entry fails the check; `npm run lint:boundaries -- --update` drops the ones that no longer occur and never adds one. +**`contracts:dto-open`** (`tests/unit/contracts/dto-open-shapes.test.ts`, wrapped by `scripts/dto-open-shapes.mjs`) finds every `export class X extends createZodDto(...)` under `src/`, imports it and counts the open shapes (loose object, record of unknown, `z.unknown()`/`z.any()`, `z.custom()`) in the schema the Zod pipe validates, with shared's `contracts:open` walk. Each DTO class is held to its entry in `scripts/dto-open-shapes-baseline.json`, and a new one may hold none, whatever its schema is called and wherever it is declared. A `createZodDto` call that is not an exported class, or two DTO classes with one name, fail the check. After narrowing a schema, `npm run contracts:dto-open -- --update` lowers the baseline (it never raises or adds an entry). It needs `shared/` built. + **`lint:size`** (`scripts/size-lint.mjs`) counts a line longer than 120 columns once per 120 columns it spans, so joining lines does not make room. Files that were already longer are held at their length in `scripts/size-baseline.json`; a file that needs to grow past its entry is split by concern instead. After a split, `npm run lint:size -- --update` lowers the baseline (it never raises or adds an entry). Single test: `npx vitest run tests/unit/nest/weather.controller.test.ts`, or `npx vitest run -t "returns 401 without cookie"`. diff --git a/server/package.json b/server/package.json index f750938ff..fcc939995 100644 --- a/server/package.json +++ b/server/package.json @@ -14,6 +14,7 @@ "db:call-graph": "node scripts/db-call-graph.mjs", "lint:size": "node scripts/size-lint.mjs", "lint:boundaries": "node scripts/import-boundaries.mjs", + "contracts:dto-open": "node scripts/dto-open-shapes.mjs", "gen:plugin-facts": "node --import tsx scripts/gen-plugin-facts.ts", "check:plugin-facts": "node --import tsx scripts/gen-plugin-facts.ts --check", "gen:entities": "node --import tsx scripts/generate-entities.ts", diff --git a/server/scripts/dto-open-shapes-baseline.json b/server/scripts/dto-open-shapes-baseline.json new file mode 100644 index 000000000..791de06ec --- /dev/null +++ b/server/scripts/dto-open-shapes-baseline.json @@ -0,0 +1,63 @@ +{ + "AccommodationUpdateDto": 1, + "AddTripPhotosDto": 3, + "AdminAddonUpdateDto": 1, + "AdminDefaultUserSettingsDto": 1, + "AdminPermissionsDto": 1, + "AdminTestNotificationDto": 2, + "AppSettingsUpdateDto": 1, + "AutoBackupSettingsDto": 1, + "BookingImportConfirmDto": 1, + "CollabPollCreateDto": 1, + "CollectionImportDto": 1, + "CollectionImportIntoDto": 1, + "CreateAlbumLinkDto": 5, + "ImmichSearchDto": 1, + "ImmichSettingsDto": 2, + "ImmichTestDto": 1, + "JourneyAddTripDto": 2, + "JourneyContributorAddDto": 3, + "JourneyContributorUpdateDto": 2, + "JourneyCreateDto": 4, + "JourneyEntryCreateDto": 2, + "JourneyEntryPhotoUploadDto": 2, + "JourneyEntryUpdateDto": 1, + "JourneyGalleryVideoDto": 2, + "JourneyLinkPhotoDto": 3, + "JourneyPhotoUpdateDto": 1, + "JourneyPreferencesDto": 1, + "JourneyProviderPhotosDto": 8, + "JourneyReorderEntriesDto": 2, + "JourneyShareLinkDto": 5, + "JourneyUpdateDto": 1, + "LlmLocalPullDto": 1, + "OauthClientCreateDto": 1, + "PackingImportDto": 1, + "PasskeyLoginVerifyDto": 1, + "PasskeyRegisterVerifyDto": 2, + "PasskeyRenameDto": 1, + "PlaceCreateDto": 1, + "PlaceEnrichmentDto": 1, + "PlaceUpdateDto": 1, + "PluginActivateDto": 1, + "PluginConfigDto": 1, + "PluginEgressHostsDto": 2, + "PluginInstallDto": 1, + "PluginLinkDto": 1, + "PluginRetrustDto": 1, + "PluginRouteDto": 1, + "PluginUninstallDto": 1, + "PluginUpdateDto": 1, + "PluginUserSettingsUpdateDto": 2, + "RemoveTripPhotoDto": 1, + "ReservationCreateDto": 1, + "ReservationUpdateDto": 1, + "SettingsBulkDto": 1, + "SettingUpsertDto": 1, + "SetTripPhotoSharingDto": 2, + "SynologySearchDto": 1, + "SynologySettingsDto": 1, + "SynologyTestDto": 1, + "TagCreateDto": 3, + "TagUpdateDto": 3 +} diff --git a/server/scripts/dto-open-shapes.mjs b/server/scripts/dto-open-shapes.mjs new file mode 100644 index 000000000..295298f11 --- /dev/null +++ b/server/scripts/dto-open-shapes.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +/* + * contracts:dto-open: runs tests/unit/contracts/dto-open-shapes.test.ts, the + * ratchet over the open shapes in every createZodDto class under src/. + * + * The check has to import the TypeScript DTO files, so it lives in that test + * and runs with the unit tests in CI; this wrapper only gives it a command and + * the lowering mode: + * + * npm run contracts:dto-open check against scripts/dto-open-shapes-baseline.json + * npm run contracts:dto-open -- --update lower the baseline to today's counts; it never + * raises or adds an entry + * + * The shared package must be built first (the DTOs import @trek/shared). + */ +import { spawnSync } from 'node:child_process'; +import { existsSync, realpathSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SERVER_ROOT = fileURLToPath(new URL('..', import.meta.url)); +const TEST = 'tests/unit/contracts/dto-open-shapes.test.ts'; + +function main(argv) { + const unknown = argv.filter((arg) => arg !== '--update'); + if (unknown.length) throw new Error(`unknown argument(s): ${unknown.join(' ')}`); + if (!existsSync(join(SERVER_ROOT, TEST))) throw new Error(`${TEST} is missing`); + const vitest = join(dirname(createRequire(import.meta.url).resolve('vitest/package.json')), 'vitest.mjs'); + const env = { ...process.env, DTO_OPEN_SHAPES_UPDATE: argv.includes('--update') ? '1' : '0' }; + const result = spawnSync(process.execPath, [vitest, 'run', TEST], { cwd: SERVER_ROOT, env, stdio: 'inherit' }); + if (result.error) throw result.error; + return result.status ?? 1; +} + +// Compared by real path, so the check still runs when the script is started through a symlink. +const isCli = + Boolean(process.argv[1]) && + existsSync(process.argv[1]) && + realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); +if (isCli) { + try { + process.exitCode = main(process.argv.slice(2)); + } catch (err) { + console.error(`FAIL ${err.message}`); + process.exitCode = 1; + } +} diff --git a/server/src/nest/roadtrip/day-boundaries.controller.ts b/server/src/nest/roadtrip/day-boundaries.controller.ts index 191b99062..fff30d746 100644 --- a/server/src/nest/roadtrip/day-boundaries.controller.ts +++ b/server/src/nest/roadtrip/day-boundaries.controller.ts @@ -9,7 +9,7 @@ import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.g import { RealtimeService } from '../realtime/realtime.service'; import { DayBoundariesService } from './day-boundaries.service'; -class DayBoundaryDto extends createZodDto(roadtripDayBoundarySchema) {} +export class DayBoundaryDto extends createZodDto(roadtripDayBoundarySchema) {} @Controller('api/trips/:tripId/roadtrip/day-boundaries') @UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard) diff --git a/server/src/nest/roadtrip/google-route.controller.ts b/server/src/nest/roadtrip/google-route.controller.ts index 978f51423..8a86ff3bb 100644 --- a/server/src/nest/roadtrip/google-route.controller.ts +++ b/server/src/nest/roadtrip/google-route.controller.ts @@ -9,8 +9,8 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { TripAccessGuard } from '../permissions/trip-access.guard'; import { GoogleRouteService } from './google-route.service'; -class PreviewDto extends createZodDto(googleRoutePreviewRequestSchema) {} -class ImportDto extends createZodDto(googleRouteImportSchema) {} +export class PreviewDto extends createZodDto(googleRoutePreviewRequestSchema) {} +export class ImportDto extends createZodDto(googleRouteImportSchema) {} @Controller('api') @UseGuards(AddonGuard, JwtAuthGuard) diff --git a/server/src/nest/roadtrip/roadtrip-preferences.controller.ts b/server/src/nest/roadtrip/roadtrip-preferences.controller.ts index 5f99fcf39..433b92851 100644 --- a/server/src/nest/roadtrip/roadtrip-preferences.controller.ts +++ b/server/src/nest/roadtrip/roadtrip-preferences.controller.ts @@ -9,7 +9,7 @@ import { roadtripPreferencesUpdateSchema } from '@trek/shared'; import { createZodDto } from 'nestjs-zod'; -class PreferencesDto extends createZodDto(roadtripPreferencesUpdateSchema) {} +export class PreferencesDto extends createZodDto(roadtripPreferencesUpdateSchema) {} @Controller('api/trips/:tripId/roadtrip/preferences') @UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard) diff --git a/server/src/nest/roadtrip/roadtrip-search.controller.ts b/server/src/nest/roadtrip/roadtrip-search.controller.ts index 7c5777706..f2dab890c 100644 --- a/server/src/nest/roadtrip/roadtrip-search.controller.ts +++ b/server/src/nest/roadtrip/roadtrip-search.controller.ts @@ -8,7 +8,7 @@ import { RequireAddon } from '../addons/require-addon.decorator'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { RoadtripSearchService } from './roadtrip-search.service'; -class SearchAreaDto extends createZodDto(roadtripSearchAreaSchema) {} +export class SearchAreaDto extends createZodDto(roadtripSearchAreaSchema) {} @Controller('api/roadtrip/search-area') @UseGuards(JwtAuthGuard, AddonGuard) diff --git a/server/tests/unit/contracts/dto-open-shapes.test.ts b/server/tests/unit/contracts/dto-open-shapes.test.ts new file mode 100644 index 000000000..1133b7a32 --- /dev/null +++ b/server/tests/unit/contracts/dto-open-shapes.test.ts @@ -0,0 +1,238 @@ +/** + * contracts:dto-open: the contracts the server really binds may only get tighter. + * + * shared's contracts:open counts the open shapes in every export named + * *RequestSchema, but the name is a convention, not what the server binds: + * dozens of DTOs wrap a schema named *BodySchema, *InputSchema or just + * *Schema, and some wrap a schema declared in the dto file itself. This walks + * the other way round. It finds every `class X extends createZodDto(...)` under + * src/, imports the file, and counts the open shapes (the same walk as + * shared/scripts/open-request-schemas.mjs) in the schema that class hands the + * global ZodValidationPipe. The count per class is held against + * scripts/dto-open-shapes-baseline.json: a new DTO may hold none, an existing + * one no more than its entry. + * + * npm run contracts:dto-open this file, as CI runs it in the unit tests + * npm run contracts:dto-open -- --update lower the baseline to today's counts; it + * never raises or adds an entry + * + * It fails closed: a missing or malformed baseline, a createZodDto call that is + * not an exported class, two DTO classes with one name, or a tree without a + * single DTO stop it instead of passing with nothing checked. + */ +import { createZodDto } from 'nestjs-zod'; +import { isZodDto } from 'nestjs-zod/dto'; +import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +const SERVER_ROOT = path.resolve(__dirname, '../../..'); +const SRC = path.join(SERVER_ROOT, 'src'); +const BASELINE = path.join(SERVER_ROOT, 'scripts/dto-open-shapes-baseline.json'); +const OPEN_SHAPES_SCRIPT = path.resolve(SERVER_ROOT, '../shared/scripts/open-request-schemas.mjs'); + +type Counts = Record; + +interface OpenShapesModule { + openShapes(root: unknown): string[]; + compare(baseline: Counts, counts: Counts): { grown: [string, number][]; lowerable: [string, number][] }; + lowerBaseline(baseline: Counts, counts: Counts): Counts; +} + +interface DtoDeclaration { + file: string; + name: string; +} + +const DTO_CLASS = /\b(export\s+)?class\s+(\w+)\s+extends\s+createZodDto\s*\(/g; +const DTO_CALL = /\bcreateZodDto\s*\(/g; + +function sourceFiles(dir: string, files: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const full = path.join(dir, name); + if (statSync(full).isDirectory()) sourceFiles(full, files); + else if (name.endsWith('.ts') && !name.endsWith('.d.ts')) files.push(full); + } + return files; +} + +/** The source without its comments, so a createZodDto named in prose is not counted as a call. */ +function stripComments(text: string): string { + return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); +} + +/** + * Every DTO class declared under `root`, and the problems that keep the walk + * from seeing one: a createZodDto call that is not `export class X extends + * createZodDto(...)` (its schema could not be reached through the module), or + * a class name used twice (the baseline is keyed by name). + */ +function findDtoClasses(root: string): { dtos: DtoDeclaration[]; problems: string[] } { + const dtos: DtoDeclaration[] = []; + const problems: string[] = []; + const firstFile = new Map(); + for (const full of sourceFiles(root)) { + const code = stripComments(readFileSync(full, 'utf8')); + const file = path.relative(SERVER_ROOT, full).split(path.sep).join('/'); + const calls = code.match(DTO_CALL)?.length ?? 0; + const classes = [...code.matchAll(DTO_CLASS)]; + if (calls !== classes.length) { + problems.push( + `${file}: ${calls - classes.length} createZodDto call(s) outside \`export class X extends createZodDto(...)\``, + ); + } + for (const [, exported, name] of classes) { + if (!exported) problems.push(`${file}: ${name} is not exported, so its schema cannot be counted`); + const seen = firstFile.get(name); + if (seen) problems.push(`${name} is declared in ${seen} and ${file}: DTO class names must be unique`); + firstFile.set(name, file); + dtos.push({ file, name }); + } + } + return { dtos, problems }; +} + +/** The baseline as committed. Missing, unreadable or malformed is an error, never an empty baseline. */ +function readBaseline(file = BASELINE): Counts { + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(file, 'utf8')); + } catch (err) { + throw new Error(`scripts/dto-open-shapes-baseline.json cannot be read: ${(err as Error).message}`, { cause: err }); + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('scripts/dto-open-shapes-baseline.json must be an object of DTO class names to counts'); + } + for (const [name, n] of Object.entries(parsed)) { + if (!Number.isInteger(n) || (n as number) < 1) { + throw new Error( + `scripts/dto-open-shapes-baseline.json: ${name} holds ${JSON.stringify(n)}, expected a whole number above 0`, + ); + } + } + return parsed as Counts; +} + +/** Open-shape counts for every ZodDto class among a module's exports. */ +function countDtoExports(namespace: Record, names: string[], shapes: OpenShapesModule): Counts { + const counts: Counts = {}; + for (const name of names) { + const dto = namespace[name]; + if (typeof dto !== 'function' || !isZodDto(dto)) { + throw new Error(`${name} is not an exported createZodDto class`); + } + counts[name] = shapes.openShapes(dto.schema).length; + } + return counts; +} + +let shapes: OpenShapesModule; +beforeAll(async () => { + shapes = (await import(pathToFileURL(OPEN_SHAPES_SCRIPT).href)) as OpenShapesModule; +}); + +describe('open shapes in the DTO contracts', () => { + let counts: Counts; + let problems: string[]; + beforeAll(async () => { + const found = findDtoClasses(SRC); + problems = found.problems; + counts = {}; + const byFile = new Map(); + for (const { file, name } of found.dtos) byFile.set(file, [...(byFile.get(file) ?? []), name]); + for (const [file, names] of byFile) { + const namespace = (await import(pathToFileURL(path.join(SERVER_ROOT, file)).href)) as Record; + for (const name of names) { + try { + Object.assign(counts, countDtoExports(namespace, [name], shapes)); + } catch (err) { + // Reported once by DTO-001, together with the reason the scan found. + if (!problems.some((p) => p.includes(` ${name} `))) problems.push(`${file}: ${(err as Error).message}`); + } + } + } + }, 120_000); + + it('DTO-001: every createZodDto call is an exported, uniquely named class, and there are some', () => { + expect(problems).toEqual([]); + expect( + Object.keys(counts).length, + 'no DTO class found under src/: the check would look at nothing', + ).toBeGreaterThan(0); + }); + + it('DTO-002: no DTO holds more open shapes than the baseline allows', () => { + const { grown } = shapes.compare(readBaseline(), counts); + expect( + grown, + 'type the fields instead of accepting any object (shared/CLAUDE.md, "Rules for new contracts")', + ).toEqual([]); + }); + + it('DTO-003: the baseline names only DTOs that exist and are still open', () => { + let baseline = readBaseline(); + if (process.env.DTO_OPEN_SHAPES_UPDATE === '1') { + baseline = shapes.lowerBaseline(baseline, counts); + writeFileSync(BASELINE, JSON.stringify(baseline, null, 2) + '\n'); + } + const { lowerable } = shapes.compare(baseline, counts); + expect(lowerable, 'run `npm run contracts:dto-open -- --update` after narrowing a schema').toEqual([]); + }); +}); + +describe('the DTO walk itself', () => { + it('DTO-004: counts an open body whatever its schema is called and fails on a new one', () => { + // A body named like most of the tree (fooBodySchema, pluginConfigSchema), not *RequestSchema. + const pluginConfigSchema = z.record(z.string(), z.unknown()); + const fooBodySchema = z.looseObject({ name: z.string(), extra: z.unknown() }); + class PluginConfigDto extends createZodDto(pluginConfigSchema) {} + class FooBodyDto extends createZodDto(fooBodySchema) {} + class ClosedDto extends createZodDto(z.strictObject({ a: z.string() })) {} + + const counts = countDtoExports( + { PluginConfigDto, FooBodyDto, ClosedDto }, + ['PluginConfigDto', 'FooBodyDto', 'ClosedDto'], + shapes, + ); + expect(counts).toEqual({ PluginConfigDto: 1, FooBodyDto: 2, ClosedDto: 0 }); + expect(shapes.compare({}, counts).grown).toEqual([ + ['PluginConfigDto', 1], + ['FooBodyDto', 2], + ]); + expect(shapes.compare({ FooBodyDto: 1, PluginConfigDto: 1 }, counts).grown).toEqual([['FooBodyDto', 2]]); + expect(() => countDtoExports({}, ['GoneDto'], shapes)).toThrow(/not an exported createZodDto class/); + }); + + it('DTO-005: refuses a DTO it cannot reach and a name used twice', () => { + const root = path.join(tmpdir(), `trek-dto-walk-${process.pid}-${Date.now()}`); + const nested = path.join(root, 'b'); + try { + mkdirSync(nested, { recursive: true }); + writeFileSync( + path.join(root, 'a.dto.ts'), + [ + '// createZodDto( in a comment is not a call', + 'export class ADto extends createZodDto(a) {}', + 'class HiddenDto extends createZodDto(b) {}', + 'const Loose = createZodDto(c);', + ].join('\r\n'), + ); + writeFileSync(path.join(nested, 'b.dto.ts'), 'export class ADto extends createZodDto(d) {}\n'); + const { dtos, problems } = findDtoClasses(root); + expect(dtos.map((d) => d.name).sort()).toEqual(['ADto', 'ADto', 'HiddenDto']); + expect(problems.some((p) => p.includes('1 createZodDto call(s) outside'))).toBe(true); + expect(problems.some((p) => p.includes('HiddenDto is not exported'))).toBe(true); + expect(problems.some((p) => p.includes('ADto is declared in'))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('DTO-006: a missing or malformed baseline is an error, never an empty baseline', () => { + expect(() => readBaseline(path.join(SERVER_ROOT, 'scripts/definitely-not-here.json'))).toThrow(/cannot be read/); + expect(() => readBaseline(path.join(SERVER_ROOT, 'package.json'))).toThrow(/expected a whole number/); + }); +}); diff --git a/server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts b/server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts index 614acfd9c..f3360ef91 100644 --- a/server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts +++ b/server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts @@ -168,8 +168,14 @@ declare const repo: { run(sql: string, params?: unknown[]): Promise; ge for (const file of exempt) { const options = await selectorsFor(file); expect(hits(options, "void connection.execute('PRAGMA wal_checkpoint(TRUNCATE)');"), file).toEqual([]); - expect(messages(options, `${PRELUDE} -void safeFetch(url);`), file).toContain(TIMEOUT); + expect( + messages( + options, + `${PRELUDE} +void safeFetch(url);`, + ), + file, + ).toContain(TIMEOUT); } }); @@ -185,7 +191,9 @@ void safeFetch(url);`), file).toContain(TIMEOUT); EXEMPT_SQL_STRINGS, ); for (const [file, n] of Object.entries(counts)) { - expect(n, `${file} holds no SQL string any more: drop it from the ignores in eslint.config.mjs`).toBeGreaterThan(0); + expect(n, `${file} holds no SQL string any more: drop it from the ignores in eslint.config.mjs`).toBeGreaterThan( + 0, + ); } }); diff --git a/shared/CLAUDE.md b/shared/CLAUDE.md index 8d06013ad..d757c3719 100644 --- a/shared/CLAUDE.md +++ b/shared/CLAUDE.md @@ -36,7 +36,7 @@ One folder per domain exporting Zod schemas plus inferred types (`export type X The parity rule governs **existing** routes; it is not a license to mint new debt: - **The contract describes the wire, not the storage engine.** New booleans are `z.boolean()` — convert SQLite 0/1 at the service boundary, never add a `z.union([z.boolean(), z.number()])`. New ids use `idSchema`/`idParamSchema`, never `number | string`. Request and response types for a field must agree. -- **Use the shared primitives** instead of re-declaring bare `z.number()`/`z.string()` per domain. Narrow an existing open object rather than adding a new `z.record(...)`/passthrough body — "any object" gives zero drift protection. `contracts:open` (`scripts/open-request-schemas.mjs`) counts the open shapes in every exported `*RequestSchema` (a loose object, a record of unknown, a `z.unknown()`/`z.any()` field or body) against `scripts/open-request-schemas-baseline.json`: a new request schema may hold none, an existing one no more than its entry. It reads the built `dist/`; `src/open-request-schemas.spec.ts` runs the same check over `src/` in `npm test`. After narrowing a schema, `npm run contracts:open -- --update` lowers the baseline (it never raises or adds an entry). +- **Use the shared primitives** instead of re-declaring bare `z.number()`/`z.string()` per domain. Narrow an existing open object rather than adding a new `z.record(...)`/passthrough body — "any object" gives zero drift protection. `contracts:open` (`scripts/open-request-schemas.mjs`) counts the open shapes in every exported `*RequestSchema` (a loose object, a record of unknown, a `z.unknown()`/`z.any()` field or body, a `z.custom()`/`z.instanceof()`, each counted once per path it sits at) against `scripts/open-request-schemas-baseline.json`: a new request schema may hold none, an existing one no more than its entry. It reads the built `dist/`; `src/open-request-schemas.spec.ts` runs the same check over `src/` in `npm test`. After narrowing a schema, `npm run contracts:open -- --update` lowers the baseline (it never raises or adds an entry). The suffix is only a convention, so the server holds the schemas it really binds to the same count: `npm run contracts:dto-open --workspace=server` walks every `createZodDto` class, whatever its schema is called, against `server/scripts/dto-open-shapes-baseline.json`. - **Add a schema, add a spec** — especially for lenient parsers fed by untrusted or LLM input. - **Stay lean and isomorphic**: zero imports from client/server, no `node:` APIs, effectively no new runtime dependencies (the contract layer approaches `zod`-only; i18n is a separable concern — don't couple new contract code to it). - **Never hand-copy a locale list.** `SUPPORTED_LANGUAGES` in `src/i18n/languages.ts` is the one registry — derive barrels, loaders and spec maps from it.