@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.
- package/dist/cjs/src/bin/helpers/OpenApiSpecDiff.js +14 -2
- package/dist/cjs/src/bin/index.js +14 -1
- package/dist/cjs/src/cli/helpers/PackageManager.js +18 -14
- package/dist/cjs/src/cli/index.js +2 -0
- package/dist/cjs/src/controller/index.js +105 -19
- package/dist/cjs/src/devtools/helpers/launchDevServer.js +4 -0
- package/dist/cjs/src/encrypt/internal-encrypt.js +2 -16
- package/dist/cjs/src/error/devtools/LaunchDevServerRequiresDevelopmentOrTest.js +22 -0
- package/dist/cjs/src/error/http/InternalServerError.js +4 -0
- package/dist/cjs/src/error/openapi/OpenApiSpecDiffRequiresDevelopmentOrTest.js +15 -0
- package/dist/cjs/src/generate/controller.js +3 -1
- package/dist/cjs/src/generate/helpers/generateControllerContent.js +33 -3
- package/dist/cjs/src/generate/helpers/generateResourceControllerSpecContent.js +1 -23
- package/dist/cjs/src/generate/helpers/paramSafeColumnNamesFromCliTokens.js +35 -0
- package/dist/cjs/src/generate/helpers/parseAttribute.js +35 -0
- package/dist/cjs/src/generate/helpers/reduxBindings/writeInitializer.js +3 -2
- package/dist/cjs/src/generate/helpers/syncOpenapiTypescript/generateInitializer.js +2 -2
- package/dist/cjs/src/generate/helpers/syncOpenapiTypescript/installOpenapiTypescript.js +3 -3
- package/dist/cjs/src/generate/helpers/zustandBindings/writeInitializer.js +3 -2
- package/dist/cjs/src/generate/openapi/reduxBindings.js +2 -1
- package/dist/cjs/src/generate/openapi/zustandBindings.js +2 -1
- package/dist/cjs/src/generate/resource.js +9 -0
- package/dist/cjs/src/helpers/isSafeRedirectTarget.js +64 -0
- package/dist/cjs/src/helpers/validateOpenApiSchema.js +2 -2
- package/dist/cjs/src/i18n/provider.js +9 -0
- package/dist/cjs/src/openapi-renderer/SerializerOpenapiRenderer.js +13 -3
- package/dist/cjs/src/psychic-app/index.js +52 -10
- package/dist/cjs/src/router/helpers.js +4 -1
- package/dist/cjs/src/router/index.js +2 -1
- package/dist/cjs/src/server/index.js +17 -2
- package/dist/cjs/src/server/params.js +38 -0
- package/dist/cjs/src/session/index.js +6 -0
- package/dist/cjs/src/watcher/Watcher.js +2 -1
- package/dist/esm/src/bin/helpers/OpenApiSpecDiff.js +14 -2
- package/dist/esm/src/bin/index.js +14 -1
- package/dist/esm/src/cli/helpers/PackageManager.js +18 -14
- package/dist/esm/src/cli/index.js +2 -0
- package/dist/esm/src/controller/index.js +105 -19
- package/dist/esm/src/devtools/helpers/launchDevServer.js +4 -0
- package/dist/esm/src/encrypt/internal-encrypt.js +2 -16
- package/dist/esm/src/error/devtools/LaunchDevServerRequiresDevelopmentOrTest.js +22 -0
- package/dist/esm/src/error/http/InternalServerError.js +4 -0
- package/dist/esm/src/error/openapi/OpenApiSpecDiffRequiresDevelopmentOrTest.js +15 -0
- package/dist/esm/src/generate/controller.js +3 -1
- package/dist/esm/src/generate/helpers/generateControllerContent.js +33 -3
- package/dist/esm/src/generate/helpers/generateResourceControllerSpecContent.js +1 -23
- package/dist/esm/src/generate/helpers/paramSafeColumnNamesFromCliTokens.js +35 -0
- package/dist/esm/src/generate/helpers/parseAttribute.js +35 -0
- package/dist/esm/src/generate/helpers/reduxBindings/writeInitializer.js +3 -2
- package/dist/esm/src/generate/helpers/syncOpenapiTypescript/generateInitializer.js +2 -2
- package/dist/esm/src/generate/helpers/syncOpenapiTypescript/installOpenapiTypescript.js +3 -3
- package/dist/esm/src/generate/helpers/zustandBindings/writeInitializer.js +3 -2
- package/dist/esm/src/generate/openapi/reduxBindings.js +2 -1
- package/dist/esm/src/generate/openapi/zustandBindings.js +2 -1
- package/dist/esm/src/generate/resource.js +9 -0
- package/dist/esm/src/helpers/isSafeRedirectTarget.js +64 -0
- package/dist/esm/src/helpers/validateOpenApiSchema.js +2 -2
- package/dist/esm/src/i18n/provider.js +9 -0
- package/dist/esm/src/openapi-renderer/SerializerOpenapiRenderer.js +13 -3
- package/dist/esm/src/psychic-app/index.js +52 -10
- package/dist/esm/src/router/helpers.js +4 -1
- package/dist/esm/src/router/index.js +2 -1
- package/dist/esm/src/server/index.js +17 -2
- package/dist/esm/src/server/params.js +38 -0
- package/dist/esm/src/session/index.js +6 -0
- package/dist/esm/src/watcher/Watcher.js +2 -1
- package/dist/types/src/bin/helpers/OpenApiSpecDiff.d.ts +2 -1
- package/dist/types/src/bin/index.d.ts +2 -0
- package/dist/types/src/cli/helpers/PackageManager.d.ts +7 -3
- package/dist/types/src/cli/index.d.ts +2 -0
- package/dist/types/src/controller/index.d.ts +88 -15
- package/dist/types/src/encrypt/internal-encrypt.d.ts +1 -3
- package/dist/types/src/error/devtools/LaunchDevServerRequiresDevelopmentOrTest.d.ts +5 -0
- package/dist/types/src/error/http/InternalServerError.d.ts +4 -0
- package/dist/types/src/error/openapi/OpenApiSpecDiffRequiresDevelopmentOrTest.d.ts +3 -0
- package/dist/types/src/generate/controller.d.ts +3 -1
- package/dist/types/src/generate/helpers/generateControllerContent.d.ts +4 -1
- package/dist/types/src/generate/helpers/paramSafeColumnNamesFromCliTokens.d.ts +20 -0
- package/dist/types/src/generate/helpers/parseAttribute.d.ts +18 -0
- package/dist/types/src/generate/resource.d.ts +2 -0
- package/dist/types/src/helpers/isSafeRedirectTarget.d.ts +25 -0
- package/dist/types/src/helpers/validateOpenApiSchema.d.ts +4 -3
- package/dist/types/src/i18n/provider.d.ts +9 -0
- package/dist/types/src/psychic-app/index.d.ts +40 -7
- package/dist/types/src/server/params.d.ts +46 -0
- 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
|
|
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('${
|
|
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
|
|
7
|
+
const { command, args } = PackageManager.add('openapi-typescript', { dev: true });
|
|
8
8
|
try {
|
|
9
|
-
await DreamCLI.spawn(
|
|
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
|
-
"${
|
|
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
|
|
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('${
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
170
|
-
*
|
|
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(
|
|
173
|
+
psyCmd(cmd, args = []) {
|
|
174
|
+
return PackageManager.run('psy', [cmd, ...args]);
|
|
174
175
|
}
|
|
175
176
|
/**
|
|
176
|
-
* prints a warning in
|
|
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 (
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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"
|
|
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' ||
|
|
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
|
-
|
|
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
|
|
8
|
-
? dependencyOrDependencies
|
|
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
|
|
13
|
+
return { command: 'npm', args: ['install', '--save-dev', ...list] };
|
|
14
14
|
default:
|
|
15
|
-
return
|
|
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
|
|
21
|
+
return { command: 'npm', args: ['install', ...list] };
|
|
22
22
|
default:
|
|
23
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
|
42
|
+
return { command: 'npm', args: ['exec', '--', cmd, ...args] };
|
|
39
43
|
case 'yarn':
|
|
40
|
-
return
|
|
44
|
+
return { command: 'yarn', args: [cmd, ...args] };
|
|
41
45
|
default:
|
|
42
|
-
return
|
|
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.
|