@rvoh/psychic 3.9.0 → 3.10.1

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.
@@ -44,7 +44,9 @@ export default class PsychicApp {
44
44
  if (!psychicApp.routesCb)
45
45
  throw new PsychicAppInitMissingRoutesCallback();
46
46
  if (psychicApp.encryption?.cookies?.current)
47
- this.checkEncryptionKey('cookies', psychicApp.encryption.cookies.current.key, psychicApp.encryption.cookies.current.algorithm);
47
+ this.checkEncryptionKey('cookies', 'current', psychicApp.encryption.cookies.current.key, psychicApp.encryption.cookies.current.algorithm);
48
+ if (psychicApp.encryption?.cookies?.legacy)
49
+ this.checkEncryptionKey('cookies', 'legacy', psychicApp.encryption.cookies.legacy.key, psychicApp.encryption.cookies.legacy.algorithm);
48
50
  await psychicApp.inflections?.();
49
51
  dreamApp.on('repl:start', context => {
50
52
  const psychicApp = PsychicApp.getOrFail();
@@ -187,14 +189,15 @@ export default class PsychicApp {
187
189
  * are not broken by a hard failure.
188
190
  *
189
191
  * @param encryptionIdentifier - currently must be 'cookies', though this may change in the future
192
+ * @param keyType - whether this is the 'current' or 'legacy' key (named in the error so a misconfigured legacy key is not mistaken for the current one)
190
193
  * @param key - the encryption key you want to check
191
194
  * @param algorithm - the encryption algorithm you want to check
192
195
  */
193
- static checkEncryptionKey(encryptionIdentifier, key, algorithm) {
196
+ static checkEncryptionKey(encryptionIdentifier, keyType, key, algorithm) {
194
197
  if (Encrypt.validateKey(key, algorithm))
195
198
  return;
196
199
  const message = `
197
- Your current key value for ${encryptionIdentifier} encryption is invalid.
200
+ Your ${keyType} key value for ${encryptionIdentifier} encryption is invalid.
198
201
  Try setting it to something valid, like:
199
202
  ${Encrypt.generateKey(algorithm)}
200
203
 
@@ -59,8 +59,11 @@ export default class Params {
59
59
  else if (columnMetadata?.enumValues) {
60
60
  const paramValue = params[columnName];
61
61
  if (columnMetadata.isArray) {
62
+ // Single-value query arrays are conformed to arrays upstream in
63
+ // conformQueryArrayParamsToOpenapiShape, so a non-array reaching
64
+ // here is a real error rather than something to coerce.
62
65
  if (!Array.isArray(paramValue))
63
- returnObj[columnName] = ['expected an array of enum values'];
66
+ throw new ParamValidationError(columnName.toString(), ['expected an array of enum values']);
64
67
  returnObj[columnName] = paramValue.map(p => {
65
68
  return new this(params).cast(columnName.toString(), p, 'string', {
66
69
  allowNull: columnMetadata.allowNull,
@@ -177,10 +180,16 @@ export default class Params {
177
180
  return paramValue;
178
181
  }
179
182
  const integerRegexp = /^-?\d+$/;
183
+ // Plain decimal numeric string: optional sign, digits with an optional
184
+ // fractional part (or a bare fractional part), and an optional decimal
185
+ // exponent. Deliberately excludes hex/octal/binary literals and the
186
+ // "Infinity"/"NaN" keywords that `Number()` would otherwise accept.
187
+ const decimalNumberRegexp = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
180
188
  switch (expectedType) {
181
189
  case 'string':
182
190
  if (typeof paramValue !== 'string')
183
191
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
192
+ this.throwUnlessWithinLength(paramName, paramValue, opts);
184
193
  if (opts?.enum && !opts.enum.includes(paramValue))
185
194
  throw new ParamValidationError(paramName, ['did not match expected enum values']);
186
195
  if (opts?.match)
@@ -191,6 +200,7 @@ export default class Params {
191
200
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
192
201
  if (!integerRegexp.test(paramValue?.toString()))
193
202
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
203
+ this.throwUnlessWithinRange(paramName, BigInt(paramValue.toString()), opts);
194
204
  return paramValue.toString();
195
205
  case 'boolean':
196
206
  if ([true, 'true', 1, '1'].includes(paramValue))
@@ -254,25 +264,47 @@ export default class Params {
254
264
  }
255
265
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
256
266
  }
257
- case 'integer':
267
+ case 'integer': {
258
268
  if (typeof paramValue !== 'string' && typeof paramValue !== 'number')
259
269
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
260
270
  if (!integerRegexp.test(paramValue?.toString()))
261
271
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
262
- return parseInt(paramValue, 10);
272
+ const integerValue = parseInt(paramValue, 10);
273
+ // Reject values outside the safe-integer range: a long digit string
274
+ // (e.g. a 40-digit number) would otherwise be silently rounded to a
275
+ // nearby float by parseInt. Genuinely large integers should be cast as
276
+ // `bigint`, which preserves the exact value as a string.
277
+ if (!Number.isSafeInteger(integerValue))
278
+ throw new ParamValidationError(paramName, [
279
+ 'expected an integer within the safe integer range (use bigint for larger values)',
280
+ ]);
281
+ this.throwUnlessWithinRange(paramName, integerValue, opts);
282
+ return integerValue;
283
+ }
263
284
  case 'json':
264
285
  if (typeof paramValue !== 'object')
265
286
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
266
287
  return paramValue;
267
- case 'number':
268
- if (typeof paramValue === 'number')
288
+ case 'number': {
289
+ if (typeof paramValue === 'number') {
290
+ if (!Number.isFinite(paramValue))
291
+ throw new ParamValidationError(paramName, [typeToError(expectedType)]);
292
+ this.throwUnlessWithinRange(paramName, paramValue, opts);
269
293
  return paramValue;
294
+ }
270
295
  if (typeof paramValue === 'string') {
271
- if (paramValue.length === 0 || Number.isNaN(Number(paramValue)))
296
+ // Require a plain decimal numeric string and a finite result. This
297
+ // rejects hex/octal/binary forms ("0x10"), the literals "Infinity"/
298
+ // "NaN", and magnitudes that overflow to Infinity ("1e999"), all of
299
+ // which `Number()` would otherwise silently coerce.
300
+ const numberValue = Number(paramValue);
301
+ if (!decimalNumberRegexp.test(paramValue) || !Number.isFinite(numberValue))
272
302
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
273
- return Number(paramValue);
303
+ this.throwUnlessWithinRange(paramName, numberValue, opts);
304
+ return numberValue;
274
305
  }
275
306
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
307
+ }
276
308
  case 'null':
277
309
  if (paramValue !== null)
278
310
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
@@ -352,6 +384,45 @@ export default class Params {
352
384
  return paramValue;
353
385
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
354
386
  }
387
+ /**
388
+ * Enforces `minLength`/`maxLength` (inclusive) against an already-coerced
389
+ * string value, throwing {@link ParamValidationError} on violation. Mirrors
390
+ * JSON-schema / OpenAPI string-length constraints.
391
+ */
392
+ throwUnlessWithinLength(paramName, paramValue, opts) {
393
+ if (opts?.minLength !== undefined && paramValue.length < opts.minLength)
394
+ throw new ParamValidationError(paramName, [
395
+ `expected string with length no less than ${opts.minLength}`,
396
+ ]);
397
+ if (opts?.maxLength !== undefined && paramValue.length > opts.maxLength)
398
+ throw new ParamValidationError(paramName, [
399
+ `expected string with length no greater than ${opts.maxLength}`,
400
+ ]);
401
+ }
402
+ /**
403
+ * Enforces `minimum`/`maximum` (inclusive) against an already-coerced numeric
404
+ * value, throwing {@link ParamValidationError} on violation. Accepts a
405
+ * `bigint` so `bigint` casts are checked without precision loss; the bounds
406
+ * themselves are compared as `bigint` when they are integers and otherwise
407
+ * fall back to floating-point comparison. Mirrors JSON-schema / OpenAPI
408
+ * numeric-range constraints.
409
+ */
410
+ throwUnlessWithinRange(paramName, paramValue, opts) {
411
+ if (opts?.minimum !== undefined && this.isBelowNumericBound(paramValue, opts.minimum))
412
+ throw new ParamValidationError(paramName, [`expected a value no less than ${opts.minimum}`]);
413
+ if (opts?.maximum !== undefined && this.isAboveNumericBound(paramValue, opts.maximum))
414
+ throw new ParamValidationError(paramName, [`expected a value no greater than ${opts.maximum}`]);
415
+ }
416
+ isBelowNumericBound(value, bound) {
417
+ if (typeof value === 'bigint')
418
+ return Number.isInteger(bound) ? value < BigInt(bound) : Number(value) < bound;
419
+ return value < bound;
420
+ }
421
+ isAboveNumericBound(value, bound) {
422
+ if (typeof value === 'bigint')
423
+ return Number.isInteger(bound) ? value > BigInt(bound) : Number(value) > bound;
424
+ return value > bound;
425
+ }
355
426
  throwUnlessAllowNull(paramName, paramValue, message, { allowNull = false } = {}) {
356
427
  const isNullOrUndefined = [null, undefined].includes(paramValue);
357
428
  if (allowNull && isNullOrUndefined)
@@ -44,7 +44,9 @@ export default class PsychicApp {
44
44
  if (!psychicApp.routesCb)
45
45
  throw new PsychicAppInitMissingRoutesCallback();
46
46
  if (psychicApp.encryption?.cookies?.current)
47
- this.checkEncryptionKey('cookies', psychicApp.encryption.cookies.current.key, psychicApp.encryption.cookies.current.algorithm);
47
+ this.checkEncryptionKey('cookies', 'current', psychicApp.encryption.cookies.current.key, psychicApp.encryption.cookies.current.algorithm);
48
+ if (psychicApp.encryption?.cookies?.legacy)
49
+ this.checkEncryptionKey('cookies', 'legacy', psychicApp.encryption.cookies.legacy.key, psychicApp.encryption.cookies.legacy.algorithm);
48
50
  await psychicApp.inflections?.();
49
51
  dreamApp.on('repl:start', context => {
50
52
  const psychicApp = PsychicApp.getOrFail();
@@ -187,14 +189,15 @@ export default class PsychicApp {
187
189
  * are not broken by a hard failure.
188
190
  *
189
191
  * @param encryptionIdentifier - currently must be 'cookies', though this may change in the future
192
+ * @param keyType - whether this is the 'current' or 'legacy' key (named in the error so a misconfigured legacy key is not mistaken for the current one)
190
193
  * @param key - the encryption key you want to check
191
194
  * @param algorithm - the encryption algorithm you want to check
192
195
  */
193
- static checkEncryptionKey(encryptionIdentifier, key, algorithm) {
196
+ static checkEncryptionKey(encryptionIdentifier, keyType, key, algorithm) {
194
197
  if (Encrypt.validateKey(key, algorithm))
195
198
  return;
196
199
  const message = `
197
- Your current key value for ${encryptionIdentifier} encryption is invalid.
200
+ Your ${keyType} key value for ${encryptionIdentifier} encryption is invalid.
198
201
  Try setting it to something valid, like:
199
202
  ${Encrypt.generateKey(algorithm)}
200
203
 
@@ -59,8 +59,11 @@ export default class Params {
59
59
  else if (columnMetadata?.enumValues) {
60
60
  const paramValue = params[columnName];
61
61
  if (columnMetadata.isArray) {
62
+ // Single-value query arrays are conformed to arrays upstream in
63
+ // conformQueryArrayParamsToOpenapiShape, so a non-array reaching
64
+ // here is a real error rather than something to coerce.
62
65
  if (!Array.isArray(paramValue))
63
- returnObj[columnName] = ['expected an array of enum values'];
66
+ throw new ParamValidationError(columnName.toString(), ['expected an array of enum values']);
64
67
  returnObj[columnName] = paramValue.map(p => {
65
68
  return new this(params).cast(columnName.toString(), p, 'string', {
66
69
  allowNull: columnMetadata.allowNull,
@@ -177,10 +180,16 @@ export default class Params {
177
180
  return paramValue;
178
181
  }
179
182
  const integerRegexp = /^-?\d+$/;
183
+ // Plain decimal numeric string: optional sign, digits with an optional
184
+ // fractional part (or a bare fractional part), and an optional decimal
185
+ // exponent. Deliberately excludes hex/octal/binary literals and the
186
+ // "Infinity"/"NaN" keywords that `Number()` would otherwise accept.
187
+ const decimalNumberRegexp = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
180
188
  switch (expectedType) {
181
189
  case 'string':
182
190
  if (typeof paramValue !== 'string')
183
191
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
192
+ this.throwUnlessWithinLength(paramName, paramValue, opts);
184
193
  if (opts?.enum && !opts.enum.includes(paramValue))
185
194
  throw new ParamValidationError(paramName, ['did not match expected enum values']);
186
195
  if (opts?.match)
@@ -191,6 +200,7 @@ export default class Params {
191
200
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
192
201
  if (!integerRegexp.test(paramValue?.toString()))
193
202
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
203
+ this.throwUnlessWithinRange(paramName, BigInt(paramValue.toString()), opts);
194
204
  return paramValue.toString();
195
205
  case 'boolean':
196
206
  if ([true, 'true', 1, '1'].includes(paramValue))
@@ -254,25 +264,47 @@ export default class Params {
254
264
  }
255
265
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
256
266
  }
257
- case 'integer':
267
+ case 'integer': {
258
268
  if (typeof paramValue !== 'string' && typeof paramValue !== 'number')
259
269
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
260
270
  if (!integerRegexp.test(paramValue?.toString()))
261
271
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
262
- return parseInt(paramValue, 10);
272
+ const integerValue = parseInt(paramValue, 10);
273
+ // Reject values outside the safe-integer range: a long digit string
274
+ // (e.g. a 40-digit number) would otherwise be silently rounded to a
275
+ // nearby float by parseInt. Genuinely large integers should be cast as
276
+ // `bigint`, which preserves the exact value as a string.
277
+ if (!Number.isSafeInteger(integerValue))
278
+ throw new ParamValidationError(paramName, [
279
+ 'expected an integer within the safe integer range (use bigint for larger values)',
280
+ ]);
281
+ this.throwUnlessWithinRange(paramName, integerValue, opts);
282
+ return integerValue;
283
+ }
263
284
  case 'json':
264
285
  if (typeof paramValue !== 'object')
265
286
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
266
287
  return paramValue;
267
- case 'number':
268
- if (typeof paramValue === 'number')
288
+ case 'number': {
289
+ if (typeof paramValue === 'number') {
290
+ if (!Number.isFinite(paramValue))
291
+ throw new ParamValidationError(paramName, [typeToError(expectedType)]);
292
+ this.throwUnlessWithinRange(paramName, paramValue, opts);
269
293
  return paramValue;
294
+ }
270
295
  if (typeof paramValue === 'string') {
271
- if (paramValue.length === 0 || Number.isNaN(Number(paramValue)))
296
+ // Require a plain decimal numeric string and a finite result. This
297
+ // rejects hex/octal/binary forms ("0x10"), the literals "Infinity"/
298
+ // "NaN", and magnitudes that overflow to Infinity ("1e999"), all of
299
+ // which `Number()` would otherwise silently coerce.
300
+ const numberValue = Number(paramValue);
301
+ if (!decimalNumberRegexp.test(paramValue) || !Number.isFinite(numberValue))
272
302
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
273
- return Number(paramValue);
303
+ this.throwUnlessWithinRange(paramName, numberValue, opts);
304
+ return numberValue;
274
305
  }
275
306
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
307
+ }
276
308
  case 'null':
277
309
  if (paramValue !== null)
278
310
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
@@ -352,6 +384,45 @@ export default class Params {
352
384
  return paramValue;
353
385
  throw new ParamValidationError(paramName, [typeToError(expectedType)]);
354
386
  }
387
+ /**
388
+ * Enforces `minLength`/`maxLength` (inclusive) against an already-coerced
389
+ * string value, throwing {@link ParamValidationError} on violation. Mirrors
390
+ * JSON-schema / OpenAPI string-length constraints.
391
+ */
392
+ throwUnlessWithinLength(paramName, paramValue, opts) {
393
+ if (opts?.minLength !== undefined && paramValue.length < opts.minLength)
394
+ throw new ParamValidationError(paramName, [
395
+ `expected string with length no less than ${opts.minLength}`,
396
+ ]);
397
+ if (opts?.maxLength !== undefined && paramValue.length > opts.maxLength)
398
+ throw new ParamValidationError(paramName, [
399
+ `expected string with length no greater than ${opts.maxLength}`,
400
+ ]);
401
+ }
402
+ /**
403
+ * Enforces `minimum`/`maximum` (inclusive) against an already-coerced numeric
404
+ * value, throwing {@link ParamValidationError} on violation. Accepts a
405
+ * `bigint` so `bigint` casts are checked without precision loss; the bounds
406
+ * themselves are compared as `bigint` when they are integers and otherwise
407
+ * fall back to floating-point comparison. Mirrors JSON-schema / OpenAPI
408
+ * numeric-range constraints.
409
+ */
410
+ throwUnlessWithinRange(paramName, paramValue, opts) {
411
+ if (opts?.minimum !== undefined && this.isBelowNumericBound(paramValue, opts.minimum))
412
+ throw new ParamValidationError(paramName, [`expected a value no less than ${opts.minimum}`]);
413
+ if (opts?.maximum !== undefined && this.isAboveNumericBound(paramValue, opts.maximum))
414
+ throw new ParamValidationError(paramName, [`expected a value no greater than ${opts.maximum}`]);
415
+ }
416
+ isBelowNumericBound(value, bound) {
417
+ if (typeof value === 'bigint')
418
+ return Number.isInteger(bound) ? value < BigInt(bound) : Number(value) < bound;
419
+ return value < bound;
420
+ }
421
+ isAboveNumericBound(value, bound) {
422
+ if (typeof value === 'bigint')
423
+ return Number.isInteger(bound) ? value > BigInt(bound) : Number(value) > bound;
424
+ return value > bound;
425
+ }
355
426
  throwUnlessAllowNull(paramName, paramValue, message, { allowNull = false } = {}) {
356
427
  const isNullOrUndefined = [null, undefined].includes(paramValue);
357
428
  if (allowNull && isNullOrUndefined)
@@ -5,7 +5,7 @@ import Koa from 'koa';
5
5
  import { ControllerHook } from '../controller/hooks.js';
6
6
  import { HttpStatusCodeInt, HttpStatusSymbol } from '../error/http/status-codes.js';
7
7
  import OpenapiEndpointRenderer from '../openapi-renderer/endpoint.js';
8
- import { ExtractParamsOpts, ParamsCastOptions, ParamsForOpts, ValidatedAllowsNull, ValidatedReturnType } from '../server/params.js';
8
+ import { type ExtractedDreamParamSafeAttributes, ExtractParamsOpts, ParamsCastOptions, ParamsForOpts, ValidatedAllowsNull, ValidatedReturnType } from '../server/params.js';
9
9
  import Session, { CustomSessionCookieOptions } from '../session/index.js';
10
10
  type SerializerResult = {
11
11
  [key: string]: any;
@@ -282,11 +282,7 @@ export default class PsychicController {
282
282
  * }
283
283
  * ```
284
284
  */
285
- paramsFor<T extends typeof Dream, I extends InstanceType<T>, const OnlyArray extends readonly (keyof DreamParamSafeAttributes<I>)[], ForOpts extends StrictInterface<ForOpts, ParamsForOpts<OnlyArray>>, ParamSafeColumnsOverride extends I['paramSafeColumns' & keyof I] extends never ? undefined : I['paramSafeColumns' & keyof I] & string[], ParamSafeColumns extends ParamSafeColumnsOverride extends string[] | Readonly<string[]> ? Extract<DreamParamSafeColumnNames<I>, ParamSafeColumnsOverride[number] & DreamParamSafeColumnNames<I>>[] : DreamParamSafeColumnNames<I>[], ParamSafeAttrs extends DreamParamSafeAttributes<InstanceType<T>>, ReturnPartialType extends ForOpts['only'] extends readonly (keyof DreamParamSafeAttributes<InstanceType<T>>)[] ? Partial<{
286
- [K in ForOpts['only'][number] & keyof ParamSafeAttrs]: ParamSafeAttrs[K & keyof ParamSafeAttrs];
287
- }> : Partial<{
288
- [K in ParamSafeColumns[number & keyof ParamSafeColumns] & string]: DreamParamSafeAttributes<InstanceType<T>>[K & keyof DreamParamSafeAttributes<InstanceType<T>>];
289
- }>, ReturnPayload extends ForOpts['array'] extends true ? ReturnPartialType[] : ReturnPartialType>(this: PsychicController, dreamClass: T, opts?: ForOpts): ReturnPayload;
285
+ paramsFor<T extends typeof Dream, I extends InstanceType<T>, const OnlyArray extends readonly (keyof DreamParamSafeAttributes<I>)[], ForOpts extends StrictInterface<ForOpts, ParamsForOpts<OnlyArray>>, ParamSafeColumnsOverride extends I['paramSafeColumns' & keyof I] extends never ? undefined : I['paramSafeColumns' & keyof I] & string[], ParamSafeColumns extends ParamSafeColumnsOverride extends string[] | Readonly<string[]> ? Extract<DreamParamSafeColumnNames<I>, ParamSafeColumnsOverride[number] & DreamParamSafeColumnNames<I>>[] : DreamParamSafeColumnNames<I>[], ParamSafeAttrs extends DreamParamSafeAttributes<InstanceType<T>>, ReturnPartialType extends ForOpts['only'] extends readonly (keyof DreamParamSafeAttributes<InstanceType<T>>)[] ? ExtractedDreamParamSafeAttributes<ParamSafeAttrs, ForOpts['only'][number] & keyof ParamSafeAttrs> : ExtractedDreamParamSafeAttributes<ParamSafeAttrs, ParamSafeColumns[number & keyof ParamSafeColumns] & string & keyof ParamSafeAttrs>, ReturnPayload extends ForOpts['array'] extends true ? ReturnPartialType[] : ReturnPartialType>(this: PsychicController, dreamClass: T, opts?: ForOpts): ReturnPayload;
290
286
  /**
291
287
  * Captures and validates parameters for the provided Dream model using an
292
288
  * explicit, required allowlist. This is the recommended primitive for
@@ -320,9 +316,7 @@ export default class PsychicController {
320
316
  * }
321
317
  * ```
322
318
  */
323
- extractParams<T extends typeof Dream, I extends InstanceType<T>, const AllowedArray extends readonly (keyof DreamParamSafeAttributes<I>)[], OptsType extends StrictInterface<OptsType, ExtractParamsOpts>, ParamSafeAttrs extends DreamParamSafeAttributes<I>, ReturnPartial extends Partial<{
324
- [K in AllowedArray[number] & keyof ParamSafeAttrs]: ParamSafeAttrs[K & keyof ParamSafeAttrs];
325
- }>, ReturnPayload extends OptsType['array'] extends true ? ReturnPartial[] : ReturnPartial>(this: PsychicController, dreamClass: T, allowed: AllowedArray, opts?: OptsType): ReturnPayload;
319
+ extractParams<T extends typeof Dream, I extends InstanceType<T>, const AllowedArray extends readonly (keyof DreamParamSafeAttributes<I>)[], OptsType extends StrictInterface<OptsType, ExtractParamsOpts>, ParamSafeAttrs extends DreamParamSafeAttributes<I>, ReturnPartial extends ExtractedDreamParamSafeAttributes<ParamSafeAttrs, AllowedArray[number] & keyof ParamSafeAttrs>, ReturnPayload extends OptsType['array'] extends true ? ReturnPartial[] : ReturnPartial>(this: PsychicController, dreamClass: T, allowed: AllowedArray, opts?: OptsType): ReturnPayload;
326
320
  /**
327
321
  * Gets a cookie value from the request and casts it to the specified type.
328
322
  *
@@ -125,6 +125,7 @@ export default class PsychicApp {
125
125
  * are not broken by a hard failure.
126
126
  *
127
127
  * @param encryptionIdentifier - currently must be 'cookies', though this may change in the future
128
+ * @param keyType - whether this is the 'current' or 'legacy' key (named in the error so a misconfigured legacy key is not mistaken for the current one)
128
129
  * @param key - the encryption key you want to check
129
130
  * @param algorithm - the encryption algorithm you want to check
130
131
  */
@@ -1,10 +1,6 @@
1
1
  import { Dream } from '@rvoh/dream';
2
2
  import { DreamParamSafeAttributes, DreamParamSafeColumnNames, StrictInterface, UpdateableProperties } from '@rvoh/dream/types';
3
- import type { OpenAPIDreamModelRequestBodyModifications } from '../params.js';
4
- export default function openapiParamNamesForDreamClass<T extends typeof Dream, I extends InstanceType<T>, const OnlyArray extends readonly (keyof DreamParamSafeAttributes<I>)[], const IncludingArray extends Exclude<keyof UpdateableProperties<I>, OnlyArray[number]>[], ForOpts extends StrictInterface<ForOpts, OpenAPIDreamModelRequestBodyModifications<OnlyArray, IncludingArray>>, ParamSafeColumnsOverride extends I['paramSafeColumns' & keyof I] extends never ? undefined : I['paramSafeColumns' & keyof I] & string[], ParamSafeColumns extends ParamSafeColumnsOverride extends string[] | Readonly<string[]> ? Extract<DreamParamSafeColumnNames<I>, ParamSafeColumnsOverride[number] & DreamParamSafeColumnNames<I>>[] : DreamParamSafeColumnNames<I>[], ParamSafeAttrs extends DreamParamSafeAttributes<InstanceType<T>>, ReturnPartialType extends ForOpts['only'] extends readonly (keyof DreamParamSafeAttributes<InstanceType<T>>)[] ? Partial<{
5
- [K in ForOpts['only'][number] & keyof ParamSafeAttrs]: ParamSafeAttrs[K & keyof ParamSafeAttrs];
6
- }> : Partial<{
7
- [K in ParamSafeColumns[number & keyof ParamSafeColumns] & string]: DreamParamSafeAttributes<InstanceType<T>>[K & keyof DreamParamSafeAttributes<InstanceType<T>>];
8
- }>, ReturnPartialTypeWithIncluding extends ForOpts['including'] extends readonly (keyof UpdateableProperties<InstanceType<T>>)[] ? ReturnPartialType & Partial<{
3
+ import type { ExtractedDreamParamSafeAttributes, OpenAPIDreamModelRequestBodyModifications } from '../params.js';
4
+ export default function openapiParamNamesForDreamClass<T extends typeof Dream, I extends InstanceType<T>, const OnlyArray extends readonly (keyof DreamParamSafeAttributes<I>)[], const IncludingArray extends Exclude<keyof UpdateableProperties<I>, OnlyArray[number]>[], ForOpts extends StrictInterface<ForOpts, OpenAPIDreamModelRequestBodyModifications<OnlyArray, IncludingArray>>, ParamSafeColumnsOverride extends I['paramSafeColumns' & keyof I] extends never ? undefined : I['paramSafeColumns' & keyof I] & string[], ParamSafeColumns extends ParamSafeColumnsOverride extends string[] | Readonly<string[]> ? Extract<DreamParamSafeColumnNames<I>, ParamSafeColumnsOverride[number] & DreamParamSafeColumnNames<I>>[] : DreamParamSafeColumnNames<I>[], ParamSafeAttrs extends DreamParamSafeAttributes<InstanceType<T>>, ReturnPartialType extends ForOpts['only'] extends readonly (keyof DreamParamSafeAttributes<InstanceType<T>>)[] ? ExtractedDreamParamSafeAttributes<ParamSafeAttrs, ForOpts['only'][number] & keyof ParamSafeAttrs> : ExtractedDreamParamSafeAttributes<ParamSafeAttrs, ParamSafeColumns[number & keyof ParamSafeColumns] & string & keyof ParamSafeAttrs>, ReturnPartialTypeWithIncluding extends ForOpts['including'] extends readonly (keyof UpdateableProperties<InstanceType<T>>)[] ? ReturnPartialType & Partial<{
9
5
  [K in Extract<keyof UpdateableProperties<InstanceType<T>>, ForOpts['including'][number & keyof ForOpts['including']]>]: UpdateableProperties<InstanceType<T>>[K];
10
6
  }> : ReturnPartialType, RetArray = (keyof ReturnPartialTypeWithIncluding)[]>(dreamClass: T, { only, including }?: ForOpts): RetArray;
@@ -1,8 +1,4 @@
1
1
  import { Dream } from '@rvoh/dream';
2
2
  import { DreamParamSafeAttributes, DreamParamSafeColumnNames, StrictInterface } from '@rvoh/dream/types';
3
- import type { ParamsForOpts } from '../params.js';
4
- export default function paramNamesForDreamClass<T extends typeof Dream, I extends InstanceType<T>, const OnlyArray extends readonly (keyof DreamParamSafeAttributes<I>)[], ForOpts extends StrictInterface<ForOpts, ParamsForOpts<OnlyArray>>, ParamSafeColumnsOverride extends I['paramSafeColumns' & keyof I] extends never ? undefined : I['paramSafeColumns' & keyof I] & string[], ParamSafeColumns extends ParamSafeColumnsOverride extends string[] | Readonly<string[]> ? Extract<DreamParamSafeColumnNames<I>, ParamSafeColumnsOverride[number] & DreamParamSafeColumnNames<I>>[] : DreamParamSafeColumnNames<I>[], ParamSafeAttrs extends DreamParamSafeAttributes<InstanceType<T>>, ReturnPartialType extends ForOpts['only'] extends readonly (keyof DreamParamSafeAttributes<InstanceType<T>>)[] ? Partial<{
5
- [K in ForOpts['only'][number] & keyof ParamSafeAttrs]: ParamSafeAttrs[K & keyof ParamSafeAttrs];
6
- }> : Partial<{
7
- [K in ParamSafeColumns[number & keyof ParamSafeColumns] & string]: DreamParamSafeAttributes<InstanceType<T>>[K & keyof DreamParamSafeAttributes<InstanceType<T>>];
8
- }>, RetArray = (keyof ReturnPartialType)[]>(dreamClass: T, { only }?: ForOpts): RetArray;
3
+ import type { ExtractedDreamParamSafeAttributes, ParamsForOpts } from '../params.js';
4
+ export default function paramNamesForDreamClass<T extends typeof Dream, I extends InstanceType<T>, const OnlyArray extends readonly (keyof DreamParamSafeAttributes<I>)[], ForOpts extends StrictInterface<ForOpts, ParamsForOpts<OnlyArray>>, ParamSafeColumnsOverride extends I['paramSafeColumns' & keyof I] extends never ? undefined : I['paramSafeColumns' & keyof I] & string[], ParamSafeColumns extends ParamSafeColumnsOverride extends string[] | Readonly<string[]> ? Extract<DreamParamSafeColumnNames<I>, ParamSafeColumnsOverride[number] & DreamParamSafeColumnNames<I>>[] : DreamParamSafeColumnNames<I>[], ParamSafeAttrs extends DreamParamSafeAttributes<InstanceType<T>>, ReturnPartialType extends ForOpts['only'] extends readonly (keyof DreamParamSafeAttributes<InstanceType<T>>)[] ? ExtractedDreamParamSafeAttributes<ParamSafeAttrs, ForOpts['only'][number] & keyof ParamSafeAttrs> : ExtractedDreamParamSafeAttributes<ParamSafeAttrs, ParamSafeColumns[number & keyof ParamSafeColumns] & string & keyof ParamSafeAttrs>, RetArray = (keyof ReturnPartialType)[]>(dreamClass: T, { only }?: ForOpts): RetArray;
@@ -3,6 +3,9 @@ import { OpenapiSchemaArray, OpenapiSchemaBody, OpenapiSchemaInteger, OpenapiSch
3
3
  import { DreamParamSafeAttributes, DreamParamSafeColumnNames, StrictInterface } from '@rvoh/dream/types';
4
4
  import { PsychicParamsDictionary, PsychicParamsPrimitive, PsychicParamsPrimitiveLiteral } from '../controller/index.js';
5
5
  import { Inc } from '../i18n/conf/types.js';
6
+ export type ExtractedDreamParamSafeAttributes<ParamSafeAttrs, ParamNames extends keyof ParamSafeAttrs> = Partial<{
7
+ [K in ParamNames]: Exclude<ParamSafeAttrs[K], undefined>;
8
+ }>;
6
9
  export default class Params {
7
10
  private $params;
8
11
  /**
@@ -19,11 +22,7 @@ export default class Params {
19
22
  * const params = Params.for(this.params.user, User)
20
23
  * ```
21
24
  */
22
- static for<T extends typeof Dream, I extends InstanceType<T>, const OnlyArray extends readonly (keyof DreamParamSafeAttributes<I>)[], ForOpts extends StrictInterface<ForOpts, ParamsForOpts<OnlyArray>>, ParamSafeColumnsOverride extends I['paramSafeColumns' & keyof I] extends never ? undefined : I['paramSafeColumns' & keyof I] & string[], ParamSafeColumns extends ParamSafeColumnsOverride extends string[] | Readonly<string[]> ? Extract<DreamParamSafeColumnNames<I>, ParamSafeColumnsOverride[number] & DreamParamSafeColumnNames<I>>[] : DreamParamSafeColumnNames<I>[], ParamSafeAttrs extends DreamParamSafeAttributes<InstanceType<T>>, ReturnPartialType extends ForOpts['only'] extends readonly (keyof DreamParamSafeAttributes<InstanceType<T>>)[] ? Partial<{
23
- [K in ForOpts['only'][number] & keyof ParamSafeAttrs]: ParamSafeAttrs[K & keyof ParamSafeAttrs];
24
- }> : Partial<{
25
- [K in ParamSafeColumns[number & keyof ParamSafeColumns] & string & keyof ParamSafeAttrs]: ParamSafeAttrs[K & keyof ParamSafeAttrs];
26
- }>, ReturnPayload extends ForOpts['array'] extends true ? ReturnPartialType[] : ReturnPartialType>(params: object, dreamClass: T, forOpts?: ForOpts): ReturnPayload;
25
+ static for<T extends typeof Dream, I extends InstanceType<T>, const OnlyArray extends readonly (keyof DreamParamSafeAttributes<I>)[], ForOpts extends StrictInterface<ForOpts, ParamsForOpts<OnlyArray>>, ParamSafeColumnsOverride extends I['paramSafeColumns' & keyof I] extends never ? undefined : I['paramSafeColumns' & keyof I] & string[], ParamSafeColumns extends ParamSafeColumnsOverride extends string[] | Readonly<string[]> ? Extract<DreamParamSafeColumnNames<I>, ParamSafeColumnsOverride[number] & DreamParamSafeColumnNames<I>>[] : DreamParamSafeColumnNames<I>[], ParamSafeAttrs extends DreamParamSafeAttributes<InstanceType<T>>, ReturnPartialType extends ForOpts['only'] extends readonly (keyof DreamParamSafeAttributes<InstanceType<T>>)[] ? ExtractedDreamParamSafeAttributes<ParamSafeAttrs, ForOpts['only'][number] & keyof ParamSafeAttrs> : ExtractedDreamParamSafeAttributes<ParamSafeAttrs, ParamSafeColumns[number & keyof ParamSafeColumns] & string & keyof ParamSafeAttrs>, ReturnPayload extends ForOpts['array'] extends true ? ReturnPartialType[] : ReturnPartialType>(params: object, dreamClass: T, forOpts?: ForOpts): ReturnPayload;
27
26
  /**
28
27
  * ### .extract
29
28
  *
@@ -39,9 +38,7 @@ export default class Params {
39
38
  * any column not also in the model's `paramSafeColumnsOrFallback()` set,
40
39
  * so TS-bypass attempts still fail closed.
41
40
  */
42
- static extract<T extends typeof Dream, I extends InstanceType<T>, const AllowedArray extends readonly (keyof DreamParamSafeAttributes<I>)[], OptsType extends StrictInterface<OptsType, ExtractParamsOpts>, ParamSafeAttrs extends DreamParamSafeAttributes<I>, ReturnPartial extends Partial<{
43
- [K in AllowedArray[number] & keyof ParamSafeAttrs]: ParamSafeAttrs[K & keyof ParamSafeAttrs];
44
- }>, ReturnPayload extends OptsType['array'] extends true ? ReturnPartial[] : ReturnPartial>(params: object, dreamClass: T, allowed: AllowedArray, opts?: OptsType): ReturnPayload;
41
+ static extract<T extends typeof Dream, I extends InstanceType<T>, const AllowedArray extends readonly (keyof DreamParamSafeAttributes<I>)[], OptsType extends StrictInterface<OptsType, ExtractParamsOpts>, ParamSafeAttrs extends DreamParamSafeAttributes<I>, ReturnPartial extends ExtractedDreamParamSafeAttributes<ParamSafeAttrs, AllowedArray[number] & keyof ParamSafeAttrs>, ReturnPayload extends OptsType['array'] extends true ? ReturnPartial[] : ReturnPartial>(params: object, dreamClass: T, allowed: AllowedArray, opts?: OptsType): ReturnPayload;
45
42
  static restrict<T extends typeof Params>(this: T, params: PsychicParamsPrimitive | PsychicParamsDictionary | PsychicParamsDictionary[], allowed: string[]): PsychicParamsDictionary;
46
43
  /**
47
44
  * ### .cast
@@ -77,6 +74,23 @@ export default class Params {
77
74
  private validateOpenapiOrThrow;
78
75
  restrict(allowed: string[]): PsychicParamsDictionary;
79
76
  private matchRegexOrThrow;
77
+ /**
78
+ * Enforces `minLength`/`maxLength` (inclusive) against an already-coerced
79
+ * string value, throwing {@link ParamValidationError} on violation. Mirrors
80
+ * JSON-schema / OpenAPI string-length constraints.
81
+ */
82
+ private throwUnlessWithinLength;
83
+ /**
84
+ * Enforces `minimum`/`maximum` (inclusive) against an already-coerced numeric
85
+ * value, throwing {@link ParamValidationError} on violation. Accepts a
86
+ * `bigint` so `bigint` casts are checked without precision loss; the bounds
87
+ * themselves are compared as `bigint` when they are integers and otherwise
88
+ * fall back to floating-point comparison. Mirrors JSON-schema / OpenAPI
89
+ * numeric-range constraints.
90
+ */
91
+ private throwUnlessWithinRange;
92
+ private isBelowNumericBound;
93
+ private isAboveNumericBound;
80
94
  private throwUnlessAllowNull;
81
95
  }
82
96
  export type ValidatedReturnType<ExpectedType, OptsType> = ExpectedType extends RegExp ? string : ExpectedType extends 'string' ? OptsType extends {
@@ -113,6 +127,30 @@ export type ParamsCastOptions<EnumType> = {
113
127
  allowNull?: boolean;
114
128
  match?: RegExp;
115
129
  enum?: EnumType;
130
+ /**
131
+ * Maximum permitted string length (inclusive). Enforced after coercion for
132
+ * `string` casts (and element-wise for `string[]`). Mirrors JSON-schema /
133
+ * OpenAPI `maxLength`.
134
+ */
135
+ maxLength?: number;
136
+ /**
137
+ * Minimum permitted string length (inclusive). Enforced after coercion for
138
+ * `string` casts (and element-wise for `string[]`). Mirrors JSON-schema /
139
+ * OpenAPI `minLength`.
140
+ */
141
+ minLength?: number;
142
+ /**
143
+ * Minimum permitted numeric value (inclusive). Enforced after coercion for
144
+ * `number`/`integer`/`bigint` casts (and element-wise for their array
145
+ * variants). Mirrors JSON-schema / OpenAPI `minimum`.
146
+ */
147
+ minimum?: number;
148
+ /**
149
+ * Maximum permitted numeric value (inclusive). Enforced after coercion for
150
+ * `number`/`integer`/`bigint` casts (and element-wise for their array
151
+ * variants). Mirrors JSON-schema / OpenAPI `maximum`.
152
+ */
153
+ maximum?: number;
116
154
  };
117
155
  interface ParamsForOptsBase<OnlyArray> {
118
156
  array?: boolean;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "type": "module",
3
3
  "name": "@rvoh/psychic",
4
4
  "description": "Typescript web framework",
5
- "version": "3.9.0",
5
+ "version": "3.10.1",
6
6
  "author": "RVOHealth",
7
7
  "publishConfig": {
8
8
  "access": "public"