@rvoh/psychic 3.8.6 → 3.10.0

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.
Files changed (28) hide show
  1. package/dist/cjs/src/bin/helpers/OpenApiSpecDiff.js +4 -0
  2. package/dist/cjs/src/openapi-renderer/app.js +2 -0
  3. package/dist/cjs/src/openapi-renderer/body-segment.js +3 -0
  4. package/dist/cjs/src/openapi-renderer/endpoint.js +10 -0
  5. package/dist/cjs/src/openapi-renderer/helpers/OpenapiPayloadValidator.js +2 -0
  6. package/dist/cjs/src/openapi-renderer/helpers/buildDreamRequestBodyShape.js +8 -2
  7. package/dist/cjs/src/openapi-renderer/helpers/dreamColumnOpenapiShape.js +1 -0
  8. package/dist/cjs/src/openapi-renderer/helpers/legacyImplicitRequestBodyParamsConfig.js +9 -0
  9. package/dist/cjs/src/psychic-app/index.js +6 -3
  10. package/dist/cjs/src/server/params.js +78 -7
  11. package/dist/esm/src/bin/helpers/OpenApiSpecDiff.js +4 -0
  12. package/dist/esm/src/openapi-renderer/app.js +2 -0
  13. package/dist/esm/src/openapi-renderer/body-segment.js +3 -0
  14. package/dist/esm/src/openapi-renderer/endpoint.js +10 -0
  15. package/dist/esm/src/openapi-renderer/helpers/OpenapiPayloadValidator.js +2 -0
  16. package/dist/esm/src/openapi-renderer/helpers/buildDreamRequestBodyShape.js +8 -2
  17. package/dist/esm/src/openapi-renderer/helpers/dreamColumnOpenapiShape.js +1 -0
  18. package/dist/esm/src/openapi-renderer/helpers/legacyImplicitRequestBodyParamsConfig.js +9 -0
  19. package/dist/esm/src/psychic-app/index.js +6 -3
  20. package/dist/esm/src/server/params.js +78 -7
  21. package/dist/types/src/bin/helpers/OpenApiSpecDiff.d.ts +5 -1
  22. package/dist/types/src/openapi-renderer/body-segment.d.ts +1 -0
  23. package/dist/types/src/openapi-renderer/endpoint.d.ts +1 -0
  24. package/dist/types/src/openapi-renderer/helpers/buildDreamRequestBodyShape.d.ts +7 -0
  25. package/dist/types/src/openapi-renderer/helpers/legacyImplicitRequestBodyParamsConfig.d.ts +6 -0
  26. package/dist/types/src/psychic-app/index.d.ts +24 -0
  27. package/dist/types/src/server/params.d.ts +41 -0
  28. package/package.json +1 -1
@@ -189,6 +189,10 @@ export class OpenApiSpecDiff {
189
189
  }
190
190
  /**
191
191
  * Retrieves head branch content for a file
192
+ *
193
+ * Marked `protected` so tests can supply a deterministic baseline instead of
194
+ * the live head branch. Production code never overrides this.
195
+ *
192
196
  * @param absoluteFilePath - Absolute path to the file
193
197
  */
194
198
  getHeadBranchContent(absoluteFilePath) {
@@ -7,6 +7,7 @@ import PsychicApp from '../psychic-app/index.js';
7
7
  import { HttpMethods } from '../router/types.js';
8
8
  import { DEFAULT_OPENAPI_COMPONENT_RESPONSES, DEFAULT_OPENAPI_COMPONENT_SCHEMAS } from './defaults.js';
9
9
  import { MissingControllerActionPairingInRoutes, } from './endpoint.js';
10
+ import legacyImplicitRequestBodyParamsConfig from './helpers/legacyImplicitRequestBodyParamsConfig.js';
10
11
  import suppressResponseEnumsConfig from './helpers/suppressResponseEnumsConfig.js';
11
12
  const debugEnabled = debuglog('psychic').enabled;
12
13
  export default class OpenapiAppRenderer {
@@ -47,6 +48,7 @@ export default class OpenapiAppRenderer {
47
48
  const renderOpts = {
48
49
  casing: 'camel',
49
50
  suppressResponseEnums: suppressResponseEnumsConfig(openapiName),
51
+ legacyImplicitRequestBodyParams: legacyImplicitRequestBodyParamsConfig(openapiName),
50
52
  };
51
53
  const alreadyExtractedDescendantSerializers = {};
52
54
  const renderedSchemasOpenapi = {};
@@ -54,6 +54,7 @@ export default class OpenapiSegmentExpander {
54
54
  bodySegment;
55
55
  casing;
56
56
  suppressResponseEnums;
57
+ legacyImplicitRequestBodyParams;
57
58
  target;
58
59
  source;
59
60
  /**
@@ -66,6 +67,7 @@ export default class OpenapiSegmentExpander {
66
67
  this.bodySegment = bodySegment;
67
68
  this.casing = renderOpts.casing;
68
69
  this.suppressResponseEnums = renderOpts.suppressResponseEnums;
70
+ this.legacyImplicitRequestBodyParams = renderOpts.legacyImplicitRequestBodyParams;
69
71
  this.target = target;
70
72
  this.source = source ?? 'unknown';
71
73
  }
@@ -489,6 +491,7 @@ The following values will be allowed:
489
491
  including: ref.including,
490
492
  required: ref.required,
491
493
  combining: ref.combining,
494
+ legacyImplicitRequestBodyParams: this.legacyImplicitRequestBodyParams,
492
495
  }, this.source);
493
496
  return this.recursivelyParseBody(paramsShape);
494
497
  }
@@ -529,7 +529,17 @@ export default class OpenapiEndpointRenderer {
529
529
  including: including,
530
530
  required: required,
531
531
  combining: combining,
532
+ legacyImplicitRequestBodyParams: renderOpts.legacyImplicitRequestBodyParams,
532
533
  }, source);
534
+ // When the model-derived shape contributes no properties and no `required`
535
+ // (e.g. the new none-default with no `params`/`only`/`combining`), there is
536
+ // nothing meaningful to advertise. Fall back to `defaultRequestBody()`, which
537
+ // omits the body entirely unless body-level pagination params are present.
538
+ const shapeProperties = paramsShape.properties;
539
+ const shapeRequired = paramsShape.required;
540
+ if (Object.keys(shapeProperties ?? {}).length === 0 && !shapeRequired) {
541
+ return this.defaultRequestBody();
542
+ }
533
543
  let processedSchema = new OpenapiSegmentExpander(paramsShape, {
534
544
  renderOpts,
535
545
  target: 'request',
@@ -2,6 +2,7 @@ import OpenapiRequestValidationFailure from '../../error/openapi/OpenapiRequestV
2
2
  import OpenapiResponseValidationFailure from '../../error/openapi/OpenapResponseValidationFailure.js';
3
3
  import { createValidator, formatAjvErrors, } from '../../helpers/validateOpenApiSchema.js';
4
4
  import PsychicApp from '../../psychic-app/index.js';
5
+ import legacyImplicitRequestBodyParamsConfig from './legacyImplicitRequestBodyParamsConfig.js';
5
6
  import suppressResponseEnumsConfig from './suppressResponseEnumsConfig.js';
6
7
  import { cacheValidator, getCachedValidator } from './validator-cache.js';
7
8
  /**
@@ -207,6 +208,7 @@ export default class OpenapiPayloadValidator {
207
208
  return {
208
209
  casing: 'camel',
209
210
  suppressResponseEnums: suppressResponseEnumsConfig(this.openapiName),
211
+ legacyImplicitRequestBodyParams: legacyImplicitRequestBodyParamsConfig(this.openapiName),
210
212
  };
211
213
  }
212
214
  /**
@@ -11,9 +11,15 @@ import { dreamColumnOpenapiShape } from './dreamColumnOpenapiShape.js';
11
11
  * truth keeps top-level and nested semantics identical.
12
12
  */
13
13
  export default function buildDreamRequestBodyShape(dreamClass, opts, source) {
14
- const { params, only, including, required, combining } = opts;
14
+ const { params, only, including, required, combining, legacyImplicitRequestBodyParams } = opts;
15
+ // When neither `params` nor `only` is provided, the default is to resolve to
16
+ // NO model columns (an empty allowlist), rather than implicitly exposing every
17
+ // param-safe column. The legacy implicit-all behavior can be re-activated via
18
+ // the `legacyImplicitRequestBodyParams` opt-in.
19
+ const resolvedOnly = params ?? only;
20
+ const onlyForResolution = resolvedOnly === undefined && !legacyImplicitRequestBodyParams ? [] : resolvedOnly;
15
21
  const paramSafeColumns = openapiParamNamesForDreamClass(dreamClass, {
16
- only: params ?? only,
22
+ only: onlyForResolution,
17
23
  including,
18
24
  });
19
25
  const paramsShape = {
@@ -18,6 +18,7 @@ source, dreamClass, column, openapi = undefined, { suppressResponseEnums = false
18
18
  renderOpts: {
19
19
  casing: 'camel',
20
20
  suppressResponseEnums: false,
21
+ legacyImplicitRequestBodyParams: false,
21
22
  },
22
23
  target: 'request',
23
24
  }).render().openapi,
@@ -0,0 +1,9 @@
1
+ import openapiOpts from './openapiOpts.js';
2
+ /**
3
+ * returns whether the named openapi config opts in to the legacy behavior of
4
+ * implicitly exposing all param-safe columns when a model-derived request body
5
+ * is given no `params`/`only`.
6
+ */
7
+ export default function legacyImplicitRequestBodyParamsConfig(openapiName) {
8
+ return !!openapiOpts(openapiName)?.legacyImplicitRequestBodyParams;
9
+ }
@@ -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)
@@ -189,6 +189,10 @@ export class OpenApiSpecDiff {
189
189
  }
190
190
  /**
191
191
  * Retrieves head branch content for a file
192
+ *
193
+ * Marked `protected` so tests can supply a deterministic baseline instead of
194
+ * the live head branch. Production code never overrides this.
195
+ *
192
196
  * @param absoluteFilePath - Absolute path to the file
193
197
  */
194
198
  getHeadBranchContent(absoluteFilePath) {
@@ -7,6 +7,7 @@ import PsychicApp from '../psychic-app/index.js';
7
7
  import { HttpMethods } from '../router/types.js';
8
8
  import { DEFAULT_OPENAPI_COMPONENT_RESPONSES, DEFAULT_OPENAPI_COMPONENT_SCHEMAS } from './defaults.js';
9
9
  import { MissingControllerActionPairingInRoutes, } from './endpoint.js';
10
+ import legacyImplicitRequestBodyParamsConfig from './helpers/legacyImplicitRequestBodyParamsConfig.js';
10
11
  import suppressResponseEnumsConfig from './helpers/suppressResponseEnumsConfig.js';
11
12
  const debugEnabled = debuglog('psychic').enabled;
12
13
  export default class OpenapiAppRenderer {
@@ -47,6 +48,7 @@ export default class OpenapiAppRenderer {
47
48
  const renderOpts = {
48
49
  casing: 'camel',
49
50
  suppressResponseEnums: suppressResponseEnumsConfig(openapiName),
51
+ legacyImplicitRequestBodyParams: legacyImplicitRequestBodyParamsConfig(openapiName),
50
52
  };
51
53
  const alreadyExtractedDescendantSerializers = {};
52
54
  const renderedSchemasOpenapi = {};
@@ -54,6 +54,7 @@ export default class OpenapiSegmentExpander {
54
54
  bodySegment;
55
55
  casing;
56
56
  suppressResponseEnums;
57
+ legacyImplicitRequestBodyParams;
57
58
  target;
58
59
  source;
59
60
  /**
@@ -66,6 +67,7 @@ export default class OpenapiSegmentExpander {
66
67
  this.bodySegment = bodySegment;
67
68
  this.casing = renderOpts.casing;
68
69
  this.suppressResponseEnums = renderOpts.suppressResponseEnums;
70
+ this.legacyImplicitRequestBodyParams = renderOpts.legacyImplicitRequestBodyParams;
69
71
  this.target = target;
70
72
  this.source = source ?? 'unknown';
71
73
  }
@@ -489,6 +491,7 @@ The following values will be allowed:
489
491
  including: ref.including,
490
492
  required: ref.required,
491
493
  combining: ref.combining,
494
+ legacyImplicitRequestBodyParams: this.legacyImplicitRequestBodyParams,
492
495
  }, this.source);
493
496
  return this.recursivelyParseBody(paramsShape);
494
497
  }
@@ -529,7 +529,17 @@ export default class OpenapiEndpointRenderer {
529
529
  including: including,
530
530
  required: required,
531
531
  combining: combining,
532
+ legacyImplicitRequestBodyParams: renderOpts.legacyImplicitRequestBodyParams,
532
533
  }, source);
534
+ // When the model-derived shape contributes no properties and no `required`
535
+ // (e.g. the new none-default with no `params`/`only`/`combining`), there is
536
+ // nothing meaningful to advertise. Fall back to `defaultRequestBody()`, which
537
+ // omits the body entirely unless body-level pagination params are present.
538
+ const shapeProperties = paramsShape.properties;
539
+ const shapeRequired = paramsShape.required;
540
+ if (Object.keys(shapeProperties ?? {}).length === 0 && !shapeRequired) {
541
+ return this.defaultRequestBody();
542
+ }
533
543
  let processedSchema = new OpenapiSegmentExpander(paramsShape, {
534
544
  renderOpts,
535
545
  target: 'request',
@@ -2,6 +2,7 @@ import OpenapiRequestValidationFailure from '../../error/openapi/OpenapiRequestV
2
2
  import OpenapiResponseValidationFailure from '../../error/openapi/OpenapResponseValidationFailure.js';
3
3
  import { createValidator, formatAjvErrors, } from '../../helpers/validateOpenApiSchema.js';
4
4
  import PsychicApp from '../../psychic-app/index.js';
5
+ import legacyImplicitRequestBodyParamsConfig from './legacyImplicitRequestBodyParamsConfig.js';
5
6
  import suppressResponseEnumsConfig from './suppressResponseEnumsConfig.js';
6
7
  import { cacheValidator, getCachedValidator } from './validator-cache.js';
7
8
  /**
@@ -207,6 +208,7 @@ export default class OpenapiPayloadValidator {
207
208
  return {
208
209
  casing: 'camel',
209
210
  suppressResponseEnums: suppressResponseEnumsConfig(this.openapiName),
211
+ legacyImplicitRequestBodyParams: legacyImplicitRequestBodyParamsConfig(this.openapiName),
210
212
  };
211
213
  }
212
214
  /**
@@ -11,9 +11,15 @@ import { dreamColumnOpenapiShape } from './dreamColumnOpenapiShape.js';
11
11
  * truth keeps top-level and nested semantics identical.
12
12
  */
13
13
  export default function buildDreamRequestBodyShape(dreamClass, opts, source) {
14
- const { params, only, including, required, combining } = opts;
14
+ const { params, only, including, required, combining, legacyImplicitRequestBodyParams } = opts;
15
+ // When neither `params` nor `only` is provided, the default is to resolve to
16
+ // NO model columns (an empty allowlist), rather than implicitly exposing every
17
+ // param-safe column. The legacy implicit-all behavior can be re-activated via
18
+ // the `legacyImplicitRequestBodyParams` opt-in.
19
+ const resolvedOnly = params ?? only;
20
+ const onlyForResolution = resolvedOnly === undefined && !legacyImplicitRequestBodyParams ? [] : resolvedOnly;
15
21
  const paramSafeColumns = openapiParamNamesForDreamClass(dreamClass, {
16
- only: params ?? only,
22
+ only: onlyForResolution,
17
23
  including,
18
24
  });
19
25
  const paramsShape = {
@@ -18,6 +18,7 @@ source, dreamClass, column, openapi = undefined, { suppressResponseEnums = false
18
18
  renderOpts: {
19
19
  casing: 'camel',
20
20
  suppressResponseEnums: false,
21
+ legacyImplicitRequestBodyParams: false,
21
22
  },
22
23
  target: 'request',
23
24
  }).render().openapi,
@@ -0,0 +1,9 @@
1
+ import openapiOpts from './openapiOpts.js';
2
+ /**
3
+ * returns whether the named openapi config opts in to the legacy behavior of
4
+ * implicitly exposing all param-safe columns when a model-derived request body
5
+ * is given no `params`/`only`.
6
+ */
7
+ export default function legacyImplicitRequestBodyParamsConfig(openapiName) {
8
+ return !!openapiOpts(openapiName)?.legacyImplicitRequestBodyParams;
9
+ }
@@ -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)
@@ -96,9 +96,13 @@ export declare class OpenApiSpecDiff {
96
96
  private createTempFilePath;
97
97
  /**
98
98
  * Retrieves head branch content for a file
99
+ *
100
+ * Marked `protected` so tests can supply a deterministic baseline instead of
101
+ * the live head branch. Production code never overrides this.
102
+ *
99
103
  * @param absoluteFilePath - Absolute path to the file
100
104
  */
101
- private getHeadBranchContent;
105
+ protected getHeadBranchContent(absoluteFilePath: string): string;
102
106
  /**
103
107
  * Compares a single OpenAPI file against head branch
104
108
  */
@@ -56,6 +56,7 @@ export default class OpenapiSegmentExpander {
56
56
  private bodySegment;
57
57
  private casing;
58
58
  private suppressResponseEnums;
59
+ private legacyImplicitRequestBodyParams;
59
60
  private target;
60
61
  private source;
61
62
  /**
@@ -11,6 +11,7 @@ import { OpenapiBodySegment, ReferencedSerializersAndOpenapiEndpointResponse, Se
11
11
  export interface OpenapiRenderOpts {
12
12
  casing: SerializerCasing;
13
13
  suppressResponseEnums: boolean;
14
+ legacyImplicitRequestBodyParams: boolean;
14
15
  }
15
16
  export interface ToPathObjectOpts {
16
17
  openapiName: string;
@@ -6,6 +6,13 @@ export interface BuildDreamRequestBodyShapeOpts {
6
6
  including?: readonly string[] | undefined;
7
7
  required?: readonly string[] | undefined;
8
8
  combining?: Record<string, unknown> | undefined;
9
+ /**
10
+ * When true, restores the legacy behavior of resolving to ALL param-safe
11
+ * columns when no `params`/`only` are provided. When false/undefined (the
12
+ * default), an absent `params`/`only` resolves to NO model columns, so that
13
+ * database-level structure is not implicitly leaked into the OpenAPI shape.
14
+ */
15
+ legacyImplicitRequestBodyParams?: boolean | undefined;
9
16
  }
10
17
  /**
11
18
  * @internal
@@ -0,0 +1,6 @@
1
+ /**
2
+ * returns whether the named openapi config opts in to the legacy behavior of
3
+ * implicitly exposing all param-safe columns when a model-derived request body
4
+ * is given no `params`/`only`.
5
+ */
6
+ export default function legacyImplicitRequestBodyParamsConfig(openapiName: string): boolean;
@@ -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
  */
@@ -356,6 +357,29 @@ interface PsychicOpenapiBaseOptions {
356
357
  * ```
357
358
  */
358
359
  suppressResponseEnums?: boolean;
360
+ /**
361
+ * @deprecated escape hatch — prefer explicit `params`/`only` on each request body.
362
+ *
363
+ * When true, restores the legacy behavior where a model-derived `requestBody`
364
+ * (or nested `$dream`/`for:` segment) given no `params`/`only` implicitly
365
+ * exposes ALL param-safe columns of the model.
366
+ *
367
+ * As of 3.9.0 the default flipped: an absent `params`/`only` now resolves to
368
+ * NO model columns, so that database-level structure is not implicitly leaked
369
+ * into the OpenAPI shape (an attacker could otherwise infer the column set).
370
+ * A top-level model-derived `requestBody` with nothing to contribute is
371
+ * omitted entirely; a nested `$dream`/`for:` segment renders an empty object.
372
+ *
373
+ * Set this to true to keep the pre-3.9.0 implicit-all behavior while you
374
+ * migrate to explicit allowlists.
375
+ *
376
+ * ```ts
377
+ * psy.set('openapi', {
378
+ * legacyImplicitRequestBodyParams: true,
379
+ * })
380
+ * ```
381
+ */
382
+ legacyImplicitRequestBodyParams?: boolean;
359
383
  /**
360
384
  * When true, psychic will use the `openapi-typescript` package
361
385
  * to read this openapi.json file and create typescript interfaces
@@ -77,6 +77,23 @@ export default class Params {
77
77
  private validateOpenapiOrThrow;
78
78
  restrict(allowed: string[]): PsychicParamsDictionary;
79
79
  private matchRegexOrThrow;
80
+ /**
81
+ * Enforces `minLength`/`maxLength` (inclusive) against an already-coerced
82
+ * string value, throwing {@link ParamValidationError} on violation. Mirrors
83
+ * JSON-schema / OpenAPI string-length constraints.
84
+ */
85
+ private throwUnlessWithinLength;
86
+ /**
87
+ * Enforces `minimum`/`maximum` (inclusive) against an already-coerced numeric
88
+ * value, throwing {@link ParamValidationError} on violation. Accepts a
89
+ * `bigint` so `bigint` casts are checked without precision loss; the bounds
90
+ * themselves are compared as `bigint` when they are integers and otherwise
91
+ * fall back to floating-point comparison. Mirrors JSON-schema / OpenAPI
92
+ * numeric-range constraints.
93
+ */
94
+ private throwUnlessWithinRange;
95
+ private isBelowNumericBound;
96
+ private isAboveNumericBound;
80
97
  private throwUnlessAllowNull;
81
98
  }
82
99
  export type ValidatedReturnType<ExpectedType, OptsType> = ExpectedType extends RegExp ? string : ExpectedType extends 'string' ? OptsType extends {
@@ -113,6 +130,30 @@ export type ParamsCastOptions<EnumType> = {
113
130
  allowNull?: boolean;
114
131
  match?: RegExp;
115
132
  enum?: EnumType;
133
+ /**
134
+ * Maximum permitted string length (inclusive). Enforced after coercion for
135
+ * `string` casts (and element-wise for `string[]`). Mirrors JSON-schema /
136
+ * OpenAPI `maxLength`.
137
+ */
138
+ maxLength?: number;
139
+ /**
140
+ * Minimum permitted string length (inclusive). Enforced after coercion for
141
+ * `string` casts (and element-wise for `string[]`). Mirrors JSON-schema /
142
+ * OpenAPI `minLength`.
143
+ */
144
+ minLength?: number;
145
+ /**
146
+ * Minimum permitted numeric value (inclusive). Enforced after coercion for
147
+ * `number`/`integer`/`bigint` casts (and element-wise for their array
148
+ * variants). Mirrors JSON-schema / OpenAPI `minimum`.
149
+ */
150
+ minimum?: number;
151
+ /**
152
+ * Maximum permitted numeric value (inclusive). Enforced after coercion for
153
+ * `number`/`integer`/`bigint` casts (and element-wise for their array
154
+ * variants). Mirrors JSON-schema / OpenAPI `maximum`.
155
+ */
156
+ maximum?: number;
116
157
  };
117
158
  interface ParamsForOptsBase<OnlyArray> {
118
159
  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.8.6",
5
+ "version": "3.10.0",
6
6
  "author": "RVOHealth",
7
7
  "publishConfig": {
8
8
  "access": "public"