@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
|
@@ -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' });
|
|
@@ -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"
|
|
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
|
-
}):
|
|
6
|
-
static run(cmd: string):
|
|
7
|
-
static exec(cmd: string):
|
|
9
|
+
}): PackageManagerCommand;
|
|
10
|
+
static run(cmd: string, args?: string[]): PackageManagerCommand;
|
|
11
|
+
static exec(cmd: string, args?: string[]): PackageManagerCommand;
|
|
8
12
|
}
|
|
@@ -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
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
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
|
-
* //
|
|
273
|
-
* const
|
|
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
|
-
* //
|
|
279
|
-
* const
|
|
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,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
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
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
|
|
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 {
|
|
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
|
|
76
|
+
export type AjvValidationOpts = ConstructorParameters<typeof Ajv2020>[0];
|
|
76
77
|
export type ValidateOpenapiSchemaOptions = AjvValidationOpts & CustomOpenapiValidationOptions;
|
|
77
78
|
export interface CustomOpenapiValidationOptions {
|
|
78
|
-
init?: (ajv:
|
|
79
|
+
init?: (ajv: Ajv2020) => void;
|
|
79
80
|
}
|