@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
|
@@ -36,6 +36,7 @@ import HttpStatusUnavailableForLegalReasons from '../error/http/UnavailableForLe
|
|
|
36
36
|
import HttpStatusUnprocessableContent from '../error/http/UnprocessableContent.js';
|
|
37
37
|
import HttpStatusUnsupportedMediaType from '../error/http/UnsupportedMediaType.js';
|
|
38
38
|
import EnvInternal from '../helpers/EnvInternal.js';
|
|
39
|
+
import isSafeRedirectTarget from '../helpers/isSafeRedirectTarget.js';
|
|
39
40
|
import toJson from '../helpers/toJson.js';
|
|
40
41
|
import OpenapiPayloadValidator from '../openapi-renderer/helpers/OpenapiPayloadValidator.js';
|
|
41
42
|
import { cacheStringify, getCachedStringify } from '../openapi-renderer/helpers/stringify-cache.js';
|
|
@@ -257,11 +258,13 @@ export default class PsychicController {
|
|
|
257
258
|
const body = this.ctx.request.body ?? {};
|
|
258
259
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
|
259
260
|
const routeParams = this.ctx.params ?? {};
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
261
|
+
// R-012 defense-in-depth: use a prototype-less object as the merge target
|
|
262
|
+
// so any `__proto__` / `constructor` / `prototype` that arrives as an OWN
|
|
263
|
+
// key via upstream parser misconfig lands here rather than mutating
|
|
264
|
+
// Object.prototype. Node's JSON.parse and qs v6.10+ already protect against
|
|
265
|
+
// the classical prototype-pollution vector; this is belt-and-suspenders.
|
|
266
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
267
|
+
const params = Object.assign(Object.create(null), query, body, routeParams);
|
|
265
268
|
return params;
|
|
266
269
|
}
|
|
267
270
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -400,10 +403,18 @@ export default class PsychicController {
|
|
|
400
403
|
return this._castParam(keys, nestedParams, expectedType, opts);
|
|
401
404
|
}
|
|
402
405
|
/**
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
406
|
+
* @deprecated Prefer {@link extractParams} (explicit allowlist at the call
|
|
407
|
+
* site) or {@link extractImplicitParams} (same implicit-allowlist behavior,
|
|
408
|
+
* new name). Security-relevant: on models with permission-bearing fields,
|
|
409
|
+
* `paramsFor(Model)` without `only` extracts every column in
|
|
410
|
+
* `paramSafeColumnsOrFallback()` — which may be too broad unless the model
|
|
411
|
+
* declares `paramSafeColumns` explicitly. `extractParams` makes the
|
|
412
|
+
* allowlist visible to reviewers at the call site.
|
|
413
|
+
*
|
|
414
|
+
* Captures and validates parameters for the provided Dream model. Will
|
|
415
|
+
* exclude parameters that are not considered "safe" by default (based on
|
|
416
|
+
* the model's paramSafeColumns). Uses `castParam` for each parameter and
|
|
417
|
+
* will raise an exception if any parameter fails validation.
|
|
407
418
|
*
|
|
408
419
|
* @param dreamClass - The Dream model class to retrieve params for
|
|
409
420
|
* @param opts - Optional configuration object
|
|
@@ -418,17 +429,11 @@ export default class PsychicController {
|
|
|
418
429
|
* ```ts
|
|
419
430
|
* class MyController extends ApplicationController {
|
|
420
431
|
* public create() {
|
|
421
|
-
* //
|
|
422
|
-
* const
|
|
423
|
-
*
|
|
424
|
-
* // Restrict to only specific fields
|
|
425
|
-
* const restrictedParams = this.paramsFor(User, { only: ['email', 'name'] })
|
|
426
|
-
*
|
|
427
|
-
* // Include normally excluded fields
|
|
428
|
-
* const extendedParams = this.paramsFor(User, { including: ['createdAt'] })
|
|
432
|
+
* // Preferred: explicit allowlist via extractParams
|
|
433
|
+
* const safe = this.extractParams(User, ['email', 'name'])
|
|
429
434
|
*
|
|
430
|
-
* //
|
|
431
|
-
* const
|
|
435
|
+
* // Equivalent of the legacy `this.paramsFor(User)` under the new name:
|
|
436
|
+
* const viaModel = this.extractImplicitParams(User)
|
|
432
437
|
* }
|
|
433
438
|
* }
|
|
434
439
|
* ```
|
|
@@ -438,6 +443,81 @@ export default class PsychicController {
|
|
|
438
443
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
439
444
|
opts);
|
|
440
445
|
}
|
|
446
|
+
/**
|
|
447
|
+
* Captures and validates parameters for the provided Dream model using an
|
|
448
|
+
* explicit, required allowlist. This is the recommended primitive for
|
|
449
|
+
* controllers handling user-editable models — the allowlist is visible at
|
|
450
|
+
* the call site, so reviewers can see exactly which fields are accepted
|
|
451
|
+
* from the request.
|
|
452
|
+
*
|
|
453
|
+
* The `allowed` array is compile-time constrained to
|
|
454
|
+
* `DreamParamSafeColumnNames<InstanceType<T>>`, so protected columns
|
|
455
|
+
* (primary key, timestamps, belongs-to foreign keys, polymorphic type
|
|
456
|
+
* fields, STI `type` column, and any column in `explicitUnsafeParamColumns`)
|
|
457
|
+
* are TypeScript errors. At runtime, the intersection against the model's
|
|
458
|
+
* `paramSafeColumnsOrFallback()` further strips anything a caller bypasses
|
|
459
|
+
* the type system to include.
|
|
460
|
+
*
|
|
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
|
+
* @param dreamClass - The Dream model class to retrieve params for
|
|
466
|
+
* @param allowed - Required. The columns permitted from the request.
|
|
467
|
+
* @param opts - Optional configuration
|
|
468
|
+
* @param opts.key - Extract params from a nested key in the params object instead of root level
|
|
469
|
+
* @param opts.array - If true, expects and returns an array of param objects
|
|
470
|
+
* @returns A typed object containing the validated and casted params
|
|
471
|
+
* @throws {ParamValidationError} When any parameter validation fails
|
|
472
|
+
*
|
|
473
|
+
* @example
|
|
474
|
+
* ```ts
|
|
475
|
+
* class BalloonsController extends ApplicationController {
|
|
476
|
+
* public create() {
|
|
477
|
+
* const params = this.extractParams(Balloon, ['name', 'color'])
|
|
478
|
+
* const balloon = await Balloon.create(params)
|
|
479
|
+
* }
|
|
480
|
+
* }
|
|
481
|
+
* ```
|
|
482
|
+
*/
|
|
483
|
+
extractParams(dreamClass, allowed, opts) {
|
|
484
|
+
const source = opts?.key ? this.params[opts.key] || {} : this.params;
|
|
485
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
486
|
+
return Params.extract(source, dreamClass, allowed, opts);
|
|
487
|
+
}
|
|
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
|
+
}
|
|
441
521
|
/**
|
|
442
522
|
* Gets a cookie value from the request and casts it to the specified type.
|
|
443
523
|
*
|
|
@@ -691,6 +771,12 @@ export default class PsychicController {
|
|
|
691
771
|
koaRedirect(statusCode, newLocation) {
|
|
692
772
|
if (this._responseSent)
|
|
693
773
|
return;
|
|
774
|
+
const allowedHosts = PsychicApp.getOrFail().redirectAllowedHosts;
|
|
775
|
+
if (!isSafeRedirectTarget(newLocation, { allowedHosts })) {
|
|
776
|
+
throw new HttpStatusInternalServerError(`[psychic] refused to redirect to unsafe target: ${JSON.stringify(newLocation)}. ` +
|
|
777
|
+
`Only same-origin paths (starting with '/') or absolute URLs whose host is in ` +
|
|
778
|
+
`PsychicApp.set('redirectAllowedHosts', [...]) are permitted.`);
|
|
779
|
+
}
|
|
694
780
|
this.ctx.status = statusCode;
|
|
695
781
|
this.ctx.redirect(newLocation);
|
|
696
782
|
this._responseSent = true;
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import { debuglog } from 'node:util';
|
|
3
|
+
import LaunchDevServerRequiresDevelopmentOrTest from '../../error/devtools/LaunchDevServerRequiresDevelopmentOrTest.js';
|
|
3
4
|
import UnexpectedUndefined from '../../error/UnexpectedUndefined.js';
|
|
5
|
+
import EnvInternal from '../../helpers/EnvInternal.js';
|
|
4
6
|
import PsychicApp from '../../psychic-app/index.js';
|
|
5
7
|
import sleep from './sleep.js';
|
|
6
8
|
const devServerProcesses = {};
|
|
7
9
|
const debugEnabled = debuglog('psychic').enabled;
|
|
8
10
|
export async function launchDevServer(key, { port = 3000, cmd = 'pnpm client', timeout = 20000, onStdOut } = {}) {
|
|
11
|
+
if (!EnvInternal.isDevelopmentOrTest)
|
|
12
|
+
throw new LaunchDevServerRequiresDevelopmentOrTest(cmd);
|
|
9
13
|
if (devServerProcesses[key])
|
|
10
14
|
return;
|
|
11
15
|
if (debugEnabled)
|
|
@@ -10,7 +10,7 @@ export default class InternalEncrypt {
|
|
|
10
10
|
throw new MissingCookieEncryptionOpts();
|
|
11
11
|
if (data === null || data === undefined)
|
|
12
12
|
return null;
|
|
13
|
-
return
|
|
13
|
+
return Encrypt.encrypt(data, encryptOpts.current);
|
|
14
14
|
}
|
|
15
15
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
16
16
|
static decryptCookie(data) {
|
|
@@ -20,21 +20,7 @@ export default class InternalEncrypt {
|
|
|
20
20
|
throw new MissingCookieEncryptionOpts();
|
|
21
21
|
if (data === null || data === undefined)
|
|
22
22
|
return null;
|
|
23
|
-
return this.doDecryption(data, encryptOpts.current, encryptOpts.legacy);
|
|
24
|
-
}
|
|
25
|
-
static doEncryption(
|
|
26
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
27
|
-
data, encryptionOpts) {
|
|
28
|
-
return Encrypt.encrypt(data, encryptionOpts);
|
|
29
|
-
}
|
|
30
|
-
static doDecryption(
|
|
31
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
|
-
data, encryptionOpts, legacyEncryptionOpts) {
|
|
33
23
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
34
|
-
|
|
35
|
-
if (decrypted === null && legacyEncryptionOpts)
|
|
36
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
37
|
-
decrypted = Encrypt.decrypt(data, legacyEncryptionOpts);
|
|
38
|
-
return decrypted;
|
|
24
|
+
return Encrypt.decrypt(data, encryptOpts.current, encryptOpts.legacy);
|
|
39
25
|
}
|
|
40
26
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export default class LaunchDevServerRequiresDevelopmentOrTest extends Error {
|
|
2
|
+
cmd;
|
|
3
|
+
constructor(cmd) {
|
|
4
|
+
super();
|
|
5
|
+
this.cmd = cmd;
|
|
6
|
+
}
|
|
7
|
+
get message() {
|
|
8
|
+
return `
|
|
9
|
+
launchDevServer refused to run outside development or test
|
|
10
|
+
(NODE_ENV must be 'development' or 'test').
|
|
11
|
+
|
|
12
|
+
This helper exists to spawn a local dev server during development
|
|
13
|
+
and tests. A deployed process has no business shelling out to boot
|
|
14
|
+
another server, so refusing here turns the dev-only contract into a
|
|
15
|
+
runtime invariant. Checking \`!isDevelopmentOrTest\` (rather than
|
|
16
|
+
\`isProduction\`) means staging-style envs and any unforeseen
|
|
17
|
+
NODE_ENV value also fail closed.
|
|
18
|
+
|
|
19
|
+
cmd: ${this.cmd}
|
|
20
|
+
`;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -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() {
|
|
4
8
|
return 500;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export default class OpenApiSpecDiffRequiresDevelopmentOrTest extends Error {
|
|
2
|
+
get message() {
|
|
3
|
+
return `
|
|
4
|
+
OpenApiSpecDiff refused to run outside development or test
|
|
5
|
+
(NODE_ENV must be 'development' or 'test').
|
|
6
|
+
|
|
7
|
+
This helper shells out to \`oasdiff\` to compare local OpenAPI specs
|
|
8
|
+
against the head branch. It is invoked only via \`pnpm psy openapi:spec-diff\`
|
|
9
|
+
in developer or CI workflows; refusing here turns the dev-only contract
|
|
10
|
+
into a runtime invariant. Checking \`!isDevelopmentOrTest\` (rather than
|
|
11
|
+
\`isProduction\`) means staging-style envs and any unforeseen NODE_ENV
|
|
12
|
+
value also fail closed.
|
|
13
|
+
`;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -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, paramExtractionStrategy, }) {
|
|
13
13
|
fullyQualifiedModelName = fullyQualifiedModelName
|
|
14
14
|
? DreamApp.system.standardizeFullyQualifiedModelName(fullyQualifiedModelName)
|
|
15
15
|
: fullyQualifiedModelName;
|
|
@@ -70,6 +70,8 @@ export default async function generateController({ fullyQualifiedControllerName,
|
|
|
70
70
|
forAdmin,
|
|
71
71
|
forInternal,
|
|
72
72
|
singular,
|
|
73
|
+
columnsWithTypes,
|
|
74
|
+
paramExtractionStrategy,
|
|
73
75
|
}));
|
|
74
76
|
}
|
|
75
77
|
catch (error) {
|
|
@@ -1,7 +1,37 @@
|
|
|
1
1
|
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 = [], paramExtractionStrategy, }) {
|
|
6
|
+
// Admin scaffolds lean on the model's declared paramSafeColumns (implicit);
|
|
7
|
+
// non-admin scaffolds require an explicit allowlist at the call site so the
|
|
8
|
+
// permitted columns are visible to reviewers. Either default can be overridden
|
|
9
|
+
// via the `--with-extract-params` / `--with-extract-implicit-params` CLI flags,
|
|
10
|
+
// which get materialized into `paramExtractionStrategy` by the caller.
|
|
11
|
+
const resolvedExtractionStrategy = paramExtractionStrategy ?? (forAdmin ? 'implicit' : 'explicit');
|
|
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
35
|
fullyQualifiedControllerName = DreamApp.system.standardizeFullyQualifiedModelName(fullyQualifiedControllerName);
|
|
6
36
|
const additionalImports = [];
|
|
7
37
|
const controllerClassName = DreamApp.system.globalClassNameFromFullyQualifiedModelName(fullyQualifiedControllerName);
|
|
@@ -42,7 +72,7 @@ export default function generateControllerContent({ ancestorName, ancestorImport
|
|
|
42
72
|
fastJsonStringify: true,
|
|
43
73
|
})
|
|
44
74
|
public async create() {
|
|
45
|
-
// let ${modelAttributeName} = await ${useDirectModelAccess ? `${modelClassName}.create(` : `this.${owningModelProperty}.createAssociation('${pluralizedModelAttributeName}', `}
|
|
75
|
+
// let ${modelAttributeName} = await ${useDirectModelAccess ? `${modelClassName}.create(` : `this.${owningModelProperty}.createAssociation('${pluralizedModelAttributeName}', `}${extractCallExpression(modelClassName)})
|
|
46
76
|
// if (${modelAttributeName}.isPersisted) ${modelAttributeName} = await ${modelAttributeName}.loadFor('${forAdmin ? 'admin' : forInternal ? 'internal' : 'default'}').execute()
|
|
47
77
|
// this.created(${modelAttributeName})
|
|
48
78
|
}`;
|
|
@@ -119,7 +149,7 @@ export default function generateControllerContent({ ancestorName, ancestorImport
|
|
|
119
149
|
})
|
|
120
150
|
public async update() {
|
|
121
151
|
// const ${modelAttributeName} = await this.${modelAttributeName}()
|
|
122
|
-
// await ${modelAttributeName}.update(
|
|
152
|
+
// await ${modelAttributeName}.update(${extractCallExpression(modelClassName)})
|
|
123
153
|
// this.noContent()
|
|
124
154
|
}`;
|
|
125
155
|
else
|
|
@@ -2,6 +2,7 @@ import { DreamApp } from '@rvoh/dream';
|
|
|
2
2
|
import { camelize, capitalize, compact, uniq } from '@rvoh/dream/utils';
|
|
3
3
|
import pluralize from 'pluralize-esm';
|
|
4
4
|
import addImportSuffix from '../../helpers/path/addImportSuffix.js';
|
|
5
|
+
import parseAttribute from './parseAttribute.js';
|
|
5
6
|
export default function generateResourceControllerSpecContent(options) {
|
|
6
7
|
const { path, pathParams } = extractPathArgsFromResourcefulPath(options.route);
|
|
7
8
|
const modelConfig = createModelConfiguration(options);
|
|
@@ -88,29 +89,6 @@ function processAttributes(columnsWithTypes, modelConfig, fullyQualifiedModelNam
|
|
|
88
89
|
}
|
|
89
90
|
return attributeData;
|
|
90
91
|
}
|
|
91
|
-
function parseAttribute(attribute) {
|
|
92
|
-
const [rawAttributeName, rawAttributeType, , enumValues] = attribute.split(':');
|
|
93
|
-
if (!rawAttributeName || !rawAttributeType)
|
|
94
|
-
return null;
|
|
95
|
-
const sanitizedAttrType = camelize(rawAttributeType)?.toLowerCase();
|
|
96
|
-
// Handle belongs_to relationships
|
|
97
|
-
if (sanitizedAttrType === 'belongsto') {
|
|
98
|
-
// For belongs_to relationships, convert "Ticketing/Ticket" to "ticket"
|
|
99
|
-
const attributeName = camelize(rawAttributeName.split('/').pop());
|
|
100
|
-
return { attributeName, attributeType: 'belongs_to', isArray: false, enumValues };
|
|
101
|
-
}
|
|
102
|
-
// Skip _type and _id columns, but not belongs_to relationships
|
|
103
|
-
if (/(_type|_id)$/.test(rawAttributeName))
|
|
104
|
-
return null;
|
|
105
|
-
const attributeName = camelize(rawAttributeName);
|
|
106
|
-
if (attributeName === 'deletedAt')
|
|
107
|
-
return null;
|
|
108
|
-
const arrayBracketRegexp = /\[\]$/;
|
|
109
|
-
const isArray = arrayBracketRegexp.test(rawAttributeType);
|
|
110
|
-
const _attributeType = rawAttributeType.replace(arrayBracketRegexp, '');
|
|
111
|
-
const attributeType = /uuid$/.test(rawAttributeName) ? 'uuid' : _attributeType;
|
|
112
|
-
return { attributeName, attributeType, isArray, enumValues };
|
|
113
|
-
}
|
|
114
92
|
function processAttributeByType({ attributeType, attributeName, isArray, enumValues, dotNotationVariable, fullyQualifiedModelName, attributeData, }) {
|
|
115
93
|
const sanitizedAttributeType = camelize(attributeType).toLowerCase();
|
|
116
94
|
switch (sanitizedAttributeType) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import parseAttribute from './parseAttribute.js';
|
|
2
|
+
/**
|
|
3
|
+
* Derive the param-safe column names from the `columnsWithTypes` CLI tokens
|
|
4
|
+
* supplied to `psy g:resource`. Filters the same set Dream's
|
|
5
|
+
* `defaultParamSafeColumns()` filters at runtime
|
|
6
|
+
* (`dream/src/Dream.ts:826-844`) — accepted deliberate duplication, since
|
|
7
|
+
* the generator runs before a live model class exists (greenfield) and
|
|
8
|
+
* therefore cannot introspect via `ModelClass.paramSafeColumnsOrFallback()`.
|
|
9
|
+
*
|
|
10
|
+
* Delegates token parsing + camelCase normalization to the shared
|
|
11
|
+
* `parseAttribute` helper so the casing emitted into generated controllers
|
|
12
|
+
* matches what every other generator emits. Then drops the additional
|
|
13
|
+
* reserved names that `parseAttribute` does not filter on its own.
|
|
14
|
+
*
|
|
15
|
+
* Omits:
|
|
16
|
+
* - Reserved camelCase names: `id`, `createdAt`, `updatedAt`, `deletedAt`, `type`
|
|
17
|
+
* - Type annotation `:belongs_to` (foreign keys)
|
|
18
|
+
* - Name suffix `_type` (polymorphic type discriminators)
|
|
19
|
+
* - Name suffix `_id` (polymorphic foreign keys)
|
|
20
|
+
*/
|
|
21
|
+
export default function paramSafeColumnNamesFromCliTokens(tokens) {
|
|
22
|
+
const reserved = new Set(['id', 'createdAt', 'updatedAt', 'deletedAt', 'type']);
|
|
23
|
+
const safe = [];
|
|
24
|
+
for (const token of tokens) {
|
|
25
|
+
const parsed = parseAttribute(token);
|
|
26
|
+
if (!parsed)
|
|
27
|
+
continue;
|
|
28
|
+
if (parsed.attributeType === 'belongs_to')
|
|
29
|
+
continue;
|
|
30
|
+
if (reserved.has(parsed.attributeName))
|
|
31
|
+
continue;
|
|
32
|
+
safe.push(parsed.attributeName);
|
|
33
|
+
}
|
|
34
|
+
return safe;
|
|
35
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { camelize } from '@rvoh/dream/utils';
|
|
2
|
+
/**
|
|
3
|
+
* Parse a `name:type[:enumName:enumValues]` CLI token into its camelCase
|
|
4
|
+
* attribute name and metadata. Returns `null` for tokens that should be
|
|
5
|
+
* dropped from the generated artifact (polymorphic `_type`/`_id` columns,
|
|
6
|
+
* `deletedAt`, or malformed tokens missing a name or type).
|
|
7
|
+
*
|
|
8
|
+
* Centralized so generators (controller scaffolds, resource specs, the
|
|
9
|
+
* paramSafe column allowlist) share one canonical interpretation of the
|
|
10
|
+
* tokens — and one canonical casing (camelCase) for the resulting attribute
|
|
11
|
+
* name.
|
|
12
|
+
*/
|
|
13
|
+
export default function parseAttribute(attribute) {
|
|
14
|
+
const [rawAttributeName, rawAttributeType, , enumValues] = attribute.split(':');
|
|
15
|
+
if (!rawAttributeName || !rawAttributeType)
|
|
16
|
+
return null;
|
|
17
|
+
const sanitizedAttrType = camelize(rawAttributeType)?.toLowerCase();
|
|
18
|
+
// Handle belongs_to relationships
|
|
19
|
+
if (sanitizedAttrType === 'belongsto') {
|
|
20
|
+
// For belongs_to relationships, convert "Ticketing/Ticket" to "ticket"
|
|
21
|
+
const attributeName = camelize(rawAttributeName.split('/').pop());
|
|
22
|
+
return { attributeName, attributeType: 'belongs_to', isArray: false, enumValues };
|
|
23
|
+
}
|
|
24
|
+
// Skip _type and _id columns, but not belongs_to relationships
|
|
25
|
+
if (/(_type|_id)$/.test(rawAttributeName))
|
|
26
|
+
return null;
|
|
27
|
+
const attributeName = camelize(rawAttributeName);
|
|
28
|
+
if (attributeName === 'deletedAt')
|
|
29
|
+
return null;
|
|
30
|
+
const arrayBracketRegexp = /\[\]$/;
|
|
31
|
+
const isArray = arrayBracketRegexp.test(rawAttributeType);
|
|
32
|
+
const _attributeType = rawAttributeType.replace(arrayBracketRegexp, '');
|
|
33
|
+
const attributeType = /uuid$/.test(rawAttributeName) ? 'uuid' : _attributeType;
|
|
34
|
+
return { attributeName, attributeType, isArray, enumValues };
|
|
35
|
+
}
|
|
@@ -23,7 +23,7 @@ export default async function writeInitializer({ exportName }) {
|
|
|
23
23
|
await fs.mkdir(destDir, { recursive: true });
|
|
24
24
|
}
|
|
25
25
|
const filePath = path.join('.', 'src', 'conf', 'openapi', `${camelized}.openapi-codegen.json`);
|
|
26
|
-
const
|
|
26
|
+
const { command, args } = PackageManager.exec('rtk-query-codegen-openapi', [filePath]);
|
|
27
27
|
const contents = `\
|
|
28
28
|
import { DreamCLI } from '@rvoh/dream/system'
|
|
29
29
|
import { PsychicApp } from '@rvoh/psychic'
|
|
@@ -33,7 +33,8 @@ export default function initialize${pascalized}(psy: PsychicApp) {
|
|
|
33
33
|
psy.on('cli:sync', async () => {
|
|
34
34
|
if (AppEnv.isDevelopmentOrTest) {
|
|
35
35
|
await DreamCLI.logger.logProgress(\`[${camelized}] syncing...\`, async () => {
|
|
36
|
-
await DreamCLI.spawn('${
|
|
36
|
+
await DreamCLI.spawn('${command}', {
|
|
37
|
+
args: ${JSON.stringify(args)},
|
|
37
38
|
onStdout: message => {
|
|
38
39
|
DreamCLI.logger.logContinueProgress(\`[${camelized}]\` + ' ' + message, {
|
|
39
40
|
logPrefixColor: 'green',
|
|
@@ -16,7 +16,7 @@ export default async function generateInitializer(openapiFilepath, outfile, init
|
|
|
16
16
|
catch {
|
|
17
17
|
await fs.mkdir(destDir, { recursive: true });
|
|
18
18
|
}
|
|
19
|
-
const
|
|
19
|
+
const { command, args } = PackageManager.exec('openapi-typescript', [openapiFilepath, '-o', outfile]);
|
|
20
20
|
const contents = `\
|
|
21
21
|
import { DreamCLI } from '@rvoh/dream/system'
|
|
22
22
|
import { PsychicApp } from "@rvoh/psychic"
|
|
@@ -26,7 +26,7 @@ export default (psy: PsychicApp) => {
|
|
|
26
26
|
psy.on('cli:sync', async () => {
|
|
27
27
|
if (AppEnv.isDevelopmentOrTest) {
|
|
28
28
|
await DreamCLI.logger.logProgress(\`[${hyphenized}] extracting types from ${openapiFilepath} to ${outfile}...\`, async () => {
|
|
29
|
-
await DreamCLI.spawn('${
|
|
29
|
+
await DreamCLI.spawn('${command}', { args: ${JSON.stringify(args)} })
|
|
30
30
|
})
|
|
31
31
|
}
|
|
32
32
|
})
|
|
@@ -4,14 +4,14 @@ import EnvInternal from '../../../helpers/EnvInternal.js';
|
|
|
4
4
|
export default async function installOpenapiTypescript() {
|
|
5
5
|
if (EnvInternal.isTest)
|
|
6
6
|
return;
|
|
7
|
-
const
|
|
7
|
+
const { command, args } = PackageManager.add('openapi-typescript', { dev: true });
|
|
8
8
|
try {
|
|
9
|
-
await DreamCLI.spawn(
|
|
9
|
+
await DreamCLI.spawn(command, { args });
|
|
10
10
|
}
|
|
11
11
|
catch {
|
|
12
12
|
console.log(`Failed to install openapi-typescript as a dev dependency. Please make sure the following command succeeds:
|
|
13
13
|
|
|
14
|
-
"${
|
|
14
|
+
"${command} ${args.join(' ')}"
|
|
15
15
|
`);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
@@ -6,7 +6,7 @@ import psychicPath from '../../../helpers/path/psychicPath.js';
|
|
|
6
6
|
export default async function writeInitializer({ exportName, schemaFile, outputDir, }) {
|
|
7
7
|
const pascalized = pascalize(exportName);
|
|
8
8
|
const camelized = camelize(exportName);
|
|
9
|
-
const
|
|
9
|
+
const { command, args } = PackageManager.exec('openapi-ts', ['-i', schemaFile, '-o', outputDir]);
|
|
10
10
|
const destDir = path.join(psychicPath('conf'), 'initializers', 'openapi');
|
|
11
11
|
const initializerFilename = `${camelized}.ts`;
|
|
12
12
|
const initializerPath = path.join(destDir, initializerFilename);
|
|
@@ -20,7 +20,8 @@ export default function initialize${pascalized}(psy: PsychicApp) {
|
|
|
20
20
|
psy.on('cli:sync', async () => {
|
|
21
21
|
if (AppEnv.isDevelopmentOrTest) {
|
|
22
22
|
await DreamCLI.logger.logProgress(\`[${camelized}] syncing...\`, async () => {
|
|
23
|
-
await DreamCLI.spawn('${
|
|
23
|
+
await DreamCLI.spawn('${command}', {
|
|
24
|
+
args: ${JSON.stringify(args)},
|
|
24
25
|
onStdout: message => {
|
|
25
26
|
DreamCLI.logger.logContinueProgress(\`[${camelized}]\` + ' ' + message, {
|
|
26
27
|
logPrefixColor: 'green',
|
|
@@ -25,6 +25,7 @@ export default async function generateOpenapiReduxBindings(options = {}) {
|
|
|
25
25
|
await writeOpenapiJsonFile(opts);
|
|
26
26
|
await writeApiFile(opts);
|
|
27
27
|
await writeInitializer(opts);
|
|
28
|
-
|
|
28
|
+
const { command, args } = PackageManager.add(['@rtk-query/codegen-openapi', 'ts-node'], { dev: true });
|
|
29
|
+
await DreamCLI.spawn(command, { args });
|
|
29
30
|
printFinalStepsMessage(opts);
|
|
30
31
|
}
|
|
@@ -23,6 +23,7 @@ export default async function generateOpenapiZustandBindings(options = {}) {
|
|
|
23
23
|
const opts = await promptForOptions(options);
|
|
24
24
|
await writeClientConfigFile(opts);
|
|
25
25
|
await writeInitializer(opts);
|
|
26
|
-
|
|
26
|
+
const { command, args } = PackageManager.add(['@hey-api/openapi-ts'], { dev: true });
|
|
27
|
+
await DreamCLI.spawn(command, { args });
|
|
27
28
|
printFinalStepsMessage(opts);
|
|
28
29
|
}
|
|
@@ -32,6 +32,14 @@ export default async function generateResource({ route, fullyQualifiedModelName,
|
|
|
32
32
|
softDelete: options.softDelete,
|
|
33
33
|
},
|
|
34
34
|
});
|
|
35
|
+
if (options.withExtractParams && options.withExtractImplicitParams) {
|
|
36
|
+
throw new Error('--with-extract-params and --with-extract-implicit-params are mutually exclusive; pass only one.');
|
|
37
|
+
}
|
|
38
|
+
const paramExtractionStrategy = options.withExtractParams
|
|
39
|
+
? 'explicit'
|
|
40
|
+
: options.withExtractImplicitParams
|
|
41
|
+
? 'implicit'
|
|
42
|
+
: undefined;
|
|
35
43
|
await generateController({
|
|
36
44
|
fullyQualifiedControllerName,
|
|
37
45
|
fullyQualifiedModelName,
|
|
@@ -42,6 +50,7 @@ export default async function generateResource({ route, fullyQualifiedModelName,
|
|
|
42
50
|
resourceSpecs: true,
|
|
43
51
|
singular: options.singular,
|
|
44
52
|
owningModel: options.owningModel,
|
|
53
|
+
paramExtractionStrategy,
|
|
45
54
|
});
|
|
46
55
|
await addResourceToRoutes(route, {
|
|
47
56
|
singular: options.singular,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true when `target` is a safe destination for an HTTP redirect.
|
|
3
|
+
*
|
|
4
|
+
* A safe target is either:
|
|
5
|
+
* - a same-origin relative path beginning with a single `/` (not `//`, not `/\`),
|
|
6
|
+
* with no control characters (CR/LF/NUL/etc.) that would enable response-splitting
|
|
7
|
+
* - an absolute `http:` or `https:` URL whose hostname exactly matches an entry in
|
|
8
|
+
* `opts.allowedHosts` (case-insensitive, port-insensitive — use explicit
|
|
9
|
+
* entries with a port if port-restriction is desired at a deeper layer)
|
|
10
|
+
*
|
|
11
|
+
* Everything else is rejected, including:
|
|
12
|
+
* - `javascript:`, `data:`, `vbscript:`, `file:` and other non-http(s) schemes
|
|
13
|
+
* - scheme-relative URLs (`//evil.com`) and their backslash variants (`/\evil.com`)
|
|
14
|
+
* - absolute URLs to hostnames not in the allowlist (even with userinfo tricks
|
|
15
|
+
* like `http://allowed.com@evil.com/…` — WHATWG URL parses the host as `evil.com`)
|
|
16
|
+
* - any input containing ASCII control characters, leading/trailing whitespace,
|
|
17
|
+
* or raw NULs
|
|
18
|
+
*
|
|
19
|
+
* The allowlist is purposely restrictive (no wildcards yet). Applications that
|
|
20
|
+
* legitimately redirect to external destinations register hosts via
|
|
21
|
+
* `PsychicApp.set('redirectAllowedHosts', ['oauth.example.com'])`.
|
|
22
|
+
*/
|
|
23
|
+
export default function isSafeRedirectTarget(target, opts) {
|
|
24
|
+
if (typeof target !== 'string')
|
|
25
|
+
return false;
|
|
26
|
+
if (target.length === 0)
|
|
27
|
+
return false;
|
|
28
|
+
// Reject any control character (CR, LF, NUL, BEL, DEL …). Without this,
|
|
29
|
+
// `\r\n` in the Location value enables header / response splitting.
|
|
30
|
+
// eslint-disable-next-line no-control-regex
|
|
31
|
+
if (/[\x00-\x1F\x7F]/.test(target))
|
|
32
|
+
return false;
|
|
33
|
+
// Whitespace padding is used to defeat simple prefix checks; require a
|
|
34
|
+
// clean input rather than silently trimming.
|
|
35
|
+
if (target !== target.trim())
|
|
36
|
+
return false;
|
|
37
|
+
// Scheme-relative URLs and their backslash variants are treated by browsers
|
|
38
|
+
// as network-path references and would escape the origin.
|
|
39
|
+
if (target.startsWith('//'))
|
|
40
|
+
return false;
|
|
41
|
+
if (target.startsWith('\\'))
|
|
42
|
+
return false;
|
|
43
|
+
if (target.startsWith('/\\'))
|
|
44
|
+
return false;
|
|
45
|
+
if (target.startsWith('/%5C') || target.startsWith('/%5c'))
|
|
46
|
+
return false;
|
|
47
|
+
// Relative same-origin path: must begin with exactly one '/'.
|
|
48
|
+
if (target.startsWith('/'))
|
|
49
|
+
return true;
|
|
50
|
+
// Absolute URLs are validated via the WHATWG URL parser. This normalizes
|
|
51
|
+
// userinfo / percent-encoding / unicode-homograph tricks so the allowlist
|
|
52
|
+
// check runs against the parsed hostname rather than a raw substring.
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = new URL(target);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
61
|
+
return false;
|
|
62
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
63
|
+
return opts.allowedHosts.some(allowed => allowed.toLowerCase() === hostname);
|
|
64
|
+
}
|