@rvoh/psychic 3.1.3 → 3.2.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.
Files changed (86) hide show
  1. package/dist/cjs/src/bin/helpers/OpenApiSpecDiff.js +14 -2
  2. package/dist/cjs/src/bin/index.js +14 -1
  3. package/dist/cjs/src/cli/helpers/PackageManager.js +18 -14
  4. package/dist/cjs/src/cli/index.js +2 -0
  5. package/dist/cjs/src/controller/index.js +105 -19
  6. package/dist/cjs/src/devtools/helpers/launchDevServer.js +4 -0
  7. package/dist/cjs/src/encrypt/internal-encrypt.js +2 -16
  8. package/dist/cjs/src/error/devtools/LaunchDevServerRequiresDevelopmentOrTest.js +22 -0
  9. package/dist/cjs/src/error/http/InternalServerError.js +4 -0
  10. package/dist/cjs/src/error/openapi/OpenApiSpecDiffRequiresDevelopmentOrTest.js +15 -0
  11. package/dist/cjs/src/generate/controller.js +3 -1
  12. package/dist/cjs/src/generate/helpers/generateControllerContent.js +33 -3
  13. package/dist/cjs/src/generate/helpers/generateResourceControllerSpecContent.js +1 -23
  14. package/dist/cjs/src/generate/helpers/paramSafeColumnNamesFromCliTokens.js +35 -0
  15. package/dist/cjs/src/generate/helpers/parseAttribute.js +35 -0
  16. package/dist/cjs/src/generate/helpers/reduxBindings/writeInitializer.js +3 -2
  17. package/dist/cjs/src/generate/helpers/syncOpenapiTypescript/generateInitializer.js +2 -2
  18. package/dist/cjs/src/generate/helpers/syncOpenapiTypescript/installOpenapiTypescript.js +3 -3
  19. package/dist/cjs/src/generate/helpers/zustandBindings/writeInitializer.js +3 -2
  20. package/dist/cjs/src/generate/openapi/reduxBindings.js +2 -1
  21. package/dist/cjs/src/generate/openapi/zustandBindings.js +2 -1
  22. package/dist/cjs/src/generate/resource.js +9 -0
  23. package/dist/cjs/src/helpers/isSafeRedirectTarget.js +64 -0
  24. package/dist/cjs/src/helpers/validateOpenApiSchema.js +2 -2
  25. package/dist/cjs/src/i18n/provider.js +9 -0
  26. package/dist/cjs/src/openapi-renderer/SerializerOpenapiRenderer.js +13 -3
  27. package/dist/cjs/src/psychic-app/index.js +52 -10
  28. package/dist/cjs/src/router/helpers.js +4 -1
  29. package/dist/cjs/src/router/index.js +2 -1
  30. package/dist/cjs/src/server/index.js +17 -2
  31. package/dist/cjs/src/server/params.js +38 -0
  32. package/dist/cjs/src/session/index.js +6 -0
  33. package/dist/cjs/src/watcher/Watcher.js +2 -1
  34. package/dist/esm/src/bin/helpers/OpenApiSpecDiff.js +14 -2
  35. package/dist/esm/src/bin/index.js +14 -1
  36. package/dist/esm/src/cli/helpers/PackageManager.js +18 -14
  37. package/dist/esm/src/cli/index.js +2 -0
  38. package/dist/esm/src/controller/index.js +105 -19
  39. package/dist/esm/src/devtools/helpers/launchDevServer.js +4 -0
  40. package/dist/esm/src/encrypt/internal-encrypt.js +2 -16
  41. package/dist/esm/src/error/devtools/LaunchDevServerRequiresDevelopmentOrTest.js +22 -0
  42. package/dist/esm/src/error/http/InternalServerError.js +4 -0
  43. package/dist/esm/src/error/openapi/OpenApiSpecDiffRequiresDevelopmentOrTest.js +15 -0
  44. package/dist/esm/src/generate/controller.js +3 -1
  45. package/dist/esm/src/generate/helpers/generateControllerContent.js +33 -3
  46. package/dist/esm/src/generate/helpers/generateResourceControllerSpecContent.js +1 -23
  47. package/dist/esm/src/generate/helpers/paramSafeColumnNamesFromCliTokens.js +35 -0
  48. package/dist/esm/src/generate/helpers/parseAttribute.js +35 -0
  49. package/dist/esm/src/generate/helpers/reduxBindings/writeInitializer.js +3 -2
  50. package/dist/esm/src/generate/helpers/syncOpenapiTypescript/generateInitializer.js +2 -2
  51. package/dist/esm/src/generate/helpers/syncOpenapiTypescript/installOpenapiTypescript.js +3 -3
  52. package/dist/esm/src/generate/helpers/zustandBindings/writeInitializer.js +3 -2
  53. package/dist/esm/src/generate/openapi/reduxBindings.js +2 -1
  54. package/dist/esm/src/generate/openapi/zustandBindings.js +2 -1
  55. package/dist/esm/src/generate/resource.js +9 -0
  56. package/dist/esm/src/helpers/isSafeRedirectTarget.js +64 -0
  57. package/dist/esm/src/helpers/validateOpenApiSchema.js +2 -2
  58. package/dist/esm/src/i18n/provider.js +9 -0
  59. package/dist/esm/src/openapi-renderer/SerializerOpenapiRenderer.js +13 -3
  60. package/dist/esm/src/psychic-app/index.js +52 -10
  61. package/dist/esm/src/router/helpers.js +4 -1
  62. package/dist/esm/src/router/index.js +2 -1
  63. package/dist/esm/src/server/index.js +17 -2
  64. package/dist/esm/src/server/params.js +38 -0
  65. package/dist/esm/src/session/index.js +6 -0
  66. package/dist/esm/src/watcher/Watcher.js +2 -1
  67. package/dist/types/src/bin/helpers/OpenApiSpecDiff.d.ts +2 -1
  68. package/dist/types/src/bin/index.d.ts +2 -0
  69. package/dist/types/src/cli/helpers/PackageManager.d.ts +7 -3
  70. package/dist/types/src/cli/index.d.ts +2 -0
  71. package/dist/types/src/controller/index.d.ts +88 -15
  72. package/dist/types/src/encrypt/internal-encrypt.d.ts +1 -3
  73. package/dist/types/src/error/devtools/LaunchDevServerRequiresDevelopmentOrTest.d.ts +5 -0
  74. package/dist/types/src/error/http/InternalServerError.d.ts +4 -0
  75. package/dist/types/src/error/openapi/OpenApiSpecDiffRequiresDevelopmentOrTest.d.ts +3 -0
  76. package/dist/types/src/generate/controller.d.ts +3 -1
  77. package/dist/types/src/generate/helpers/generateControllerContent.d.ts +4 -1
  78. package/dist/types/src/generate/helpers/paramSafeColumnNamesFromCliTokens.d.ts +20 -0
  79. package/dist/types/src/generate/helpers/parseAttribute.d.ts +18 -0
  80. package/dist/types/src/generate/resource.d.ts +2 -0
  81. package/dist/types/src/helpers/isSafeRedirectTarget.d.ts +25 -0
  82. package/dist/types/src/helpers/validateOpenApiSchema.d.ts +4 -3
  83. package/dist/types/src/i18n/provider.d.ts +9 -0
  84. package/dist/types/src/psychic-app/index.d.ts +40 -7
  85. package/dist/types/src/server/params.d.ts +46 -0
  86. package/package.json +5 -4
@@ -1,4 +1,4 @@
1
- import { Ajv } from 'ajv';
1
+ import { Ajv2020 } from 'ajv/dist/2020.js';
2
2
  import addFormats from 'ajv-formats';
3
3
  /**
4
4
  * @internal
@@ -99,7 +99,7 @@ export function createValidator(schema, options = {}) {
99
99
  ...options,
100
100
  init: undefined,
101
101
  };
102
- const ajv = new Ajv({
102
+ const ajv = new Ajv2020({
103
103
  removeAdditional: false,
104
104
  useDefaults: true,
105
105
  strict: false,
@@ -12,6 +12,15 @@ export default class I18nProvider {
12
12
  *
13
13
  * @param allLocales - the list of all locales in your app. something like `{ en: { ... }, ['en-UK']: { ... }, es: { ... }, ... }`
14
14
  * @param singleLocaleKey - the key from the allLocales object that you want to use as your type base. i.e. 'en'
15
+ *
16
+ * Output-encoding note: interpolated values are substituted verbatim into the
17
+ * translation string. There is no HTML escaping at this layer — Psychic emits
18
+ * JSON, and the client renderer (React / Vue / Svelte / etc.) is responsible
19
+ * for context-appropriate escaping when the translated string enters a DOM.
20
+ * Do not pipe translated strings through `dangerouslySetInnerHTML` / `v-html` /
21
+ * `{@html}`. For rich-text translations, use structured dictionaries instead
22
+ * of HTML inside translation values. See `psychic-guides` → Security →
23
+ * Output Encoding & i18n.
15
24
  * */
16
25
  static provide(allLocales, singleLocaleKey) {
17
26
  return function i18n(locale, i18nPathString, interpolations) {
@@ -51,11 +51,15 @@ export default class SerializerOpenapiRenderer {
51
51
  alreadyExtractedDescendantSerializers[this.serializer.globalName] = true;
52
52
  const referencedSerializersAndOpenapiSchemaBodyShorthand = this._renderedOpenapi(alreadyExtractedDescendantSerializers);
53
53
  if (this.allOfSiblings.length) {
54
- const openapi = referencedSerializersAndOpenapiSchemaBodyShorthand.openapi;
54
+ // Property-level locks live only at the `allOf` wrapper (never on the
55
+ // inline branch or the `$ref`'d siblings) so that all branches'
56
+ // properties are visible to `unevaluatedProperties` for the union check.
55
57
  return {
56
58
  ...referencedSerializersAndOpenapiSchemaBodyShorthand,
57
59
  openapi: {
58
- allOf: [openapi, ...this.allOfSiblings],
60
+ type: 'object',
61
+ allOf: [referencedSerializersAndOpenapiSchemaBodyShorthand.openapi, ...this.allOfSiblings],
62
+ unevaluatedProperties: false,
59
63
  },
60
64
  };
61
65
  }
@@ -98,7 +102,13 @@ export default class SerializerOpenapiRenderer {
98
102
  type: 'object',
99
103
  required: sort(uniq(requiredProperties.map(property => this.setCase(property)))),
100
104
  properties: sortObjectByKey(referencedSerializersAndAttributes.attributes),
101
- additionalProperties: false,
105
+ // Property-level locks (`additionalProperties` / `unevaluatedProperties`)
106
+ // are not emitted on leaf schemas: when a leaf is composed via `$ref`
107
+ // inside an `allOf`, neither keyword sees properties contributed by
108
+ // sibling branches, so a per-leaf lock incorrectly rejects flattened
109
+ // properties. Strictness is enforced at the `allOf`-wrapper level
110
+ // (`unevaluatedProperties: false`) when flattening occurs, and at the
111
+ // validation-pipeline level for top-level schemas.
102
112
  },
103
113
  };
104
114
  }
@@ -166,30 +166,45 @@ export default class PsychicApp {
166
166
  /**
167
167
  * @internal
168
168
  *
169
- * adds the necessary package manager prefix to the psy command provided
170
- * i.e. `psyCmd('sync')`
169
+ * Returns the argv-form invocation for a `psy` subcommand, ready to pass
170
+ * to `DreamCLI.spawn(command, { args })`. Example: `psyCmd('sync')`
171
+ * `{ command: 'pnpm', args: ['psy', 'sync'] }` (under pnpm).
171
172
  */
172
- psyCmd(cmd) {
173
- return PackageManager.run(`psy ${cmd}`);
173
+ psyCmd(cmd, args = []) {
174
+ return PackageManager.run('psy', [cmd, ...args]);
174
175
  }
175
176
  /**
176
- * prints a warning in the console if the encryption key
177
- * is not valid for the provided algorithm.
177
+ * Throws (in production) or prints a warning (in non-production) if the
178
+ * encryption key is not valid for the provided algorithm.
179
+ *
180
+ * In production, a misconfigured key is a security hazard — the app would
181
+ * appear to boot successfully, then fail at runtime the first time a cookie
182
+ * is encrypted or decrypted, potentially leaving users with mysterious
183
+ * session errors long after the deploy. Fail-closed at boot instead.
184
+ *
185
+ * In development, continue to warn so that onboarding flows (where a
186
+ * placeholder key may briefly be in place while the env is being set up)
187
+ * are not broken by a hard failure.
178
188
  *
179
189
  * @param encryptionIdentifier - currently must be 'cookies', though this may change in the future
180
190
  * @param key - the encryption key you want to check
181
191
  * @param algorithm - the encryption algorithm you want to check
182
192
  */
183
193
  static checkEncryptionKey(encryptionIdentifier, key, algorithm) {
184
- if (!Encrypt.validateKey(key, algorithm))
185
- console.warn(`
194
+ if (Encrypt.validateKey(key, algorithm))
195
+ return;
196
+ const message = `
186
197
  Your current key value for ${encryptionIdentifier} encryption is invalid.
187
198
  Try setting it to something valid, like:
188
199
  ${Encrypt.generateKey(algorithm)}
189
200
 
190
201
  (This was done by calling:
191
202
  Encrypt.generateKey('${algorithm}')
192
- `);
203
+ `;
204
+ if (EnvInternal.isProduction) {
205
+ throw new Error(message);
206
+ }
207
+ console.warn(message);
193
208
  }
194
209
  /**
195
210
  * Returns the cached psychic application if it has been set.
@@ -249,7 +264,32 @@ Try setting it to something valid, like:
249
264
  get corsOptions() {
250
265
  return this._corsOptions;
251
266
  }
267
+ _redirectAllowedHosts = Object.freeze([]);
268
+ /**
269
+ * Hostnames allowlisted for absolute-URL redirects issued via
270
+ * controller `redirect()` / `found()` / `movedPermanently()` / etc.
271
+ * Same-origin relative paths are always permitted; absolute URLs are
272
+ * rejected unless their hostname appears here (case-insensitive). The
273
+ * default is an empty allowlist, meaning external redirects fail-closed.
274
+ */
275
+ get redirectAllowedHosts() {
276
+ return this._redirectAllowedHosts;
277
+ }
252
278
  _jsonOptions;
279
+ /**
280
+ * Options passed through to `koa-bodyparser`. When unset, the upstream
281
+ * defaults apply — notably `jsonLimit: '1mb'` and `formLimit: '56kb'`,
282
+ * which cap request-body size at the framework boundary and defend against
283
+ * unbounded-payload memory DoS even when no edge (WAF / API Gateway)
284
+ * enforces a cap.
285
+ *
286
+ * Configure via `psy.set('json', { jsonLimit: '256kb' })` to tighten.
287
+ * Values are spread-merged, so partial overrides preserve untouched limits.
288
+ *
289
+ * See https://github.com/koajs/bodyparser for the full option list
290
+ * (`jsonLimit`, `formLimit`, `textLimit`, `xmlLimit`, `enableTypes`,
291
+ * `strict`, `detectJSON`, etc.).
292
+ */
253
293
  get jsonOptions() {
254
294
  return this._jsonOptions;
255
295
  }
@@ -369,7 +409,6 @@ Try setting it to something valid, like:
369
409
  }
370
410
  _baseDefaultResponseHeaders = {
371
411
  ['cache-control']: 'max-age=0, private, must-revalidate',
372
- ['SameSite']: 'Strict',
373
412
  };
374
413
  _defaultResponseHeaders = {};
375
414
  get defaultResponseHeaders() {
@@ -505,6 +544,9 @@ Try setting it to something valid, like:
505
544
  case 'cors':
506
545
  this._corsOptions = { ...this.corsOptions, ...value };
507
546
  break;
547
+ case 'redirectAllowedHosts':
548
+ this._redirectAllowedHosts = Object.freeze([...value]);
549
+ break;
508
550
  case 'cookie':
509
551
  this._cookieOptions = {
510
552
  ...this.cookieOptions,
@@ -4,7 +4,10 @@ import CannotInferControllerFromTopLevelRouteError from '../error/router/cannot-
4
4
  import pascalizeFileName from '../helpers/pascalizeFileName.js';
5
5
  import PsychicApp from '../psychic-app/index.js';
6
6
  export function routePath(routePath) {
7
- return `/${routePath.replace(/^\//, '')}`;
7
+ const normalized = `/${routePath.replace(/^\//, '')}`;
8
+ if (normalized === '/')
9
+ return normalized;
10
+ return normalized.replace(/\/+$/, '');
8
11
  }
9
12
  export function resourcePath(routePath) {
10
13
  return `/${routePath}/:id`;
@@ -72,7 +72,8 @@ export default class PsychicRouter {
72
72
  prefixPathWithNamespaces(str) {
73
73
  if (!this.currentNamespaces.length)
74
74
  return str;
75
- return '/' + this.currentNamespacePaths.join('/') + '/' + str;
75
+ const prefix = '/' + this.currentNamespacePaths.join('/');
76
+ return str ? `${prefix}/${str}` : prefix;
76
77
  }
77
78
  crud(httpMethod, path, controllerOrMiddleware, action) {
78
79
  this.checkPathForInvalidChars(path);
@@ -86,7 +86,14 @@ export default class PsychicServer {
86
86
  setSecureDefaultHeaders() {
87
87
  // Koa doesn't send x-powered-by by default, no need to disable it.
88
88
  this.koaApp.use(async (ctx, next) => {
89
+ // Prevent MIME-sniffing; browsers must honor the declared Content-Type
90
+ // even if an endpoint accidentally serves something looking like HTML.
89
91
  ctx.set('X-Content-Type-Options', 'nosniff');
92
+ // Block cross-origin pages from embedding this response as a resource
93
+ // (<script src>, <img>, <link>, etc.), which raises the bar for
94
+ // speculative cross-origin reads (Spectre / XS-Leaks class). Applies
95
+ // to API responses regardless of Content-Type.
96
+ ctx.set('Cross-Origin-Resource-Policy', 'same-origin');
90
97
  if (EnvInternal.isProduction) {
91
98
  ctx.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
92
99
  }
@@ -155,8 +162,16 @@ export default class PsychicServer {
155
162
  this.koaApp = new Koa();
156
163
  }
157
164
  initializeCors() {
158
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
159
- this.koaApp.use(cors(PsychicApp.getOrFail().corsOptions));
165
+ const corsOptions = PsychicApp.getOrFail().corsOptions;
166
+ // When the app hasn't called psy.set('cors', ...), don't mount @koa/cors
167
+ // at all. @koa/cors with undefined options defaults `origin` to '*' and
168
+ // would emit Access-Control-Allow-Origin: * on every response — a
169
+ // foot-gun for an API serving user-scoped data. Not mounting leaves the
170
+ // response with no CORS headers, which the browser treats as same-origin
171
+ // only. Apps opt in to cross-origin by configuring cors explicitly.
172
+ if (corsOptions === undefined)
173
+ return;
174
+ this.koaApp.use(cors(corsOptions));
160
175
  }
161
176
  initializeJSON() {
162
177
  this.koaApp.use(koaBodyparser(PsychicApp.getOrFail().jsonOptions));
@@ -89,6 +89,44 @@ export default class Params {
89
89
  }
90
90
  return returnObj;
91
91
  }
92
+ /**
93
+ * ### .extract
94
+ *
95
+ * Typed + runtime-enforced allowlist extraction. Equivalent to
96
+ * {@link Params.for} with `only` moved to a required positional argument.
97
+ * Use from a controller via {@link PsychicController.extractParams}.
98
+ *
99
+ * The `allowed` array is constrained to `DreamParamSafeColumnNames<I>` at
100
+ * the type level, so protected columns (primary key, timestamps, belongs-to
101
+ * foreign keys, polymorphic type fields, STI `type` column, and any column
102
+ * in `explicitUnsafeParamColumns`) are TypeScript compile errors. At
103
+ * runtime, the existing intersection in `paramNamesForDreamClass` strips
104
+ * any column not also in the model's `paramSafeColumnsOrFallback()` set,
105
+ * so TS-bypass attempts still fail closed.
106
+ */
107
+ static extract(params, dreamClass, allowed, opts) {
108
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
109
+ return Params.for(params, dreamClass, { ...(opts ?? {}), only: allowed });
110
+ }
111
+ /**
112
+ * ### .extractImplicit
113
+ *
114
+ * Implicit-allowlist extraction driven by the model's `paramSafeColumns`
115
+ * declaration (or, when undeclared, the framework's default-safe fallback
116
+ * from `paramSafeColumnsOrFallback()`). Equivalent to {@link Params.for}
117
+ * without `only`. Use from a controller via
118
+ * {@link PsychicController.extractImplicitParams}.
119
+ *
120
+ * Prefer {@link Params.extract} when the caller can enumerate the allowed
121
+ * columns at the call site — it makes the allowlist visible to reviewers
122
+ * at the point of use. Reach for this method when the model-level
123
+ * `paramSafeColumns` declaration is the canonical allowlist and duplicating
124
+ * it at each call site would create maintenance drift.
125
+ */
126
+ static extractImplicit(params, dreamClass, opts) {
127
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
128
+ return Params.for(params, dreamClass, (opts ?? {}));
129
+ }
92
130
  static restrict(params, allowed) {
93
131
  if (params === null || params === undefined)
94
132
  return {};
@@ -18,6 +18,12 @@ export default class Session {
18
18
  ...opts,
19
19
  secure: opts.secure ?? EnvInternal.isProduction,
20
20
  httpOnly: opts.httpOnly ?? true,
21
+ // Psychic is a pure JSON API backend — it never renders HTML, so there
22
+ // is no link-click-navigates-to-Psychic UX that would need Lax. Strict
23
+ // blocks ALL cross-site cookie sending, which is the correct default
24
+ // for an API: the cookie should only ride requests that originated
25
+ // from our own client code, never from third-party pages.
26
+ sameSite: opts.sameSite ?? 'strict',
21
27
  maxAge: opts.maxAge
22
28
  ? cookieMaxAgeFromCookieOpts(opts.maxAge)
23
29
  : (PsychicApp.getOrFail().cookieOptions?.maxAge ?? cookieMaxAgeFromCookieOpts()),
@@ -27,7 +27,8 @@ export default class Watcher {
27
27
  const psy = PsychicApp.getOrFail();
28
28
  this.syncing = true;
29
29
  try {
30
- await DreamCLI.spawn(psy.psyCmd('sync'));
30
+ const { command, args } = psy.psyCmd('sync');
31
+ await DreamCLI.spawn(command, { args });
31
32
  }
32
33
  catch (err) {
33
34
  DreamCLI.logger.log(`ERROR!`, { logPrefixColor: 'red' });
@@ -81,7 +81,8 @@ export declare class OpenApiSpecDiff {
81
81
  /**
82
82
  * Detects oasdiff output that indicates no reportable changes for the
83
83
  * given subcommand. Newer oasdiff versions print informational messages
84
- * like "No changes detected" or "No changes to report, but the specs are
84
+ * like "No changes detected", "No changes to report, but the specs are
85
+ * different", or "No breaking changes to report, but the specs are
85
86
  * different" to stdout rather than returning empty output.
86
87
  */
87
88
  private isNoChangesOutput;
@@ -10,6 +10,8 @@ export default class PsychicBin {
10
10
  modelName?: string;
11
11
  tableName?: string;
12
12
  softDelete: boolean;
13
+ withExtractParams?: boolean;
14
+ withExtractImplicitParams?: boolean;
13
15
  }): Promise<void>;
14
16
  static printRoutes(): void;
15
17
  static printControllerHierarchy(controllersPath?: string): void;
@@ -1,8 +1,12 @@
1
+ export interface PackageManagerCommand {
2
+ command: string;
3
+ args: string[];
4
+ }
1
5
  export default class PackageManager {
2
6
  static get packageManager(): "pnpm" | "yarn" | "npm";
3
7
  static add(dependencyOrDependencies: string | string[], { dev }?: {
4
8
  dev?: boolean;
5
- }): string;
6
- static run(cmd: string): string;
7
- static exec(cmd: string): string;
9
+ }): PackageManagerCommand;
10
+ static run(cmd: string, args?: string[]): PackageManagerCommand;
11
+ static exec(cmd: string, args?: string[]): PackageManagerCommand;
8
12
  }
@@ -32,6 +32,8 @@ export default class PsychicCLI {
32
32
  tableName?: string;
33
33
  modelName?: string;
34
34
  softDelete: boolean;
35
+ withExtractParams?: boolean;
36
+ withExtractImplicitParams?: boolean;
35
37
  };
36
38
  columnsWithTypes: string[];
37
39
  }): Promise<void>;
@@ -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 { ParamsCastOptions, ParamsForOpts, ValidatedAllowsNull, ValidatedReturnType } from '../server/params.js';
8
+ import { 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;
@@ -251,10 +251,18 @@ export default class PsychicController {
251
251
  castParam<const EnumType extends readonly string[], OptsType extends StrictInterface<OptsType, ParamsCastOptions<EnumType>>, const ExpectedType extends PsychicParamsPrimitiveLiteral | RegExp | OpenapiSchemaBody>(key: string, expectedType: ExpectedType, opts?: OptsType): ValidatedAllowsNull<ExpectedType, OptsType> extends infer T ? T extends ValidatedAllowsNull<ExpectedType, OptsType> ? T extends true ? ValidatedReturnType<ExpectedType, OptsType> | null | undefined : ValidatedReturnType<ExpectedType, OptsType> : never : never;
252
252
  private _castParam;
253
253
  /**
254
- * Captures and validates parameters for the provided Dream model. Will exclude
255
- * parameters that are not considered "safe" by default (based on the model's paramSafeColumns).
256
- * Uses `castParam` for each parameter and will raise an exception if any parameter
257
- * fails validation.
254
+ * @deprecated Prefer {@link extractParams} (explicit allowlist at the call
255
+ * site) or {@link extractImplicitParams} (same implicit-allowlist behavior,
256
+ * new name). Security-relevant: on models with permission-bearing fields,
257
+ * `paramsFor(Model)` without `only` extracts every column in
258
+ * `paramSafeColumnsOrFallback()` — which may be too broad unless the model
259
+ * declares `paramSafeColumns` explicitly. `extractParams` makes the
260
+ * allowlist visible to reviewers at the call site.
261
+ *
262
+ * Captures and validates parameters for the provided Dream model. Will
263
+ * exclude parameters that are not considered "safe" by default (based on
264
+ * the model's paramSafeColumns). Uses `castParam` for each parameter and
265
+ * will raise an exception if any parameter fails validation.
258
266
  *
259
267
  * @param dreamClass - The Dream model class to retrieve params for
260
268
  * @param opts - Optional configuration object
@@ -269,17 +277,11 @@ export default class PsychicController {
269
277
  * ```ts
270
278
  * class MyController extends ApplicationController {
271
279
  * public create() {
272
- * // Get safe params for User model (excludes sensitive fields like createdAt)
273
- * const userParams = this.paramsFor(User)
274
- *
275
- * // Restrict to only specific fields
276
- * const restrictedParams = this.paramsFor(User, { only: ['email', 'name'] })
280
+ * // Preferred: explicit allowlist via extractParams
281
+ * const safe = this.extractParams(User, ['email', 'name'])
277
282
  *
278
- * // Include normally excluded fields
279
- * const extendedParams = this.paramsFor(User, { including: ['createdAt'] })
280
- *
281
- * // Extract from nested key
282
- * const nestedParams = this.paramsFor(User, { key: 'user' })
283
+ * // Equivalent of the legacy `this.paramsFor(User)` under the new name:
284
+ * const viaModel = this.extractImplicitParams(User)
283
285
  * }
284
286
  * }
285
287
  * ```
@@ -289,6 +291,77 @@ export default class PsychicController {
289
291
  }> : Partial<{
290
292
  [K in ParamSafeColumns[number & keyof ParamSafeColumns] & string]: DreamParamSafeAttributes<InstanceType<T>>[K & keyof DreamParamSafeAttributes<InstanceType<T>>];
291
293
  }>, ReturnPayload extends ForOpts['array'] extends true ? ReturnPartialType[] : ReturnPartialType>(this: PsychicController, dreamClass: T, opts?: ForOpts): ReturnPayload;
294
+ /**
295
+ * Captures and validates parameters for the provided Dream model using an
296
+ * explicit, required allowlist. This is the recommended primitive for
297
+ * controllers handling user-editable models — the allowlist is visible at
298
+ * the call site, so reviewers can see exactly which fields are accepted
299
+ * from the request.
300
+ *
301
+ * The `allowed` array is compile-time constrained to
302
+ * `DreamParamSafeColumnNames<InstanceType<T>>`, so protected columns
303
+ * (primary key, timestamps, belongs-to foreign keys, polymorphic type
304
+ * fields, STI `type` column, and any column in `explicitUnsafeParamColumns`)
305
+ * are TypeScript errors. At runtime, the intersection against the model's
306
+ * `paramSafeColumnsOrFallback()` further strips anything a caller bypasses
307
+ * the type system to include.
308
+ *
309
+ * Use {@link extractImplicitParams} when the model's declared
310
+ * `paramSafeColumns` is the canonical allowlist and duplicating it at each
311
+ * call site would create drift.
312
+ *
313
+ * @param dreamClass - The Dream model class to retrieve params for
314
+ * @param allowed - Required. The columns permitted from the request.
315
+ * @param opts - Optional configuration
316
+ * @param opts.key - Extract params from a nested key in the params object instead of root level
317
+ * @param opts.array - If true, expects and returns an array of param objects
318
+ * @returns A typed object containing the validated and casted params
319
+ * @throws {ParamValidationError} When any parameter validation fails
320
+ *
321
+ * @example
322
+ * ```ts
323
+ * class BalloonsController extends ApplicationController {
324
+ * public create() {
325
+ * const params = this.extractParams(Balloon, ['name', 'color'])
326
+ * const balloon = await Balloon.create(params)
327
+ * }
328
+ * }
329
+ * ```
330
+ */
331
+ 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<{
332
+ [K in AllowedArray[number] & keyof ParamSafeAttrs]: ParamSafeAttrs[K & keyof ParamSafeAttrs];
333
+ }>, ReturnPayload extends OptsType['array'] extends true ? ReturnPartial[] : ReturnPartial>(this: PsychicController, dreamClass: T, allowed: AllowedArray, opts?: OptsType): ReturnPayload;
334
+ /**
335
+ * Captures and validates parameters for the provided Dream model using the
336
+ * model's declared `paramSafeColumns` (or, when undeclared, the framework's
337
+ * default-safe fallback from `paramSafeColumnsOrFallback()`).
338
+ *
339
+ * Prefer {@link extractParams} when the caller can enumerate the allowed
340
+ * columns at the call site — explicit allowlists are more visible to
341
+ * reviewers. Reach for `extractImplicitParams` when the model-level
342
+ * declaration is the canonical allowlist and you want to avoid duplicating
343
+ * it at every call site.
344
+ *
345
+ * @param dreamClass - The Dream model class to retrieve params for
346
+ * @param opts - Optional configuration
347
+ * @param opts.key - Extract params from a nested key in the params object instead of root level
348
+ * @param opts.array - If true, expects and returns an array of param objects
349
+ * @returns A typed object containing the validated and casted params
350
+ * @throws {ParamValidationError} When any parameter validation fails
351
+ *
352
+ * @example
353
+ * ```ts
354
+ * class AdminBalloonsController extends ApplicationController {
355
+ * public create() {
356
+ * const params = this.extractImplicitParams(Balloon)
357
+ * const balloon = await Balloon.create(params)
358
+ * }
359
+ * }
360
+ * ```
361
+ */
362
+ extractImplicitParams<T extends typeof Dream, I extends InstanceType<T>, OptsType extends StrictInterface<OptsType, ExtractParamsOpts>, 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<I>, ReturnPartial extends Partial<{
363
+ [K in ParamSafeColumns[number & keyof ParamSafeColumns] & string]: ParamSafeAttrs[K & keyof ParamSafeAttrs];
364
+ }>, ReturnPayload extends OptsType['array'] extends true ? ReturnPartial[] : ReturnPartial>(this: PsychicController, dreamClass: T, opts?: OptsType): ReturnPayload;
292
365
  /**
293
366
  * Gets a cookie value from the request and casts it to the specified type.
294
367
  *
@@ -1,6 +1,4 @@
1
1
  export default class InternalEncrypt {
2
2
  static encryptCookie(data: any): string | null;
3
- static decryptCookie(data: any): unknown;
4
- private static doEncryption;
5
- private static doDecryption;
3
+ static decryptCookie<RetType>(data: any): RetType | null;
6
4
  }
@@ -0,0 +1,5 @@
1
+ export default class LaunchDevServerRequiresDevelopmentOrTest extends Error {
2
+ private cmd;
3
+ constructor(cmd: string);
4
+ get message(): string;
5
+ }
@@ -1,4 +1,8 @@
1
1
  import HttpError from './index.js';
2
+ /**
3
+ * 500 Internal Server Error. `data` is for server-side diagnostics only:
4
+ * it is logged but never sent to the client.
5
+ */
2
6
  export default class HttpStatusInternalServerError extends HttpError {
3
7
  get status(): number;
4
8
  }
@@ -0,0 +1,3 @@
1
+ export default class OpenApiSpecDiffRequiresDevelopmentOrTest extends Error {
2
+ get message(): string;
3
+ }
@@ -1,4 +1,5 @@
1
- export default function generateController({ fullyQualifiedControllerName, fullyQualifiedModelName, actions, columnsWithTypes, resourceSpecs, owningModel, singular, }: {
1
+ import { ParamExtractionStrategy } from './helpers/generateControllerContent.js';
2
+ export default function generateController({ fullyQualifiedControllerName, fullyQualifiedModelName, actions, columnsWithTypes, resourceSpecs, owningModel, singular, paramExtractionStrategy, }: {
2
3
  fullyQualifiedControllerName: string;
3
4
  fullyQualifiedModelName?: string;
4
5
  actions: string[];
@@ -6,4 +7,5 @@ export default function generateController({ fullyQualifiedControllerName, fully
6
7
  resourceSpecs?: boolean;
7
8
  owningModel?: string | undefined;
8
9
  singular: boolean;
10
+ paramExtractionStrategy?: ParamExtractionStrategy | undefined;
9
11
  }): Promise<void>;
@@ -1,4 +1,5 @@
1
- export default function generateControllerContent({ ancestorName, ancestorImportStatement, fullyQualifiedControllerName, fullyQualifiedModelName, actions, omitOpenApi, owningModel, forAdmin, forInternal, singular, }: {
1
+ export type ParamExtractionStrategy = 'explicit' | 'implicit';
2
+ export default function generateControllerContent({ ancestorName, ancestorImportStatement, fullyQualifiedControllerName, fullyQualifiedModelName, actions, omitOpenApi, owningModel, forAdmin, forInternal, singular, columnsWithTypes, paramExtractionStrategy, }: {
2
3
  ancestorName: string;
3
4
  ancestorImportStatement: string;
4
5
  fullyQualifiedControllerName: string;
@@ -9,4 +10,6 @@ export default function generateControllerContent({ ancestorName, ancestorImport
9
10
  forAdmin: boolean;
10
11
  forInternal?: boolean;
11
12
  singular: boolean;
13
+ columnsWithTypes?: string[];
14
+ paramExtractionStrategy?: ParamExtractionStrategy | undefined;
12
15
  }): string;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Derive the param-safe column names from the `columnsWithTypes` CLI tokens
3
+ * supplied to `psy g:resource`. Filters the same set Dream's
4
+ * `defaultParamSafeColumns()` filters at runtime
5
+ * (`dream/src/Dream.ts:826-844`) — accepted deliberate duplication, since
6
+ * the generator runs before a live model class exists (greenfield) and
7
+ * therefore cannot introspect via `ModelClass.paramSafeColumnsOrFallback()`.
8
+ *
9
+ * Delegates token parsing + camelCase normalization to the shared
10
+ * `parseAttribute` helper so the casing emitted into generated controllers
11
+ * matches what every other generator emits. Then drops the additional
12
+ * reserved names that `parseAttribute` does not filter on its own.
13
+ *
14
+ * Omits:
15
+ * - Reserved camelCase names: `id`, `createdAt`, `updatedAt`, `deletedAt`, `type`
16
+ * - Type annotation `:belongs_to` (foreign keys)
17
+ * - Name suffix `_type` (polymorphic type discriminators)
18
+ * - Name suffix `_id` (polymorphic foreign keys)
19
+ */
20
+ export default function paramSafeColumnNamesFromCliTokens(tokens: string[]): string[];
@@ -0,0 +1,18 @@
1
+ export interface ParsedAttribute {
2
+ attributeName: string;
3
+ attributeType: string;
4
+ isArray: boolean;
5
+ enumValues?: string | undefined;
6
+ }
7
+ /**
8
+ * Parse a `name:type[:enumName:enumValues]` CLI token into its camelCase
9
+ * attribute name and metadata. Returns `null` for tokens that should be
10
+ * dropped from the generated artifact (polymorphic `_type`/`_id` columns,
11
+ * `deletedAt`, or malformed tokens missing a name or type).
12
+ *
13
+ * Centralized so generators (controller scaffolds, resource specs, the
14
+ * paramSafe column allowlist) share one canonical interpretation of the
15
+ * tokens — and one canonical casing (camelCase) for the resulting attribute
16
+ * name.
17
+ */
18
+ export default function parseAttribute(attribute: string): ParsedAttribute | null;
@@ -12,6 +12,8 @@ export default function generateResource({ route, fullyQualifiedModelName, optio
12
12
  tableName?: string;
13
13
  modelName?: string;
14
14
  softDelete: boolean;
15
+ withExtractParams?: boolean;
16
+ withExtractImplicitParams?: boolean;
15
17
  };
16
18
  columnsWithTypes: string[];
17
19
  }): Promise<void>;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Returns true when `target` is a safe destination for an HTTP redirect.
3
+ *
4
+ * A safe target is either:
5
+ * - a same-origin relative path beginning with a single `/` (not `//`, not `/\`),
6
+ * with no control characters (CR/LF/NUL/etc.) that would enable response-splitting
7
+ * - an absolute `http:` or `https:` URL whose hostname exactly matches an entry in
8
+ * `opts.allowedHosts` (case-insensitive, port-insensitive — use explicit
9
+ * entries with a port if port-restriction is desired at a deeper layer)
10
+ *
11
+ * Everything else is rejected, including:
12
+ * - `javascript:`, `data:`, `vbscript:`, `file:` and other non-http(s) schemes
13
+ * - scheme-relative URLs (`//evil.com`) and their backslash variants (`/\evil.com`)
14
+ * - absolute URLs to hostnames not in the allowlist (even with userinfo tricks
15
+ * like `http://allowed.com@evil.com/…` — WHATWG URL parses the host as `evil.com`)
16
+ * - any input containing ASCII control characters, leading/trailing whitespace,
17
+ * or raw NULs
18
+ *
19
+ * The allowlist is purposely restrictive (no wildcards yet). Applications that
20
+ * legitimately redirect to external destinations register hosts via
21
+ * `PsychicApp.set('redirectAllowedHosts', ['oauth.example.com'])`.
22
+ */
23
+ export default function isSafeRedirectTarget(target: unknown, opts: {
24
+ allowedHosts: readonly string[];
25
+ }): boolean;
@@ -1,4 +1,5 @@
1
- import { Ajv, type ErrorObject, type JSONSchemaType, type ValidateFunction } from 'ajv';
1
+ import { type ErrorObject, type JSONSchemaType, type ValidateFunction } from 'ajv';
2
+ import { Ajv2020 } from 'ajv/dist/2020.js';
2
3
  /**
3
4
  * @internal
4
5
  *
@@ -72,8 +73,8 @@ export interface ValidationError {
72
73
  message: string;
73
74
  params?: Record<string, unknown>;
74
75
  }
75
- export type AjvValidationOpts = ConstructorParameters<typeof Ajv>[0];
76
+ export type AjvValidationOpts = ConstructorParameters<typeof Ajv2020>[0];
76
77
  export type ValidateOpenapiSchemaOptions = AjvValidationOpts & CustomOpenapiValidationOptions;
77
78
  export interface CustomOpenapiValidationOptions {
78
- init?: (ajv: Ajv) => void;
79
+ init?: (ajv: Ajv2020) => void;
79
80
  }