@devmoods/express-extras 0.49.3 → 0.50.0

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.
@@ -1,8 +1,12 @@
1
1
  import { type AnyUser, type AuthProvider } from './types.js';
2
+ export interface AuthPasswordChangedMetadata {
3
+ ip: string;
4
+ userAgent: string;
5
+ }
2
6
  export interface EmailAndPasswordOptions<UserModel = AnyUser> {
3
7
  login: (email: string, verifyPassword: (realPassword: string) => Promise<string>) => Promise<UserModel>;
4
8
  onPasswordResetRequest?(user: UserModel, temporaryToken: string): void | Promise<void>;
5
- onPasswordChanged?(user: UserModel, newPassword: string): Promise<void>;
9
+ onPasswordChanged?(user: UserModel, newPassword: string, metadata: AuthPasswordChangedMetadata): Promise<void>;
6
10
  onEmailVerificationRequest?(user: UserModel, temporaryToken: string): Promise<void>;
7
11
  onEmailVerified?(user: UserModel): Promise<void>;
8
12
  }
@@ -52,7 +52,10 @@ export function EmailAndPasswordAuthProvider(options) {
52
52
  throw Unauthorized('Invalid token');
53
53
  }
54
54
  context.setCurrentUser(user);
55
- await options.onPasswordChanged?.(user, await hashPassword(password));
55
+ await options.onPasswordChanged?.(user, await hashPassword(password), {
56
+ ip: req.ip ?? '',
57
+ userAgent: req.headers['user-agent'] ?? '',
58
+ });
56
59
  logger.info('user changed password');
57
60
  return 200;
58
61
  }));
package/dist/faktory.d.ts CHANGED
@@ -14,7 +14,6 @@ export declare function createJobs<GlobalContext extends AnyJobContext>({ queueN
14
14
  $job: boolean;
15
15
  jobName: string;
16
16
  options: JobOptions;
17
- readonly executionCount: number;
18
17
  delay(args?: Parameters<Fn>[0]): Promise<`${string}-${string}-${string}-${string}-${string}`>;
19
18
  execute(args: Parameters<Fn>[0], context: GlobalContext): Promise<any>;
20
19
  };
package/dist/faktory.js CHANGED
@@ -14,7 +14,6 @@ export function createJobs({ queueName, faktory: createFactory = create, }) {
14
14
  throw new Error('job() must get a function as the first or second argument');
15
15
  }
16
16
  const jobName = fn.name;
17
- let executionCount = 0;
18
17
  if (!jobName) {
19
18
  throw new Error('Can not create job without a name');
20
19
  }
@@ -22,9 +21,6 @@ export function createJobs({ queueName, faktory: createFactory = create, }) {
22
21
  $job: true,
23
22
  jobName,
24
23
  options,
25
- get executionCount() {
26
- return executionCount;
27
- },
28
24
  async delay(args) {
29
25
  const jid = randomUUID();
30
26
  try {
@@ -49,7 +45,6 @@ export function createJobs({ queueName, faktory: createFactory = create, }) {
49
45
  async execute(args, context) {
50
46
  try {
51
47
  const result = await fn(args, context);
52
- executionCount += 1;
53
48
  return result;
54
49
  }
55
50
  catch (error) {
package/dist/listen.js CHANGED
@@ -6,7 +6,7 @@ import {} from 'express';
6
6
  import { config } from './config.js';
7
7
  import { BadRequest } from './errors.js';
8
8
  import { getLogger } from './logger.js';
9
- import { before, route } from './middleware.js';
9
+ import { route } from './middleware.js';
10
10
  import { envPort, startTimer } from './utils.js';
11
11
  import { validateQuery } from './validate.js';
12
12
  const logger = getLogger('http');
@@ -103,7 +103,7 @@ export function createExpressServer(app, options = {}) {
103
103
  }
104
104
  const healthTypes = ['live', 'ready'];
105
105
  if (!app.get('__healthAttached')) {
106
- app.get('/health/:type?', before(), validateQuery({
106
+ app.get('/health/:type?', validateQuery({
107
107
  type: 'object',
108
108
  properties: {
109
109
  type: { type: 'string', enum: ['live', 'ready'] },
package/dist/manage.js CHANGED
@@ -46,8 +46,16 @@ export function registerDefaultCommands(app) {
46
46
  }));
47
47
  });
48
48
  registerManagementCommand((command, cli) => {
49
- command('show-config').action(cli.wrap(async () => {
50
- console.log(config.value);
49
+ command('show-config')
50
+ .option('--json', 'Output JSON')
51
+ .action(cli.wrap(async (options) => {
52
+ if (options.json) {
53
+ console.log(JSON.stringify(config.value, null, 2));
54
+ return;
55
+ }
56
+ for (const [key, value] of Object.entries(config.value)) {
57
+ cli.writeLine(`${key}=${value}\n`);
58
+ }
51
59
  }));
52
60
  });
53
61
  }
@@ -13,18 +13,42 @@ export interface BeforeMiddlewareCSPDirectives {
13
13
  [key: string]: ContentSecurityPolicyDirectiveValue[];
14
14
  }
15
15
  export interface BeforeMiddlewareOptions {
16
+ /**
17
+ * Enable cookie parsing
18
+ */
16
19
  cookies?: boolean;
20
+ /**
21
+ * Enable helmet security headers
22
+ *
23
+ * https://github.com/helmetjs/helmet
24
+ */
17
25
  helmet?: boolean;
26
+ /**
27
+ * Enable JSON body parsing
28
+ */
18
29
  json?: boolean;
30
+ /**
31
+ * Enable URL encoded body parsing
32
+ */
33
+ urlencoded?: boolean;
34
+ /**
35
+ * Enable CSRF protection
36
+ */
19
37
  csrf?: boolean | CsrfMiddlewareOptions;
38
+ /**
39
+ * Allow unsafe inline scripts
40
+ */
20
41
  allowUnsafeInlineScripts?: boolean;
42
+ /**
43
+ * Custom CSP directives
44
+ */
21
45
  cspDirectives?: (defaults: BeforeMiddlewareCSPDirectives) => BeforeMiddlewareCSPDirectives;
22
46
  }
23
47
  export declare function useCspNonce(): string;
24
48
  /**
25
49
  * Register middleware that must come before the app routes.
26
50
  */
27
- export declare function before({ cookies, helmet: useHelmet, json: useJson, csrf: useCsrf, allowUnsafeInlineScripts, cspDirectives, }?: BeforeMiddlewareOptions): import("compose-middleware").RequestHandler<unknown, unknown, void>;
51
+ export declare function before({ cookies, helmet: useHelmet, json: useJson, csrf: useCsrf, urlencoded: useUrlencoded, allowUnsafeInlineScripts, cspDirectives, }?: BeforeMiddlewareOptions): import("compose-middleware").RequestHandler<unknown, unknown, void>;
28
52
  /**
29
53
  * Middleware that sends a 404
30
54
  *
@@ -43,4 +67,4 @@ type ResponseWrapper<TResponseBody extends AnyResponseBody> = TResponseBody | TR
43
67
  /**
44
68
  * Alias of `route()`
45
69
  */
46
- export declare function asyncMiddleware<RequestBody, RequestQuery extends Record<string, any> = Record<string, any>, ResponseBody extends AnyResponseBody = Record<string, any>>(fn: (req: Request<Record<string, string>, ResponseBody, RequestBody, RequestQuery, Record<string, any>>, res: Response<ResponseBody, object>, next: NextFunction) => ResponseWrapper<ResponseBody> | Promise<ResponseWrapper<ResponseBody>>): RequestHandler<Record<string, string>, ResponseBody, RequestBody, RequestQuery>;
70
+ export declare function asyncMiddleware<RequestBody, RequestQuery extends Record<string, any> = Record<string, any>, ResponseBody extends AnyResponseBody = Record<string, any>, ParamKeys extends string = string>(fn: (req: Request<Record<ParamKeys, string>, ResponseBody, RequestBody, RequestQuery, Record<string, any>>, res: Response<ResponseBody, object>, next: NextFunction) => ResponseWrapper<ResponseBody> | Promise<ResponseWrapper<ResponseBody>>): RequestHandler<Record<string, string>, ResponseBody, RequestBody, RequestQuery>;
@@ -20,7 +20,7 @@ export function useCspNonce() {
20
20
  /**
21
21
  * Register middleware that must come before the app routes.
22
22
  */
23
- export function before({ cookies = false, helmet: useHelmet = true, json: useJson = true, csrf: useCsrf = false, allowUnsafeInlineScripts = false, cspDirectives = (x) => x, } = {}) {
23
+ export function before({ cookies = false, helmet: useHelmet = true, json: useJson = true, csrf: useCsrf = false, urlencoded: useUrlencoded = false, allowUnsafeInlineScripts = false, cspDirectives = (x) => x, } = {}) {
24
24
  return compose([
25
25
  withRequestContext(),
26
26
  typeof useCsrf === 'object' && useCsrf != null
@@ -29,10 +29,7 @@ export function before({ cookies = false, helmet: useHelmet = true, json: useJso
29
29
  ? csrf()
30
30
  : undefined,
31
31
  logRequestDuration(),
32
- ((req, res, next) => {
33
- setContext(CSP_NONCE_KEY, crypto.randomBytes(16).toString('base64'));
34
- next();
35
- }),
32
+ setCspNonce(),
36
33
  useHelmet &&
37
34
  helmet({
38
35
  contentSecurityPolicy: {
@@ -65,7 +62,7 @@ export function before({ cookies = false, helmet: useHelmet = true, json: useJso
65
62
  },
66
63
  }),
67
64
  useJson && jsonParser(),
68
- urlencodedParser({ extended: true }),
65
+ useUrlencoded && urlencodedParser({ extended: true }),
69
66
  (cookies || useCsrf) && cookieParser(),
70
67
  analyticsProxy(),
71
68
  ].filter(Boolean));
@@ -106,6 +103,15 @@ export function notFound() {
106
103
  next(NotFound(`Could not ${req.method} ${req.path}`));
107
104
  };
108
105
  }
106
+ /**
107
+ * Set a CSP nonce for the request
108
+ */
109
+ function setCspNonce() {
110
+ return (req, res, next) => {
111
+ setContext(CSP_NONCE_KEY, crypto.randomBytes(16).toString('base64'));
112
+ next();
113
+ };
114
+ }
109
115
  /**
110
116
  * In production, we could directly proxy the url
111
117
  * to an analytics service from nginx, but in dev
@@ -1,4 +1,5 @@
1
1
  import { type Options } from 'ajv';
2
+ import { Type } from 'arktype';
2
3
  import { type RequestHandler } from 'express';
3
4
  export type ValidationSchema = {
4
5
  type?: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object' | 'null';
@@ -27,9 +28,10 @@ export type ValidationSchema = {
27
28
  title?: string;
28
29
  description?: string;
29
30
  } | boolean;
30
- export declare function validateBody(schema: ValidationSchema): RequestHandler;
31
- export declare function validateQuery(schema: ValidationSchema): RequestHandler;
32
- export declare function validateParams(schema: ValidationSchema): RequestHandler;
31
+ type AnySchema = Type | ValidationSchema;
32
+ export declare function validateBody(schema: AnySchema): RequestHandler;
33
+ export declare function validateQuery(schema: AnySchema): RequestHandler;
34
+ export declare function validateParams(schema: AnySchema): RequestHandler;
33
35
  export type ValidationResult = {
34
36
  isValid: true;
35
37
  errors: null;
@@ -48,4 +50,5 @@ export declare function compileValidateSchema(schema: ValidationSchema, options?
48
50
  [key: string]: any[];
49
51
  };
50
52
  };
51
- export declare function validateSchema(data: any, schema: ValidationSchema, options?: Options): ValidationResult;
53
+ export declare function validateSchema(data: any, schema: AnySchema, options?: Options): ValidationResult;
54
+ export {};
package/dist/validate.js CHANGED
@@ -1,8 +1,16 @@
1
1
  import _Ajv, {} from 'ajv';
2
+ import { Type } from 'arktype';
2
3
  import {} from 'express';
3
4
  import { BadRequest } from './errors.js';
4
5
  const Ajv = _Ajv;
6
+ const toValidationSchema = (schema) => {
7
+ if (schema instanceof Type) {
8
+ return schema.toJsonSchema();
9
+ }
10
+ return schema;
11
+ };
5
12
  export function validateBody(schema) {
13
+ schema = toValidationSchema(schema);
6
14
  const validate = compileValidateSchema(schema);
7
15
  const middleware = (req, res, next) => {
8
16
  const result = validate(req.body);
@@ -15,6 +23,7 @@ export function validateBody(schema) {
15
23
  return middleware;
16
24
  }
17
25
  export function validateQuery(schema) {
26
+ schema = toValidationSchema(schema);
18
27
  const validate = compileValidateSchema(schema, { coerceTypes: true });
19
28
  const middleware = (req, res, next) => {
20
29
  const result = validate(req.query);
@@ -27,6 +36,7 @@ export function validateQuery(schema) {
27
36
  return middleware;
28
37
  }
29
38
  export function validateParams(schema) {
39
+ schema = toValidationSchema(schema);
30
40
  const validate = compileValidateSchema(schema, { coerceTypes: true });
31
41
  const middleware = (req, res, next) => {
32
42
  const result = validate(req.params);
@@ -68,6 +78,7 @@ export function compileValidateSchema(schema, options = {}) {
68
78
  };
69
79
  }
70
80
  export function validateSchema(data, schema, options = {}) {
81
+ schema = toValidationSchema(schema);
71
82
  const ajv = createAjv(options);
72
83
  const valid = ajv.validate(schema, data);
73
84
  if (valid) {
@@ -39,7 +39,7 @@ export function defineViteConfig({ base = '', reactCompiler = false, ssr = false
39
39
  }
40
40
  : {
41
41
  input: {
42
- index: path.resolve(process.cwd(), '_index.html'),
42
+ index: path.resolve(process.cwd(), 'index.html'),
43
43
  },
44
44
  },
45
45
  },
@@ -49,12 +49,12 @@ export function defineViteConfig({ base = '', reactCompiler = false, ssr = false
49
49
  port,
50
50
  strictPort: true,
51
51
  origin: readEnv('VITE_ORIGIN', `http://localhost:${port}`),
52
- }
53
- : {
54
52
  proxy: {
55
53
  '/api': `http://localhost:${readEnv.number('PORT', 4000)}`,
54
+ '/analytics.js': `http://localhost:${readEnv.number('PORT', 4000)}`,
56
55
  },
57
- },
56
+ }
57
+ : {},
58
58
  ssr: ssr
59
59
  ? {
60
60
  noExternal: ['react-helmet-async'],
package/dist/vite/ssr.js CHANGED
@@ -49,6 +49,7 @@ export async function createSsrMiddleware({ root = process.cwd(), getInitialData
49
49
  window.$RefreshSig$ = () => (type) => type
50
50
  window.__vite_plugin_react_preamble_installed__ = true
51
51
  `,
52
+ publicUrl: config.value.PUBLIC_URL,
52
53
  });
53
54
  const readManifest = async () => {
54
55
  if (useDevServer) {
@@ -5,6 +5,7 @@ export interface SsrRenderOptions {
5
5
  nonce: string;
6
6
  preamble: string;
7
7
  getInitialData: (req: string) => Promise<unknown>;
8
+ publicUrl: string;
8
9
  }
9
10
  export type EntryRenderFn = (req: Request, res: Response, options: SsrRenderOptions) => Promise<{
10
11
  html: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devmoods/express-extras",
3
- "version": "0.49.3",
3
+ "version": "0.50.0",
4
4
  "description": "Kubernetes-ready utilities for rapid Node.js application development",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -44,12 +44,12 @@
44
44
  ],
45
45
  "license": "ISC",
46
46
  "devDependencies": {
47
- "@devmoods/eslint-config": "^2.5.0",
47
+ "@devmoods/eslint-config": "^2.6.0",
48
48
  "@types/koa-compose": "^3.2.8",
49
- "@types/node": "22.10.5",
49
+ "@types/node": "22.10.7",
50
50
  "@types/supertest": "^6.0.2",
51
51
  "@vitejs/plugin-react": "^4.3.4",
52
- "@vitest/coverage-v8": "^2.1.8",
52
+ "@vitest/coverage-v8": "^3.0.2",
53
53
  "cookiejar": "^2.1.4",
54
54
  "eslint": "^9.18.0",
55
55
  "faktory-worker": "^4.6.0",
@@ -59,13 +59,13 @@
59
59
  "tsx": "^4.19.2",
60
60
  "typescript": "5.7.3",
61
61
  "vite": "^6.0.7",
62
- "vitest": "^2.1.8",
62
+ "vitest": "^3.0.2",
63
63
  "vitest-fail-on-console": "^0.7.1"
64
64
  },
65
65
  "dependencies": {
66
66
  "@devmoods/fetch": "^4.0.1",
67
- "@sentry/node": "^8.48.0",
68
- "@sentry/profiling-node": "^8.48.0",
67
+ "@sentry/node": "^8.50.0",
68
+ "@sentry/profiling-node": "^8.50.0",
69
69
  "@types/bcrypt": "^5.0.2",
70
70
  "@types/cookie-parser": "^1.4.8",
71
71
  "@types/express": "^5.0.0",
@@ -76,6 +76,7 @@
76
76
  "@types/uuid": "^10.0.0",
77
77
  "ajv": "^8.17.1",
78
78
  "argon2": "^0.41.1",
79
+ "arktype": "^2.0.0",
79
80
  "bcrypt": "^5.1.1",
80
81
  "chalk": "^5.4.1",
81
82
  "commander": "^13.0.0",