diff --git a/src/middleware/validate.ts b/src/middleware/validate.ts index 73c85fc..b964f1a 100644 --- a/src/middleware/validate.ts +++ b/src/middleware/validate.ts @@ -52,9 +52,16 @@ function makeValidator( respond400(res, result.error); return; } - // Narrow typed re-assignment: Express's default types allow this, - // but we cast to satisfy stricter consumers. - (req as unknown as Record)[source] = result.data; + // Express 5 exposes `req.query` (and `req.params`) as getter-only + // properties on Request.prototype — plain `req.query = ...` + // silently no-ops. Define an own property to shadow the prototype + // accessor so handlers see the parsed-and-transformed value. + Object.defineProperty(req, source, { + value: result.data, + writable: true, + configurable: true, + enumerable: true, + }); next(); }; } diff --git a/src/routes/memories.ts b/src/routes/memories.ts index 4bf86b8..1b2bac3 100644 --- a/src/routes/memories.ts +++ b/src/routes/memories.ts @@ -1,18 +1,43 @@ /** * Memory API routes for ingest, search, listing, stats, config, and deletion. * Keeps the existing `/v1/memories/ingest` and `/v1/memories/search` contract stable. + * + * Request validation for every route is delegated to the Zod-based + * validators in `src/middleware/validate.ts` using schemas authored in + * `src/schemas/memories.ts`. `validateBody` / `validateQuery` / + * `validateParams` replace the hand-written `parseIngestBody` / + * `parseSearchBody` / `requireBodyString` helpers this file previously + * exported. The 400 response envelope `{ error: string }` is + * preserved byte-for-byte by `formatZodIssues`. */ import { Router, type Request, type Response } from 'express'; import { config, updateRuntimeConfig, type EmbeddingProviderName, type LLMProviderName } from '../config.js'; import { MemoryService, type RetrievalResult } from '../services/memory-service.js'; -import type { RetrievalMode, MemoryScope, RetrievalObservability } from '../services/memory-service-types.js'; +import type { MemoryScope, RetrievalObservability } from '../services/memory-service-types.js'; import type { AgentScope, WorkspaceContext } from '../db/repository-types.js'; -import { InputError, handleRouteError } from './route-errors.js'; +import { handleRouteError } from './route-errors.js'; +import { validateBody, validateQuery, validateParams } from '../middleware/validate.js'; +import { + IngestBodySchema, + type IngestBody, + SearchBodySchema, + type SearchBody, + ExpandBodySchema, + ConsolidateBodySchema, + DecayBodySchema, + ReconcileBodySchema, + ResetSourceBodySchema, + LessonReportBodySchema, + UserIdQuerySchema, + UserIdLimitQuerySchema, + ListQuerySchema, + MemoryByIdQuerySchema, + UuidIdParamSchema, + FreeIdParamSchema, + ConfigBodySchema, +} from '../schemas/memories.js'; -const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -const MAX_SEARCH_LIMIT = 100; -const MAX_CONVERSATION_LENGTH = 100_000; const ALLOWED_ORIGINS = new Set( (process.env.ALLOWED_ORIGINS ?? 'http://localhost:3050,http://localhost:3081') .split(',') @@ -101,50 +126,37 @@ function registerCors(router: Router): void { } function registerIngestRoute(router: Router, service: MemoryService): void { - registerIngestHandler(router, '/ingest', service, (body) => - service.ingest(body.userId, body.conversation, body.sourceSite, body.sourceUrl), - ); -} - -function registerQuickIngestRoute(router: Router, service: MemoryService): void { - router.post('/ingest/quick', async (req: Request, res: Response) => { + router.post('/ingest', validateBody(IngestBodySchema), async (req: Request, res: Response) => { try { - const body = parseIngestBody(req.body); - const skipExtraction = req.body.skip_extraction === true; + const body = req.body as IngestBody; const result = body.workspace ? await service.workspaceIngest(body.userId, body.conversation, body.sourceSite, body.sourceUrl, body.workspace) - : skipExtraction - ? await service.storeVerbatim(body.userId, body.conversation, body.sourceSite, body.sourceUrl) - : await service.quickIngest(body.userId, body.conversation, body.sourceSite, body.sourceUrl); + : await service.ingest(body.userId, body.conversation, body.sourceSite, body.sourceUrl); res.json(result); } catch (err) { - handleRouteError(res, 'POST /v1/memories/ingest/quick', err); + handleRouteError(res, 'POST /v1/memories/ingest', err); } }); } -/** Shared handler for ingest routes — workspace requests always use workspaceIngest. */ -function registerIngestHandler( - router: Router, - path: string, - service: MemoryService, - nonWorkspaceIngest: (body: ReturnType) => Promise, -): void { - router.post(path, async (req: Request, res: Response) => { +function registerQuickIngestRoute(router: Router, service: MemoryService): void { + router.post('/ingest/quick', validateBody(IngestBodySchema), async (req: Request, res: Response) => { try { - const body = parseIngestBody(req.body); + const body = req.body as IngestBody; const result = body.workspace ? await service.workspaceIngest(body.userId, body.conversation, body.sourceSite, body.sourceUrl, body.workspace) - : await nonWorkspaceIngest(body); + : body.skipExtraction + ? await service.storeVerbatim(body.userId, body.conversation, body.sourceSite, body.sourceUrl) + : await service.quickIngest(body.userId, body.conversation, body.sourceSite, body.sourceUrl); res.json(result); } catch (err) { - handleRouteError(res, `POST /v1/memories${path}`, err); + handleRouteError(res, 'POST /v1/memories/ingest/quick', err); } }); } -function resolveSearchPreamble(body: ReturnType, configRouteAdapter: RuntimeConfigRouteAdapter) { - const scope = toMemoryScope(body.userId, body.workspace, body.agentScope); +function resolveSearchPreamble(body: SearchBody, configRouteAdapter: RuntimeConfigRouteAdapter) { + const scope = toMemoryScope(body.userId, body.workspace, body.agentScope as AgentScope | undefined); const requestLimit = body.limit === undefined ? undefined : resolveEffectiveSearchLimit(body.limit, configRouteAdapter.current()); @@ -156,11 +168,11 @@ function registerSearchRoute( service: MemoryService, configRouteAdapter: RuntimeConfigRouteAdapter, ): void { - router.post('/search', async (req: Request, res: Response) => { + router.post('/search', validateBody(SearchBodySchema), async (req: Request, res: Response) => { try { - const body = parseSearchBody(req.body); + const body = req.body as SearchBody; const { scope, requestLimit } = resolveSearchPreamble(body, configRouteAdapter); - const retrievalOptions: { retrievalMode?: typeof body.retrievalMode; tokenBudget?: typeof body.tokenBudget; skipRepairLoop?: boolean } = { + const retrievalOptions: { retrievalMode?: SearchBody['retrievalMode']; tokenBudget?: SearchBody['tokenBudget']; skipRepairLoop?: boolean } = { retrievalMode: body.retrievalMode, tokenBudget: body.tokenBudget, ...(body.skipRepair ? { skipRepairLoop: true } : {}), @@ -188,9 +200,9 @@ function registerFastSearchRoute( service: MemoryService, configRouteAdapter: RuntimeConfigRouteAdapter, ): void { - router.post('/search/fast', async (req: Request, res: Response) => { + router.post('/search/fast', validateBody(SearchBodySchema), async (req: Request, res: Response) => { try { - const body = parseSearchBody(req.body); + const body = req.body as SearchBody; const { scope, requestLimit } = resolveSearchPreamble(body, configRouteAdapter); const result = await service.scopedSearch(scope, body.query, { fast: true, @@ -206,16 +218,15 @@ function registerFastSearchRoute( } function registerExpandRoute(router: Router, service: MemoryService): void { - router.post('/expand', async (req: Request, res: Response) => { + router.post('/expand', validateBody(ExpandBodySchema), async (req: Request, res: Response) => { try { - const userId = requireBodyString(req.body.user_id, 'user_id (string) is required'); - const memoryIds = req.body.memory_ids; - if (!Array.isArray(memoryIds) || !memoryIds.every((id: unknown) => typeof id === 'string')) { - throw new InputError('memory_ids (string[]) is required'); - } - const workspace = parseOptionalWorkspaceContext(req.body); + const { userId, memoryIds, workspace } = req.body as { + userId: string; + memoryIds: string[]; + workspace: WorkspaceContext | undefined; + }; const scope = toMemoryScope(userId, workspace, undefined); - const expanded = await service.scopedExpand(scope, memoryIds as string[]); + const expanded = await service.scopedExpand(scope, memoryIds); res.json({ memories: expanded }); } catch (err) { handleRouteError(res, 'POST /v1/memories/expand', err); @@ -224,20 +235,24 @@ function registerExpandRoute(router: Router, service: MemoryService): void { } function registerListRoute(router: Router, service: MemoryService): void { - router.get('/list', async (req: Request, res: Response) => { + router.get('/list', validateQuery(ListQuerySchema), async (req: Request, res: Response) => { try { - const { userId, limit } = parseUserIdAndLimit(req.query); - const offset = parseInt(String(req.query.offset ?? '0'), 10); - const workspaceId = optionalQueryString(req.query.workspace_id); - const agentId = optionalUuidQuery(req.query.agent_id, 'agent_id'); - const sourceSite = optionalQueryString(req.query.source_site); - const episodeId = optionalUuidQuery(req.query.episode_id, 'episode_id'); - if (workspaceId && !agentId) { - throw new InputError('agent_id is required for workspace queries'); - } - const memories = workspaceId - ? await service.scopedList({ kind: 'workspace', userId, workspaceId, agentId: agentId! }, limit, offset) - : await service.list(userId, limit, offset, sourceSite, episodeId); + const q = req.query as unknown as { + userId: string; + limit: number; + offset: number; + workspaceId: string | undefined; + agentId: string | undefined; + sourceSite: string | undefined; + episodeId: string | undefined; + }; + const memories = q.workspaceId + ? await service.scopedList( + { kind: 'workspace', userId: q.userId, workspaceId: q.workspaceId, agentId: q.agentId! }, + q.limit, + q.offset, + ) + : await service.list(q.userId, q.limit, q.offset, q.sourceSite, q.episodeId); res.json({ memories, count: memories.length }); } catch (err) { handleRouteError(res, 'GET /v1/memories/list', err); @@ -246,9 +261,9 @@ function registerListRoute(router: Router, service: MemoryService): void { } function registerStatsRoute(router: Router, service: MemoryService): void { - router.get('/stats', async (req: Request, res: Response) => { + router.get('/stats', validateQuery(UserIdQuerySchema), async (req: Request, res: Response) => { try { - const userId = requireQueryString(req.query.user_id, 'user_id is required'); + const { userId } = req.query as unknown as { userId: string }; res.json(await service.getStats(userId)); } catch (err) { handleRouteError(res, 'GET /v1/memories/stats', err); @@ -263,7 +278,7 @@ function registerHealthRoute(router: Router, configRouteAdapter: RuntimeConfigRo } function registerConfigRoute(router: Router, configRouteAdapter: RuntimeConfigRouteAdapter): void { - router.put('/config', async (req: Request, res: Response) => { + router.put('/config', validateBody(ConfigBodySchema), async (req: Request, res: Response) => { try { if (!configRouteAdapter.current().runtimeConfigMutationEnabled) { res.status(410).json({ @@ -272,7 +287,7 @@ function registerConfigRoute(router: Router, configRouteAdapter: RuntimeConfigRo }); return; } - const rejected = STARTUP_ONLY_CONFIG_FIELDS.filter((field) => req.body[field] !== undefined); + const rejected = STARTUP_ONLY_CONFIG_FIELDS.filter((field) => (req.body as Record)[field] !== undefined); if (rejected.length > 0) { res.status(400).json({ error: 'Provider/model selection is startup-only', @@ -281,11 +296,12 @@ function registerConfigRoute(router: Router, configRouteAdapter: RuntimeConfigRo }); return; } + const body = req.body as Record; const applied = configRouteAdapter.update({ - similarityThreshold: req.body.similarity_threshold, - audnCandidateThreshold: req.body.audn_candidate_threshold, - clarificationConflictThreshold: req.body.clarification_conflict_threshold, - maxSearchResults: req.body.max_search_results, + similarityThreshold: body.similarity_threshold as number | undefined, + audnCandidateThreshold: body.audn_candidate_threshold as number | undefined, + clarificationConflictThreshold: body.clarification_conflict_threshold as number | undefined, + maxSearchResults: body.max_search_results as number | undefined, }); res.json({ applied, @@ -299,17 +315,13 @@ function registerConfigRoute(router: Router, configRouteAdapter: RuntimeConfigRo } function registerConsolidateRoute(router: Router, service: MemoryService): void { - router.post('/consolidate', async (req: Request, res: Response) => { + router.post('/consolidate', validateBody(ConsolidateBodySchema), async (req: Request, res: Response) => { try { - const userId = requireBodyString(req.body.user_id, 'user_id (string) is required'); - const execute = req.body.execute === true; - if (execute) { - const result = await service.executeConsolidation(userId); - res.json(result); - } else { - const result = await service.consolidate(userId); - res.json(result); - } + const { userId, execute } = req.body as { userId: string; execute: boolean }; + const result = execute + ? await service.executeConsolidation(userId) + : await service.consolidate(userId); + res.json(result); } catch (err) { handleRouteError(res, 'POST /v1/memories/consolidate', err); } @@ -317,10 +329,9 @@ function registerConsolidateRoute(router: Router, service: MemoryService): void } function registerDecayRoute(router: Router, service: MemoryService): void { - router.post('/decay', async (req: Request, res: Response) => { + router.post('/decay', validateBody(DecayBodySchema), async (req: Request, res: Response) => { try { - const userId = requireBodyString(req.body.user_id, 'user_id (string) is required'); - const dryRun = req.body.dry_run !== false; + const { userId, dryRun } = req.body as { userId: string; dryRun: boolean }; const result = await service.evaluateDecay(userId); if (!dryRun && result.candidatesForArchival.length > 0) { const archived = await service.archiveDecayed(userId, result.candidatesForArchival.map((c) => c.id)); @@ -335,9 +346,9 @@ function registerDecayRoute(router: Router, service: MemoryService): void { } function registerCapRoute(router: Router, service: MemoryService): void { - router.get('/cap', async (req: Request, res: Response) => { + router.get('/cap', validateQuery(UserIdQuerySchema), async (req: Request, res: Response) => { try { - const userId = requireQueryString(req.query.user_id, 'user_id is required'); + const { userId } = req.query as unknown as { userId: string }; const result = await service.checkCap(userId); res.json(result); } catch (err) { @@ -347,9 +358,9 @@ function registerCapRoute(router: Router, service: MemoryService): void { } function registerLessonRoutes(router: Router, service: MemoryService): void { - router.get('/lessons', async (req: Request, res: Response) => { + router.get('/lessons', validateQuery(UserIdQuerySchema), async (req: Request, res: Response) => { try { - const userId = requireQueryString(req.query.user_id, 'user_id is required'); + const { userId } = req.query as unknown as { userId: string }; const lessons = await service.getLessons(userId); res.json({ lessons, count: lessons.length }); } catch (err) { @@ -357,9 +368,9 @@ function registerLessonRoutes(router: Router, service: MemoryService): void { } }); - router.get('/lessons/stats', async (req: Request, res: Response) => { + router.get('/lessons/stats', validateQuery(UserIdQuerySchema), async (req: Request, res: Response) => { try { - const userId = requireQueryString(req.query.user_id, 'user_id is required'); + const { userId } = req.query as unknown as { userId: string }; const stats = await service.getLessonStats(userId); res.json(stats); } catch (err) { @@ -367,34 +378,42 @@ function registerLessonRoutes(router: Router, service: MemoryService): void { } }); - router.post('/lessons/report', async (req: Request, res: Response) => { + router.post('/lessons/report', validateBody(LessonReportBodySchema), async (req: Request, res: Response) => { try { - const userId = requireBodyString(req.body.user_id, 'user_id (string) is required'); - const pattern = requireBodyString(req.body.pattern, 'pattern (string) is required'); - const sourceMemoryIds = Array.isArray(req.body.source_memory_ids) ? req.body.source_memory_ids : []; - const severity = req.body.severity; - const lessonId = await service.reportLesson(userId, pattern, sourceMemoryIds, severity); + const { userId, pattern, sourceMemoryIds, severity } = req.body as { + userId: string; + pattern: string; + sourceMemoryIds: string[]; + severity: unknown; + }; + const lessonId = await service.reportLesson(userId, pattern, sourceMemoryIds, severity as never); res.json({ lessonId }); } catch (err) { handleRouteError(res, 'POST /v1/memories/lessons/report', err); } }); - router.delete('/lessons/:id', async (req: Request, res: Response) => { - try { - const userId = requireQueryString(req.query.user_id, 'user_id is required'); - await service.deactivateLesson(userId, String(req.params.id)); - res.json({ success: true }); - } catch (err) { - handleRouteError(res, 'DELETE /v1/memories/lessons/:id', err); - } - }); + router.delete( + '/lessons/:id', + validateParams(FreeIdParamSchema), + validateQuery(UserIdQuerySchema), + async (req: Request, res: Response) => { + try { + const { id } = req.params as unknown as { id: string }; + const { userId } = req.query as unknown as { userId: string }; + await service.deactivateLesson(userId, id); + res.json({ success: true }); + } catch (err) { + handleRouteError(res, 'DELETE /v1/memories/lessons/:id', err); + } + }, + ); } function registerReconcileRoute(router: Router, service: MemoryService): void { - router.post('/reconcile', async (req: Request, res: Response) => { + router.post('/reconcile', validateBody(ReconcileBodySchema), async (req: Request, res: Response) => { try { - const userId = optionalBodyString(req.body.user_id); + const { userId } = req.body as { userId: string | undefined }; const result = userId ? await service.reconcileDeferred(userId) : await service.reconcileDeferredAll(); @@ -404,9 +423,9 @@ function registerReconcileRoute(router: Router, service: MemoryService): void { } }); - router.get('/reconcile/status', async (req: Request, res: Response) => { + router.get('/reconcile/status', validateQuery(UserIdQuerySchema), async (req: Request, res: Response) => { try { - const userId = requireQueryString(req.query.user_id, 'user_id is required'); + const { userId } = req.query as unknown as { userId: string }; const status = await service.getDeferredStatus(userId); res.json(status); } catch (err) { @@ -416,10 +435,9 @@ function registerReconcileRoute(router: Router, service: MemoryService): void { } function registerResetSourceRoute(router: Router, service: MemoryService): void { - router.post('/reset-source', async (req: Request, res: Response) => { + router.post('/reset-source', validateBody(ResetSourceBodySchema), async (req: Request, res: Response) => { try { - const userId = requireBodyString(req.body.user_id, 'user_id (string) is required'); - const sourceSite = requireBodyString(req.body.source_site, 'source_site (string) is required'); + const { userId, sourceSite } = req.body as { userId: string; sourceSite: string }; const result = await service.resetBySource(userId, sourceSite); res.json({ success: true, ...result }); } catch (err) { @@ -429,56 +447,73 @@ function registerResetSourceRoute(router: Router, service: MemoryService): void } function registerGetRoute(router: Router, service: MemoryService): void { - router.get('/:id', async (req: Request, res: Response) => { - try { - const memoryId = requireUuidParam(String(req.params.id), 'id'); - const userId = requireQueryString(req.query.user_id, 'user_id is required'); - const workspaceId = optionalQueryString(req.query.workspace_id); - const agentId = optionalUuidQuery(req.query.agent_id, 'agent_id'); - if (workspaceId && !agentId) { - throw new InputError('agent_id is required for workspace queries'); - } - const memory = workspaceId - ? await service.scopedGet({ kind: 'workspace', userId, workspaceId, agentId: agentId! }, memoryId) - : await service.get(memoryId, userId); - if (!memory) { - res.status(404).json({ error: 'Memory not found' }); - return; + router.get( + '/:id', + validateParams(UuidIdParamSchema), + validateQuery(MemoryByIdQuerySchema), + async (req: Request, res: Response) => { + try { + const { id: memoryId } = req.params as unknown as { id: string }; + const q = req.query as unknown as { + userId: string; + workspaceId: string | undefined; + agentId: string | undefined; + }; + const memory = q.workspaceId + ? await service.scopedGet( + { kind: 'workspace', userId: q.userId, workspaceId: q.workspaceId, agentId: q.agentId! }, + memoryId, + ) + : await service.get(memoryId, q.userId); + if (!memory) { + res.status(404).json({ error: 'Memory not found' }); + return; + } + res.json(memory); + } catch (err) { + handleRouteError(res, 'GET /v1/memories/:id', err); } - res.json(memory); - } catch (err) { - handleRouteError(res, 'GET /v1/memories/:id', err); - } - }); + }, + ); } function registerDeleteRoute(router: Router, service: MemoryService): void { - router.delete('/:id', async (req: Request, res: Response) => { - try { - const memoryId = requireUuidParam(String(req.params.id), 'id'); - const userId = requireQueryString(req.query.user_id, 'user_id is required'); - const workspaceId = optionalQueryString(req.query.workspace_id); - const agentId = optionalUuidQuery(req.query.agent_id, 'agent_id'); - if (workspaceId && !agentId) { - throw new InputError('agent_id is required for workspace queries'); + router.delete( + '/:id', + validateParams(UuidIdParamSchema), + validateQuery(MemoryByIdQuerySchema), + async (req: Request, res: Response) => { + try { + const { id: memoryId } = req.params as unknown as { id: string }; + const q = req.query as unknown as { + userId: string; + workspaceId: string | undefined; + agentId: string | undefined; + }; + if (q.workspaceId) { + const deleted = await service.scopedDelete( + { kind: 'workspace', userId: q.userId, workspaceId: q.workspaceId, agentId: q.agentId! }, + memoryId, + ); + if (!deleted) { + res.status(404).json({ error: 'Memory not found' }); + return; + } + } else { + await service.delete(memoryId, q.userId); + } + res.json({ success: true }); + } catch (err) { + handleRouteError(res, 'DELETE /v1/memories/:id', err); } - if (workspaceId) { - const deleted = await service.scopedDelete({ kind: 'workspace', userId, workspaceId, agentId: agentId! }, memoryId); - if (!deleted) { res.status(404).json({ error: 'Memory not found' }); return; } - } else { - await service.delete(memoryId, userId); - } - res.json({ success: true }); - } catch (err) { - handleRouteError(res, 'DELETE /v1/memories/:id', err); - } - }); + }, + ); } function registerAuditSummaryRoute(router: Router, service: MemoryService): void { - router.get('/audit/summary', async (req: Request, res: Response) => { + router.get('/audit/summary', validateQuery(UserIdQuerySchema), async (req: Request, res: Response) => { try { - const userId = requireQueryString(req.query.user_id, 'user_id is required'); + const { userId } = req.query as unknown as { userId: string }; const summary = await service.getMutationSummary(userId); res.json(summary); } catch (err) { @@ -488,9 +523,9 @@ function registerAuditSummaryRoute(router: Router, service: MemoryService): void } function registerAuditRecentRoute(router: Router, service: MemoryService): void { - router.get('/audit/recent', async (req: Request, res: Response) => { + router.get('/audit/recent', validateQuery(UserIdLimitQuerySchema), async (req: Request, res: Response) => { try { - const { userId, limit } = parseUserIdAndLimit(req.query); + const { userId, limit } = req.query as unknown as { userId: string; limit: number }; const mutations = await service.getRecentMutations(userId, limit); res.json({ mutations, count: mutations.length }); } catch (err) { @@ -500,150 +535,21 @@ function registerAuditRecentRoute(router: Router, service: MemoryService): void } function registerAuditTrailRoute(router: Router, service: MemoryService): void { - router.get('/:id/audit', async (req: Request, res: Response) => { - try { - const memoryId = requireUuidParam(String(req.params.id), 'id'); - const userId = requireQueryString(req.query.user_id, 'user_id is required'); - const trail = await service.getAuditTrail(userId, memoryId); - res.json({ memoryId, trail, versionCount: trail.length }); - } catch (err) { - handleRouteError(res, 'GET /v1/memories/:id/audit', err); - } - }); -} - -function parseIngestBody(body: Record) { - const userId = requireBodyString(body.user_id, 'user_id (string) is required'); - const conversation = requireBodyString(body.conversation, 'conversation (string) is required'); - const sourceSite = requireBodyString(body.source_site, 'source_site (string) is required'); - if (conversation.length > MAX_CONVERSATION_LENGTH) { - throw new InputError(`conversation exceeds max length of ${MAX_CONVERSATION_LENGTH} characters`); - } - return { - userId, - conversation, - sourceSite, - sourceUrl: optionalBodyString(body.source_url) ?? '', - workspace: parseOptionalWorkspaceContext(body), - }; -} - -function parseSearchBody(body: Record) { - return { - userId: requireBodyString(body.user_id, 'user_id (string) is required'), - query: requireBodyString(body.query, 'query (string) is required'), - sourceSite: optionalBodyString(body.source_site), - limit: parseLimit(body.limit), - asOf: parseOptionalIsoTimestamp(body.as_of), - retrievalMode: parseRetrievalMode(body.retrieval_mode), - tokenBudget: parseTokenBudget(body.token_budget), - namespaceScope: optionalBodyString(body.namespace_scope), - skipRepair: body.skip_repair === true, - workspace: parseOptionalWorkspaceContext(body), - agentScope: parseOptionalAgentScope(body.agent_scope), - }; -} - -const VALID_RETRIEVAL_MODES = new Set(['flat', 'tiered', 'abstract-aware']); -const MAX_TOKEN_BUDGET = 50_000; -const MIN_TOKEN_BUDGET = 100; - -function parseRetrievalMode(value: unknown): RetrievalMode | undefined { - if (value === undefined || value === null) return undefined; - if (typeof value !== 'string') throw new InputError('retrieval_mode must be a string'); - if (!VALID_RETRIEVAL_MODES.has(value as RetrievalMode)) { - throw new InputError(`retrieval_mode must be one of: ${[...VALID_RETRIEVAL_MODES].join(', ')}`); - } - return value as RetrievalMode; -} - -function parseTokenBudget(value: unknown): number | undefined { - if (value === undefined || value === null) return undefined; - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new InputError('token_budget must be a finite number'); - } - if (value < MIN_TOKEN_BUDGET || value > MAX_TOKEN_BUDGET) { - throw new InputError(`token_budget must be between ${MIN_TOKEN_BUDGET} and ${MAX_TOKEN_BUDGET}`); - } - return Math.floor(value); -} - -function requireBodyString(value: unknown, message: string): string { - if (!value || typeof value !== 'string') throw new InputError(message); - return value; -} - -function optionalBodyString(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined; -} - -function requireQueryString(value: unknown, message: string): string { - if (!value || typeof value !== 'string') throw new InputError(message); - return value; -} - -function optionalQueryString(value: unknown): string | undefined { - return typeof value === 'string' && value.length > 0 ? value : undefined; -} - -function requireUuidParam(value: string, label: string): string { - if (!UUID_REGEX.test(value)) throw new InputError(`${label} must be a valid UUID`); - return value; -} - -function optionalUuidQuery(value: unknown, label: string): string | undefined { - const str = optionalQueryString(value); - if (!str) return undefined; - if (!UUID_REGEX.test(str)) throw new InputError(`${label} must be a valid UUID`); - return str; -} - -function parseOptionalWorkspaceContext(body: Record): WorkspaceContext | undefined { - const workspaceId = optionalBodyString(body.workspace_id); - const agentId = optionalBodyString(body.agent_id); - if (!workspaceId || !agentId) return undefined; - const visibility = optionalBodyString(body.visibility); - const validVisibility = ['agent_only', 'restricted', 'workspace'] as const; - type Vis = typeof validVisibility[number]; - return { - workspaceId, - agentId, - visibility: visibility && validVisibility.includes(visibility as Vis) - ? visibility as Vis - : undefined, - }; -} - -function parseOptionalAgentScope(value: unknown): AgentScope | undefined { - if (value === undefined || value === null) return undefined; - if (typeof value === 'string') { - if (['all', 'self', 'others'].includes(value)) return value as AgentScope; - return value; - } - if (Array.isArray(value) && value.every((v) => typeof v === 'string')) { - return value as string[]; - } - return undefined; -} - -function parseLimit(value: unknown): number | undefined { - if (typeof value !== 'number') return undefined; - return Math.max(1, Math.min(MAX_SEARCH_LIMIT, Math.floor(value))); -} - -/** Parse the common user_id + optional limit query parameters. */ -function parseUserIdAndLimit(query: Request['query'], defaultLimit = 20): { userId: string; limit: number } { - const userId = requireQueryString(query.user_id, 'user_id is required'); - const limit = parseInt(String(query.limit ?? String(defaultLimit)), 10); - return { userId, limit }; -} - -function parseOptionalIsoTimestamp(value: unknown): string | undefined { - if (value === undefined || value === null || value === '') return undefined; - if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { - throw new InputError('as_of must be a valid ISO timestamp'); - } - return value; + router.get( + '/:id/audit', + validateParams(UuidIdParamSchema), + validateQuery(UserIdQuerySchema), + async (req: Request, res: Response) => { + try { + const { id: memoryId } = req.params as unknown as { id: string }; + const { userId } = req.query as unknown as { userId: string }; + const trail = await service.getAuditTrail(userId, memoryId); + res.json({ memoryId, trail, versionCount: trail.length }); + } catch (err) { + handleRouteError(res, 'GET /v1/memories/:id/audit', err); + } + }, + ); } function resolveEffectiveSearchLimit( @@ -718,6 +624,7 @@ function formatHealthConfig(runtimeConfig: RuntimeConfigRouteSnapshot) { repair_loop_enabled: runtimeConfig.repairLoopEnabled, }; } + function formatSearchResponse(result: RetrievalResult, scope: MemoryScope) { const observability = buildRetrievalObservability(result); return { diff --git a/src/schemas/__tests__/memories.test.ts b/src/schemas/__tests__/memories.test.ts new file mode 100644 index 0000000..2106905 --- /dev/null +++ b/src/schemas/__tests__/memories.test.ts @@ -0,0 +1,128 @@ +/** + * @file Regression tests pinning wire-contract-preserving behavior of + * the memories route schemas. + * + * The codex review on the Phase 2 refactor flagged that generic Zod + * messages ("Invalid input: expected string, received undefined") + * would leak through to API clients that match on the exact route- + * specific error text produced by the pre-refactor inline parsers. + * This test file locks in the preserved messages and the preserved + * empty-string pass-through on POST /search filters. + */ + +import { describe, it, expect } from 'vitest'; +import { + IngestBodySchema, + SearchBodySchema, + ExpandBodySchema, + ResetSourceBodySchema, + LessonReportBodySchema, +} from '../memories'; + +function firstIssueMessage(result: { success: boolean; error?: { issues: { message: string }[] } }): string { + if (result.success) throw new Error('expected schema parse to fail'); + return result.error!.issues[0]?.message ?? ''; +} + +describe('IngestBodySchema — preserved error messages', () => { + it('missing user_id → "user_id (string) is required"', () => { + const r = IngestBodySchema.safeParse({ + conversation: 'x', + source_site: 's', + }); + expect(firstIssueMessage(r)).toBe('user_id (string) is required'); + }); + + it('non-string user_id → same message', () => { + const r = IngestBodySchema.safeParse({ + user_id: 42, + conversation: 'x', + source_site: 's', + }); + expect(firstIssueMessage(r)).toBe('user_id (string) is required'); + }); + + it('empty-string user_id → same message', () => { + const r = IngestBodySchema.safeParse({ + user_id: '', + conversation: 'x', + source_site: 's', + }); + expect(firstIssueMessage(r)).toBe('user_id (string) is required'); + }); + + it('missing conversation → "conversation (string) is required"', () => { + const r = IngestBodySchema.safeParse({ user_id: 'u', source_site: 's' }); + expect(firstIssueMessage(r)).toBe('conversation (string) is required'); + }); + + it('missing source_site → "source_site (string) is required"', () => { + const r = IngestBodySchema.safeParse({ user_id: 'u', conversation: 'x' }); + expect(firstIssueMessage(r)).toBe('source_site (string) is required'); + }); + + it('over-length conversation → "conversation exceeds max length of 100000 characters"', () => { + const r = IngestBodySchema.safeParse({ + user_id: 'u', + conversation: 'x'.repeat(100_001), + source_site: 's', + }); + expect(firstIssueMessage(r)).toBe( + 'conversation exceeds max length of 100000 characters', + ); + }); +}); + +describe('SearchBodySchema — preserved empty-string pass-through', () => { + it('preserves source_site: "" verbatim (matches optionalBodyString)', () => { + const r = SearchBodySchema.parse({ + user_id: 'u', + query: 'q', + source_site: '', + }); + expect(r.sourceSite).toBe(''); + }); + + it('preserves namespace_scope: "" verbatim', () => { + const r = SearchBodySchema.parse({ + user_id: 'u', + query: 'q', + namespace_scope: '', + }); + expect(r.namespaceScope).toBe(''); + }); + + it('required fields still emit exact prior-parser messages', () => { + const r = SearchBodySchema.safeParse({ query: 'q' }); + expect(firstIssueMessage(r)).toBe('user_id (string) is required'); + }); +}); + +describe('ExpandBodySchema — preserved error messages', () => { + it('missing memory_ids → "memory_ids (string[]) is required"', () => { + const r = ExpandBodySchema.safeParse({ user_id: 'u' }); + expect(firstIssueMessage(r)).toBe('memory_ids (string[]) is required'); + }); + + it('non-array memory_ids → same message', () => { + const r = ExpandBodySchema.safeParse({ user_id: 'u', memory_ids: 'abc' }); + expect(firstIssueMessage(r)).toBe('memory_ids (string[]) is required'); + }); + + it('array with non-string elements → same message', () => { + const r = ExpandBodySchema.safeParse({ user_id: 'u', memory_ids: ['a', 42] }); + expect(firstIssueMessage(r)).toBe('memory_ids (string[]) is required'); + }); +}); + +describe('ResetSourceBodySchema / LessonReportBodySchema — preserved messages', () => { + it('reset-source missing source_site → "source_site (string) is required"', () => { + const r = ResetSourceBodySchema.safeParse({ user_id: 'u' }); + expect(firstIssueMessage(r)).toBe('source_site (string) is required'); + }); + + it('lessons/report missing pattern → "pattern (string) is required"', () => { + const r = LessonReportBodySchema.safeParse({ user_id: 'u' }); + expect(firstIssueMessage(r)).toBe('pattern (string) is required'); + }); +}); diff --git a/src/schemas/memories.ts b/src/schemas/memories.ts new file mode 100644 index 0000000..c66bdaf --- /dev/null +++ b/src/schemas/memories.ts @@ -0,0 +1,465 @@ +/** + * @file Zod schemas for every /v1/memories/* route. + * + * Each request body schema authors fields in **snake_case** (the wire + * format) and `.transform()`s to a camelCase output consumed by + * handlers. The output shape of each schema was chosen to drop-in + * replace the value previously returned by `parseIngestBody` / + * `parseSearchBody` etc. so handler bodies don't change. + * + * ⚠️ Behavior-preservation invariants worth noting: + * - `requireBodyString` rejects empty string AND non-string with + * the same 400 message. `requiredStringBody(label)` below emits + * the exact "${label} (string) is required" text for every + * failure mode (missing, null, wrong type, empty). + * - `parseOptionalWorkspaceContext` / `parseOptionalAgentScope` + * NEVER 400 on invalid shapes — they silently drop to undefined. + * Composition here uses the `.catch(undefined)` primitives from + * `./common`. + * - `parseOptionalIsoTimestamp` treats `''` and `null` as absent, + * rejects other invalid strings. `IsoTimestamp` in `./common` + * preserves that with a preprocess step. + * - `retrieval_mode` absence is silent (undefined), invalid values + * throw the exact message from memories.ts:553-555. + * - `token_budget` must be a finite number in [100, 50000], floored + * on success. Matches memories.ts:560-568. + * - `limit` on POST /search / /search/fast bodies: non-number yields + * undefined (not an error); number is clamped to + * [1, MAX_SEARCH_LIMIT=100] and floored. Matches memories.ts:629-632. + * - `conversation` max length = 100_000 chars. Over the limit + * throws 'conversation exceeds max length of 100000 characters'. + * + * Source: `src/routes/memories.ts:515-647` (the inline parsers this + * file replaces). + */ + +import { z } from './zod-setup'; +import { + IsoTimestamp, + RetrievalModeSchema, + MemoryVisibilitySchema, + AgentScopeSchema, + WorkspaceIdField, + AgentIdField, + VisibilityField, + type WorkspaceContext, +} from './common'; + +// --------------------------------------------------------------------------- +// Constants mirroring memories.ts limits +// --------------------------------------------------------------------------- + +const MAX_CONVERSATION_LENGTH = 100_000; +const MAX_SEARCH_LIMIT = 100; +const MAX_TOKEN_BUDGET = 50_000; +const MIN_TOKEN_BUDGET = 100; +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +// --------------------------------------------------------------------------- +// Reusable body-level field schemas +// --------------------------------------------------------------------------- + +/** + * Matches `optionalBodyString` (memories.ts:576): `typeof v === 'string' + * ? v : undefined`. Preserves empty string verbatim — callers may + * distinguish `""` from `undefined` downstream (e.g. search filters). + */ +const OptionalBodyString = z.string().optional().catch(undefined); + +/** + * Build a schema that produces the exact error message + * `"${label} (string) is required"` for every failure mode that + * `requireBodyString` threw on (missing, null, wrong type, empty + * string). Preserves wire-contract text clients may match against. + */ +function requiredStringBody(label: string) { + const message = `${label} (string) is required`; + return z + .unknown() + .refine((v): v is string => typeof v === 'string' && v.length > 0, { + message, + }) + .transform(v => v as string); +} + +/** + * Build a schema that produces `"${label} (string[]) is required"` for + * every failure mode that the old array guard threw on. Matches the + * memory_ids check at memories.ts:213-215. + */ +function requiredStringArrayBody(label: string) { + const message = `${label} (string[]) is required`; + return z + .unknown() + .refine( + (v): v is string[] => + Array.isArray(v) && v.every(x => typeof x === 'string'), + { message }, + ) + .transform(v => v as string[]); +} + +/** POST /search and /search/fast accept body.limit as a number only; other types → undefined. */ +const SearchBodyLimit = z + .preprocess( + v => (typeof v === 'number' && Number.isFinite(v) ? v : undefined), + z.number().optional(), + ) + .transform(n => + typeof n === 'number' + ? Math.max(1, Math.min(MAX_SEARCH_LIMIT, Math.floor(n))) + : undefined, + ); + +/** token_budget: finite number in [100, 50000], floored. Throws on invalid. */ +const TokenBudgetSchema = z + .preprocess(v => (v === undefined || v === null ? undefined : v), z.unknown().optional()) + .refine( + v => + v === undefined || + (typeof v === 'number' && Number.isFinite(v)), + { message: 'token_budget must be a finite number' }, + ) + .refine( + v => + v === undefined || + (typeof v === 'number' && + v >= MIN_TOKEN_BUDGET && + v <= MAX_TOKEN_BUDGET), + { + message: `token_budget must be between ${MIN_TOKEN_BUDGET} and ${MAX_TOKEN_BUDGET}`, + }, + ) + .transform(v => (typeof v === 'number' ? Math.floor(v) : undefined)); + +/** + * retrieval_mode: string enum or undefined. Absent/null → undefined; + * wrong type → throw 'retrieval_mode must be a string'; wrong enum + * value → throw the full valid-list message. Matches memories.ts:551-557. + */ +const RetrievalModeField = z + .preprocess( + v => (v === undefined || v === null ? undefined : v), + z.unknown().optional(), + ) + .superRefine((v, ctx) => { + if (v === undefined) return; + if (typeof v !== 'string') { + ctx.addIssue({ code: 'custom', message: 'retrieval_mode must be a string' }); + return; + } + if (!['flat', 'tiered', 'abstract-aware'].includes(v)) { + ctx.addIssue({ + code: 'custom', + message: `retrieval_mode must be one of: ${['flat', 'tiered', 'abstract-aware'].join(', ')}`, + }); + } + }) + .transform(v => (v === undefined ? undefined : (v as z.infer))); + +// --------------------------------------------------------------------------- +// Ingest +// --------------------------------------------------------------------------- + +export const IngestBodySchema = z + .object({ + user_id: requiredStringBody('user_id'), + conversation: requiredStringBody('conversation').refine( + s => s.length <= MAX_CONVERSATION_LENGTH, + { message: `conversation exceeds max length of ${MAX_CONVERSATION_LENGTH} characters` }, + ), + source_site: requiredStringBody('source_site'), + source_url: OptionalBodyString, + workspace_id: WorkspaceIdField, + agent_id: AgentIdField, + visibility: VisibilityField, + /** Only POST /ingest/quick reads this — safely ignored elsewhere. */ + skip_extraction: z.boolean().optional().catch(undefined), + }) + .transform(b => ({ + userId: b.user_id, + conversation: b.conversation, + sourceSite: b.source_site, + sourceUrl: b.source_url ?? '', + workspace: buildWorkspaceContext(b.workspace_id, b.agent_id, b.visibility), + skipExtraction: b.skip_extraction === true, + })) + .openapi({ + description: + 'Ingest a conversation transcript. User-scoped unless workspace_id + agent_id are both provided.', + }); + +export type IngestBody = z.infer; + +// --------------------------------------------------------------------------- +// Search +// --------------------------------------------------------------------------- + +export const SearchBodySchema = z + .object({ + user_id: requiredStringBody('user_id'), + query: requiredStringBody('query'), + // source_site / namespace_scope intentionally preserve empty + // string — optionalBodyString() did not collapse '' to undefined. + source_site: OptionalBodyString, + limit: SearchBodyLimit, + as_of: IsoTimestamp, + retrieval_mode: RetrievalModeField, + token_budget: TokenBudgetSchema, + namespace_scope: OptionalBodyString, + skip_repair: z.boolean().optional().catch(undefined), + workspace_id: WorkspaceIdField, + agent_id: AgentIdField, + visibility: VisibilityField, + agent_scope: AgentScopeSchema, + }) + .transform(b => ({ + userId: b.user_id, + query: b.query, + sourceSite: b.source_site, + limit: b.limit, + asOf: b.as_of, + retrievalMode: b.retrieval_mode, + tokenBudget: b.token_budget, + namespaceScope: b.namespace_scope, + skipRepair: b.skip_repair === true, + workspace: buildWorkspaceContext(b.workspace_id, b.agent_id, b.visibility), + agentScope: b.agent_scope, + })) + .openapi({ + description: + 'Search memories. User-scoped unless workspace_id + agent_id are both provided.', + }); + +export type SearchBody = z.infer; + +// --------------------------------------------------------------------------- +// Expand +// --------------------------------------------------------------------------- + +export const ExpandBodySchema = z + .object({ + user_id: requiredStringBody('user_id'), + memory_ids: requiredStringArrayBody('memory_ids'), + workspace_id: WorkspaceIdField, + agent_id: AgentIdField, + visibility: VisibilityField, + }) + .transform(b => ({ + userId: b.user_id, + memoryIds: b.memory_ids, + workspace: buildWorkspaceContext(b.workspace_id, b.agent_id, b.visibility), + })) + .openapi({ + description: 'Expand a list of memory IDs into full objects.', + }); + +export type ExpandBody = z.infer; + +// --------------------------------------------------------------------------- +// Admin routes (consolidate / decay / cap / reset-source / reconcile) +// --------------------------------------------------------------------------- + +export const ConsolidateBodySchema = z + .object({ + user_id: requiredStringBody('user_id'), + execute: z.boolean().optional().catch(undefined), + }) + .transform(b => ({ userId: b.user_id, execute: b.execute === true })); + +export type ConsolidateBody = z.infer; + +export const DecayBodySchema = z + .object({ + user_id: requiredStringBody('user_id'), + /** Defaults to true — false means actually archive. */ + dry_run: z.boolean().optional().catch(undefined), + }) + .transform(b => ({ userId: b.user_id, dryRun: b.dry_run !== false })); + +export type DecayBody = z.infer; + +export const ReconcileBodySchema = z + .object({ + // user_id is genuinely optional on this route — empty string + // behaves the same as absent (falls back to reconcileDeferredAll). + user_id: OptionalBodyString, + }) + .transform(b => ({ + userId: typeof b.user_id === 'string' && b.user_id.length > 0 ? b.user_id : undefined, + })); + +export type ReconcileBody = z.infer; + +export const ResetSourceBodySchema = z + .object({ + user_id: requiredStringBody('user_id'), + source_site: requiredStringBody('source_site'), + }) + .transform(b => ({ userId: b.user_id, sourceSite: b.source_site })); + +export type ResetSourceBody = z.infer; + +// --------------------------------------------------------------------------- +// Lessons +// --------------------------------------------------------------------------- + +export const LessonReportBodySchema = z + .object({ + user_id: requiredStringBody('user_id'), + pattern: requiredStringBody('pattern'), + source_memory_ids: z.array(z.string()).optional().catch([]), + severity: z.unknown().optional(), + }) + .transform(b => ({ + userId: b.user_id, + pattern: b.pattern, + sourceMemoryIds: Array.isArray(b.source_memory_ids) ? b.source_memory_ids : [], + severity: b.severity, + })); + +export type LessonReportBody = z.infer; + +// --------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------- + +/** requireQueryString: truthy + typeof string. Matches memories.ts:580-583. */ +const RequiredQueryString = z.string().min(1); + +export const UserIdQuerySchema = z + .object({ user_id: RequiredQueryString }) + .transform(q => ({ userId: q.user_id })); + +export type UserIdQuery = z.infer; + +/** Auto-converts limit to number with default; matches parseUserIdAndLimit. */ +export const UserIdLimitQuerySchema = z + .object({ + user_id: RequiredQueryString, + limit: z.string().optional(), + }) + .transform(q => ({ + userId: q.user_id, + limit: parseIntegerLimit(q.limit, 20), + })); + +export type UserIdLimitQuery = z.infer; + +export const ListQuerySchema = z + .object({ + user_id: RequiredQueryString, + limit: z.string().optional(), + offset: z.string().optional(), + workspace_id: OptionalQueryField(), + agent_id: OptionalUuidQueryField('agent_id'), + source_site: OptionalQueryField(), + episode_id: OptionalUuidQueryField('episode_id'), + }) + .transform(q => ({ + userId: q.user_id, + limit: parseIntegerLimit(q.limit, 20), + offset: parseIntegerLimit(q.offset, 0), + workspaceId: q.workspace_id, + agentId: q.agent_id, + sourceSite: q.source_site, + episodeId: q.episode_id, + })) + .refine(q => !(q.workspaceId && !q.agentId), { + message: 'agent_id is required for workspace queries', + }); + +export type ListQuery = z.infer; + +/** Used by GET /:id and DELETE /:id. Same workspace-requires-agent rule. */ +export const MemoryByIdQuerySchema = z + .object({ + user_id: RequiredQueryString, + workspace_id: OptionalQueryField(), + agent_id: OptionalUuidQueryField('agent_id'), + }) + .transform(q => ({ + userId: q.user_id, + workspaceId: q.workspace_id, + agentId: q.agent_id, + })) + .refine(q => !(q.workspaceId && !q.agentId), { + message: 'agent_id is required for workspace queries', + }); + +export type MemoryByIdQuery = z.infer; + +// --------------------------------------------------------------------------- +// Path params +// --------------------------------------------------------------------------- + +export const UuidIdParamSchema = z + .object({ + id: z.string().regex(UUID_REGEX, 'id must be a valid UUID'), + }) + .transform(p => ({ id: p.id })); + +export type UuidIdParam = z.infer; + +/** Non-UUID :id used by DELETE /lessons/:id (lessonId is a free string). */ +export const FreeIdParamSchema = z + .object({ + id: z.string().min(1), + }) + .transform(p => ({ id: p.id })); + +export type FreeIdParam = z.infer; + +// --------------------------------------------------------------------------- +// Config (PUT /config) — special case +// --------------------------------------------------------------------------- + +/** + * PUT /config body is intentionally loose: the handler enforces the + * startup-only-fields + 410 checks. We expose the body as an open + * object so the 410-first-check-then-reject-then-apply flow stays in + * the handler where it belongs. + */ +export const ConfigBodySchema = z + .object({}) + .passthrough() + .openapi({ description: 'Runtime config mutation. See handler for 410 and rejected[] paths.' }); + +export type ConfigBody = z.infer; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function buildWorkspaceContext( + workspaceId: string | undefined, + agentId: string | undefined, + visibility: z.infer | undefined, +): WorkspaceContext | undefined { + if (!workspaceId || !agentId) return undefined; + return { workspaceId, agentId, visibility }; +} + +/** parseInt with default. Non-numeric strings → NaN → handler falls back. */ +function parseIntegerLimit(raw: string | undefined, defaultVal: number): number { + return parseInt(String(raw ?? String(defaultVal)), 10); +} + +function OptionalQueryField() { + return z + .string() + .optional() + .catch(undefined) + .transform(s => (typeof s === 'string' && s.length > 0 ? s : undefined)); +} + +function OptionalUuidQueryField(label: string) { + return z + .string() + .optional() + .catch(undefined) + .transform(s => (typeof s === 'string' && s.length > 0 ? s : undefined)) + .refine(s => s === undefined || UUID_REGEX.test(s), { + message: `${label} must be a valid UUID`, + }); +}