@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
@@ -16,7 +16,7 @@ export default async function generateInitializer(openapiFilepath, outfile, init
16
16
  catch {
17
17
  await fs.mkdir(destDir, { recursive: true });
18
18
  }
19
- const execCmd = PackageManager.exec(`openapi-typescript ${openapiFilepath} -o ${outfile}`);
19
+ const { command, args } = PackageManager.exec('openapi-typescript', [openapiFilepath, '-o', outfile]);
20
20
  const contents = `\
21
21
  import { DreamCLI } from '@rvoh/dream/system'
22
22
  import { PsychicApp } from "@rvoh/psychic"
@@ -26,7 +26,7 @@ export default (psy: PsychicApp) => {
26
26
  psy.on('cli:sync', async () => {
27
27
  if (AppEnv.isDevelopmentOrTest) {
28
28
  await DreamCLI.logger.logProgress(\`[${hyphenized}] extracting types from ${openapiFilepath} to ${outfile}...\`, async () => {
29
- await DreamCLI.spawn('${execCmd}')
29
+ await DreamCLI.spawn('${command}', { args: ${JSON.stringify(args)} })
30
30
  })
31
31
  }
32
32
  })
@@ -4,14 +4,14 @@ import EnvInternal from '../../../helpers/EnvInternal.js';
4
4
  export default async function installOpenapiTypescript() {
5
5
  if (EnvInternal.isTest)
6
6
  return;
7
- const cmd = PackageManager.add('openapi-typescript', { dev: true });
7
+ const { command, args } = PackageManager.add('openapi-typescript', { dev: true });
8
8
  try {
9
- await DreamCLI.spawn(cmd);
9
+ await DreamCLI.spawn(command, { args });
10
10
  }
11
11
  catch {
12
12
  console.log(`Failed to install openapi-typescript as a dev dependency. Please make sure the following command succeeds:
13
13
 
14
- "${cmd}"
14
+ "${command} ${args.join(' ')}"
15
15
  `);
16
16
  }
17
17
  }
@@ -6,7 +6,7 @@ import psychicPath from '../../../helpers/path/psychicPath.js';
6
6
  export default async function writeInitializer({ exportName, schemaFile, outputDir, }) {
7
7
  const pascalized = pascalize(exportName);
8
8
  const camelized = camelize(exportName);
9
- const execCmd = PackageManager.exec(`openapi-ts -i ${schemaFile} -o ${outputDir}`);
9
+ const { command, args } = PackageManager.exec('openapi-ts', ['-i', schemaFile, '-o', outputDir]);
10
10
  const destDir = path.join(psychicPath('conf'), 'initializers', 'openapi');
11
11
  const initializerFilename = `${camelized}.ts`;
12
12
  const initializerPath = path.join(destDir, initializerFilename);
@@ -20,7 +20,8 @@ export default function initialize${pascalized}(psy: PsychicApp) {
20
20
  psy.on('cli:sync', async () => {
21
21
  if (AppEnv.isDevelopmentOrTest) {
22
22
  await DreamCLI.logger.logProgress(\`[${camelized}] syncing...\`, async () => {
23
- await DreamCLI.spawn('${execCmd}', {
23
+ await DreamCLI.spawn('${command}', {
24
+ args: ${JSON.stringify(args)},
24
25
  onStdout: message => {
25
26
  DreamCLI.logger.logContinueProgress(\`[${camelized}]\` + ' ' + message, {
26
27
  logPrefixColor: 'green',
@@ -25,6 +25,7 @@ export default async function generateOpenapiReduxBindings(options = {}) {
25
25
  await writeOpenapiJsonFile(opts);
26
26
  await writeApiFile(opts);
27
27
  await writeInitializer(opts);
28
- await DreamCLI.spawn(PackageManager.add(['@rtk-query/codegen-openapi', 'ts-node'], { dev: true }));
28
+ const { command, args } = PackageManager.add(['@rtk-query/codegen-openapi', 'ts-node'], { dev: true });
29
+ await DreamCLI.spawn(command, { args });
29
30
  printFinalStepsMessage(opts);
30
31
  }
@@ -23,6 +23,7 @@ export default async function generateOpenapiZustandBindings(options = {}) {
23
23
  const opts = await promptForOptions(options);
24
24
  await writeClientConfigFile(opts);
25
25
  await writeInitializer(opts);
26
- await DreamCLI.spawn(PackageManager.add(['@hey-api/openapi-ts'], { dev: true }));
26
+ const { command, args } = PackageManager.add(['@hey-api/openapi-ts'], { dev: true });
27
+ await DreamCLI.spawn(command, { args });
27
28
  printFinalStepsMessage(opts);
28
29
  }
@@ -32,6 +32,14 @@ export default async function generateResource({ route, fullyQualifiedModelName,
32
32
  softDelete: options.softDelete,
33
33
  },
34
34
  });
35
+ if (options.withExtractParams && options.withExtractImplicitParams) {
36
+ throw new Error('--with-extract-params and --with-extract-implicit-params are mutually exclusive; pass only one.');
37
+ }
38
+ const paramExtractionStrategy = options.withExtractParams
39
+ ? 'explicit'
40
+ : options.withExtractImplicitParams
41
+ ? 'implicit'
42
+ : undefined;
35
43
  await generateController({
36
44
  fullyQualifiedControllerName,
37
45
  fullyQualifiedModelName,
@@ -42,6 +50,7 @@ export default async function generateResource({ route, fullyQualifiedModelName,
42
50
  resourceSpecs: true,
43
51
  singular: options.singular,
44
52
  owningModel: options.owningModel,
53
+ paramExtractionStrategy,
45
54
  });
46
55
  await addResourceToRoutes(route, {
47
56
  singular: options.singular,
@@ -0,0 +1,64 @@
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, opts) {
24
+ if (typeof target !== 'string')
25
+ return false;
26
+ if (target.length === 0)
27
+ return false;
28
+ // Reject any control character (CR, LF, NUL, BEL, DEL …). Without this,
29
+ // `\r\n` in the Location value enables header / response splitting.
30
+ // eslint-disable-next-line no-control-regex
31
+ if (/[\x00-\x1F\x7F]/.test(target))
32
+ return false;
33
+ // Whitespace padding is used to defeat simple prefix checks; require a
34
+ // clean input rather than silently trimming.
35
+ if (target !== target.trim())
36
+ return false;
37
+ // Scheme-relative URLs and their backslash variants are treated by browsers
38
+ // as network-path references and would escape the origin.
39
+ if (target.startsWith('//'))
40
+ return false;
41
+ if (target.startsWith('\\'))
42
+ return false;
43
+ if (target.startsWith('/\\'))
44
+ return false;
45
+ if (target.startsWith('/%5C') || target.startsWith('/%5c'))
46
+ return false;
47
+ // Relative same-origin path: must begin with exactly one '/'.
48
+ if (target.startsWith('/'))
49
+ return true;
50
+ // Absolute URLs are validated via the WHATWG URL parser. This normalizes
51
+ // userinfo / percent-encoding / unicode-homograph tricks so the allowlist
52
+ // check runs against the parsed hostname rather than a raw substring.
53
+ let parsed;
54
+ try {
55
+ parsed = new URL(target);
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
61
+ return false;
62
+ const hostname = parsed.hostname.toLowerCase();
63
+ return opts.allowedHosts.some(allowed => allowed.toLowerCase() === hostname);
64
+ }
@@ -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' });
@@ -3,6 +3,8 @@ import * as cp from 'node:child_process';
3
3
  import * as fs from 'node:fs';
4
4
  import * as path from 'node:path';
5
5
  import colorize from '../../cli/helpers/colorize.js';
6
+ import OpenApiSpecDiffRequiresDevelopmentOrTest from '../../error/openapi/OpenApiSpecDiffRequiresDevelopmentOrTest.js';
7
+ import EnvInternal from '../../helpers/EnvInternal.js';
6
8
  import PsychicApp from '../../psychic-app/index.js';
7
9
  /**
8
10
  * Class-based OpenAPI specification diff tool
@@ -45,6 +47,8 @@ export class OpenApiSpecDiff {
45
47
  * ```
46
48
  */
47
49
  compare(openapiConfigs) {
50
+ if (!EnvInternal.isDevelopmentOrTest)
51
+ throw new OpenApiSpecDiffRequiresDevelopmentOrTest();
48
52
  const results = [];
49
53
  this.oasdiffConfig = this.getOasDiffConfig();
50
54
  const comparing = colorize(`🔍 Comparing current OpenAPI Specs against ${this.oasdiffConfig.headBranch}...`, { color: 'cyanBright' });
@@ -115,6 +119,11 @@ export class OpenApiSpecDiff {
115
119
  args.push(...flags.map(flag => `--${flag}`));
116
120
  }
117
121
  try {
122
+ // shell: true is intentional — `oasdiff` may be installed as a `.cmd` shim
123
+ // on Windows, which `execFile` cannot invoke directly. This helper runs
124
+ // only via `pnpm psy openapi:spec-diff` (developer / CI invocation), never
125
+ // in a deployed process; `args` are literal subcommand strings plus
126
+ // developer-controlled file paths, not request input. See docs/SECURITY_CVE_CHECKLIST.md R-026.
118
127
  const output = cp.execFileSync(this.oasdiffConfig.command, args, {
119
128
  shell: true,
120
129
  encoding: 'utf8',
@@ -131,12 +140,15 @@ export class OpenApiSpecDiff {
131
140
  /**
132
141
  * Detects oasdiff output that indicates no reportable changes for the
133
142
  * given subcommand. Newer oasdiff versions print informational messages
134
- * like "No changes detected" or "No changes to report, but the specs are
143
+ * like "No changes detected", "No changes to report, but the specs are
144
+ * different", or "No breaking changes to report, but the specs are
135
145
  * different" to stdout rather than returning empty output.
136
146
  */
137
147
  isNoChangesOutput(output) {
138
148
  const trimmed = output.trim();
139
- return trimmed === 'No changes detected' || trimmed.startsWith('No changes to report');
149
+ return (trimmed === 'No changes detected' ||
150
+ trimmed.startsWith('No changes to report') ||
151
+ trimmed.startsWith('No breaking changes to report'));
140
152
  }
141
153
  /**
142
154
  * Compares two OpenAPI files using oasdiff
@@ -4,6 +4,7 @@ import * as path from 'node:path';
4
4
  import ASTPsychicTypesBuilder from '../cli/helpers/ASTPsychicTypesBuilder.js';
5
5
  import generateController from '../generate/controller.js';
6
6
  import generateResource from '../generate/resource.js';
7
+ import EnvInternal from '../helpers/EnvInternal.js';
7
8
  import isObject from '../helpers/isObject.js';
8
9
  import OpenapiAppRenderer from '../openapi-renderer/app.js';
9
10
  import PsychicApp from '../psychic-app/index.js';
@@ -34,6 +35,11 @@ export default class PsychicBin {
34
35
  return controllerHierarchyViolations(controllersPath);
35
36
  }
36
37
  static async sync({ bypassDreamSync = false, schemaOnly = false, } = {}) {
38
+ if (!EnvInternal.isTest) {
39
+ DreamCLI.logger.logStartProgress(`skipping sync: auto-generated type/schema files are only built when NODE_ENV=test (current NODE_ENV: ${process.env.NODE_ENV ?? 'unset'}). Run with NODE_ENV=test to regenerate.`);
40
+ DreamCLI.logger.logEndProgress();
41
+ return;
42
+ }
37
43
  if (!bypassDreamSync)
38
44
  await DreamBin.sync(() => { }, { schemaOnly });
39
45
  if (schemaOnly)
@@ -43,7 +49,9 @@ export default class PsychicBin {
43
49
  DreamCLI.logger.logStartProgress('running post-sync operations...');
44
50
  // call post-sync command in a separate process, so that newly-generated
45
51
  // types can be reloaded and brought into all classes.
46
- await DreamCLI.spawn(psychicApp.psyCmd('post-sync'), {
52
+ const { command, args } = psychicApp.psyCmd('post-sync');
53
+ await DreamCLI.spawn(command, {
54
+ args,
47
55
  onStdout: message => {
48
56
  DreamCLI.logger.logContinueProgress(`[post-sync]` + ' ' + message, {
49
57
  logPrefixColor: 'greenBright',
@@ -53,6 +61,11 @@ export default class PsychicBin {
53
61
  DreamCLI.logger.logEndProgress();
54
62
  }
55
63
  static async postSync() {
64
+ if (!EnvInternal.isTest) {
65
+ DreamCLI.logger.logStartProgress(`skipping post-sync: auto-generated type/schema files are only built when NODE_ENV=test (current NODE_ENV: ${process.env.NODE_ENV ?? 'unset'}). Run with NODE_ENV=test to regenerate.`);
66
+ DreamCLI.logger.logEndProgress();
67
+ return;
68
+ }
56
69
  await this.syncOpenapiJson();
57
70
  await this.runCliHooksAndUpdatePsychicTypesFileWithOutput();
58
71
  await this.syncOpenapiTypescriptFiles();
@@ -4,42 +4,46 @@ export default class PackageManager {
4
4
  return PsychicApp.getOrFail().packageManager;
5
5
  }
6
6
  static add(dependencyOrDependencies, { dev } = {}) {
7
- const dependency = Array.isArray(dependencyOrDependencies)
8
- ? dependencyOrDependencies.join(' ')
9
- : dependencyOrDependencies;
7
+ const list = Array.isArray(dependencyOrDependencies)
8
+ ? dependencyOrDependencies
9
+ : [dependencyOrDependencies];
10
10
  if (dev) {
11
11
  switch (this.packageManager) {
12
12
  case 'npm':
13
- return `${this.packageManager} install --save-dev ${dependency}`;
13
+ return { command: 'npm', args: ['install', '--save-dev', ...list] };
14
14
  default:
15
- return `${this.packageManager} add -D ${dependency}`;
15
+ return { command: this.packageManager, args: ['add', '-D', ...list] };
16
16
  }
17
17
  }
18
18
  else {
19
19
  switch (this.packageManager) {
20
20
  case 'npm':
21
- return `${this.packageManager} install ${dependency}`;
21
+ return { command: 'npm', args: ['install', ...list] };
22
22
  default:
23
- return `${this.packageManager} add ${dependency}`;
23
+ return { command: this.packageManager, args: ['add', ...list] };
24
24
  }
25
25
  }
26
26
  }
27
- static run(cmd) {
27
+ static run(cmd, args = []) {
28
28
  switch (this.packageManager) {
29
29
  case 'npm':
30
- return `npm run ${cmd}`;
30
+ // npm requires `--` to separate npm args from script args
31
+ return {
32
+ command: 'npm',
33
+ args: args.length ? ['run', cmd, '--', ...args] : ['run', cmd],
34
+ };
31
35
  default:
32
- return `${this.packageManager} ${cmd}`;
36
+ return { command: this.packageManager, args: [cmd, ...args] };
33
37
  }
34
38
  }
35
- static exec(cmd) {
39
+ static exec(cmd, args = []) {
36
40
  switch (this.packageManager) {
37
41
  case 'npm':
38
- return `npm exec -- ${cmd}`;
42
+ return { command: 'npm', args: ['exec', '--', cmd, ...args] };
39
43
  case 'yarn':
40
- return `yarn ${cmd}`;
44
+ return { command: 'yarn', args: [cmd, ...args] };
41
45
  default:
42
- return `${this.packageManager} exec ${cmd}`;
46
+ return { command: this.packageManager, args: ['exec', cmd, ...args] };
43
47
  }
44
48
  }
45
49
  }
@@ -141,6 +141,8 @@ ${INDENT}Example:
141
141
  ${INDENT} pnpm psy g:resource --model-name=GroupDanceLesson v1/lessons/dance/groups Lesson/Dance/Group
142
142
  ${INDENT} # model is named GroupDanceLesson instead of LessonDanceGroup`)
143
143
  .option('--no-soft-delete', `skip generating the @SoftDelete() decorator and the corresponding nullable \`deleted_at\` column. By default, generated models use soft-delete semantics (rows are marked deleted via \`deleted_at\` instead of being removed from the database). Pass this flag when you want records to be hard-deleted.`)
144
+ .option('--with-extract-params', `override the default scaffold to emit \`this.extractParams(Model, [...])\` (explicit allowlist). Only valid for admin-namespaced resources, which otherwise default to \`this.extractImplicitParams(Model)\`. Mutually exclusive with --with-extract-implicit-params.`, false)
145
+ .option('--with-extract-implicit-params', `override the default scaffold to emit \`this.extractImplicitParams(Model)\` (model-declared allowlist). Only useful for non-admin-namespaced resources, which otherwise default to \`this.extractParams(Model, [...])\`. Mutually exclusive with --with-extract-params.`, false)
144
146
  .argument('<path>', `The URL path for this resource's routes, relative to the root domain. Use \`\\{\\}\` as a placeholder for a parent resource's ID parameter when nesting.
145
147
  ${INDENT}
146
148
  ${INDENT}The path determines the controller namespace hierarchy. Paths that begin with "admin" and "internal" remove the \`currentUser\` scoping of queries (\`--owning-model\` may be provided to apply query scoping). Each segment maps to a directory level in the controllers folder.