@simtlix/simfinity-core 3.5.7 → 3.5.9

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/src/scalars.js CHANGED
@@ -67,26 +67,56 @@ export const PositiveFloatScalar = createValidatedScalar(
67
67
  },
68
68
  );
69
69
 
70
+ // A null or omitted bound means no bound (#97). Other values keep JavaScript's comparison, so numeric strings work.
71
+ const hasBound = (bound) => bound !== undefined && bound !== null;
72
+
73
+ // The published description names only the bounds that are set. A NaN bound, which no comparison applies, is left out
74
+ // too. With both bounds the text is the same as in earlier versions.
75
+ const describeBounds = (min, max, texts) => {
76
+ const described = (bound) => hasBound(bound) && !(typeof bound === 'number' && Number.isNaN(bound));
77
+ if (described(min) && described(max)) return texts.between;
78
+ if (described(min)) return texts.atLeast;
79
+ if (described(max)) return texts.atMost;
80
+ return texts.unbounded;
81
+ };
82
+
83
+ const checkNumberBounds = (min, max) => (value) => {
84
+ if (typeof value !== 'number' || isNaN(value)) {
85
+ throw new Error('Value must be a number');
86
+ }
87
+ if (hasBound(min) && value < min) {
88
+ throw new Error(`Value must be at least ${min}`);
89
+ }
90
+ if (hasBound(max) && value > max) {
91
+ throw new Error(`Value must be at most ${max}`);
92
+ }
93
+ };
94
+
70
95
  /**
71
96
  * Factory function to create a bounded string scalar
72
97
  * @param {string} name - Name for the scalar
73
- * @param {number} min - Minimum length
74
- * @param {number} max - Maximum length
98
+ * @param {number|null} [min] - Minimum length; null or undefined means no minimum
99
+ * @param {number|null} [max] - Maximum length; null or undefined means no maximum
75
100
  * @returns {GraphQLScalarType} A scalar type with length validation
76
101
  */
77
102
  export const createBoundedStringScalar = (name, min, max) => {
78
103
  return createValidatedScalar(
79
104
  name,
80
- `A string with length between ${min} and ${max} characters`,
105
+ describeBounds(min, max, {
106
+ between: `A string with length between ${min} and ${max} characters`,
107
+ atLeast: `A string with at least ${min} characters`,
108
+ atMost: `A string with at most ${max} characters`,
109
+ unbounded: 'A string',
110
+ }),
81
111
  GraphQLString,
82
112
  (value) => {
83
113
  if (typeof value !== 'string') {
84
114
  throw new Error('Value must be a string');
85
115
  }
86
- if (min !== undefined && value.length < min) {
116
+ if (hasBound(min) && value.length < min) {
87
117
  throw new Error(`String must be at least ${min} characters`);
88
118
  }
89
- if (max !== undefined && value.length > max) {
119
+ if (hasBound(max) && value.length > max) {
90
120
  throw new Error(`String must be at most ${max} characters`);
91
121
  }
92
122
  },
@@ -96,52 +126,42 @@ export const createBoundedStringScalar = (name, min, max) => {
96
126
  /**
97
127
  * Factory function to create a bounded integer scalar
98
128
  * @param {string} name - Name for the scalar
99
- * @param {number} min - Minimum value
100
- * @param {number} max - Maximum value
129
+ * @param {number|null} [min] - Minimum value; null or undefined means no minimum
130
+ * @param {number|null} [max] - Maximum value; null or undefined means no maximum
101
131
  * @returns {GraphQLScalarType} A scalar type with range validation
102
132
  */
103
133
  export const createBoundedIntScalar = (name, min, max) => {
104
134
  return createValidatedScalar(
105
135
  name,
106
- `An integer between ${min} and ${max}`,
136
+ describeBounds(min, max, {
137
+ between: `An integer between ${min} and ${max}`,
138
+ atLeast: `An integer of at least ${min}`,
139
+ atMost: `An integer of at most ${max}`,
140
+ unbounded: 'An integer',
141
+ }),
107
142
  GraphQLInt,
108
- (value) => {
109
- if (typeof value !== 'number' || isNaN(value)) {
110
- throw new Error('Value must be a number');
111
- }
112
- if (min !== undefined && value < min) {
113
- throw new Error(`Value must be at least ${min}`);
114
- }
115
- if (max !== undefined && value > max) {
116
- throw new Error(`Value must be at most ${max}`);
117
- }
118
- },
143
+ checkNumberBounds(min, max),
119
144
  );
120
145
  };
121
146
 
122
147
  /**
123
148
  * Factory function to create a bounded float scalar
124
149
  * @param {string} name - Name for the scalar
125
- * @param {number} min - Minimum value
126
- * @param {number} max - Maximum value
150
+ * @param {number|null} [min] - Minimum value; null or undefined means no minimum
151
+ * @param {number|null} [max] - Maximum value; null or undefined means no maximum
127
152
  * @returns {GraphQLScalarType} A scalar type with range validation
128
153
  */
129
154
  export const createBoundedFloatScalar = (name, min, max) => {
130
155
  return createValidatedScalar(
131
156
  name,
132
- `A float between ${min} and ${max}`,
157
+ describeBounds(min, max, {
158
+ between: `A float between ${min} and ${max}`,
159
+ atLeast: `A float of at least ${min}`,
160
+ atMost: `A float of at most ${max}`,
161
+ unbounded: 'A float',
162
+ }),
133
163
  GraphQLFloat,
134
- (value) => {
135
- if (typeof value !== 'number' || isNaN(value)) {
136
- throw new Error('Value must be a number');
137
- }
138
- if (min !== undefined && value < min) {
139
- throw new Error(`Value must be at least ${min}`);
140
- }
141
- if (max !== undefined && value > max) {
142
- throw new Error(`Value must be at most ${max}`);
143
- }
144
- },
164
+ checkNumberBounds(min, max),
145
165
  );
146
166
  };
147
167
 
package/src/validators.js CHANGED
@@ -43,6 +43,9 @@ const createValidator = (validatorFn, required = false) => {
43
43
  };
44
44
  };
45
45
 
46
+ // A null or omitted bound means no bound (#97). Other values keep JavaScript's comparison, so numeric strings work.
47
+ const hasBound = (bound) => bound !== undefined && bound !== null;
48
+
46
49
  /**
47
50
  * String validators
48
51
  */
@@ -52,11 +55,11 @@ export const stringLength = (name, min, max) => {
52
55
  throw new SimfinityError(`${name} must be a string`, 'VALIDATION_ERROR', 400);
53
56
  }
54
57
 
55
- if (min !== undefined && value.length < min) {
58
+ if (hasBound(min) && value.length < min) {
56
59
  throw new SimfinityError(`${name} must be at least ${min} characters`, 'VALIDATION_ERROR', 400);
57
60
  }
58
61
 
59
- if (max !== undefined && value.length > max) {
62
+ if (hasBound(max) && value.length > max) {
60
63
  throw new SimfinityError(`${name} must be at most ${max} characters`, 'VALIDATION_ERROR', 400);
61
64
  }
62
65
  }, true); // Required for CREATE operations
@@ -68,7 +71,7 @@ export const maxLength = (name, max) => {
68
71
  throw new SimfinityError(`${name} must be a string`, 'VALIDATION_ERROR', 400);
69
72
  }
70
73
 
71
- if (value.length > max) {
74
+ if (hasBound(max) && value.length > max) {
72
75
  throw new SimfinityError(`${name} must be at most ${max} characters`, 'VALIDATION_ERROR', 400);
73
76
  }
74
77
  }, false); // Optional
@@ -125,11 +128,11 @@ export const numberRange = (name, min, max) => {
125
128
  throw new SimfinityError(`${name} must be a number`, 'VALIDATION_ERROR', 400);
126
129
  }
127
130
 
128
- if (min !== undefined && value < min) {
131
+ if (hasBound(min) && value < min) {
129
132
  throw new SimfinityError(`${name} must be at least ${min}`, 'VALIDATION_ERROR', 400);
130
133
  }
131
134
 
132
- if (max !== undefined && value > max) {
135
+ if (hasBound(max) && value > max) {
133
136
  throw new SimfinityError(`${name} must be at most ${max}`, 'VALIDATION_ERROR', 400);
134
137
  }
135
138
  }, false); // Optional
@@ -150,22 +153,96 @@ export const positive = (name) => {
150
153
  /**
151
154
  * Array validators
152
155
  */
156
+
157
+ // What arrayLength() runs on each item (#92). A validator is anything with a validate() function.
158
+ const isFieldValidator = (candidate) => candidate !== null
159
+ && (typeof candidate === 'object' || typeof candidate === 'function')
160
+ && typeof candidate.validate === 'function';
161
+
162
+ // A helper result such as validators.maxLength('Tag', 3). Items of a supplied list are always present, so they get
163
+ // the helper's CREATE rules on create and update, like the documented validators.maxLength('Tag', 3).CREATE.
164
+ const isFieldValidations = (candidate) => candidate !== null && typeof candidate === 'object'
165
+ && !Array.isArray(candidate) && Array.isArray(candidate.CREATE) && candidate.CREATE.every(isFieldValidator);
166
+
167
+ // Array entries: every validator runs, as before, and a helper result runs its CREATE rules.
168
+ const itemRulesOf = (entry) => {
169
+ if (isFieldValidator(entry)) return [entry];
170
+ if (isFieldValidations(entry)) return entry.CREATE;
171
+ return null;
172
+ };
173
+
174
+ // A single itemValidator was ignored before, so only shapes written for Simfinity run now: helper results, and plain
175
+ // objects or functions with validate(). A class instance with its own validate(), such as a Joi or yup schema, takes
176
+ // other arguments, so it stays ignored.
177
+ const isPlainValidator = (candidate) => isFieldValidator(candidate)
178
+ && (typeof candidate === 'function' || [Object.prototype, null].includes(Object.getPrototypeOf(candidate)));
179
+
180
+ const ITEM_VALIDATOR_SHAPES = 'a validator object with validate(), a helper result such as validators.maxLength(\'Item\', 20), or an array of them';
181
+
182
+ // Resolves itemValidator once, when arrayLength() runs. No shape throws here, so every application that starts keeps
183
+ // starting; unusable shapes log one 'Configuration issue' warning instead.
184
+ const itemValidationOf = (name, itemValidator) => {
185
+ // undefined, null, false, '' and 0 add no item checks, so `flag && rules` works.
186
+ if (!itemValidator) return null;
187
+
188
+ if (Array.isArray(itemValidator)) {
189
+ // Falsy entries are skipped. Any other unusable entry keeps failing lists with items, as before.
190
+ const unusable = itemValidator.flatMap((entry, index) => (!entry || itemRulesOf(entry) ? [] : [`itemValidator[${index}]`]));
191
+ if (unusable.length > 0) {
192
+ console.warn(`Configuration issue: validators.arrayLength('${name}') has an unusable ${unusable.join(', ')}; `
193
+ + `lists with items fail with a TypeError. Pass ${ITEM_VALIDATOR_SHAPES}.`);
194
+ }
195
+ // The array is read on every validation, as before, so entries added after creation run too.
196
+ return async (typeName, fieldName, item, session) => {
197
+ for (let index = 0; index < itemValidator.length; index++) {
198
+ const entry = itemValidator[index];
199
+ if (!entry) continue;
200
+ const rules = itemRulesOf(entry);
201
+ if (!rules) {
202
+ throw new TypeError(`validators.arrayLength('${name}'): itemValidator[${index}] is not a validator with a validate() function`);
203
+ }
204
+ for (const rule of rules) {
205
+ await rule.validate(typeName, fieldName, item, session);
206
+ }
207
+ }
208
+ };
209
+ }
210
+
211
+ let rules = null;
212
+ if (isPlainValidator(itemValidator)) {
213
+ rules = [itemValidator];
214
+ } else if (isFieldValidations(itemValidator)) {
215
+ rules = itemValidator.CREATE;
216
+ }
217
+ if (!rules) {
218
+ const shape = isFieldValidator(itemValidator)
219
+ ? 'a class instance whose validate() is not a Simfinity validator (such as a Joi or yup schema)'
220
+ : `not ${ITEM_VALIDATOR_SHAPES}`;
221
+ console.warn(`Configuration issue: validators.arrayLength('${name}') ignores its itemValidator, which is ${shape}; `
222
+ + 'items are not validated.');
223
+ return null;
224
+ }
225
+ return async (typeName, fieldName, item, session) => {
226
+ for (const rule of rules) {
227
+ await rule.validate(typeName, fieldName, item, session);
228
+ }
229
+ };
230
+ };
231
+
153
232
  export const arrayLength = (name, maxItems, itemValidator) => {
233
+ const validateItem = itemValidationOf(name, itemValidator);
154
234
  return createValidator(async (typeName, fieldName, value, session) => {
155
235
  if (!Array.isArray(value)) {
156
236
  throw new SimfinityError(`${name} must be an array`, 'VALIDATION_ERROR', 400);
157
237
  }
158
238
 
159
- if (maxItems !== undefined && value.length > maxItems) {
239
+ if (hasBound(maxItems) && value.length > maxItems) {
160
240
  throw new SimfinityError(`${name} must have at most ${maxItems} items`, 'VALIDATION_ERROR', 400);
161
241
  }
162
242
 
163
- // If itemValidator is provided, validate each item
164
- if (itemValidator && Array.isArray(itemValidator)) {
243
+ if (validateItem) {
165
244
  for (let i = 0; i < value.length; i++) {
166
- for (const validator of itemValidator) {
167
- await validator.validate(typeName, fieldName, value[i], session);
168
- }
245
+ await validateItem(typeName, fieldName, value[i], session);
169
246
  }
170
247
  }
171
248
  }, false); // Optional
@@ -174,52 +251,87 @@ export const arrayLength = (name, maxItems, itemValidator) => {
174
251
  /**
175
252
  * Date validators
176
253
  */
254
+
255
+ // Calendar arithmetic (#94), independent of the process time zone.
256
+ const isLeapYear = (year) => (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
257
+ const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
258
+ const isCalendarDate = (year, month, day) => month >= 1 && month <= 12 && day >= 1
259
+ && day <= (month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month - 1]);
260
+
261
+ // The only format dateFormat() checks.
262
+ const ISO_DATE_FORMAT = 'YYYY-MM-DD';
263
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
264
+ // A leading ISO date, alone or followed by a time or a zone.
265
+ const ISO_DATE_PREFIX = /^(\d{4})-(\d{2})-(\d{2})(?!\d)/;
266
+ // JavaScript reads slashed dates month first.
267
+ const SLASHED_DATE = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/;
268
+
269
+ // Strings that JavaScript parses by rolling an impossible day into the next month, such as 2024-02-31 or 02/31/2024.
270
+ // Only values whose stored date never matched what was sent qualify, so no correctly stored value is rejected.
271
+ const isRolledOverDate = (value) => {
272
+ const iso = ISO_DATE_PREFIX.exec(value);
273
+ if (iso) return !isCalendarDate(Number(iso[1]), Number(iso[2]), Number(iso[3]));
274
+ const slashed = SLASHED_DATE.exec(value);
275
+ if (slashed) return !isCalendarDate(Number(slashed[3]), Number(slashed[1]), Number(slashed[2]));
276
+ return false;
277
+ };
278
+
279
+ // Date objects, and strings or timestamps that JavaScript can parse, as before.
280
+ const parseDate = (name, value) => {
281
+ let date;
282
+ if (value instanceof Date) {
283
+ date = value;
284
+ } else if (typeof value === 'string' || typeof value === 'number') {
285
+ date = new Date(value);
286
+ } else {
287
+ throw new SimfinityError(`${name} must be a valid date`, 'VALIDATION_ERROR', 400);
288
+ }
289
+
290
+ if (isNaN(date.getTime())) {
291
+ throw new SimfinityError(`${name} must be a valid date`, 'VALIDATION_ERROR', 400);
292
+ }
293
+ return date;
294
+ };
295
+
296
+ // parseDate, plus the calendar check for strings that JavaScript would roll into the next month.
297
+ const toDate = (name, value) => {
298
+ const date = parseDate(name, value);
299
+ if (typeof value === 'string' && isRolledOverDate(value)) {
300
+ throw new SimfinityError(`${name} must be a valid date`, 'VALIDATION_ERROR', 400);
301
+ }
302
+ return date;
303
+ };
304
+
177
305
  export const dateFormat = (name, format) => {
306
+ const checksFormat = format === ISO_DATE_FORMAT;
307
+ // Other formats were never checked. They keep that behavior, and the warning says so once, here.
308
+ if (format && !checksFormat) {
309
+ const label = typeof format === 'string' ? `'${format}'` : `of type ${typeof format}`;
310
+ console.warn(`Configuration issue: validators.dateFormat('${name}') does not check the format ${label}; `
311
+ + `only '${ISO_DATE_FORMAT}' is checked, so other values pass when JavaScript can parse them as a valid date.`);
312
+ }
313
+
178
314
  return createValidator(async (typeName, fieldName, value) => {
179
- // Handle Date objects, ISO strings, and timestamps
180
- let date;
181
- if (value instanceof Date) {
182
- date = value;
183
- } else if (typeof value === 'string') {
184
- date = new Date(value);
185
- } else if (typeof value === 'number') {
186
- date = new Date(value);
187
- } else {
188
- throw new SimfinityError(`${name} must be a valid date`, 'VALIDATION_ERROR', 400);
189
- }
190
-
191
- if (isNaN(date.getTime())) {
192
- throw new SimfinityError(`${name} must be a valid date`, 'VALIDATION_ERROR', 400);
193
- }
194
-
195
- // If format is provided, validate format
196
- if (format && typeof value === 'string') {
197
- // Simple format validation - can be enhanced
198
- const formatRegex = /^\d{4}-\d{2}-\d{2}$/; // YYYY-MM-DD
199
- if (format === 'YYYY-MM-DD' && !formatRegex.test(value)) {
315
+ if (checksFormat && typeof value === 'string') {
316
+ // As before: an unparseable value is not a valid date, and any other shape gets the format message.
317
+ parseDate(name, value);
318
+ if (!ISO_DATE.test(value)) {
200
319
  throw new SimfinityError(`${name} must be in format ${format}`, 'VALIDATION_ERROR', 400);
201
320
  }
202
- // Add more format patterns as needed
321
+ // A value in the format must be a calendar date.
322
+ if (isRolledOverDate(value)) {
323
+ throw new SimfinityError(`${name} must be a valid date`, 'VALIDATION_ERROR', 400);
324
+ }
325
+ return;
203
326
  }
327
+ // Date objects, timestamps, and strings when no format is checked.
328
+ toDate(name, value);
204
329
  }, false); // Optional
205
330
  };
206
331
 
207
332
  export const futureDate = (name) => {
208
333
  return createValidator(async (typeName, fieldName, value) => {
209
- let date;
210
- if (value instanceof Date) {
211
- date = value;
212
- } else if (typeof value === 'string') {
213
- date = new Date(value);
214
- } else if (typeof value === 'number') {
215
- date = new Date(value);
216
- } else {
217
- throw new SimfinityError(`${name} must be a valid date`, 'VALIDATION_ERROR', 400);
218
- }
219
-
220
- if (isNaN(date.getTime())) {
221
- throw new SimfinityError(`${name} must be a valid date`, 'VALIDATION_ERROR', 400);
222
- }
334
+ const date = toDate(name, value);
223
335
 
224
336
  if (date <= new Date()) {
225
337
  throw new SimfinityError(`${name} must be a future date`, 'VALIDATION_ERROR', 400);
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Declarations for `@simtlix/simfinity-core/auth/errors`. Every member has the type of the
3
+ * same member of the root `auth` namespace, so the two cannot drift.
4
+ */
5
+ import { auth } from '../index.js';
6
+
7
+ // The root classes themselves, which `auth.UnauthenticatedError` and `auth.ForbiddenError` also are.
8
+ export { UnauthenticatedError, ForbiddenError } from '../index.js';
9
+ export const createAuthError: typeof auth.createAuthError;
10
+
11
+ declare const errors: Pick<typeof auth, 'UnauthenticatedError' | 'ForbiddenError' | 'createAuthError'>;
12
+ export default errors;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Declarations for `@simtlix/simfinity-core/auth/expressions`. Every named export has the type of
3
+ * the same member of the root `auth` namespace, so the two cannot drift.
4
+ */
5
+ import { auth } from '../index.js';
6
+
7
+ export type { PolicyExpression } from '../index.js';
8
+ export const evaluateExpression: typeof auth.evaluateExpression;
9
+ export const isPolicyExpression: typeof auth.isPolicyExpression;
10
+ export const createRuleFromExpression: typeof auth.createRuleFromExpression;
11
+
12
+ declare const expressions: Pick<typeof auth, 'evaluateExpression' | 'isPolicyExpression' | 'createRuleFromExpression'> & {
13
+ /**
14
+ * Low-level helper kept on the default object only: reads a dotted `parent.`, `args.` or `ctx.`
15
+ * path from `{ parent, args, ctx }`; an invalid path gives `undefined`. Prefer
16
+ * `createRuleFromExpression`.
17
+ */
18
+ resolveRef(refPath: string, context: any): unknown;
19
+ /**
20
+ * Low-level helper kept on the default object only: resolves a `{ ref }` operand, or returns a
21
+ * literal as is.
22
+ */
23
+ resolveValue(value: unknown, context: any): unknown;
24
+ };
25
+ export default expressions;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Declarations for `@simtlix/simfinity-core/auth`. The default export is the root `auth`
3
+ * namespace, and every named export has the type of the same member, so the two cannot drift.
4
+ */
5
+ import { auth } from '../index.js';
6
+
7
+ export type {
8
+ AuthPluginOptions,
9
+ AuthRule,
10
+ AuthRuleFunction,
11
+ EnvelopSchemaPlugin,
12
+ PermissionSchema,
13
+ PolicyExpression,
14
+ TypePermissions,
15
+ } from '../index.js';
16
+ export { UnauthenticatedError, ForbiddenError, createAuthError } from './errors.js';
17
+ export { evaluateExpression, isPolicyExpression, createRuleFromExpression } from './expressions.js';
18
+ export {
19
+ resolvePath,
20
+ requireAuth,
21
+ requireRole,
22
+ requirePermission,
23
+ composeRules,
24
+ anyRule,
25
+ isOwner,
26
+ createRule,
27
+ allow,
28
+ deny,
29
+ } from './rules.js';
30
+ export const createAuthPlugin: typeof auth.createAuthPlugin;
31
+ /** @deprecated Use createAuthPlugin instead. graphql-middleware compatible middleware. */
32
+ export const createAuthMiddleware: typeof auth.createAuthMiddleware;
33
+ /** @deprecated Use createAuthPlugin instead. The same function as createAuthMiddleware. */
34
+ export const createFieldMiddleware: typeof auth.createFieldMiddleware;
35
+
36
+ export default auth;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Declarations for `@simtlix/simfinity-core/auth/rules`. Every member has the type of the same
3
+ * member of the root `auth` namespace, so the two cannot drift.
4
+ */
5
+ import { auth } from '../index.js';
6
+
7
+ export type { AuthRuleFunction } from '../index.js';
8
+ export const resolvePath: typeof auth.resolvePath;
9
+ export const requireAuth: typeof auth.requireAuth;
10
+ export const requireRole: typeof auth.requireRole;
11
+ export const requirePermission: typeof auth.requirePermission;
12
+ export const composeRules: typeof auth.composeRules;
13
+ export const anyRule: typeof auth.anyRule;
14
+ export const isOwner: typeof auth.isOwner;
15
+ export const createRule: typeof auth.createRule;
16
+ export const allow: typeof auth.allow;
17
+ export const deny: typeof auth.deny;
18
+
19
+ declare const rules: Pick<typeof auth,
20
+ | 'resolvePath'
21
+ | 'requireAuth'
22
+ | 'requireRole'
23
+ | 'requirePermission'
24
+ | 'composeRules'
25
+ | 'anyRule'
26
+ | 'isOwner'
27
+ | 'createRule'
28
+ | 'allow'
29
+ | 'deny'>;
30
+ export default rules;