@rvoh/psychic 3.3.0 → 3.4.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 +1 -0
- package/dist/cjs/src/cli/index.js +11 -4
- package/dist/cjs/src/controller/index.js +7 -48
- package/dist/cjs/src/generate/controller.js +1 -2
- package/dist/cjs/src/generate/helpers/generateControllerContent.js +27 -31
- package/dist/cjs/src/generate/helpers/parseAttribute.js +34 -4
- package/dist/cjs/src/generate/resource.js +0 -9
- package/dist/cjs/src/server/params.js +0 -19
- package/dist/esm/src/bin/helpers/OpenApiSpecDiff.js +1 -0
- package/dist/esm/src/cli/index.js +11 -4
- package/dist/esm/src/controller/index.js +7 -48
- package/dist/esm/src/generate/controller.js +1 -2
- package/dist/esm/src/generate/helpers/generateControllerContent.js +27 -31
- package/dist/esm/src/generate/helpers/parseAttribute.js +34 -4
- package/dist/esm/src/generate/resource.js +0 -9
- package/dist/esm/src/server/params.js +0 -19
- package/dist/types/src/bin/index.d.ts +0 -2
- package/dist/types/src/cli/index.d.ts +0 -2
- package/dist/types/src/controller/index.d.ts +7 -46
- package/dist/types/src/generate/controller.d.ts +1 -3
- package/dist/types/src/generate/helpers/generateControllerContent.d.ts +1 -3
- package/dist/types/src/generate/helpers/parseAttribute.d.ts +9 -0
- package/dist/types/src/generate/resource.d.ts +0 -2
- package/dist/types/src/server/params.d.ts +3 -22
- package/package.json +5 -5
|
@@ -8,7 +8,7 @@ import generateOpenapiZustandBindings from '../generate/openapi/zustandBindings.
|
|
|
8
8
|
import generateResource from '../generate/resource.js';
|
|
9
9
|
import Watcher from '../watcher/Watcher.js';
|
|
10
10
|
const INDENT = ' ';
|
|
11
|
-
const baseColumnsWithTypesDescription = `space separated snake-case (except for belongs_to model name) properties like this:
|
|
11
|
+
const baseColumnsWithTypesDescription = `space separated snake-case (except for belongs_to model name, which may take an @alias suffix to rename the FK) properties like this:
|
|
12
12
|
${INDENT} title:citext subtitle:string body_markdown:text style:enum:post_styles:formal,informal User:belongs_to
|
|
13
13
|
${INDENT}
|
|
14
14
|
${INDENT}all properties default to not nullable; null can be allowed by appending ':optional':
|
|
@@ -68,7 +68,16 @@ ${INDENT}
|
|
|
68
68
|
${INDENT} use the fully qualified model name (matching its path under src/app/models/):
|
|
69
69
|
${INDENT} User:belongs_to # creates user_id column + BelongsTo association
|
|
70
70
|
${INDENT} Health/Coach:belongs_to # creates health_coach_id column + BelongsTo association
|
|
71
|
-
${INDENT} User:belongs_to:optional # nullable foreign key (for optional associations)
|
|
71
|
+
${INDENT} User:belongs_to:optional # nullable foreign key (for optional associations)
|
|
72
|
+
${INDENT}
|
|
73
|
+
${INDENT} rename the association with Model@alias — the snake_case alias drives the FK column name AND the
|
|
74
|
+
${INDENT} @deco.BelongsTo association + typed FK property on the generated model:
|
|
75
|
+
${INDENT} InternalUser@canceled_by:belongs_to:optional # canceled_by_id column, canceledById property, canceledBy association,
|
|
76
|
+
${INDENT} # @deco.BelongsTo('InternalUser', { on: 'canceledById', optional: true })
|
|
77
|
+
${INDENT} Messaging/Template@template:belongs_to # template_id column, templateId property, template association
|
|
78
|
+
${INDENT} # (strips the namespace from the property/association names while keeping
|
|
79
|
+
${INDENT} # the namespaced model reference intact)
|
|
80
|
+
${INDENT} Aliasing also lets you declare multiple FKs to the same model in one generator call without column collisions.`;
|
|
72
81
|
export default class PsychicCLI {
|
|
73
82
|
static provide(program, { initializePsychicApp, seedDb, }) {
|
|
74
83
|
DreamCLI.generateDreamCli(program, {
|
|
@@ -141,8 +150,6 @@ ${INDENT}Example:
|
|
|
141
150
|
${INDENT} pnpm psy g:resource --model-name=GroupDanceLesson v1/lessons/dance/groups Lesson/Dance/Group
|
|
142
151
|
${INDENT} # model is named GroupDanceLesson instead of LessonDanceGroup`)
|
|
143
152
|
.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)
|
|
146
153
|
.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.
|
|
147
154
|
${INDENT}
|
|
148
155
|
${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.
|
|
@@ -403,13 +403,13 @@ export default class PsychicController {
|
|
|
403
403
|
return this._castParam(keys, nestedParams, expectedType, opts);
|
|
404
404
|
}
|
|
405
405
|
/**
|
|
406
|
-
* @deprecated Prefer {@link extractParams}
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
* `
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
406
|
+
* @deprecated Prefer {@link extractParams} — the explicit-allowlist form
|
|
407
|
+
* keeps the permitted columns visible at the call site. Security-relevant:
|
|
408
|
+
* on models with permission-bearing fields, `paramsFor(Model)` without
|
|
409
|
+
* `only` extracts every column in `paramSafeColumnsOrFallback()` — which
|
|
410
|
+
* may be too broad unless the model declares `paramSafeColumns` explicitly.
|
|
411
|
+
* `extractParams` makes the allowlist visible to reviewers at the call
|
|
412
|
+
* site.
|
|
413
413
|
*
|
|
414
414
|
* Captures and validates parameters for the provided Dream model. Will
|
|
415
415
|
* exclude parameters that are not considered "safe" by default (based on
|
|
@@ -429,11 +429,7 @@ export default class PsychicController {
|
|
|
429
429
|
* ```ts
|
|
430
430
|
* class MyController extends ApplicationController {
|
|
431
431
|
* public create() {
|
|
432
|
-
* // Preferred: explicit allowlist via extractParams
|
|
433
432
|
* const safe = this.extractParams(User, ['email', 'name'])
|
|
434
|
-
*
|
|
435
|
-
* // Equivalent of the legacy `this.paramsFor(User)` under the new name:
|
|
436
|
-
* const viaModel = this.extractImplicitParams(User)
|
|
437
433
|
* }
|
|
438
434
|
* }
|
|
439
435
|
* ```
|
|
@@ -458,10 +454,6 @@ export default class PsychicController {
|
|
|
458
454
|
* `paramSafeColumnsOrFallback()` further strips anything a caller bypasses
|
|
459
455
|
* the type system to include.
|
|
460
456
|
*
|
|
461
|
-
* Use {@link extractImplicitParams} when the model's declared
|
|
462
|
-
* `paramSafeColumns` is the canonical allowlist and duplicating it at each
|
|
463
|
-
* call site would create drift.
|
|
464
|
-
*
|
|
465
457
|
* @param dreamClass - The Dream model class to retrieve params for
|
|
466
458
|
* @param allowed - Required. The columns permitted from the request.
|
|
467
459
|
* @param opts - Optional configuration
|
|
@@ -485,39 +477,6 @@ export default class PsychicController {
|
|
|
485
477
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
486
478
|
return Params.extract(source, dreamClass, allowed, opts);
|
|
487
479
|
}
|
|
488
|
-
/**
|
|
489
|
-
* Captures and validates parameters for the provided Dream model using the
|
|
490
|
-
* model's declared `paramSafeColumns` (or, when undeclared, the framework's
|
|
491
|
-
* default-safe fallback from `paramSafeColumnsOrFallback()`).
|
|
492
|
-
*
|
|
493
|
-
* Prefer {@link extractParams} when the caller can enumerate the allowed
|
|
494
|
-
* columns at the call site — explicit allowlists are more visible to
|
|
495
|
-
* reviewers. Reach for `extractImplicitParams` when the model-level
|
|
496
|
-
* declaration is the canonical allowlist and you want to avoid duplicating
|
|
497
|
-
* it at every call site.
|
|
498
|
-
*
|
|
499
|
-
* @param dreamClass - The Dream model class to retrieve params for
|
|
500
|
-
* @param opts - Optional configuration
|
|
501
|
-
* @param opts.key - Extract params from a nested key in the params object instead of root level
|
|
502
|
-
* @param opts.array - If true, expects and returns an array of param objects
|
|
503
|
-
* @returns A typed object containing the validated and casted params
|
|
504
|
-
* @throws {ParamValidationError} When any parameter validation fails
|
|
505
|
-
*
|
|
506
|
-
* @example
|
|
507
|
-
* ```ts
|
|
508
|
-
* class AdminBalloonsController extends ApplicationController {
|
|
509
|
-
* public create() {
|
|
510
|
-
* const params = this.extractImplicitParams(Balloon)
|
|
511
|
-
* const balloon = await Balloon.create(params)
|
|
512
|
-
* }
|
|
513
|
-
* }
|
|
514
|
-
* ```
|
|
515
|
-
*/
|
|
516
|
-
extractImplicitParams(dreamClass, opts) {
|
|
517
|
-
const source = opts?.key ? this.params[opts.key] || {} : this.params;
|
|
518
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
519
|
-
return Params.extractImplicit(source, dreamClass, opts);
|
|
520
|
-
}
|
|
521
480
|
/**
|
|
522
481
|
* Gets a cookie value from the request and casts it to the specified type.
|
|
523
482
|
*
|
|
@@ -9,7 +9,7 @@ import psychicPath from '../helpers/path/psychicPath.js';
|
|
|
9
9
|
import generateControllerContent from './helpers/generateControllerContent.js';
|
|
10
10
|
import generateControllerSpecContent from './helpers/generateControllerSpecContent.js';
|
|
11
11
|
import generateResourceControllerSpecContent from './helpers/generateResourceControllerSpecContent.js';
|
|
12
|
-
export default async function generateController({ fullyQualifiedControllerName, fullyQualifiedModelName, actions, columnsWithTypes = [], resourceSpecs = false, owningModel, singular,
|
|
12
|
+
export default async function generateController({ fullyQualifiedControllerName, fullyQualifiedModelName, actions, columnsWithTypes = [], resourceSpecs = false, owningModel, singular, }) {
|
|
13
13
|
fullyQualifiedModelName = fullyQualifiedModelName
|
|
14
14
|
? DreamApp.system.standardizeFullyQualifiedModelName(fullyQualifiedModelName)
|
|
15
15
|
: fullyQualifiedModelName;
|
|
@@ -71,7 +71,6 @@ export default async function generateController({ fullyQualifiedControllerName,
|
|
|
71
71
|
forInternal,
|
|
72
72
|
singular,
|
|
73
73
|
columnsWithTypes,
|
|
74
|
-
paramExtractionStrategy,
|
|
75
74
|
}));
|
|
76
75
|
}
|
|
77
76
|
catch (error) {
|
|
@@ -2,36 +2,14 @@ import { DreamApp } from '@rvoh/dream';
|
|
|
2
2
|
import { camelize, hyphenize } from '@rvoh/dream/utils';
|
|
3
3
|
import pluralize from 'pluralize-esm';
|
|
4
4
|
import paramSafeColumnNamesFromCliTokens from './paramSafeColumnNamesFromCliTokens.js';
|
|
5
|
-
export default function generateControllerContent({ ancestorName, ancestorImportStatement, fullyQualifiedControllerName, fullyQualifiedModelName, actions = [], omitOpenApi = false, owningModel, forAdmin, forInternal = false, singular, columnsWithTypes = [],
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
* Returns the `this.extract*Params(...)` expression that replaces the legacy
|
|
14
|
-
* `this.paramsFor(Model)` in the scaffold's commented hints. Does NOT include
|
|
15
|
-
* the outer closing paren that the surrounding call expects (e.g. the one
|
|
16
|
-
* closing `update(...)` or `create(...)` or `createAssociation(...)`); the
|
|
17
|
-
* caller appends that as part of its own template.
|
|
18
|
-
*/
|
|
19
|
-
const extractCallExpression = (modelClass) => {
|
|
20
|
-
if (resolvedExtractionStrategy === 'implicit') {
|
|
21
|
-
return `this.extractImplicitParams(${modelClass})`;
|
|
22
|
-
}
|
|
23
|
-
const safeColumns = paramSafeColumnNamesFromCliTokens(columnsWithTypes);
|
|
24
|
-
const serializedSafeColumns = safeColumns.length
|
|
25
|
-
? `[${safeColumns.map(name => `'${name}'`).join(', ')}]`
|
|
26
|
-
: '[]';
|
|
27
|
-
// The emitted list contains every implicitly-allowed column. When
|
|
28
|
-
// uncommenting the action body, the developer or agent is responsible
|
|
29
|
-
// for narrowing it down to only the columns this action should actually
|
|
30
|
-
// accept.
|
|
31
|
-
return `this.extractParams(${modelClass},
|
|
32
|
-
// ${serializedSafeColumns},
|
|
33
|
-
// )`;
|
|
34
|
-
};
|
|
5
|
+
export default function generateControllerContent({ ancestorName, ancestorImportStatement, fullyQualifiedControllerName, fullyQualifiedModelName, actions = [], omitOpenApi = false, owningModel, forAdmin, forInternal = false, singular, columnsWithTypes = [], }) {
|
|
6
|
+
// The scaffold emits a `paramSafeColumns` const at the top of the file
|
|
7
|
+
// (alongside `openApiTags`) and references it from both the `create` and
|
|
8
|
+
// `update` action hints. The list contains every implicitly-allowed column;
|
|
9
|
+
// when uncommenting the action body, the developer or agent is responsible
|
|
10
|
+
// for narrowing the const down to only the columns the actions should
|
|
11
|
+
// actually accept.
|
|
12
|
+
const extractCallExpression = (modelClass) => `this.extractParams(${modelClass}, paramSafeColumns)`;
|
|
35
13
|
fullyQualifiedControllerName = DreamApp.system.standardizeFullyQualifiedModelName(fullyQualifiedControllerName);
|
|
36
14
|
const additionalImports = [];
|
|
37
15
|
const controllerClassName = DreamApp.system.globalClassNameFromFullyQualifiedModelName(fullyQualifiedControllerName);
|
|
@@ -70,6 +48,9 @@ export default function generateControllerContent({ ancestorName, ancestorImport
|
|
|
70
48
|
tags: openApiTags,
|
|
71
49
|
description: 'Create ${aOrAnDreamModelName(modelClassName)}',${defaultOpenapiSerializerKeyProperty}
|
|
72
50
|
fastJsonStringify: true,
|
|
51
|
+
requestBody: {
|
|
52
|
+
only: paramSafeColumns,
|
|
53
|
+
},
|
|
73
54
|
})
|
|
74
55
|
public async create() {
|
|
75
56
|
// let ${modelAttributeName} = await ${useDirectModelAccess ? `${modelClassName}.create(` : `this.${owningModelProperty}.createAssociation('${pluralizedModelAttributeName}', `}${extractCallExpression(modelClassName)})
|
|
@@ -146,6 +127,9 @@ export default function generateControllerContent({ ancestorName, ancestorImport
|
|
|
146
127
|
tags: openApiTags,
|
|
147
128
|
description: 'Update ${aOrAnDreamModelName(modelClassName)}',
|
|
148
129
|
fastJsonStringify: true,
|
|
130
|
+
requestBody: {
|
|
131
|
+
only: paramSafeColumns,
|
|
132
|
+
},
|
|
149
133
|
})
|
|
150
134
|
public async update() {
|
|
151
135
|
// const ${modelAttributeName} = await this.${modelAttributeName}()
|
|
@@ -213,8 +197,20 @@ export default function generateControllerContent({ ancestorName, ancestorImport
|
|
|
213
197
|
});
|
|
214
198
|
const openApiImport = `import { OpenAPI } from '@rvoh/psychic'`;
|
|
215
199
|
const openApiTags = `const openApiTags = ['${hyphenize(pluralizedModelAttributeName || controllerClassName.replace(/Controller$/, ''))}']`;
|
|
200
|
+
const emitParamSafeColumns = !!modelClassName && actions.some(action => action === 'create' || action === 'update');
|
|
201
|
+
const safeColumns = emitParamSafeColumns ? paramSafeColumnNamesFromCliTokens(columnsWithTypes) : [];
|
|
202
|
+
// The const is typed against the model's safe-column names rather than
|
|
203
|
+
// `as const`, so editing the array literal gives the developer (or agent)
|
|
204
|
+
// autocomplete of valid columns and a compile error on anything that is
|
|
205
|
+
// not param-safe — directly in the assignment, no call-site round-trip.
|
|
206
|
+
const paramSafeColumnsDecl = !emitParamSafeColumns
|
|
207
|
+
? ''
|
|
208
|
+
: `\n\nconst paramSafeColumns: DreamParamSafeColumnNames<${modelClassName}>[] = [${safeColumns.map(name => `'${name}'`).join(', ')}]`;
|
|
209
|
+
const dreamTypesImport = emitParamSafeColumns
|
|
210
|
+
? `import { DreamParamSafeColumnNames } from '@rvoh/dream/types'\n`
|
|
211
|
+
: '';
|
|
216
212
|
return `\
|
|
217
|
-
${omitOpenApi ? '' : openApiImport + '\n'}${ancestorImportStatement}${additionalImports.length ? '\n' + additionalImports.join('\n') : ''}${omitOpenApi ? '' : '\n\n' + openApiTags}
|
|
213
|
+
${omitOpenApi ? '' : openApiImport + '\n' + dreamTypesImport}${ancestorImportStatement}${additionalImports.length ? '\n' + additionalImports.join('\n') : ''}${omitOpenApi ? '' : '\n\n' + openApiTags}${paramSafeColumnsDecl}
|
|
218
214
|
|
|
219
215
|
export default class ${controllerClassName} extends ${ancestorName} {
|
|
220
216
|
${methodDefs.join('\n\n')}${modelClassName ? privateMethods(forAdmin, forInternal, modelClassName, actions, loadQueryBase, singular) : ''}
|
|
@@ -9,16 +9,46 @@ import { camelize } from '@rvoh/dream/utils';
|
|
|
9
9
|
* paramSafe column allowlist) share one canonical interpretation of the
|
|
10
10
|
* tokens — and one canonical casing (camelCase) for the resulting attribute
|
|
11
11
|
* name.
|
|
12
|
+
*
|
|
13
|
+
* Supports the `Model@alias:belongs_to` shorthand for renaming the FK
|
|
14
|
+
* association. When `@alias` is present, the camelized alias becomes
|
|
15
|
+
* `attributeName`; otherwise (legacy form `Model:belongs_to`) the
|
|
16
|
+
* attribute name is derived from the model's last namespace segment.
|
|
17
|
+
*
|
|
18
|
+
* Mirrors Dream's CLI-side `parseAttribute` (kept as a parallel
|
|
19
|
+
* implementation rather than a shared import so neither package leaks an
|
|
20
|
+
* internal CLI helper through its public API surface).
|
|
12
21
|
*/
|
|
13
22
|
export default function parseAttribute(attribute) {
|
|
14
|
-
const
|
|
15
|
-
|
|
23
|
+
const segments = attribute.split(':');
|
|
24
|
+
const rawSegmentOne = segments[0];
|
|
25
|
+
const rawAttributeType = segments[1];
|
|
26
|
+
if (!rawSegmentOne || !rawAttributeType)
|
|
16
27
|
return null;
|
|
28
|
+
// Extract optional `@alias` from segment-1 (used by the belongs_to FK alias
|
|
29
|
+
// shorthand, e.g., `InternalUser@canceled_by:belongs_to`).
|
|
30
|
+
let rawAttributeName = rawSegmentOne;
|
|
31
|
+
let aliasName;
|
|
32
|
+
const atIndex = rawSegmentOne.indexOf('@');
|
|
33
|
+
if (atIndex !== -1) {
|
|
34
|
+
rawAttributeName = rawSegmentOne.slice(0, atIndex);
|
|
35
|
+
const rawAlias = rawSegmentOne.slice(atIndex + 1);
|
|
36
|
+
if (!rawAttributeName || !rawAlias)
|
|
37
|
+
return null;
|
|
38
|
+
aliasName = rawAlias;
|
|
39
|
+
}
|
|
40
|
+
// Pop trailing `optional` keyword from descriptors so it doesn't get
|
|
41
|
+
// mistaken for enum values or other positional descriptors.
|
|
42
|
+
const descriptors = segments.slice(2);
|
|
43
|
+
if (descriptors[descriptors.length - 1] === 'optional')
|
|
44
|
+
descriptors.pop();
|
|
45
|
+
const enumValues = descriptors[1];
|
|
17
46
|
const sanitizedAttrType = camelize(rawAttributeType)?.toLowerCase();
|
|
18
47
|
// Handle belongs_to relationships
|
|
19
48
|
if (sanitizedAttrType === 'belongsto') {
|
|
20
|
-
// For belongs_to relationships,
|
|
21
|
-
|
|
49
|
+
// For belongs_to relationships, prefer the explicit alias when present;
|
|
50
|
+
// otherwise convert "Ticketing/Ticket" → "ticket".
|
|
51
|
+
const attributeName = aliasName ? camelize(aliasName) : camelize(rawAttributeName.split('/').pop());
|
|
22
52
|
return { attributeName, attributeType: 'belongs_to', isArray: false, enumValues };
|
|
23
53
|
}
|
|
24
54
|
// Skip _type and _id columns, but not belongs_to relationships
|
|
@@ -32,14 +32,6 @@ 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;
|
|
43
35
|
await generateController({
|
|
44
36
|
fullyQualifiedControllerName,
|
|
45
37
|
fullyQualifiedModelName,
|
|
@@ -50,7 +42,6 @@ export default async function generateResource({ route, fullyQualifiedModelName,
|
|
|
50
42
|
resourceSpecs: true,
|
|
51
43
|
singular: options.singular,
|
|
52
44
|
owningModel: options.owningModel,
|
|
53
|
-
paramExtractionStrategy,
|
|
54
45
|
});
|
|
55
46
|
await addResourceToRoutes(route, {
|
|
56
47
|
singular: options.singular,
|
|
@@ -108,25 +108,6 @@ export default class Params {
|
|
|
108
108
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
109
109
|
return Params.for(params, dreamClass, { ...(opts ?? {}), only: allowed });
|
|
110
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
|
-
}
|
|
130
111
|
static restrict(params, allowed) {
|
|
131
112
|
if (params === null || params === undefined)
|
|
132
113
|
return {};
|
|
@@ -8,7 +8,7 @@ import generateOpenapiZustandBindings from '../generate/openapi/zustandBindings.
|
|
|
8
8
|
import generateResource from '../generate/resource.js';
|
|
9
9
|
import Watcher from '../watcher/Watcher.js';
|
|
10
10
|
const INDENT = ' ';
|
|
11
|
-
const baseColumnsWithTypesDescription = `space separated snake-case (except for belongs_to model name) properties like this:
|
|
11
|
+
const baseColumnsWithTypesDescription = `space separated snake-case (except for belongs_to model name, which may take an @alias suffix to rename the FK) properties like this:
|
|
12
12
|
${INDENT} title:citext subtitle:string body_markdown:text style:enum:post_styles:formal,informal User:belongs_to
|
|
13
13
|
${INDENT}
|
|
14
14
|
${INDENT}all properties default to not nullable; null can be allowed by appending ':optional':
|
|
@@ -68,7 +68,16 @@ ${INDENT}
|
|
|
68
68
|
${INDENT} use the fully qualified model name (matching its path under src/app/models/):
|
|
69
69
|
${INDENT} User:belongs_to # creates user_id column + BelongsTo association
|
|
70
70
|
${INDENT} Health/Coach:belongs_to # creates health_coach_id column + BelongsTo association
|
|
71
|
-
${INDENT} User:belongs_to:optional # nullable foreign key (for optional associations)
|
|
71
|
+
${INDENT} User:belongs_to:optional # nullable foreign key (for optional associations)
|
|
72
|
+
${INDENT}
|
|
73
|
+
${INDENT} rename the association with Model@alias — the snake_case alias drives the FK column name AND the
|
|
74
|
+
${INDENT} @deco.BelongsTo association + typed FK property on the generated model:
|
|
75
|
+
${INDENT} InternalUser@canceled_by:belongs_to:optional # canceled_by_id column, canceledById property, canceledBy association,
|
|
76
|
+
${INDENT} # @deco.BelongsTo('InternalUser', { on: 'canceledById', optional: true })
|
|
77
|
+
${INDENT} Messaging/Template@template:belongs_to # template_id column, templateId property, template association
|
|
78
|
+
${INDENT} # (strips the namespace from the property/association names while keeping
|
|
79
|
+
${INDENT} # the namespaced model reference intact)
|
|
80
|
+
${INDENT} Aliasing also lets you declare multiple FKs to the same model in one generator call without column collisions.`;
|
|
72
81
|
export default class PsychicCLI {
|
|
73
82
|
static provide(program, { initializePsychicApp, seedDb, }) {
|
|
74
83
|
DreamCLI.generateDreamCli(program, {
|
|
@@ -141,8 +150,6 @@ ${INDENT}Example:
|
|
|
141
150
|
${INDENT} pnpm psy g:resource --model-name=GroupDanceLesson v1/lessons/dance/groups Lesson/Dance/Group
|
|
142
151
|
${INDENT} # model is named GroupDanceLesson instead of LessonDanceGroup`)
|
|
143
152
|
.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)
|
|
146
153
|
.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.
|
|
147
154
|
${INDENT}
|
|
148
155
|
${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.
|
|
@@ -403,13 +403,13 @@ export default class PsychicController {
|
|
|
403
403
|
return this._castParam(keys, nestedParams, expectedType, opts);
|
|
404
404
|
}
|
|
405
405
|
/**
|
|
406
|
-
* @deprecated Prefer {@link extractParams}
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
* `
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
406
|
+
* @deprecated Prefer {@link extractParams} — the explicit-allowlist form
|
|
407
|
+
* keeps the permitted columns visible at the call site. Security-relevant:
|
|
408
|
+
* on models with permission-bearing fields, `paramsFor(Model)` without
|
|
409
|
+
* `only` extracts every column in `paramSafeColumnsOrFallback()` — which
|
|
410
|
+
* may be too broad unless the model declares `paramSafeColumns` explicitly.
|
|
411
|
+
* `extractParams` makes the allowlist visible to reviewers at the call
|
|
412
|
+
* site.
|
|
413
413
|
*
|
|
414
414
|
* Captures and validates parameters for the provided Dream model. Will
|
|
415
415
|
* exclude parameters that are not considered "safe" by default (based on
|
|
@@ -429,11 +429,7 @@ export default class PsychicController {
|
|
|
429
429
|
* ```ts
|
|
430
430
|
* class MyController extends ApplicationController {
|
|
431
431
|
* public create() {
|
|
432
|
-
* // Preferred: explicit allowlist via extractParams
|
|
433
432
|
* const safe = this.extractParams(User, ['email', 'name'])
|
|
434
|
-
*
|
|
435
|
-
* // Equivalent of the legacy `this.paramsFor(User)` under the new name:
|
|
436
|
-
* const viaModel = this.extractImplicitParams(User)
|
|
437
433
|
* }
|
|
438
434
|
* }
|
|
439
435
|
* ```
|
|
@@ -458,10 +454,6 @@ export default class PsychicController {
|
|
|
458
454
|
* `paramSafeColumnsOrFallback()` further strips anything a caller bypasses
|
|
459
455
|
* the type system to include.
|
|
460
456
|
*
|
|
461
|
-
* Use {@link extractImplicitParams} when the model's declared
|
|
462
|
-
* `paramSafeColumns` is the canonical allowlist and duplicating it at each
|
|
463
|
-
* call site would create drift.
|
|
464
|
-
*
|
|
465
457
|
* @param dreamClass - The Dream model class to retrieve params for
|
|
466
458
|
* @param allowed - Required. The columns permitted from the request.
|
|
467
459
|
* @param opts - Optional configuration
|
|
@@ -485,39 +477,6 @@ export default class PsychicController {
|
|
|
485
477
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
486
478
|
return Params.extract(source, dreamClass, allowed, opts);
|
|
487
479
|
}
|
|
488
|
-
/**
|
|
489
|
-
* Captures and validates parameters for the provided Dream model using the
|
|
490
|
-
* model's declared `paramSafeColumns` (or, when undeclared, the framework's
|
|
491
|
-
* default-safe fallback from `paramSafeColumnsOrFallback()`).
|
|
492
|
-
*
|
|
493
|
-
* Prefer {@link extractParams} when the caller can enumerate the allowed
|
|
494
|
-
* columns at the call site — explicit allowlists are more visible to
|
|
495
|
-
* reviewers. Reach for `extractImplicitParams` when the model-level
|
|
496
|
-
* declaration is the canonical allowlist and you want to avoid duplicating
|
|
497
|
-
* it at every call site.
|
|
498
|
-
*
|
|
499
|
-
* @param dreamClass - The Dream model class to retrieve params for
|
|
500
|
-
* @param opts - Optional configuration
|
|
501
|
-
* @param opts.key - Extract params from a nested key in the params object instead of root level
|
|
502
|
-
* @param opts.array - If true, expects and returns an array of param objects
|
|
503
|
-
* @returns A typed object containing the validated and casted params
|
|
504
|
-
* @throws {ParamValidationError} When any parameter validation fails
|
|
505
|
-
*
|
|
506
|
-
* @example
|
|
507
|
-
* ```ts
|
|
508
|
-
* class AdminBalloonsController extends ApplicationController {
|
|
509
|
-
* public create() {
|
|
510
|
-
* const params = this.extractImplicitParams(Balloon)
|
|
511
|
-
* const balloon = await Balloon.create(params)
|
|
512
|
-
* }
|
|
513
|
-
* }
|
|
514
|
-
* ```
|
|
515
|
-
*/
|
|
516
|
-
extractImplicitParams(dreamClass, opts) {
|
|
517
|
-
const source = opts?.key ? this.params[opts.key] || {} : this.params;
|
|
518
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
519
|
-
return Params.extractImplicit(source, dreamClass, opts);
|
|
520
|
-
}
|
|
521
480
|
/**
|
|
522
481
|
* Gets a cookie value from the request and casts it to the specified type.
|
|
523
482
|
*
|
|
@@ -9,7 +9,7 @@ import psychicPath from '../helpers/path/psychicPath.js';
|
|
|
9
9
|
import generateControllerContent from './helpers/generateControllerContent.js';
|
|
10
10
|
import generateControllerSpecContent from './helpers/generateControllerSpecContent.js';
|
|
11
11
|
import generateResourceControllerSpecContent from './helpers/generateResourceControllerSpecContent.js';
|
|
12
|
-
export default async function generateController({ fullyQualifiedControllerName, fullyQualifiedModelName, actions, columnsWithTypes = [], resourceSpecs = false, owningModel, singular,
|
|
12
|
+
export default async function generateController({ fullyQualifiedControllerName, fullyQualifiedModelName, actions, columnsWithTypes = [], resourceSpecs = false, owningModel, singular, }) {
|
|
13
13
|
fullyQualifiedModelName = fullyQualifiedModelName
|
|
14
14
|
? DreamApp.system.standardizeFullyQualifiedModelName(fullyQualifiedModelName)
|
|
15
15
|
: fullyQualifiedModelName;
|
|
@@ -71,7 +71,6 @@ export default async function generateController({ fullyQualifiedControllerName,
|
|
|
71
71
|
forInternal,
|
|
72
72
|
singular,
|
|
73
73
|
columnsWithTypes,
|
|
74
|
-
paramExtractionStrategy,
|
|
75
74
|
}));
|
|
76
75
|
}
|
|
77
76
|
catch (error) {
|
|
@@ -2,36 +2,14 @@ import { DreamApp } from '@rvoh/dream';
|
|
|
2
2
|
import { camelize, hyphenize } from '@rvoh/dream/utils';
|
|
3
3
|
import pluralize from 'pluralize-esm';
|
|
4
4
|
import paramSafeColumnNamesFromCliTokens from './paramSafeColumnNamesFromCliTokens.js';
|
|
5
|
-
export default function generateControllerContent({ ancestorName, ancestorImportStatement, fullyQualifiedControllerName, fullyQualifiedModelName, actions = [], omitOpenApi = false, owningModel, forAdmin, forInternal = false, singular, columnsWithTypes = [],
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
* Returns the `this.extract*Params(...)` expression that replaces the legacy
|
|
14
|
-
* `this.paramsFor(Model)` in the scaffold's commented hints. Does NOT include
|
|
15
|
-
* the outer closing paren that the surrounding call expects (e.g. the one
|
|
16
|
-
* closing `update(...)` or `create(...)` or `createAssociation(...)`); the
|
|
17
|
-
* caller appends that as part of its own template.
|
|
18
|
-
*/
|
|
19
|
-
const extractCallExpression = (modelClass) => {
|
|
20
|
-
if (resolvedExtractionStrategy === 'implicit') {
|
|
21
|
-
return `this.extractImplicitParams(${modelClass})`;
|
|
22
|
-
}
|
|
23
|
-
const safeColumns = paramSafeColumnNamesFromCliTokens(columnsWithTypes);
|
|
24
|
-
const serializedSafeColumns = safeColumns.length
|
|
25
|
-
? `[${safeColumns.map(name => `'${name}'`).join(', ')}]`
|
|
26
|
-
: '[]';
|
|
27
|
-
// The emitted list contains every implicitly-allowed column. When
|
|
28
|
-
// uncommenting the action body, the developer or agent is responsible
|
|
29
|
-
// for narrowing it down to only the columns this action should actually
|
|
30
|
-
// accept.
|
|
31
|
-
return `this.extractParams(${modelClass},
|
|
32
|
-
// ${serializedSafeColumns},
|
|
33
|
-
// )`;
|
|
34
|
-
};
|
|
5
|
+
export default function generateControllerContent({ ancestorName, ancestorImportStatement, fullyQualifiedControllerName, fullyQualifiedModelName, actions = [], omitOpenApi = false, owningModel, forAdmin, forInternal = false, singular, columnsWithTypes = [], }) {
|
|
6
|
+
// The scaffold emits a `paramSafeColumns` const at the top of the file
|
|
7
|
+
// (alongside `openApiTags`) and references it from both the `create` and
|
|
8
|
+
// `update` action hints. The list contains every implicitly-allowed column;
|
|
9
|
+
// when uncommenting the action body, the developer or agent is responsible
|
|
10
|
+
// for narrowing the const down to only the columns the actions should
|
|
11
|
+
// actually accept.
|
|
12
|
+
const extractCallExpression = (modelClass) => `this.extractParams(${modelClass}, paramSafeColumns)`;
|
|
35
13
|
fullyQualifiedControllerName = DreamApp.system.standardizeFullyQualifiedModelName(fullyQualifiedControllerName);
|
|
36
14
|
const additionalImports = [];
|
|
37
15
|
const controllerClassName = DreamApp.system.globalClassNameFromFullyQualifiedModelName(fullyQualifiedControllerName);
|
|
@@ -70,6 +48,9 @@ export default function generateControllerContent({ ancestorName, ancestorImport
|
|
|
70
48
|
tags: openApiTags,
|
|
71
49
|
description: 'Create ${aOrAnDreamModelName(modelClassName)}',${defaultOpenapiSerializerKeyProperty}
|
|
72
50
|
fastJsonStringify: true,
|
|
51
|
+
requestBody: {
|
|
52
|
+
only: paramSafeColumns,
|
|
53
|
+
},
|
|
73
54
|
})
|
|
74
55
|
public async create() {
|
|
75
56
|
// let ${modelAttributeName} = await ${useDirectModelAccess ? `${modelClassName}.create(` : `this.${owningModelProperty}.createAssociation('${pluralizedModelAttributeName}', `}${extractCallExpression(modelClassName)})
|
|
@@ -146,6 +127,9 @@ export default function generateControllerContent({ ancestorName, ancestorImport
|
|
|
146
127
|
tags: openApiTags,
|
|
147
128
|
description: 'Update ${aOrAnDreamModelName(modelClassName)}',
|
|
148
129
|
fastJsonStringify: true,
|
|
130
|
+
requestBody: {
|
|
131
|
+
only: paramSafeColumns,
|
|
132
|
+
},
|
|
149
133
|
})
|
|
150
134
|
public async update() {
|
|
151
135
|
// const ${modelAttributeName} = await this.${modelAttributeName}()
|
|
@@ -213,8 +197,20 @@ export default function generateControllerContent({ ancestorName, ancestorImport
|
|
|
213
197
|
});
|
|
214
198
|
const openApiImport = `import { OpenAPI } from '@rvoh/psychic'`;
|
|
215
199
|
const openApiTags = `const openApiTags = ['${hyphenize(pluralizedModelAttributeName || controllerClassName.replace(/Controller$/, ''))}']`;
|
|
200
|
+
const emitParamSafeColumns = !!modelClassName && actions.some(action => action === 'create' || action === 'update');
|
|
201
|
+
const safeColumns = emitParamSafeColumns ? paramSafeColumnNamesFromCliTokens(columnsWithTypes) : [];
|
|
202
|
+
// The const is typed against the model's safe-column names rather than
|
|
203
|
+
// `as const`, so editing the array literal gives the developer (or agent)
|
|
204
|
+
// autocomplete of valid columns and a compile error on anything that is
|
|
205
|
+
// not param-safe — directly in the assignment, no call-site round-trip.
|
|
206
|
+
const paramSafeColumnsDecl = !emitParamSafeColumns
|
|
207
|
+
? ''
|
|
208
|
+
: `\n\nconst paramSafeColumns: DreamParamSafeColumnNames<${modelClassName}>[] = [${safeColumns.map(name => `'${name}'`).join(', ')}]`;
|
|
209
|
+
const dreamTypesImport = emitParamSafeColumns
|
|
210
|
+
? `import { DreamParamSafeColumnNames } from '@rvoh/dream/types'\n`
|
|
211
|
+
: '';
|
|
216
212
|
return `\
|
|
217
|
-
${omitOpenApi ? '' : openApiImport + '\n'}${ancestorImportStatement}${additionalImports.length ? '\n' + additionalImports.join('\n') : ''}${omitOpenApi ? '' : '\n\n' + openApiTags}
|
|
213
|
+
${omitOpenApi ? '' : openApiImport + '\n' + dreamTypesImport}${ancestorImportStatement}${additionalImports.length ? '\n' + additionalImports.join('\n') : ''}${omitOpenApi ? '' : '\n\n' + openApiTags}${paramSafeColumnsDecl}
|
|
218
214
|
|
|
219
215
|
export default class ${controllerClassName} extends ${ancestorName} {
|
|
220
216
|
${methodDefs.join('\n\n')}${modelClassName ? privateMethods(forAdmin, forInternal, modelClassName, actions, loadQueryBase, singular) : ''}
|
|
@@ -9,16 +9,46 @@ import { camelize } from '@rvoh/dream/utils';
|
|
|
9
9
|
* paramSafe column allowlist) share one canonical interpretation of the
|
|
10
10
|
* tokens — and one canonical casing (camelCase) for the resulting attribute
|
|
11
11
|
* name.
|
|
12
|
+
*
|
|
13
|
+
* Supports the `Model@alias:belongs_to` shorthand for renaming the FK
|
|
14
|
+
* association. When `@alias` is present, the camelized alias becomes
|
|
15
|
+
* `attributeName`; otherwise (legacy form `Model:belongs_to`) the
|
|
16
|
+
* attribute name is derived from the model's last namespace segment.
|
|
17
|
+
*
|
|
18
|
+
* Mirrors Dream's CLI-side `parseAttribute` (kept as a parallel
|
|
19
|
+
* implementation rather than a shared import so neither package leaks an
|
|
20
|
+
* internal CLI helper through its public API surface).
|
|
12
21
|
*/
|
|
13
22
|
export default function parseAttribute(attribute) {
|
|
14
|
-
const
|
|
15
|
-
|
|
23
|
+
const segments = attribute.split(':');
|
|
24
|
+
const rawSegmentOne = segments[0];
|
|
25
|
+
const rawAttributeType = segments[1];
|
|
26
|
+
if (!rawSegmentOne || !rawAttributeType)
|
|
16
27
|
return null;
|
|
28
|
+
// Extract optional `@alias` from segment-1 (used by the belongs_to FK alias
|
|
29
|
+
// shorthand, e.g., `InternalUser@canceled_by:belongs_to`).
|
|
30
|
+
let rawAttributeName = rawSegmentOne;
|
|
31
|
+
let aliasName;
|
|
32
|
+
const atIndex = rawSegmentOne.indexOf('@');
|
|
33
|
+
if (atIndex !== -1) {
|
|
34
|
+
rawAttributeName = rawSegmentOne.slice(0, atIndex);
|
|
35
|
+
const rawAlias = rawSegmentOne.slice(atIndex + 1);
|
|
36
|
+
if (!rawAttributeName || !rawAlias)
|
|
37
|
+
return null;
|
|
38
|
+
aliasName = rawAlias;
|
|
39
|
+
}
|
|
40
|
+
// Pop trailing `optional` keyword from descriptors so it doesn't get
|
|
41
|
+
// mistaken for enum values or other positional descriptors.
|
|
42
|
+
const descriptors = segments.slice(2);
|
|
43
|
+
if (descriptors[descriptors.length - 1] === 'optional')
|
|
44
|
+
descriptors.pop();
|
|
45
|
+
const enumValues = descriptors[1];
|
|
17
46
|
const sanitizedAttrType = camelize(rawAttributeType)?.toLowerCase();
|
|
18
47
|
// Handle belongs_to relationships
|
|
19
48
|
if (sanitizedAttrType === 'belongsto') {
|
|
20
|
-
// For belongs_to relationships,
|
|
21
|
-
|
|
49
|
+
// For belongs_to relationships, prefer the explicit alias when present;
|
|
50
|
+
// otherwise convert "Ticketing/Ticket" → "ticket".
|
|
51
|
+
const attributeName = aliasName ? camelize(aliasName) : camelize(rawAttributeName.split('/').pop());
|
|
22
52
|
return { attributeName, attributeType: 'belongs_to', isArray: false, enumValues };
|
|
23
53
|
}
|
|
24
54
|
// Skip _type and _id columns, but not belongs_to relationships
|
|
@@ -32,14 +32,6 @@ 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;
|
|
43
35
|
await generateController({
|
|
44
36
|
fullyQualifiedControllerName,
|
|
45
37
|
fullyQualifiedModelName,
|
|
@@ -50,7 +42,6 @@ export default async function generateResource({ route, fullyQualifiedModelName,
|
|
|
50
42
|
resourceSpecs: true,
|
|
51
43
|
singular: options.singular,
|
|
52
44
|
owningModel: options.owningModel,
|
|
53
|
-
paramExtractionStrategy,
|
|
54
45
|
});
|
|
55
46
|
await addResourceToRoutes(route, {
|
|
56
47
|
singular: options.singular,
|
|
@@ -108,25 +108,6 @@ export default class Params {
|
|
|
108
108
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
109
109
|
return Params.for(params, dreamClass, { ...(opts ?? {}), only: allowed });
|
|
110
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
|
-
}
|
|
130
111
|
static restrict(params, allowed) {
|
|
131
112
|
if (params === null || params === undefined)
|
|
132
113
|
return {};
|
|
@@ -10,8 +10,6 @@ export default class PsychicBin {
|
|
|
10
10
|
modelName?: string;
|
|
11
11
|
tableName?: string;
|
|
12
12
|
softDelete: boolean;
|
|
13
|
-
withExtractParams?: boolean;
|
|
14
|
-
withExtractImplicitParams?: boolean;
|
|
15
13
|
}): Promise<void>;
|
|
16
14
|
static printRoutes(): void;
|
|
17
15
|
static printControllerHierarchy(controllersPath?: string): void;
|
|
@@ -251,13 +251,13 @@ 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
|
-
* @deprecated Prefer {@link extractParams}
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
* `
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
254
|
+
* @deprecated Prefer {@link extractParams} — the explicit-allowlist form
|
|
255
|
+
* keeps the permitted columns visible at the call site. Security-relevant:
|
|
256
|
+
* on models with permission-bearing fields, `paramsFor(Model)` without
|
|
257
|
+
* `only` extracts every column in `paramSafeColumnsOrFallback()` — which
|
|
258
|
+
* may be too broad unless the model declares `paramSafeColumns` explicitly.
|
|
259
|
+
* `extractParams` makes the allowlist visible to reviewers at the call
|
|
260
|
+
* site.
|
|
261
261
|
*
|
|
262
262
|
* Captures and validates parameters for the provided Dream model. Will
|
|
263
263
|
* exclude parameters that are not considered "safe" by default (based on
|
|
@@ -277,11 +277,7 @@ export default class PsychicController {
|
|
|
277
277
|
* ```ts
|
|
278
278
|
* class MyController extends ApplicationController {
|
|
279
279
|
* public create() {
|
|
280
|
-
* // Preferred: explicit allowlist via extractParams
|
|
281
280
|
* const safe = this.extractParams(User, ['email', 'name'])
|
|
282
|
-
*
|
|
283
|
-
* // Equivalent of the legacy `this.paramsFor(User)` under the new name:
|
|
284
|
-
* const viaModel = this.extractImplicitParams(User)
|
|
285
281
|
* }
|
|
286
282
|
* }
|
|
287
283
|
* ```
|
|
@@ -306,10 +302,6 @@ export default class PsychicController {
|
|
|
306
302
|
* `paramSafeColumnsOrFallback()` further strips anything a caller bypasses
|
|
307
303
|
* the type system to include.
|
|
308
304
|
*
|
|
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
305
|
* @param dreamClass - The Dream model class to retrieve params for
|
|
314
306
|
* @param allowed - Required. The columns permitted from the request.
|
|
315
307
|
* @param opts - Optional configuration
|
|
@@ -331,37 +323,6 @@ export default class PsychicController {
|
|
|
331
323
|
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
324
|
[K in AllowedArray[number] & keyof ParamSafeAttrs]: ParamSafeAttrs[K & keyof ParamSafeAttrs];
|
|
333
325
|
}>, 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;
|
|
365
326
|
/**
|
|
366
327
|
* Gets a cookie value from the request and casts it to the specified type.
|
|
367
328
|
*
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
export default function generateController({ fullyQualifiedControllerName, fullyQualifiedModelName, actions, columnsWithTypes, resourceSpecs, owningModel, singular, paramExtractionStrategy, }: {
|
|
1
|
+
export default function generateController({ fullyQualifiedControllerName, fullyQualifiedModelName, actions, columnsWithTypes, resourceSpecs, owningModel, singular, }: {
|
|
3
2
|
fullyQualifiedControllerName: string;
|
|
4
3
|
fullyQualifiedModelName?: string;
|
|
5
4
|
actions: string[];
|
|
@@ -7,5 +6,4 @@ export default function generateController({ fullyQualifiedControllerName, fully
|
|
|
7
6
|
resourceSpecs?: boolean;
|
|
8
7
|
owningModel?: string | undefined;
|
|
9
8
|
singular: boolean;
|
|
10
|
-
paramExtractionStrategy?: ParamExtractionStrategy | undefined;
|
|
11
9
|
}): Promise<void>;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
export
|
|
2
|
-
export default function generateControllerContent({ ancestorName, ancestorImportStatement, fullyQualifiedControllerName, fullyQualifiedModelName, actions, omitOpenApi, owningModel, forAdmin, forInternal, singular, columnsWithTypes, paramExtractionStrategy, }: {
|
|
1
|
+
export default function generateControllerContent({ ancestorName, ancestorImportStatement, fullyQualifiedControllerName, fullyQualifiedModelName, actions, omitOpenApi, owningModel, forAdmin, forInternal, singular, columnsWithTypes, }: {
|
|
3
2
|
ancestorName: string;
|
|
4
3
|
ancestorImportStatement: string;
|
|
5
4
|
fullyQualifiedControllerName: string;
|
|
@@ -11,5 +10,4 @@ export default function generateControllerContent({ ancestorName, ancestorImport
|
|
|
11
10
|
forInternal?: boolean;
|
|
12
11
|
singular: boolean;
|
|
13
12
|
columnsWithTypes?: string[];
|
|
14
|
-
paramExtractionStrategy?: ParamExtractionStrategy | undefined;
|
|
15
13
|
}): string;
|
|
@@ -14,5 +14,14 @@ export interface ParsedAttribute {
|
|
|
14
14
|
* paramSafe column allowlist) share one canonical interpretation of the
|
|
15
15
|
* tokens — and one canonical casing (camelCase) for the resulting attribute
|
|
16
16
|
* name.
|
|
17
|
+
*
|
|
18
|
+
* Supports the `Model@alias:belongs_to` shorthand for renaming the FK
|
|
19
|
+
* association. When `@alias` is present, the camelized alias becomes
|
|
20
|
+
* `attributeName`; otherwise (legacy form `Model:belongs_to`) the
|
|
21
|
+
* attribute name is derived from the model's last namespace segment.
|
|
22
|
+
*
|
|
23
|
+
* Mirrors Dream's CLI-side `parseAttribute` (kept as a parallel
|
|
24
|
+
* implementation rather than a shared import so neither package leaks an
|
|
25
|
+
* internal CLI helper through its public API surface).
|
|
17
26
|
*/
|
|
18
27
|
export default function parseAttribute(attribute: string): ParsedAttribute | null;
|
|
@@ -12,8 +12,6 @@ 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;
|
|
17
15
|
};
|
|
18
16
|
columnsWithTypes: string[];
|
|
19
17
|
}): Promise<void>;
|
|
@@ -42,24 +42,6 @@ export default class Params {
|
|
|
42
42
|
static extract<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<{
|
|
43
43
|
[K in AllowedArray[number] & keyof ParamSafeAttrs]: ParamSafeAttrs[K & keyof ParamSafeAttrs];
|
|
44
44
|
}>, ReturnPayload extends OptsType['array'] extends true ? ReturnPartial[] : ReturnPartial>(params: object, dreamClass: T, allowed: AllowedArray, opts?: OptsType): ReturnPayload;
|
|
45
|
-
/**
|
|
46
|
-
* ### .extractImplicit
|
|
47
|
-
*
|
|
48
|
-
* Implicit-allowlist extraction driven by the model's `paramSafeColumns`
|
|
49
|
-
* declaration (or, when undeclared, the framework's default-safe fallback
|
|
50
|
-
* from `paramSafeColumnsOrFallback()`). Equivalent to {@link Params.for}
|
|
51
|
-
* without `only`. Use from a controller via
|
|
52
|
-
* {@link PsychicController.extractImplicitParams}.
|
|
53
|
-
*
|
|
54
|
-
* Prefer {@link Params.extract} when the caller can enumerate the allowed
|
|
55
|
-
* columns at the call site — it makes the allowlist visible to reviewers
|
|
56
|
-
* at the point of use. Reach for this method when the model-level
|
|
57
|
-
* `paramSafeColumns` declaration is the canonical allowlist and duplicating
|
|
58
|
-
* it at each call site would create maintenance drift.
|
|
59
|
-
*/
|
|
60
|
-
static extractImplicit<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<{
|
|
61
|
-
[K in ParamSafeColumns[number & keyof ParamSafeColumns] & string]: ParamSafeAttrs[K & keyof ParamSafeAttrs];
|
|
62
|
-
}>, ReturnPayload extends OptsType['array'] extends true ? ReturnPartial[] : ReturnPartial>(params: object, dreamClass: T, opts?: OptsType): ReturnPayload;
|
|
63
45
|
static restrict<T extends typeof Params>(this: T, params: PsychicParamsPrimitive | PsychicParamsDictionary | PsychicParamsDictionary[], allowed: string[]): PsychicParamsDictionary;
|
|
64
46
|
/**
|
|
65
47
|
* ### .cast
|
|
@@ -139,10 +121,9 @@ export interface ParamsForOpts<OnlyArray> extends ParamsForOptsBase<OnlyArray> {
|
|
|
139
121
|
key?: string;
|
|
140
122
|
}
|
|
141
123
|
/**
|
|
142
|
-
* Options for {@link Params.extract}
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* `extractParams`, and `extractImplicitParams` has no allowlist argument.
|
|
124
|
+
* Options for {@link Params.extract} and the corresponding controller method.
|
|
125
|
+
* Mirrors {@link ParamsForOpts} minus `only` — the explicit allowlist moved
|
|
126
|
+
* to a required positional argument on `extractParams`.
|
|
146
127
|
*/
|
|
147
128
|
export interface ExtractParamsOpts {
|
|
148
129
|
array?: boolean;
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@rvoh/psychic",
|
|
4
4
|
"description": "Typescript web framework",
|
|
5
|
-
"version": "3.
|
|
5
|
+
"version": "3.4.1",
|
|
6
6
|
"author": "RVOHealth",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"@koa/cors": "^5.0.0",
|
|
76
76
|
"@koa/etag": "^5.0.2",
|
|
77
77
|
"@koa/router": "^15.3.0",
|
|
78
|
-
"@rvoh/dream": "^2.
|
|
78
|
+
"@rvoh/dream": "^2.10.0",
|
|
79
79
|
"@types/koa": "^3.0.1",
|
|
80
80
|
"@types/koa-bodyparser": "^4.3.12",
|
|
81
81
|
"@types/koa-conditional-get": "^2.0.3",
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"@koa/cors": "^5.0.0",
|
|
93
93
|
"@koa/etag": "^5.0.2",
|
|
94
94
|
"@koa/router": "^15.3.1",
|
|
95
|
-
"@rvoh/dream": "^2.
|
|
95
|
+
"@rvoh/dream": "^2.10.0",
|
|
96
96
|
"@rvoh/dream-spec-helpers": "^2.1.1",
|
|
97
97
|
"@rvoh/psychic-spec-helpers": "3.0.0",
|
|
98
98
|
"@types/koa": "^3.0.1",
|
|
@@ -118,8 +118,8 @@
|
|
|
118
118
|
"koa-conditional-get": "^3.0.0",
|
|
119
119
|
"koa-passport": "^6.0.0",
|
|
120
120
|
"koa-session": "^7.0.2",
|
|
121
|
-
"kysely": "^0.
|
|
122
|
-
"kysely-codegen": "~0.
|
|
121
|
+
"kysely": "^0.29.0",
|
|
122
|
+
"kysely-codegen": "~0.20.0",
|
|
123
123
|
"luxon-jest-matchers": "^0.1.14",
|
|
124
124
|
"nodemon": "^3.1.11",
|
|
125
125
|
"openapi-typescript": "^7.13.0",
|