@dreamforgestudiollc/emet 0.0.0-stage → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +136 -0
- package/CHANGELOG.md +296 -0
- package/CHARTER.md +125 -0
- package/LICENSE +22 -0
- package/README.md +783 -2
- package/docs/AUTHORSHIP.md +26 -0
- package/docs/BENCHMARKS.md +261 -0
- package/docs/BOOTSTRAP.md +127 -0
- package/docs/BOTS.md +79 -0
- package/docs/COVERAGE.md +64 -0
- package/docs/DEPLOY.md +246 -0
- package/docs/DISCIPLINES.md +29 -0
- package/docs/GUARDRAILS-AND-MONORAILS.md +107 -0
- package/docs/GUIDED-SETUP.md +296 -0
- package/docs/PERMISSIONS.md +118 -0
- package/docs/ROADMAP.md +59 -0
- package/docs/SETUP.md +259 -0
- package/guides/NEURODIVERGENT_SUPPORT.md +278 -0
- package/package.json +70 -4
- package/server.json +39 -0
- package/src/aleph.js +395 -0
- package/src/annotations.js +135 -0
- package/src/approve-throttle.js +54 -0
- package/src/atlas-role.js +58 -0
- package/src/conformance.js +688 -0
- package/src/content_analyzer.js +450 -0
- package/src/database.js +811 -0
- package/src/disciplines.js +26 -0
- package/src/dispatch.js +279 -0
- package/src/env.js +52 -0
- package/src/floor.js +187 -0
- package/src/gaps.js +476 -0
- package/src/guides.js +48 -0
- package/src/http-remote.js +269 -0
- package/src/index.js +390 -0
- package/src/invites.js +167 -0
- package/src/layers.js +449 -0
- package/src/lessons-to-checks.js +366 -0
- package/src/logger.js +299 -0
- package/src/metadata.js +61 -0
- package/src/module-path.js +32 -0
- package/src/mongo-connect.js +48 -0
- package/src/named-items.js +208 -0
- package/src/oauth-provider.js +838 -0
- package/src/persons.js +116 -0
- package/src/provenance.js +257 -0
- package/src/redact.js +231 -0
- package/src/request-log.js +124 -0
- package/src/scopes.js +97 -0
- package/src/security-headers.js +26 -0
- package/src/session-graph.js +945 -0
- package/src/session.js +1097 -0
- package/src/setup.js +1313 -0
- package/src/startup-pages.js +354 -0
- package/src/startup-payload.js +126 -0
- package/src/tools.js +4271 -0
- package/src/transcript-session.js +106 -0
- package/src/validation.js +1163 -0
- package/src/version.js +2 -0
- package/templates/BOARD.md +72 -0
- package/templates/BOTHANDOFF.md +57 -0
- package/templates/CHARACTER.md +30 -0
- package/templates/HANDOFF.md +63 -0
- package/templates/IDENTITY.md +29 -0
- package/templates/INDEX.md +82 -0
- package/templates/INSTALL.md +29 -0
- package/templates/PROCEDURE.md +39 -0
- package/templates/ROSTER.md +43 -0
- package/templates/SEED.md +65 -0
- package/templates/STAGING.md +37 -0
- package/templates/STANDARD.md +99 -0
- package/templates/THREAD.md +43 -0
- package/templates/USER.md +29 -0
|
@@ -0,0 +1,1163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMET - Enduring Memory & Epistemic Tiers
|
|
3
|
+
* Copyright (c) 2026 Dreamforge Studio LLC
|
|
4
|
+
*
|
|
5
|
+
* Derived from CASCADE Memory System
|
|
6
|
+
* Copyright (c) 2025-2026 CIPS Corp (C.I.P.S. LLC)
|
|
7
|
+
* Licensed under the MIT License - see LICENSE
|
|
8
|
+
*
|
|
9
|
+
* https://github.com/For-Sunny/cascade-memory-enterprise
|
|
10
|
+
*
|
|
11
|
+
* Validation Module - Comprehensive validation for all MCP inputs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { IMPORTANCE_BUCKETS } from './layers.js';
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// CONSTANTS - Validation Limits
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Content length limits
|
|
22
|
+
*/
|
|
23
|
+
export const CONTENT_LIMITS = {
|
|
24
|
+
MIN_CONTENT_LENGTH: 1,
|
|
25
|
+
MAX_CONTENT_LENGTH: 100000, // 100KB max content
|
|
26
|
+
MAX_CONTEXT_LENGTH: 10000, // 10KB max context
|
|
27
|
+
MAX_QUERY_LENGTH: 1000, // 1KB max query
|
|
28
|
+
MAX_METADATA_SIZE: 50000, // 50KB max metadata JSON
|
|
29
|
+
MAX_STRING_FIELD_LENGTH: 5000, // 5KB for general string fields
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Numeric range limits
|
|
34
|
+
*/
|
|
35
|
+
export const NUMERIC_LIMITS = {
|
|
36
|
+
// Importance and emotional intensity are 0-1 scales
|
|
37
|
+
MIN_IMPORTANCE: 0,
|
|
38
|
+
MAX_IMPORTANCE: 1,
|
|
39
|
+
MIN_EMOTIONAL_INTENSITY: 0,
|
|
40
|
+
MAX_EMOTIONAL_INTENSITY: 1,
|
|
41
|
+
|
|
42
|
+
// Query limits
|
|
43
|
+
MIN_LIMIT: 1,
|
|
44
|
+
MAX_LIMIT: 1000,
|
|
45
|
+
DEFAULT_LIMIT: 10,
|
|
46
|
+
|
|
47
|
+
// Timestamp ranges (Unix epoch)
|
|
48
|
+
MIN_TIMESTAMP: 0,
|
|
49
|
+
MAX_TIMESTAMP: 4102444800, // Year 2100
|
|
50
|
+
|
|
51
|
+
// ID ranges
|
|
52
|
+
MIN_ID: 1,
|
|
53
|
+
MAX_ID: Number.MAX_SAFE_INTEGER,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Valid memory layers - strict whitelist
|
|
58
|
+
*/
|
|
59
|
+
export const VALID_LAYERS = Object.freeze([
|
|
60
|
+
'episodic',
|
|
61
|
+
'semantic',
|
|
62
|
+
'procedural',
|
|
63
|
+
'meta',
|
|
64
|
+
'identity',
|
|
65
|
+
'working'
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Valid metadata field names - whitelist for safety
|
|
70
|
+
*/
|
|
71
|
+
export const VALID_METADATA_FIELDS = Object.freeze([
|
|
72
|
+
'importance',
|
|
73
|
+
'emotional_intensity',
|
|
74
|
+
'context',
|
|
75
|
+
'layer',
|
|
76
|
+
'tags',
|
|
77
|
+
'source',
|
|
78
|
+
'person_id',
|
|
79
|
+
'session_id',
|
|
80
|
+
'timestamp',
|
|
81
|
+
'related_ids',
|
|
82
|
+
'category',
|
|
83
|
+
'priority',
|
|
84
|
+
'expires_at',
|
|
85
|
+
// EMET's own vocabulary (2026-09-07). The whitelist above is inherited from
|
|
86
|
+
// upstream and did not know these, so reviseMemory's `supersedes` and every
|
|
87
|
+
// `valid_until` were silently relocated under `custom` - the whole origin of
|
|
88
|
+
// the "placement convention" and of the dual-path readers in tools.js.
|
|
89
|
+
// Nobody chose that; the whitelist did. These are first-class now.
|
|
90
|
+
'supersedes', // positive integer: id of the entry this one replaces
|
|
91
|
+
'corrects', // positive integer: id of the entry (same layer) this one partly corrects; the parent stays live
|
|
92
|
+
'superseded_by', // positive integer or null: id of the entry that replaced this one
|
|
93
|
+
'superseded_at', // Unix seconds
|
|
94
|
+
'valid_until', // Unix seconds: an end date. The entry stays readable, marked ended.
|
|
95
|
+
'valid_from', // Unix seconds: must not exceed valid_until when both present
|
|
96
|
+
'routing', // plain object: the routing decision that placed the entry
|
|
97
|
+
'source_defaulted', // boolean: the source tag came from the connector default
|
|
98
|
+
'assertion_origin', // 'user' | 'assistant' | 'consolidated' | 'imported' | null
|
|
99
|
+
'content_origin', // 'self' | 'assistant' | 'external' — who the TEXT came from
|
|
100
|
+
// Accountability markers (2026-09-07, receipt-as-fixture). Every field that
|
|
101
|
+
// is MISSING is written as a visible marker, never left absent: absence
|
|
102
|
+
// cannot be queried, "missing" can. Set by saveMemory, never by the caller.
|
|
103
|
+
'source_missing', // boolean: no source given and no default configured
|
|
104
|
+
'source_unregistered', // boolean: source is not in EMET_SOURCE_TAGS
|
|
105
|
+
'importance_defaulted', // boolean: importance came from the layer's charter midpoint
|
|
106
|
+
'importance_bucket', // constitutive|durable|binding|event|scratch
|
|
107
|
+
'importance_degree', // low|mid|high — only when degree inside the bucket matters
|
|
108
|
+
'provenance_missing', // boolean: no derived_from on a layer whose charter wants one
|
|
109
|
+
'assertion_origin_missing', // boolean: assertion_origin was not supplied at all
|
|
110
|
+
'custom' // Allow a 'custom' object for extensibility
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The boolean accountability markers - validated identically.
|
|
115
|
+
*/
|
|
116
|
+
export const MARKER_FIELDS = Object.freeze([
|
|
117
|
+
'corrected_by', // option A (2026-10-02): written on the PARENT by markCorrectedBy, never by a caller
|
|
118
|
+
'redacted_spans', // E4: written by the door from the scrubber's count, never by a caller
|
|
119
|
+
'source_defaulted',
|
|
120
|
+
'source_missing',
|
|
121
|
+
'source_unregistered',
|
|
122
|
+
'importance_defaulted',
|
|
123
|
+
'provenance_missing',
|
|
124
|
+
'assertion_origin_missing'
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Valid values for metadata.assertion_origin (null is also accepted).
|
|
129
|
+
*/
|
|
130
|
+
export const ASSERTION_ORIGINS = Object.freeze(['user', 'assistant', 'consolidated', 'imported']);
|
|
131
|
+
export const CONTENT_ORIGINS = Object.freeze(['self', 'assistant', 'external']);
|
|
132
|
+
|
|
133
|
+
// ============================================================================
|
|
134
|
+
// VALIDATION ERROR CLASS
|
|
135
|
+
// ============================================================================
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Custom validation error with field information
|
|
139
|
+
*/
|
|
140
|
+
export class ValidationError extends Error {
|
|
141
|
+
constructor(field, message, value = undefined) {
|
|
142
|
+
// One-argument calls pass the whole message as `field` (the second reader's fix (b), 2026-10-02): that used to
|
|
143
|
+
// render "Validation failed for '<message>': undefined".
|
|
144
|
+
if (message === undefined) { message = field; field = null; }
|
|
145
|
+
super(field === null ? message : `Validation failed for '${field}': ${message}`);
|
|
146
|
+
this.name = 'ValidationError';
|
|
147
|
+
this.field = field;
|
|
148
|
+
this.validationMessage = message;
|
|
149
|
+
// Don't expose the actual value in production for security
|
|
150
|
+
this.invalidValue = process.env.DEBUG === 'true' ? value : '[hidden]';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
toJSON() {
|
|
154
|
+
return {
|
|
155
|
+
error: 'ValidationError',
|
|
156
|
+
field: this.field,
|
|
157
|
+
message: this.validationMessage
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ============================================================================
|
|
163
|
+
// TYPE VALIDATORS
|
|
164
|
+
// ============================================================================
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Check if value is a string
|
|
168
|
+
*/
|
|
169
|
+
export function isString(value) {
|
|
170
|
+
return typeof value === 'string';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Check if value is a number (finite, not NaN)
|
|
175
|
+
*/
|
|
176
|
+
export function isNumber(value) {
|
|
177
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Check if value is an integer
|
|
182
|
+
*/
|
|
183
|
+
export function isInteger(value) {
|
|
184
|
+
return Number.isInteger(value);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Check if value is a plain object
|
|
189
|
+
*/
|
|
190
|
+
export function isPlainObject(value) {
|
|
191
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Check if value is an array
|
|
196
|
+
*/
|
|
197
|
+
export function isArray(value) {
|
|
198
|
+
return Array.isArray(value);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ============================================================================
|
|
202
|
+
// CONTENT VALIDATORS
|
|
203
|
+
// ============================================================================
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Validate content string
|
|
207
|
+
* @param {*} content - Content to validate
|
|
208
|
+
* @param {string} fieldName - Field name for error messages
|
|
209
|
+
* @returns {string} - Validated and trimmed content
|
|
210
|
+
* @throws {ValidationError}
|
|
211
|
+
*/
|
|
212
|
+
export function validateContent(content, fieldName = 'content') {
|
|
213
|
+
if (content === null || content === undefined) {
|
|
214
|
+
throw new ValidationError(fieldName, 'Content is required');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!isString(content)) {
|
|
218
|
+
throw new ValidationError(fieldName, 'Content must be a string', typeof content);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const trimmed = content.trim();
|
|
222
|
+
|
|
223
|
+
if (trimmed.length < CONTENT_LIMITS.MIN_CONTENT_LENGTH) {
|
|
224
|
+
throw new ValidationError(fieldName, `Content must be at least ${CONTENT_LIMITS.MIN_CONTENT_LENGTH} character(s)`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (trimmed.length > CONTENT_LIMITS.MAX_CONTENT_LENGTH) {
|
|
228
|
+
throw new ValidationError(
|
|
229
|
+
fieldName,
|
|
230
|
+
`Content exceeds maximum length of ${CONTENT_LIMITS.MAX_CONTENT_LENGTH} characters (got ${trimmed.length})`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return trimmed;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Validate query string
|
|
239
|
+
* @param {*} query - Query to validate
|
|
240
|
+
* @returns {string} - Validated and trimmed query
|
|
241
|
+
* @throws {ValidationError}
|
|
242
|
+
*/
|
|
243
|
+
export function validateQuery(query) {
|
|
244
|
+
if (query === null || query === undefined) {
|
|
245
|
+
throw new ValidationError('query', 'Query is required');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!isString(query)) {
|
|
249
|
+
throw new ValidationError('query', 'Query must be a string', typeof query);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const trimmed = query.trim();
|
|
253
|
+
|
|
254
|
+
if (trimmed.length < 1) {
|
|
255
|
+
throw new ValidationError('query', 'Query cannot be empty');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (trimmed.length > CONTENT_LIMITS.MAX_QUERY_LENGTH) {
|
|
259
|
+
throw new ValidationError(
|
|
260
|
+
'query',
|
|
261
|
+
`Query exceeds maximum length of ${CONTENT_LIMITS.MAX_QUERY_LENGTH} characters (got ${trimmed.length})`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return trimmed;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Validate context string (optional field)
|
|
270
|
+
* @param {*} context - Context to validate
|
|
271
|
+
* @returns {string} - Validated context or empty string
|
|
272
|
+
* @throws {ValidationError}
|
|
273
|
+
*/
|
|
274
|
+
export function validateContext(context) {
|
|
275
|
+
if (context === null || context === undefined || context === '') {
|
|
276
|
+
return '';
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!isString(context)) {
|
|
280
|
+
throw new ValidationError('context', 'Context must be a string', typeof context);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (context.length > CONTENT_LIMITS.MAX_CONTEXT_LENGTH) {
|
|
284
|
+
throw new ValidationError(
|
|
285
|
+
'context',
|
|
286
|
+
`Context exceeds maximum length of ${CONTENT_LIMITS.MAX_CONTEXT_LENGTH} characters (got ${context.length})`
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return context;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ============================================================================
|
|
294
|
+
// LAYER VALIDATORS
|
|
295
|
+
// ============================================================================
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Validate memory layer name (strict whitelist)
|
|
299
|
+
* @param {*} layer - Layer name to validate
|
|
300
|
+
* @param {boolean} required - Whether layer is required
|
|
301
|
+
* @returns {string|null} - Validated layer or null if not required and not provided
|
|
302
|
+
* @throws {ValidationError}
|
|
303
|
+
*/
|
|
304
|
+
export function validateLayer(layer, required = false) {
|
|
305
|
+
if (layer === null || layer === undefined || layer === '') {
|
|
306
|
+
if (required) {
|
|
307
|
+
throw new ValidationError('layer', 'Layer is required');
|
|
308
|
+
}
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (!isString(layer)) {
|
|
313
|
+
throw new ValidationError('layer', 'Layer must be a string', typeof layer);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const normalizedLayer = layer.toLowerCase().trim();
|
|
317
|
+
|
|
318
|
+
if (!VALID_LAYERS.includes(normalizedLayer)) {
|
|
319
|
+
throw new ValidationError(
|
|
320
|
+
'layer',
|
|
321
|
+
`Invalid layer '${normalizedLayer}'. Must be one of: ${VALID_LAYERS.join(', ')}`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return normalizedLayer;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ============================================================================
|
|
329
|
+
// NUMERIC VALIDATORS
|
|
330
|
+
// ============================================================================
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Validate a number is within a range
|
|
334
|
+
* @param {*} value - Value to validate
|
|
335
|
+
* @param {string} fieldName - Field name for error messages
|
|
336
|
+
* @param {number} min - Minimum value (inclusive)
|
|
337
|
+
* @param {number} max - Maximum value (inclusive)
|
|
338
|
+
* @param {number|null} defaultValue - Default if not provided
|
|
339
|
+
* @returns {number} - Validated number
|
|
340
|
+
* @throws {ValidationError}
|
|
341
|
+
*/
|
|
342
|
+
export function validateNumericRange(value, fieldName, min, max, defaultValue = null) {
|
|
343
|
+
if (value === null || value === undefined) {
|
|
344
|
+
if (defaultValue !== null) {
|
|
345
|
+
return defaultValue;
|
|
346
|
+
}
|
|
347
|
+
throw new ValidationError(fieldName, 'Value is required');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Handle string numbers (e.g., from JSON parsing edge cases)
|
|
351
|
+
let numValue = value;
|
|
352
|
+
if (isString(value)) {
|
|
353
|
+
numValue = parseFloat(value);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (!isNumber(numValue)) {
|
|
357
|
+
throw new ValidationError(fieldName, 'Value must be a number', value);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (numValue < min || numValue > max) {
|
|
361
|
+
throw new ValidationError(
|
|
362
|
+
fieldName,
|
|
363
|
+
`Value must be between ${min} and ${max} (got ${numValue})`
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return numValue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Validate importance field (0-1 scale)
|
|
372
|
+
*/
|
|
373
|
+
export function validateImportance(value) {
|
|
374
|
+
return validateNumericRange(
|
|
375
|
+
value,
|
|
376
|
+
'importance',
|
|
377
|
+
NUMERIC_LIMITS.MIN_IMPORTANCE,
|
|
378
|
+
NUMERIC_LIMITS.MAX_IMPORTANCE,
|
|
379
|
+
0.7 // Default importance
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Validate emotional intensity field (0-1 scale)
|
|
385
|
+
*/
|
|
386
|
+
export function validateEmotionalIntensity(value) {
|
|
387
|
+
return validateNumericRange(
|
|
388
|
+
value,
|
|
389
|
+
'emotional_intensity',
|
|
390
|
+
NUMERIC_LIMITS.MIN_EMOTIONAL_INTENSITY,
|
|
391
|
+
NUMERIC_LIMITS.MAX_EMOTIONAL_INTENSITY,
|
|
392
|
+
0.5 // Default emotional intensity
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Validate limit field (query results limit)
|
|
398
|
+
*/
|
|
399
|
+
export function validateLimit(value) {
|
|
400
|
+
const limit = validateNumericRange(
|
|
401
|
+
value,
|
|
402
|
+
'limit',
|
|
403
|
+
NUMERIC_LIMITS.MIN_LIMIT,
|
|
404
|
+
NUMERIC_LIMITS.MAX_LIMIT,
|
|
405
|
+
NUMERIC_LIMITS.DEFAULT_LIMIT
|
|
406
|
+
);
|
|
407
|
+
return Math.floor(limit); // Ensure integer
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Validate timestamp field
|
|
412
|
+
*/
|
|
413
|
+
export function validateTimestamp(value, fieldName = 'timestamp') {
|
|
414
|
+
if (value === null || value === undefined) {
|
|
415
|
+
return null; // Timestamps are optional in filters
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return validateNumericRange(
|
|
419
|
+
value,
|
|
420
|
+
fieldName,
|
|
421
|
+
NUMERIC_LIMITS.MIN_TIMESTAMP,
|
|
422
|
+
NUMERIC_LIMITS.MAX_TIMESTAMP,
|
|
423
|
+
null
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Validate ID field
|
|
429
|
+
*/
|
|
430
|
+
export function validateId(value) {
|
|
431
|
+
if (value === null || value === undefined) {
|
|
432
|
+
return null; // IDs are optional in filters
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const id = validateNumericRange(
|
|
436
|
+
value,
|
|
437
|
+
'id',
|
|
438
|
+
NUMERIC_LIMITS.MIN_ID,
|
|
439
|
+
NUMERIC_LIMITS.MAX_ID,
|
|
440
|
+
null
|
|
441
|
+
);
|
|
442
|
+
return Math.floor(id); // Ensure integer
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ============================================================================
|
|
446
|
+
// METADATA VALIDATORS
|
|
447
|
+
// ============================================================================
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Validate metadata object
|
|
451
|
+
* @param {*} metadata - Metadata to validate
|
|
452
|
+
* @returns {object} - Validated and sanitized metadata
|
|
453
|
+
* @throws {ValidationError}
|
|
454
|
+
*/
|
|
455
|
+
export function validateMetadata(metadata) {
|
|
456
|
+
if (metadata === null || metadata === undefined) {
|
|
457
|
+
return {};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (!isPlainObject(metadata)) {
|
|
461
|
+
throw new ValidationError('metadata', 'Metadata must be an object', typeof metadata);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Check total size of metadata JSON
|
|
465
|
+
const metadataJson = JSON.stringify(metadata);
|
|
466
|
+
if (metadataJson.length > CONTENT_LIMITS.MAX_METADATA_SIZE) {
|
|
467
|
+
throw new ValidationError(
|
|
468
|
+
'metadata',
|
|
469
|
+
`Metadata exceeds maximum size of ${CONTENT_LIMITS.MAX_METADATA_SIZE} bytes (got ${metadataJson.length})`
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const sanitized = {};
|
|
474
|
+
const relocated = [];
|
|
475
|
+
|
|
476
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
477
|
+
// Validate field name against whitelist (or allow if it's under 'custom')
|
|
478
|
+
if (!VALID_METADATA_FIELDS.includes(key) && key !== 'custom') {
|
|
479
|
+
// Log warning but don't reject - place unknown fields under 'custom'.
|
|
480
|
+
// The name is recorded so the write receipt can say what moved (A6).
|
|
481
|
+
if (!sanitized.custom) {
|
|
482
|
+
sanitized.custom = {};
|
|
483
|
+
}
|
|
484
|
+
sanitized.custom[key] = value;
|
|
485
|
+
relocated.push(key);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Validate specific known fields
|
|
490
|
+
switch (key) {
|
|
491
|
+
case 'importance':
|
|
492
|
+
sanitized.importance = validateImportance(value);
|
|
493
|
+
break;
|
|
494
|
+
|
|
495
|
+
case 'emotional_intensity':
|
|
496
|
+
sanitized.emotional_intensity = validateEmotionalIntensity(value);
|
|
497
|
+
break;
|
|
498
|
+
|
|
499
|
+
case 'context':
|
|
500
|
+
sanitized.context = validateContext(value);
|
|
501
|
+
break;
|
|
502
|
+
|
|
503
|
+
case 'layer':
|
|
504
|
+
sanitized.layer = validateLayer(value, false);
|
|
505
|
+
break;
|
|
506
|
+
|
|
507
|
+
case 'timestamp':
|
|
508
|
+
sanitized.timestamp = validateTimestamp(value);
|
|
509
|
+
break;
|
|
510
|
+
|
|
511
|
+
case 'tags':
|
|
512
|
+
sanitized.tags = validateTags(value);
|
|
513
|
+
break;
|
|
514
|
+
|
|
515
|
+
case 'source':
|
|
516
|
+
sanitized.source = validateStringField(value, 'source');
|
|
517
|
+
break;
|
|
518
|
+
|
|
519
|
+
case 'session_id':
|
|
520
|
+
// A2 (2026-09-07): a bare session_id is not provenance - it names a
|
|
521
|
+
// session without a span, which is the nearest-timestamp guess the
|
|
522
|
+
// provenance rule forbids. Rejected, with the door pointing at the field
|
|
523
|
+
// that carries a checkable pointer.
|
|
524
|
+
throw new ValidationError(
|
|
525
|
+
'session_id',
|
|
526
|
+
'metadata.session_id is not accepted: supply derived_from {session_id, exchange_start, exchange_end} ' +
|
|
527
|
+
'(a transcript span) or derived_from {entries: [...]} (a consolidation) instead',
|
|
528
|
+
typeof value
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
case 'category':
|
|
532
|
+
sanitized.category = validateStringField(value, 'category');
|
|
533
|
+
break;
|
|
534
|
+
|
|
535
|
+
case 'priority':
|
|
536
|
+
sanitized.priority = validateNumericRange(value, 'priority', 0, 100, 50);
|
|
537
|
+
break;
|
|
538
|
+
|
|
539
|
+
case 'expires_at': {
|
|
540
|
+
// A2 (2026-09-07): `valid_until` is canonical; `expires_at` is the
|
|
541
|
+
// upstream (CASCADE) spelling and is accepted as an alias of it. Two
|
|
542
|
+
// spellings with two values is a contradiction, not a choice.
|
|
543
|
+
const aliased = validateTimestamp(value, 'expires_at');
|
|
544
|
+
if (sanitized.valid_until !== undefined && sanitized.valid_until !== null &&
|
|
545
|
+
aliased !== null && sanitized.valid_until !== aliased) {
|
|
546
|
+
throw new ValidationError('expires_at', `expires_at (${aliased}) disagrees with valid_until (${sanitized.valid_until}); supply one`, typeof value);
|
|
547
|
+
}
|
|
548
|
+
if (sanitized.valid_until === undefined || sanitized.valid_until === null) sanitized.valid_until = aliased;
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
case 'related_ids':
|
|
553
|
+
sanitized.related_ids = validateIdArray(value);
|
|
554
|
+
break;
|
|
555
|
+
|
|
556
|
+
// ---- EMET vocabulary (first-class, never relocated) ----
|
|
557
|
+
case 'supersedes':
|
|
558
|
+
sanitized.supersedes = validatePositiveInteger(value, 'supersedes');
|
|
559
|
+
break;
|
|
560
|
+
|
|
561
|
+
case 'corrects':
|
|
562
|
+
sanitized.corrects = validatePositiveInteger(value, 'corrects');
|
|
563
|
+
break;
|
|
564
|
+
|
|
565
|
+
case 'superseded_by':
|
|
566
|
+
sanitized.superseded_by = (value === null || value === undefined)
|
|
567
|
+
? null
|
|
568
|
+
: validatePositiveInteger(value, 'superseded_by');
|
|
569
|
+
break;
|
|
570
|
+
|
|
571
|
+
case 'superseded_at':
|
|
572
|
+
sanitized.superseded_at = validateTimestamp(value, 'superseded_at');
|
|
573
|
+
break;
|
|
574
|
+
|
|
575
|
+
case 'valid_until': {
|
|
576
|
+
const canonical = validateTimestamp(value, 'valid_until');
|
|
577
|
+
// If expires_at was seen first, valid_until is already set from the alias.
|
|
578
|
+
if (sanitized.valid_until !== undefined && sanitized.valid_until !== null &&
|
|
579
|
+
canonical !== null && sanitized.valid_until !== canonical) {
|
|
580
|
+
throw new ValidationError('valid_until', `valid_until (${canonical}) disagrees with expires_at (${sanitized.valid_until}); supply one`, typeof value);
|
|
581
|
+
}
|
|
582
|
+
sanitized.valid_until = canonical;
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
case 'valid_from':
|
|
587
|
+
sanitized.valid_from = validateTimestamp(value, 'valid_from');
|
|
588
|
+
break;
|
|
589
|
+
|
|
590
|
+
case 'routing':
|
|
591
|
+
if (value === null || value === undefined) {
|
|
592
|
+
sanitized.routing = null;
|
|
593
|
+
} else if (!isPlainObject(value)) {
|
|
594
|
+
throw new ValidationError('routing', 'Routing must be a plain object', typeof value);
|
|
595
|
+
} else {
|
|
596
|
+
sanitized.routing = value;
|
|
597
|
+
}
|
|
598
|
+
break;
|
|
599
|
+
|
|
600
|
+
case 'source_defaulted':
|
|
601
|
+
case 'source_missing':
|
|
602
|
+
case 'source_unregistered':
|
|
603
|
+
case 'importance_defaulted':
|
|
604
|
+
case 'provenance_missing':
|
|
605
|
+
case 'assertion_origin_missing':
|
|
606
|
+
if (typeof value !== 'boolean') {
|
|
607
|
+
throw new ValidationError(key, 'Value must be a boolean', typeof value);
|
|
608
|
+
}
|
|
609
|
+
sanitized[key] = value;
|
|
610
|
+
break;
|
|
611
|
+
|
|
612
|
+
case 'assertion_origin':
|
|
613
|
+
if (value === null || value === undefined) {
|
|
614
|
+
sanitized.assertion_origin = null;
|
|
615
|
+
} else if (!isString(value) || !ASSERTION_ORIGINS.includes(value)) {
|
|
616
|
+
throw new ValidationError(
|
|
617
|
+
'assertion_origin',
|
|
618
|
+
`Invalid assertion_origin '${value}'. Must be one of: ${ASSERTION_ORIGINS.join(', ')} (or null)`
|
|
619
|
+
);
|
|
620
|
+
} else {
|
|
621
|
+
sanitized.assertion_origin = value;
|
|
622
|
+
}
|
|
623
|
+
break;
|
|
624
|
+
|
|
625
|
+
case 'custom':
|
|
626
|
+
// Allow custom object but validate its size
|
|
627
|
+
if (isPlainObject(value)) {
|
|
628
|
+
const customJson = JSON.stringify(value);
|
|
629
|
+
if (customJson.length <= CONTENT_LIMITS.MAX_METADATA_SIZE / 2) {
|
|
630
|
+
sanitized.custom = { ...sanitized.custom, ...value };
|
|
631
|
+
} else {
|
|
632
|
+
throw new ValidationError('custom', 'Custom metadata object is too large');
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
break;
|
|
636
|
+
|
|
637
|
+
default:
|
|
638
|
+
// Should not reach here due to whitelist check above
|
|
639
|
+
sanitized[key] = value;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Cross-validation: a validity window cannot close before it opens
|
|
644
|
+
if (sanitized.valid_from !== undefined && sanitized.valid_from !== null &&
|
|
645
|
+
sanitized.valid_until !== undefined && sanitized.valid_until !== null) {
|
|
646
|
+
if (sanitized.valid_from > sanitized.valid_until) {
|
|
647
|
+
throw new ValidationError(
|
|
648
|
+
'valid_from',
|
|
649
|
+
`valid_from (${sanitized.valid_from}) cannot be greater than valid_until (${sanitized.valid_until})`
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (relocated.length > 0) {
|
|
655
|
+
RELOCATED_FIELDS.set(sanitized, relocated);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return sanitized;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Names of the fields validateMetadata moved under `custom`, keyed by the
|
|
663
|
+
* object it returned. A WeakMap rather than a property on the result so the
|
|
664
|
+
* return shape is unchanged for every existing caller and nothing extra is
|
|
665
|
+
* ever serialized into the store.
|
|
666
|
+
*/
|
|
667
|
+
const RELOCATED_FIELDS = new WeakMap();
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Which top-level metadata fields validateMetadata relocated under `custom`.
|
|
671
|
+
* @param {object} validated - the object validateMetadata returned
|
|
672
|
+
* @returns {string[]} - field names, in input order; empty when nothing moved
|
|
673
|
+
*/
|
|
674
|
+
export function relocatedFields(validated) {
|
|
675
|
+
const names = RELOCATED_FIELDS.get(validated);
|
|
676
|
+
return names ? [...names] : [];
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Validate a positive integer id-like field (supersedes, superseded_by)
|
|
681
|
+
* @param {*} value - Value to validate
|
|
682
|
+
* @param {string} fieldName - Field name for error messages
|
|
683
|
+
* @returns {number} - Validated positive integer
|
|
684
|
+
* @throws {ValidationError}
|
|
685
|
+
*/
|
|
686
|
+
export function validatePositiveInteger(value, fieldName) {
|
|
687
|
+
if (value === null || value === undefined) {
|
|
688
|
+
throw new ValidationError(fieldName, 'Value is required');
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
let numValue = value;
|
|
692
|
+
if (isString(value) && value.trim() !== '') {
|
|
693
|
+
numValue = Number(value);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (!isNumber(numValue) || !isInteger(numValue) || numValue < NUMERIC_LIMITS.MIN_ID) {
|
|
697
|
+
throw new ValidationError(fieldName, 'Value must be a positive integer', value);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return numValue;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Validate tags array
|
|
705
|
+
* @param {*} tags - Tags to validate
|
|
706
|
+
* @returns {string[]} - Validated tags array
|
|
707
|
+
* @throws {ValidationError}
|
|
708
|
+
*/
|
|
709
|
+
export function validateTags(tags) {
|
|
710
|
+
if (tags === null || tags === undefined) {
|
|
711
|
+
return [];
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (!isArray(tags)) {
|
|
715
|
+
throw new ValidationError('tags', 'Tags must be an array', typeof tags);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (tags.length > 50) {
|
|
719
|
+
throw new ValidationError('tags', 'Too many tags (max 50)');
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const validatedTags = [];
|
|
723
|
+
for (let i = 0; i < tags.length; i++) {
|
|
724
|
+
const tag = tags[i];
|
|
725
|
+
if (!isString(tag)) {
|
|
726
|
+
throw new ValidationError(`tags[${i}]`, 'Each tag must be a string', typeof tag);
|
|
727
|
+
}
|
|
728
|
+
const trimmed = tag.trim();
|
|
729
|
+
if (trimmed.length > 100) {
|
|
730
|
+
throw new ValidationError(`tags[${i}]`, 'Tag exceeds maximum length of 100 characters');
|
|
731
|
+
}
|
|
732
|
+
if (trimmed.length > 0) {
|
|
733
|
+
validatedTags.push(trimmed);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
return validatedTags;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Validate ID array
|
|
742
|
+
* @param {*} ids - IDs to validate
|
|
743
|
+
* @returns {number[]} - Validated IDs array
|
|
744
|
+
* @throws {ValidationError}
|
|
745
|
+
*/
|
|
746
|
+
export function validateIdArray(ids) {
|
|
747
|
+
if (ids === null || ids === undefined) {
|
|
748
|
+
return [];
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (!isArray(ids)) {
|
|
752
|
+
throw new ValidationError('related_ids', 'Related IDs must be an array', typeof ids);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (ids.length > 100) {
|
|
756
|
+
throw new ValidationError('related_ids', 'Too many related IDs (max 100)');
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const validatedIds = [];
|
|
760
|
+
for (let i = 0; i < ids.length; i++) {
|
|
761
|
+
const id = validateNumericRange(
|
|
762
|
+
ids[i],
|
|
763
|
+
`related_ids[${i}]`,
|
|
764
|
+
NUMERIC_LIMITS.MIN_ID,
|
|
765
|
+
NUMERIC_LIMITS.MAX_ID,
|
|
766
|
+
null
|
|
767
|
+
);
|
|
768
|
+
if (id !== null) {
|
|
769
|
+
validatedIds.push(Math.floor(id));
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return validatedIds;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Validate generic string field
|
|
778
|
+
* @param {*} value - Value to validate
|
|
779
|
+
* @param {string} fieldName - Field name for error messages
|
|
780
|
+
* @returns {string} - Validated string
|
|
781
|
+
* @throws {ValidationError}
|
|
782
|
+
*/
|
|
783
|
+
export function validateStringField(value, fieldName) {
|
|
784
|
+
if (value === null || value === undefined) {
|
|
785
|
+
return '';
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (!isString(value)) {
|
|
789
|
+
throw new ValidationError(fieldName, 'Value must be a string', typeof value);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (value.length > CONTENT_LIMITS.MAX_STRING_FIELD_LENGTH) {
|
|
793
|
+
throw new ValidationError(
|
|
794
|
+
fieldName,
|
|
795
|
+
`Value exceeds maximum length of ${CONTENT_LIMITS.MAX_STRING_FIELD_LENGTH} characters`
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return value;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ============================================================================
|
|
803
|
+
// FILTER VALIDATORS
|
|
804
|
+
// ============================================================================
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* The filter keys validateFilters recognises. Everything else in a filters
|
|
808
|
+
* object is discarded silently by design - the validator is a whitelist - so a
|
|
809
|
+
* caller who passes an unrecognised key gets a full UNFILTERED page back and no
|
|
810
|
+
* error, which is indistinguishable from "the filter did not work". That
|
|
811
|
+
* symptom cost a bug hunt on 2026-09-08 (a recorded lesson). The list is exported
|
|
812
|
+
* so callers can NAME the dropped keys instead of swallowing them.
|
|
813
|
+
*/
|
|
814
|
+
export const RECOGNISED_FILTER_KEYS = Object.freeze([
|
|
815
|
+
'importance_min',
|
|
816
|
+
'importance_max',
|
|
817
|
+
'importance_bucket',
|
|
818
|
+
'emotional_intensity_min',
|
|
819
|
+
'emotional_intensity_max',
|
|
820
|
+
'timestamp_after',
|
|
821
|
+
'timestamp_before',
|
|
822
|
+
'content_contains',
|
|
823
|
+
'context_contains',
|
|
824
|
+
'id'
|
|
825
|
+
]);
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Return the keys of a filters object that validateFilters will discard.
|
|
829
|
+
* Non-objects yield an empty list; shape errors are validateFilters's to throw.
|
|
830
|
+
* @param {*} filters
|
|
831
|
+
* @returns {string[]}
|
|
832
|
+
*/
|
|
833
|
+
export function unrecognisedFilterKeys(filters) {
|
|
834
|
+
if (!isPlainObject(filters)) {
|
|
835
|
+
return [];
|
|
836
|
+
}
|
|
837
|
+
return Object.keys(filters).filter((key) => !RECOGNISED_FILTER_KEYS.includes(key));
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Validate query filters object
|
|
842
|
+
* @param {*} filters - Filters to validate
|
|
843
|
+
* @returns {object} - Validated filters
|
|
844
|
+
* @throws {ValidationError}
|
|
845
|
+
*/
|
|
846
|
+
export function validateFilters(filters) {
|
|
847
|
+
if (filters === null || filters === undefined) {
|
|
848
|
+
return {};
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (!isPlainObject(filters)) {
|
|
852
|
+
throw new ValidationError('filters', 'Filters must be an object', typeof filters);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const validated = {};
|
|
856
|
+
|
|
857
|
+
// Importance filters
|
|
858
|
+
if ('importance_min' in filters) {
|
|
859
|
+
validated.importance_min = validateNumericRange(
|
|
860
|
+
filters.importance_min,
|
|
861
|
+
'filters.importance_min',
|
|
862
|
+
NUMERIC_LIMITS.MIN_IMPORTANCE,
|
|
863
|
+
NUMERIC_LIMITS.MAX_IMPORTANCE,
|
|
864
|
+
null
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if ('importance_max' in filters) {
|
|
869
|
+
validated.importance_max = validateNumericRange(
|
|
870
|
+
filters.importance_max,
|
|
871
|
+
'filters.importance_max',
|
|
872
|
+
NUMERIC_LIMITS.MIN_IMPORTANCE,
|
|
873
|
+
NUMERIC_LIMITS.MAX_IMPORTANCE,
|
|
874
|
+
null
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if ('importance_bucket' in filters) {
|
|
879
|
+
const name = String(filters.importance_bucket).trim().toLowerCase();
|
|
880
|
+
const spec = IMPORTANCE_BUCKETS[name];
|
|
881
|
+
if (!spec) {
|
|
882
|
+
throw new ValidationError(
|
|
883
|
+
'filters.importance_bucket',
|
|
884
|
+
`Unknown bucket '${filters.importance_bucket}'. Use constitutive, durable, binding, event, or scratch.`
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
validated.importance_bucket = name;
|
|
888
|
+
if (validated.importance_min === undefined) validated.importance_min = spec.range[0];
|
|
889
|
+
if (validated.importance_max === undefined) validated.importance_max = spec.range[1];
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Emotional intensity filters
|
|
893
|
+
if ('emotional_intensity_min' in filters) {
|
|
894
|
+
validated.emotional_intensity_min = validateNumericRange(
|
|
895
|
+
filters.emotional_intensity_min,
|
|
896
|
+
'filters.emotional_intensity_min',
|
|
897
|
+
NUMERIC_LIMITS.MIN_EMOTIONAL_INTENSITY,
|
|
898
|
+
NUMERIC_LIMITS.MAX_EMOTIONAL_INTENSITY,
|
|
899
|
+
null
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if ('emotional_intensity_max' in filters) {
|
|
904
|
+
validated.emotional_intensity_max = validateNumericRange(
|
|
905
|
+
filters.emotional_intensity_max,
|
|
906
|
+
'filters.emotional_intensity_max',
|
|
907
|
+
NUMERIC_LIMITS.MIN_EMOTIONAL_INTENSITY,
|
|
908
|
+
NUMERIC_LIMITS.MAX_EMOTIONAL_INTENSITY,
|
|
909
|
+
null
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Timestamp filters
|
|
914
|
+
if ('timestamp_after' in filters) {
|
|
915
|
+
validated.timestamp_after = validateTimestamp(filters.timestamp_after, 'filters.timestamp_after');
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if ('timestamp_before' in filters) {
|
|
919
|
+
validated.timestamp_before = validateTimestamp(filters.timestamp_before, 'filters.timestamp_before');
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Content search filters
|
|
923
|
+
if ('content_contains' in filters) {
|
|
924
|
+
validated.content_contains = validateStringField(filters.content_contains, 'filters.content_contains');
|
|
925
|
+
if (validated.content_contains.length > CONTENT_LIMITS.MAX_QUERY_LENGTH) {
|
|
926
|
+
throw new ValidationError(
|
|
927
|
+
'filters.content_contains',
|
|
928
|
+
`Search term exceeds maximum length of ${CONTENT_LIMITS.MAX_QUERY_LENGTH}`
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if ('context_contains' in filters) {
|
|
934
|
+
validated.context_contains = validateStringField(filters.context_contains, 'filters.context_contains');
|
|
935
|
+
if (validated.context_contains.length > CONTENT_LIMITS.MAX_QUERY_LENGTH) {
|
|
936
|
+
throw new ValidationError(
|
|
937
|
+
'filters.context_contains',
|
|
938
|
+
`Search term exceeds maximum length of ${CONTENT_LIMITS.MAX_QUERY_LENGTH}`
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// ID filter
|
|
944
|
+
if ('id' in filters) {
|
|
945
|
+
validated.id = validateId(filters.id);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Cross-validation: min should not exceed max
|
|
949
|
+
if (validated.importance_min !== undefined && validated.importance_max !== undefined) {
|
|
950
|
+
if (validated.importance_min > validated.importance_max) {
|
|
951
|
+
throw new ValidationError(
|
|
952
|
+
'filters',
|
|
953
|
+
'importance_min cannot be greater than importance_max'
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
if (validated.emotional_intensity_min !== undefined && validated.emotional_intensity_max !== undefined) {
|
|
959
|
+
if (validated.emotional_intensity_min > validated.emotional_intensity_max) {
|
|
960
|
+
throw new ValidationError(
|
|
961
|
+
'filters',
|
|
962
|
+
'emotional_intensity_min cannot be greater than emotional_intensity_max'
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
if (validated.timestamp_after !== undefined && validated.timestamp_before !== undefined) {
|
|
968
|
+
if (validated.timestamp_after > validated.timestamp_before) {
|
|
969
|
+
throw new ValidationError(
|
|
970
|
+
'filters',
|
|
971
|
+
'timestamp_after cannot be greater than timestamp_before'
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
return validated;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// ============================================================================
|
|
980
|
+
// OPTIONS VALIDATORS
|
|
981
|
+
// ============================================================================
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Validate query_layer options
|
|
985
|
+
* @param {*} options - Options to validate
|
|
986
|
+
* @returns {object} - Validated options
|
|
987
|
+
* @throws {ValidationError}
|
|
988
|
+
*/
|
|
989
|
+
export function validateQueryOptions(options) {
|
|
990
|
+
if (options === null || options === undefined) {
|
|
991
|
+
return { limit: NUMERIC_LIMITS.DEFAULT_LIMIT };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
if (!isPlainObject(options)) {
|
|
995
|
+
throw new ValidationError('options', 'Options must be an object', typeof options);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const validated = {};
|
|
999
|
+
|
|
1000
|
+
// Validate limit
|
|
1001
|
+
if ('limit' in options) {
|
|
1002
|
+
validated.limit = validateLimit(options.limit);
|
|
1003
|
+
} else {
|
|
1004
|
+
validated.limit = NUMERIC_LIMITS.DEFAULT_LIMIT;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Validate order_by (already sanitized in main code, but validate format here)
|
|
1008
|
+
if ('order_by' in options) {
|
|
1009
|
+
if (!isString(options.order_by)) {
|
|
1010
|
+
throw new ValidationError('options.order_by', 'order_by must be a string');
|
|
1011
|
+
}
|
|
1012
|
+
validated.order_by = options.order_by.trim();
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Validate filters
|
|
1016
|
+
if ('filters' in options) {
|
|
1017
|
+
validated.filters = validateFilters(options.filters);
|
|
1018
|
+
// validateFilters is a whitelist: unrecognised keys vanish without error and
|
|
1019
|
+
// the query then runs unfiltered. Carry the dropped names up so the caller
|
|
1020
|
+
// can report them, mirroring the deprecated-'where' flag below.
|
|
1021
|
+
const droppedFilterKeys = unrecognisedFilterKeys(options.filters);
|
|
1022
|
+
if (droppedFilterKeys.length > 0) {
|
|
1023
|
+
validated._unknown_filter_keys = droppedFilterKeys;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// Legacy params support (deprecated but validated)
|
|
1028
|
+
if ('params' in options) {
|
|
1029
|
+
if (isArray(options.params)) {
|
|
1030
|
+
validated.params = options.params.slice(0, 10); // Limit params count
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Ignore deprecated 'where' field - log warning handled in main code
|
|
1035
|
+
if ('where' in options) {
|
|
1036
|
+
validated._deprecated_where_used = true;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
return validated;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// ============================================================================
|
|
1043
|
+
// COMPLETE INPUT VALIDATORS (for tool handlers)
|
|
1044
|
+
// ============================================================================
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Validate save_to_layer input (the former 'remember' wrapper used this too)
|
|
1048
|
+
*/
|
|
1049
|
+
export function validateRememberInput(args) {
|
|
1050
|
+
if (!isPlainObject(args)) {
|
|
1051
|
+
throw new ValidationError('args', 'Arguments must be an object');
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
return {
|
|
1055
|
+
content: validateContent(args.content),
|
|
1056
|
+
layer: validateLayer(args.layer, false),
|
|
1057
|
+
metadata: validateMetadata(args.metadata)
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Validate 'recall' tool input
|
|
1063
|
+
*/
|
|
1064
|
+
export function validateRecallInput(args) {
|
|
1065
|
+
if (!isPlainObject(args)) {
|
|
1066
|
+
throw new ValidationError('args', 'Arguments must be an object');
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
return {
|
|
1070
|
+
query: validateQuery(args.query),
|
|
1071
|
+
layer: validateLayer(args.layer, false),
|
|
1072
|
+
limit: validateLimit(args.limit)
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Validate 'query_layer' tool input
|
|
1078
|
+
*/
|
|
1079
|
+
export function validateQueryLayerInput(args) {
|
|
1080
|
+
if (!isPlainObject(args)) {
|
|
1081
|
+
throw new ValidationError('args', 'Arguments must be an object');
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
return {
|
|
1085
|
+
layer: validateLayer(args.layer, true), // Layer is required for query_layer
|
|
1086
|
+
options: validateQueryOptions(args.options)
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Validate 'save_to_layer' tool input
|
|
1092
|
+
*/
|
|
1093
|
+
export function validateSaveToLayerInput(args) {
|
|
1094
|
+
if (!isPlainObject(args)) {
|
|
1095
|
+
throw new ValidationError('args', 'Arguments must be an object');
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
return {
|
|
1099
|
+
layer: validateLayer(args.layer, true), // Layer is required
|
|
1100
|
+
content: validateContent(args.content),
|
|
1101
|
+
metadata: validateMetadata(args.metadata)
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// ============================================================================
|
|
1106
|
+
// EXPORT DEFAULT VALIDATOR OBJECT
|
|
1107
|
+
// ============================================================================
|
|
1108
|
+
|
|
1109
|
+
export default {
|
|
1110
|
+
// Constants
|
|
1111
|
+
CONTENT_LIMITS,
|
|
1112
|
+
NUMERIC_LIMITS,
|
|
1113
|
+
VALID_LAYERS,
|
|
1114
|
+
VALID_METADATA_FIELDS,
|
|
1115
|
+
|
|
1116
|
+
// Error class
|
|
1117
|
+
ValidationError,
|
|
1118
|
+
|
|
1119
|
+
// Type checkers
|
|
1120
|
+
isString,
|
|
1121
|
+
isNumber,
|
|
1122
|
+
isInteger,
|
|
1123
|
+
isPlainObject,
|
|
1124
|
+
isArray,
|
|
1125
|
+
|
|
1126
|
+
// Content validators
|
|
1127
|
+
validateContent,
|
|
1128
|
+
validateQuery,
|
|
1129
|
+
validateContext,
|
|
1130
|
+
|
|
1131
|
+
// Layer validators
|
|
1132
|
+
validateLayer,
|
|
1133
|
+
|
|
1134
|
+
// Numeric validators
|
|
1135
|
+
validateNumericRange,
|
|
1136
|
+
validateImportance,
|
|
1137
|
+
validateEmotionalIntensity,
|
|
1138
|
+
validateLimit,
|
|
1139
|
+
validateTimestamp,
|
|
1140
|
+
validateId,
|
|
1141
|
+
|
|
1142
|
+
// Metadata validators
|
|
1143
|
+
validateMetadata,
|
|
1144
|
+
relocatedFields,
|
|
1145
|
+
validatePositiveInteger,
|
|
1146
|
+
validateTags,
|
|
1147
|
+
validateIdArray,
|
|
1148
|
+
validateStringField,
|
|
1149
|
+
ASSERTION_ORIGINS,
|
|
1150
|
+
MARKER_FIELDS,
|
|
1151
|
+
|
|
1152
|
+
// Filter/Options validators
|
|
1153
|
+
validateFilters,
|
|
1154
|
+
RECOGNISED_FILTER_KEYS,
|
|
1155
|
+
unrecognisedFilterKeys,
|
|
1156
|
+
validateQueryOptions,
|
|
1157
|
+
|
|
1158
|
+
// Complete input validators
|
|
1159
|
+
validateRememberInput,
|
|
1160
|
+
validateRecallInput,
|
|
1161
|
+
validateQueryLayerInput,
|
|
1162
|
+
validateSaveToLayerInput
|
|
1163
|
+
};
|