@devmoods/express-extras 0.37.0 → 0.39.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.
package/dist/cache.js CHANGED
@@ -78,6 +78,7 @@ export function createCache(adapter) {
78
78
  const expiresMs = typeof options === 'number' || options == null
79
79
  ? options
80
80
  : options.expiresMs;
81
+ console.log({ expiresMs });
81
82
  const result = await fn(...args);
82
83
  await adapter.set(cacheKey, result, expiresMs);
83
84
  return result;
package/dist/cli.js CHANGED
@@ -16,7 +16,7 @@ export class CLI {
16
16
  redLine(message, output = stderr) {
17
17
  output.write(`${chalk.red(message)}\n`);
18
18
  }
19
- /* v8 ignore next 10 */
19
+ /* c8 ignore next 10 */
20
20
  readInput(question) {
21
21
  const rl = readline.createInterface({ input: stdin, output: stderr });
22
22
  return new Promise((resolve) => {
package/dist/csrf.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { type RequestHandler, type Request, type Response } from 'express';
2
+ export interface CsrfMiddlewareOptions {
3
+ excempt?: boolean;
4
+ cookieName?: string;
5
+ sessionIdentifier?: (req: Request) => string;
6
+ }
7
+ interface CsrfOptions {
8
+ excempt: boolean;
9
+ cookieName: string;
10
+ sessionIdentifier: string;
11
+ }
12
+ export declare function csrf({ excempt, sessionIdentifier, cookieName, }?: CsrfMiddlewareOptions): RequestHandler;
13
+ export declare function setCsrfOptions(options: CsrfOptions): void;
14
+ interface TokenOptions {
15
+ overwrite?: boolean;
16
+ }
17
+ export declare const generateCsrfToken: (req: Request<any, any, any, any>, res: Response, options?: TokenOptions) => string;
18
+ export declare function doubleCsrfProtection(req: Request<any, any, any, any>): () => Promise<void>;
19
+ export {};
package/dist/csrf.js ADDED
@@ -0,0 +1,114 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import {} from 'express';
3
+ import { setContext, useContext } from './context.js';
4
+ import { Forbidden } from './errors.js';
5
+ import { readEnv } from './utils.js';
6
+ const ignoredMethods = new Set(['GET', 'HEAD', 'OPTIONS']);
7
+ const defaultCookieName = () => {
8
+ return process.env.NODE_ENV === 'production'
9
+ ? '__Host-devmoods.x-csrf-token'
10
+ : 'devmoods.x-csrf-token';
11
+ };
12
+ export function csrf({ excempt = false, sessionIdentifier, cookieName, } = {}) {
13
+ return (req, res, next) => {
14
+ const existingOptions = useCsrfOptions();
15
+ setCsrfOptions({
16
+ cookieName: cookieName ?? existingOptions.cookieName ?? defaultCookieName(),
17
+ sessionIdentifier: sessionIdentifier
18
+ ? sessionIdentifier(req)
19
+ : existingOptions.sessionIdentifier,
20
+ excempt,
21
+ });
22
+ next();
23
+ };
24
+ }
25
+ const CSRF_CONTEXT_KEY = '@csrfConfig';
26
+ export function setCsrfOptions(options) {
27
+ setContext(CSRF_CONTEXT_KEY, options);
28
+ }
29
+ function useCsrfOptions() {
30
+ return (useContext(CSRF_CONTEXT_KEY) || {
31
+ excempt: true,
32
+ cookieName: defaultCookieName(),
33
+ sessionIdentifier: '',
34
+ });
35
+ }
36
+ const generateTokenAndHash = (req, { overwrite = false } = {}) => {
37
+ const secret = readEnv('CSRF_SECRET', '');
38
+ const { sessionIdentifier, cookieName } = useCsrfOptions();
39
+ const csrfCookie = req.cookies[cookieName];
40
+ if (typeof csrfCookie === 'string' && !overwrite) {
41
+ const [csrfToken, csrfTokenHash] = csrfCookie.split('|');
42
+ if (validateTokenHash({
43
+ csrfToken,
44
+ csrfTokenHash,
45
+ secret,
46
+ sessionIdentifier,
47
+ })) {
48
+ return { csrfToken, csrfTokenHash };
49
+ }
50
+ throw Forbidden('Invalid CSRF token');
51
+ }
52
+ const csrfToken = randomBytes(64).toString('hex');
53
+ const csrfTokenHash = createHash('sha256')
54
+ .update(`${sessionIdentifier}${csrfToken}${secret}`)
55
+ .digest('hex');
56
+ return { csrfToken, csrfTokenHash };
57
+ };
58
+ export const generateCsrfToken = (req, res, options = {}) => {
59
+ const { csrfToken, csrfTokenHash } = generateTokenAndHash(req, options);
60
+ const cookieContent = `${csrfToken}|${csrfTokenHash}`;
61
+ const { cookieName } = useCsrfOptions();
62
+ res.cookie(cookieName, cookieContent, {
63
+ sameSite: 'lax',
64
+ path: '/',
65
+ secure: process.env.NODE_ENV === 'production',
66
+ httpOnly: true,
67
+ });
68
+ return csrfToken;
69
+ };
70
+ const validateTokenHash = ({ csrfToken, csrfTokenHash, secret, sessionIdentifier, }) => {
71
+ if (typeof csrfToken !== 'string' || typeof csrfTokenHash !== 'string') {
72
+ return false;
73
+ }
74
+ const expectedHash = createHash('sha256')
75
+ .update(`${sessionIdentifier}${csrfToken}${secret}`)
76
+ .digest('hex');
77
+ return csrfTokenHash === expectedHash;
78
+ };
79
+ const validateRequest = (req) => {
80
+ const { cookieName, sessionIdentifier } = useCsrfOptions();
81
+ const csrfCookie = req.cookies[cookieName];
82
+ if (typeof csrfCookie !== 'string') {
83
+ throw Forbidden('Missing CSRF cookie');
84
+ }
85
+ const [csrfToken, csrfTokenHash] = csrfCookie.split('|');
86
+ const csrfTokenFromRequest = req.headers['x-csrf-token'];
87
+ const secret = readEnv('CSRF_SECRET', '');
88
+ if (!csrfTokenFromRequest) {
89
+ throw Forbidden('Missing CSRF token');
90
+ }
91
+ if (csrfToken !== csrfTokenFromRequest) {
92
+ throw Forbidden('CSRF token mismatch');
93
+ }
94
+ if (!validateTokenHash({
95
+ csrfToken: csrfTokenFromRequest,
96
+ csrfTokenHash,
97
+ secret,
98
+ sessionIdentifier,
99
+ })) {
100
+ throw Forbidden('Invalid CSRF token');
101
+ }
102
+ };
103
+ export function doubleCsrfProtection(req) {
104
+ return async () => {
105
+ const { excempt } = useCsrfOptions();
106
+ if (excempt) {
107
+ return;
108
+ }
109
+ if (ignoredMethods.has(req.method)) {
110
+ return;
111
+ }
112
+ validateRequest(req);
113
+ };
114
+ }
package/dist/faktory.js CHANGED
@@ -11,7 +11,7 @@ export function createJobs({ queueName, faktory: createFactory = create, }) {
11
11
  const fn = typeof optionsOrFn === 'function' ? optionsOrFn : fnOrUndefined;
12
12
  const options = typeof optionsOrFn === 'function' ? {} : optionsOrFn;
13
13
  if (!fn) {
14
- throw new Error('job() must get a function has the first or second argument');
14
+ throw new Error('job() must get a function as the first or second argument');
15
15
  }
16
16
  const jobName = fn.name;
17
17
  let executionCount = 0;
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export * from './auth.js';
2
2
  export * from './cache.js';
3
3
  export * from './cli.js';
4
4
  export * from './context.js';
5
+ export * from './csrf.js';
5
6
  export * from './email.js';
6
7
  export * from './errors.js';
7
8
  export * from './faktory.js';
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
- /* v8 ignore next 100 */
1
+ /* c8 ignore next 100 */
2
2
  export * from './auth.js';
3
3
  export * from './cache.js';
4
4
  export * from './cli.js';
5
5
  export * from './context.js';
6
+ export * from './csrf.js';
6
7
  export * from './email.js';
7
8
  export * from './errors.js';
8
9
  export * from './faktory.js';
package/dist/logger.js CHANGED
@@ -56,7 +56,7 @@ export function createLogger({ name, getContext, level = readEnv('LOG_LEVEL', 'I
56
56
  v: 1,
57
57
  });
58
58
  if (level >= logLevel) {
59
- output.write(payload + '\n');
59
+ output.write(`${payload}\n`);
60
60
  }
61
61
  }
62
62
  const api = {
package/dist/manage.js CHANGED
@@ -21,8 +21,9 @@ export function registerDefaultCommands(app) {
21
21
  .option('--openapi', 'Output OpenAPI schema')
22
22
  .option('--json', 'Output JSON')
23
23
  .action(cli.wrap(async (options) => {
24
- if (!app)
24
+ if (!app) {
25
25
  return;
26
+ }
26
27
  if (options.openapi) {
27
28
  console.error('Not implemented yet');
28
29
  return;
@@ -1,5 +1,6 @@
1
1
  import bodyParser from 'body-parser';
2
2
  import { type NextFunction, type RequestHandler, type Request, type Response } from 'express';
3
+ import { type CsrfMiddlewareOptions } from './csrf.js';
3
4
  type UrlencodedParser = typeof bodyParser.urlencoded;
4
5
  type JsonParser = typeof bodyParser.json;
5
6
  export declare const urlencodedParser: UrlencodedParser;
@@ -15,13 +16,14 @@ export interface BeforeMiddlewareOptions {
15
16
  cookies?: boolean;
16
17
  helmet?: boolean;
17
18
  json?: boolean;
19
+ csrf?: boolean | CsrfMiddlewareOptions;
18
20
  allowUnsafeInlineScripts?: boolean;
19
21
  cspDirectives?: (defaults: BeforeMiddlewareCSPDirectives) => BeforeMiddlewareCSPDirectives;
20
22
  }
21
23
  /**
22
24
  * Register middleware that must come before the app routes.
23
25
  */
24
- export declare function before({ cookies, helmet: useHelmet, json: useJson, allowUnsafeInlineScripts, cspDirectives, }?: BeforeMiddlewareOptions): import("compose-middleware").RequestHandler<unknown, unknown, void>;
26
+ export declare function before({ cookies, helmet: useHelmet, json: useJson, csrf: useCsrf, allowUnsafeInlineScripts, cspDirectives, }?: BeforeMiddlewareOptions): import("compose-middleware").RequestHandler<unknown, unknown, void>;
25
27
  /**
26
28
  * Middleware that sends a 404
27
29
  *
@@ -4,6 +4,7 @@ import cookieParser from 'cookie-parser';
4
4
  import {} from 'express';
5
5
  import helmet from 'helmet';
6
6
  import { withRequestContext } from './context.js';
7
+ import { csrf, doubleCsrfProtection, } from './csrf.js';
7
8
  import { NotFound } from './errors.js';
8
9
  import { getLogger } from './logger.js';
9
10
  import { startTimer } from './utils.js';
@@ -14,9 +15,14 @@ export { asyncMiddleware as route };
14
15
  /**
15
16
  * Register middleware that must come before the app routes.
16
17
  */
17
- export function before({ cookies = false, helmet: useHelmet = true, json: useJson = true, allowUnsafeInlineScripts = false, cspDirectives = (x) => x, } = {}) {
18
+ export function before({ cookies = false, helmet: useHelmet = true, json: useJson = true, csrf: useCsrf = false, allowUnsafeInlineScripts = false, cspDirectives = (x) => x, } = {}) {
18
19
  return compose([
19
20
  withRequestContext(),
21
+ typeof useCsrf === 'object' && useCsrf != null
22
+ ? csrf(useCsrf)
23
+ : useCsrf
24
+ ? csrf()
25
+ : undefined,
20
26
  logRequestDuration(),
21
27
  useHelmet &&
22
28
  helmet({
@@ -48,7 +54,7 @@ export function before({ cookies = false, helmet: useHelmet = true, json: useJso
48
54
  },
49
55
  }),
50
56
  useJson && jsonParser(),
51
- cookies && cookieParser(),
57
+ (cookies || useCsrf) && cookieParser(),
52
58
  analyticsProxy(),
53
59
  ].filter(Boolean));
54
60
  }
@@ -111,7 +117,9 @@ export function after() {
111
117
  */
112
118
  export function asyncMiddleware(fn) {
113
119
  const handler = (req, res, next) => {
114
- return Promise.resolve(fn(req, res, next))
120
+ return Promise.resolve()
121
+ .then(doubleCsrfProtection(req))
122
+ .then(() => fn(req, res, next))
115
123
  .then((result) => {
116
124
  if (Array.isArray(result) &&
117
125
  (result.length === 2 || result.length === 3) &&
package/dist/postgres.js CHANGED
@@ -88,8 +88,9 @@ export function createPostgres({ onError, url = readEnv('POSTGRES_URL'), ...conf
88
88
  * Wipe the database schema
89
89
  */
90
90
  async function clear(force = false) {
91
- if (!force)
91
+ if (!force) {
92
92
  return;
93
+ }
93
94
  return withConnection(async (connection) => {
94
95
  await connection.query('DROP SCHEMA public CASCADE');
95
96
  await connection.query(`CREATE SCHEMA public`);
package/dist/routes.js CHANGED
@@ -45,40 +45,24 @@ const buildPathParamsSchema = (pathParams) => {
45
45
  required,
46
46
  };
47
47
  };
48
+ // Fork of express-route-parser
48
49
  class ExpressPathParser {
49
50
  getPaths() {
50
51
  return this._appPaths;
51
52
  }
52
53
  constructor(app) {
53
- /**
54
- * Recursive traversal method for the express router and middleware tree.
55
- *
56
- * @param path The current path segment that we have traversed so far
57
- * @param layer The current 'layer' of the router tree
58
- * @param keys The keys for the parameter's in the current path branch of the traversal
59
- * @returns void - base case saves result to internal object
60
- */
61
54
  this.traverse = (path, layer, keys) => {
62
55
  keys = [...keys, ...layer.keys];
63
56
  if (layer.name === 'router' && layer.handle) {
64
- layer.handle.stack.forEach((l) => {
65
- path = path || '';
66
- this.traverse(`${path}/${pathRegexParser(layer.regexp, layer.keys)}`, l, keys);
67
- });
57
+ for (const l of layer.handle.stack) {
58
+ this.traverse(`${path || ''}/${pathRegexParser(layer.regexp, layer.keys)}`, l, keys);
59
+ }
68
60
  }
69
61
  if (!layer.route || !layer.route.stack.length) {
70
62
  return;
71
63
  }
72
64
  this._appPaths.push(this.parseRouteLayer(layer, keys, path));
73
65
  };
74
- /**
75
- * Parses a route object. Route objects are the leafs of an express router tree
76
- *
77
- * @param layer The layer of this route object - represents the stack of middleware and other meta data
78
- * @param keys The full set of keys for this particular route
79
- * @param basePath The base path as it was initial declared for this route
80
- * @returns A ExpressPath object holding the meta data for a given route
81
- */
82
66
  this.parseRouteLayer = (layer, keys, basePath) => {
83
67
  const lastRequestHandler = layer.route.stack[layer.route.stack.length - 1];
84
68
  const pathParams = keys.map((key) => {
@@ -105,14 +89,6 @@ class ExpressPathParser {
105
89
  }
106
90
  }
107
91
  }
108
- /** Parses an express layer's regex and converts it to the original format seen in code.
109
- *
110
- * @param layerRegexPath The layer's regex pattern
111
- * @param keys The keys that represent the layer's path parameters
112
- * @returns The path string for that layer
113
- * Code inspired and modify from:
114
- * https://github.com/expressjs/express/issues/3308#issuecomment-300957572
115
- */
116
92
  const pathRegexParser = (layerRegexPath, keys) => {
117
93
  if (typeof layerRegexPath === 'string') {
118
94
  return layerRegexPath;
@@ -140,13 +116,6 @@ const pathRegexParser = (layerRegexPath, keys) => {
140
116
  }
141
117
  return layerRegexPath.toString();
142
118
  };
143
- /**
144
- * Map's the keys/path variables to the regex inside a given path
145
- *
146
- * @param layerRegexPath The regex for a react router with path parameters
147
- * @param keys The keys that represent the path parameters
148
- * @returns The regex for a path variable converted to original string on the express route
149
- */
150
119
  const mapKeysToPath = (layerRegexPath, keys) => {
151
120
  if (!keys || keys.length === 0) {
152
121
  throw Error('must include atleast one key to map');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devmoods/express-extras",
3
- "version": "0.37.0",
3
+ "version": "0.39.0",
4
4
  "description": "Kubernetes-ready utilities for rapid Node.js application development",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -18,14 +18,14 @@
18
18
  ],
19
19
  "license": "ISC",
20
20
  "devDependencies": {
21
- "@devmoods/eslint-config": "^1.14.0",
21
+ "@devmoods/eslint-config": "^1.15.0",
22
22
  "@types/koa-compose": "^3.2.8",
23
- "@types/node": "22.8.1",
23
+ "@types/node": "22.8.6",
24
24
  "@types/supertest": "^6.0.2",
25
- "@typescript-eslint/eslint-plugin": "^8.11.0",
26
- "@typescript-eslint/parser": "^8.11.0",
27
- "@vitest/coverage-v8": "^2.1.3",
28
- "chalk": "^5.3.0",
25
+ "@typescript-eslint/eslint-plugin": "^8.12.2",
26
+ "@typescript-eslint/parser": "^8.12.2",
27
+ "@vitest/coverage-v8": "^2.1.4",
28
+ "cookiejar": "^2.1.4",
29
29
  "eslint": "^8.57.0",
30
30
  "eslint-plugin-import": "^2.31.0",
31
31
  "eslint-plugin-prettier": "^5.2.1",
@@ -33,15 +33,15 @@
33
33
  "prettier": "3.3.3",
34
34
  "supertest": "7.0.0",
35
35
  "ts-node": "10.9.2",
36
- "tsx": "^4.19.1",
36
+ "tsx": "^4.19.2",
37
37
  "typescript": "5.6.3",
38
- "vitest": "^2.1.3",
38
+ "vitest": "^2.1.4",
39
39
  "vitest-fail-on-console": "^0.7.1"
40
40
  },
41
41
  "dependencies": {
42
42
  "@devmoods/fetch": "^4.0.1",
43
- "@sentry/node": "^8.35.0",
44
- "@sentry/profiling-node": "^8.35.0",
43
+ "@sentry/node": "^8.36.0",
44
+ "@sentry/profiling-node": "^8.36.0",
45
45
  "@types/bcrypt": "^5.0.2",
46
46
  "@types/body-parser": "^1.19.5",
47
47
  "@types/cookie-parser": "^1.4.7",
@@ -54,6 +54,7 @@
54
54
  "ajv": "^8.17.1",
55
55
  "bcrypt": "^5.1.1",
56
56
  "body-parser": "^1.20.3",
57
+ "chalk": "^5.3.0",
57
58
  "commander": "^12.1.0",
58
59
  "compose-middleware": "^5.0.1",
59
60
  "cookie-parser": "^1.4.7",
@@ -61,11 +62,11 @@
61
62
  "express": "^4.21.1",
62
63
  "helmet": "^8.0.0",
63
64
  "jsonwebtoken": "^9.0.2",
64
- "nodemailer": "^6.9.15",
65
+ "nodemailer": "^6.9.16",
65
66
  "pg": "^8.13.1",
66
67
  "redis": "^4.7.0",
67
68
  "sql-template-strings": "^2.2.2",
68
- "uuid": "^10.0.0"
69
+ "uuid": "^11.0.2"
69
70
  },
70
71
  "prettier": {
71
72
  "singleQuote": true