@devmoods/express-extras 0.42.1 → 0.44.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/auth.d.ts CHANGED
@@ -44,6 +44,7 @@ export interface AuthOptions<UserModel extends AnyUser> {
44
44
  providers: AuthProvider<UserModel>[];
45
45
  redis: RedisClient;
46
46
  filterUser(user: UserModel): Partial<UserModel>;
47
+ loginUrl?: string;
47
48
  }
48
49
  export interface CreateAuth<UserModel extends AnyUser> {
49
50
  createRouter: () => Router;
@@ -54,7 +55,7 @@ export interface CreateAuth<UserModel extends AnyUser> {
54
55
  export type AuthMiddlewareOptions<UserModel extends AnyUser> = false | {
55
56
  isAuthorized?: (user: UserModel) => Promise<boolean> | boolean;
56
57
  };
57
- export declare function createAuth<UserModel extends AnyUser>({ getUserByToken, filterUser, redis, providers, }: AuthOptions<UserModel>): CreateAuth<UserModel>;
58
+ export declare function createAuth<UserModel extends AnyUser>({ getUserByToken, filterUser, redis, providers, loginUrl, }: AuthOptions<UserModel>): CreateAuth<UserModel>;
58
59
  /**
59
60
  * Extract a Bearer token from the HTTP Authorization header
60
61
  * of `req`.
@@ -66,6 +67,7 @@ export declare function parseAuthorizationHeader(req: Request): string | null;
66
67
  * Checks multiple places and uses the first match. Priority:
67
68
  * 1. Request header `Authorization: `
68
69
  * 2. Cookie named `access_token`
70
+ *
69
71
  */
70
72
  export declare function extractAccessTokenFromRequest(req: Request): string | null;
71
73
  /**
package/dist/auth.js CHANGED
@@ -3,17 +3,17 @@ import bcrypt from 'bcrypt';
3
3
  import { Router, } from 'express';
4
4
  import jwt from 'jsonwebtoken';
5
5
  import { v4 } from 'uuid';
6
+ import { config } from './config.js';
6
7
  import { setContext, useContext } from './context.js';
7
- import { ApiError, BadRequest, Forbidden, NotFound, Unauthorized, } from './errors.js';
8
+ import { BadRequest, Forbidden, NotFound, Unauthorized } from './errors.js';
8
9
  import { getLogger } from './logger.js';
9
10
  import { route } from './middleware.js';
10
11
  import {} from './redis.js';
11
- import { readEnv } from './utils.js';
12
12
  import { validateBody, validateParams, } from './validate.js';
13
13
  const logger = getLogger('auth');
14
- const TOKEN_EXPIRES_IN = readEnv.number('AUTH_TOKEN_EXPIRES', 3600 * 48);
15
- const TEMP_TOKEN_EXIRES_IN = readEnv.number('AUTH_TEMP_TOKEN_EXPIRES', 3600);
16
- const AUTH_SECRET = readEnv('AUTH_JWT_SECRET', '');
14
+ const TOKEN_EXPIRES_IN = config.value.AUTH_TOKEN_EXPIRES_IN;
15
+ const TEMP_TOKEN_EXIRES_IN = config.value.AUTH_TEMP_TOKEN_EXPIRES_IN;
16
+ const AUTH_SECRET = config.value.AUTH_SECRET;
17
17
  const tokenSchema = {
18
18
  type: 'string',
19
19
  minLength: 50,
@@ -40,7 +40,7 @@ export function decodeAuthToken(token, secret = AUTH_SECRET) {
40
40
  return { ...decoded };
41
41
  }
42
42
  catch (error) {
43
- throw new Error(`Failed to decode auth token: ${error.message}`);
43
+ throw new InvalidToken(`Failed to decode auth token: ${error.message}`);
44
44
  }
45
45
  }
46
46
  export function EmailAndPasswordAuthProvider(options) {
@@ -140,7 +140,7 @@ export function EmailAndPasswordAuthProvider(options) {
140
140
  },
141
141
  };
142
142
  }
143
- export function createAuth({ getUserByToken, filterUser, redis, providers, }) {
143
+ export function createAuth({ getUserByToken, filterUser, redis, providers, loginUrl = '/', }) {
144
144
  const useCurrentUser = () => useContext('currentUser');
145
145
  const setCurrentUser = (user) => setContext('currentUser', user);
146
146
  const login = async (res, user) => {
@@ -230,21 +230,27 @@ export function createAuth({ getUserByToken, filterUser, redis, providers, }) {
230
230
  throw new InvalidToken('token expired');
231
231
  }
232
232
  const decoded = decodeAuthToken(token);
233
- const user = await getUserByToken(decoded);
234
- if (options.isAuthorized && !(await options.isAuthorized(user))) {
235
- throw Forbidden('Permission denied');
233
+ try {
234
+ const user = await getUserByToken(decoded);
235
+ if (options.isAuthorized && !(await options.isAuthorized(user))) {
236
+ throw Forbidden('Permission denied');
237
+ }
238
+ setCurrentUser(user);
239
+ }
240
+ catch {
241
+ throw new InvalidToken('User not found');
236
242
  }
237
- setCurrentUser(user);
238
243
  next();
239
244
  }
240
245
  catch (error) {
241
246
  logger.error(error);
242
- if (error instanceof ApiError) {
243
- next(error);
244
- }
245
- else {
247
+ if (error instanceof InvalidToken) {
248
+ if (req.accepts('html')) {
249
+ return res.redirect(loginUrl);
250
+ }
246
251
  next(Unauthorized('Invalid token'));
247
252
  }
253
+ next(error);
248
254
  }
249
255
  };
250
256
  }
@@ -284,7 +290,7 @@ export function parseAuthorizationHeader(req) {
284
290
  .split(' ')
285
291
  .map((s) => s.trim());
286
292
  if (token && type !== 'Bearer') {
287
- throw new Error('Only Bearer type supported in jwt()');
293
+ throw new InvalidToken('Only Bearer type supported in jwt()');
288
294
  }
289
295
  // No token given, this is not necessarilly an error case
290
296
  if (!token) {
@@ -302,6 +308,7 @@ export function parseAuthorizationHeader(req) {
302
308
  * Checks multiple places and uses the first match. Priority:
303
309
  * 1. Request header `Authorization: `
304
310
  * 2. Cookie named `access_token`
311
+ *
305
312
  */
306
313
  export function extractAccessTokenFromRequest(req) {
307
314
  const authorizationHeader = parseAuthorizationHeader(req);
@@ -0,0 +1,19 @@
1
+ export declare const config: {
2
+ value: {
3
+ CI: boolean;
4
+ PORT: number;
5
+ AUTH_TOKEN_EXPIRES_IN: number;
6
+ AUTH_TEMP_TOKEN_EXPIRES_IN: number;
7
+ AUTH_SECRET: string;
8
+ REDIS_URL: string;
9
+ POSTGRES_URL: string;
10
+ SENTRY_DSN: string;
11
+ LOG_LEVEL: string;
12
+ LOG_SQL: boolean;
13
+ NODE_ENV: string;
14
+ CSRF_SECRET: string;
15
+ READINESS_PERIOD_MS: number;
16
+ GRACE_PERIOD_MS: number;
17
+ SLACK_WEBHOOK_URL: string;
18
+ };
19
+ };
package/dist/config.js ADDED
@@ -0,0 +1,27 @@
1
+ import { readEnv } from './utils.js';
2
+ function lazy(fn) {
3
+ return {
4
+ get value() {
5
+ const value = fn();
6
+ Object.defineProperty(this, 'value', { value });
7
+ return value;
8
+ },
9
+ };
10
+ }
11
+ export const config = lazy(() => ({
12
+ CI: readEnv.boolean('CI', false),
13
+ PORT: readEnv.number('PORT', 0),
14
+ AUTH_TOKEN_EXPIRES_IN: readEnv.number('AUTH_TOKEN_EXPIRES', 3600 * 24 * 30), // 30 days
15
+ AUTH_TEMP_TOKEN_EXPIRES_IN: readEnv.number('AUTH_TEMP_TOKEN_EXPIRES', 3600),
16
+ AUTH_SECRET: readEnv('AUTH_JWT_SECRET', ''),
17
+ REDIS_URL: readEnv('REDIS_URL', ''),
18
+ POSTGRES_URL: readEnv('POSTGRES_URL', ''),
19
+ SENTRY_DSN: readEnv('SENTRY_DSN', ''),
20
+ LOG_LEVEL: readEnv('LOG_LEVEL', 'INFO'),
21
+ LOG_SQL: readEnv.boolean('LOG_SQL', false),
22
+ NODE_ENV: readEnv('NODE_ENV', 'development'),
23
+ CSRF_SECRET: readEnv('CSRF_SECRET', ''),
24
+ READINESS_PERIOD_MS: readEnv.number('READINESS_PERIOD_MS', 0),
25
+ GRACE_PERIOD_MS: readEnv.number('GRACE_PERIOD_MS', 20000),
26
+ SLACK_WEBHOOK_URL: readEnv('SLACK_WEBHOOK_URL', ''),
27
+ }));
package/dist/csrf.js CHANGED
@@ -1,16 +1,16 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import {} from 'express';
3
+ import { config } from './config.js';
3
4
  import { setContext, useContext } from './context.js';
4
5
  import { Forbidden } from './errors.js';
5
- import { readEnv } from './utils.js';
6
6
  const ignoredMethods = new Set(['GET', 'HEAD', 'OPTIONS']);
7
7
  const defaultCookieName = () => {
8
- return process.env.NODE_ENV === 'production' && !readEnv.boolean('CI', false)
8
+ return config.value.NODE_ENV === 'production' && !config.value.CI
9
9
  ? '__Host-devmoods.x-csrf-token'
10
10
  : 'devmoods.x-csrf-token';
11
11
  };
12
12
  export const getCsrfSecret = () => {
13
- const secret = readEnv('CSRF_SECRET', '');
13
+ const secret = config.value.CSRF_SECRET;
14
14
  if (!secret) {
15
15
  throw new Error('CSRF_SECRET is not set');
16
16
  }
package/dist/faktory.js CHANGED
@@ -1,5 +1,5 @@
1
- import { AsyncLocalStorage } from 'async_hooks';
2
- import { randomUUID } from 'crypto';
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { randomUUID } from 'node:crypto';
3
3
  import { create } from 'faktory-worker';
4
4
  import { getLogger } from './logger.js';
5
5
  import { startTimer } from './utils.js';
package/dist/listen.js CHANGED
@@ -3,13 +3,14 @@ import {} from 'node:http';
3
3
  import {} from 'node:net';
4
4
  import { delay } from '@devmoods/fetch';
5
5
  import {} from 'express';
6
+ import { config } from './config.js';
6
7
  import { getLogger } from './logger.js';
7
8
  import { before, route } from './middleware.js';
8
- import { envPort, readEnv } from './utils.js';
9
+ import { envPort } from './utils.js';
9
10
  import { validateQuery } from './validate.js';
10
11
  const logger = getLogger('http');
11
- const READINESS_PERIOD = readEnv.number('READINESS_PERIOD_MS', 0);
12
- const GRACE_PERIOD = readEnv.number('GRACE_PERIOD_MS', 20000);
12
+ const READINESS_PERIOD = config.value.READINESS_PERIOD_MS;
13
+ const GRACE_PERIOD = config.value.GRACE_PERIOD_MS;
13
14
  export function stoppable(server, grace = Infinity) {
14
15
  let stopped = false;
15
16
  let gracefully = true;
package/dist/logger.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as os from 'node:os';
2
2
  import * as Sentry from '@sentry/node';
3
+ import { config } from './config.js';
3
4
  import { useRequestId } from './context.js';
4
- import { readEnv } from './utils.js';
5
5
  const LEVELS = {
6
6
  TRACE: 10,
7
7
  DEBUG: 20,
@@ -24,7 +24,7 @@ function defaultGetContext() {
24
24
  ..._globalGetContext?.(),
25
25
  };
26
26
  }
27
- export function createLogger({ name, getContext, level = readEnv('LOG_LEVEL', 'INFO'), output = process.stdout, }) {
27
+ export function createLogger({ name, getContext, level = config.value.LOG_LEVEL, output = process.stdout, }) {
28
28
  if (_loggers.has(name)) {
29
29
  throw new Error(`A logger named ${name} already exists`);
30
30
  }
package/dist/manage.js CHANGED
@@ -2,6 +2,7 @@ import { program } from 'commander';
2
2
  import {} from 'express';
3
3
  import { hashPassword } from './auth.js';
4
4
  import { CLI } from './cli.js';
5
+ import { config } from './config.js';
5
6
  import { asCli } from './migrate.js';
6
7
  import { parseRoutes } from './routes.js';
7
8
  export function registerManagementCommand(register) {
@@ -44,4 +45,9 @@ export function registerDefaultCommands(app) {
44
45
  cli.writeLine(`${hash}\n`);
45
46
  }));
46
47
  });
48
+ registerManagementCommand((command, cli) => {
49
+ command('show-config').action(cli.wrap(async () => {
50
+ console.log(config.value);
51
+ }));
52
+ });
47
53
  }
@@ -39,7 +39,7 @@ export declare class Transaction extends Connection {
39
39
  *
40
40
  * @param config
41
41
  */
42
- export declare function createPostgres({ onError, url, ...config }?: PostgresOptions): {
42
+ export declare function createPostgres({ onError, url, ...postgresOptions }?: PostgresOptions): {
43
43
  insert: <T extends QueryResultRow>(text: string | SqlStatement, values?: Value[]) => Promise<QueryResult<T>>;
44
44
  query: <T extends QueryResultRow>(text: string | SqlStatement, values?: Value[]) => Promise<QueryResult<T>>;
45
45
  first: <T extends QueryResultRow>(text: string | SqlStatement, values?: Value[]) => Promise<T | null>;
package/dist/postgres.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import pg, {} from 'pg';
2
2
  import { format } from 'sql-formatter';
3
- import { readEnv, startTimer } from './utils.js';
3
+ import { config } from './config.js';
4
+ import { startTimer } from './utils.js';
4
5
  export const escapePostgresIdentifier = pg.escapeIdentifier;
5
6
  export const escapePostgresLiteral = pg.escapeLiteral;
6
7
  export const formatSql = (statement) => {
@@ -39,7 +40,7 @@ export class Connection {
39
40
  if (this.options.onLogStatement) {
40
41
  return this.options.onLogStatement(text, values, duration);
41
42
  }
42
- if (readEnv.boolean('LOG_SQL', false)) {
43
+ if (config.value.LOG_SQL) {
43
44
  console.log(`\n${formatSql(text)}\n\nValues: ${JSON.stringify(values)}\nDuration: ${duration}ms\n`);
44
45
  }
45
46
  }
@@ -93,15 +94,17 @@ export class Transaction extends Connection {
93
94
  *
94
95
  * @param config
95
96
  */
96
- export function createPostgres({ onError, url = readEnv('POSTGRES_URL'), ...config } = {}) {
97
- const pool = new pg.Pool({ connectionString: url, ...config });
97
+ export function createPostgres({ onError, url = config.value.POSTGRES_URL, ...postgresOptions } = {}) {
98
+ const pool = new pg.Pool({ connectionString: url, ...postgresOptions });
98
99
  pool.on('error', (e, client) => {
99
100
  onError?.(e, client);
100
101
  });
101
102
  async function withConnection(callback) {
102
103
  const client = await pool.connect();
103
104
  try {
104
- const result = await callback(new Connection(client, { onLogStatement: config.onLogStatement }));
105
+ const result = await callback(new Connection(client, {
106
+ onLogStatement: postgresOptions.onLogStatement,
107
+ }));
105
108
  return result;
106
109
  }
107
110
  finally {
@@ -127,7 +130,9 @@ export function createPostgres({ onError, url = readEnv('POSTGRES_URL'), ...conf
127
130
  const client = await pool.connect();
128
131
  try {
129
132
  await client.query('BEGIN');
130
- const result = await callback(new Transaction(client, { onLogStatement: config.onLogStatement }));
133
+ const result = await callback(new Transaction(client, {
134
+ onLogStatement: postgresOptions.onLogStatement,
135
+ }));
131
136
  await client.query('COMMIT');
132
137
  return result;
133
138
  }
package/dist/redis.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createClient } from 'redis';
2
- import { readEnv } from './utils.js';
3
- export function createRedis({ url = readEnv('REDIS_URL', ''), onError, } = {}) {
2
+ import { config } from './config.js';
3
+ export function createRedis({ url = config.value.REDIS_URL, onError, } = {}) {
4
4
  const client = createClient({ url });
5
5
  // Always attach a listener even if
6
6
  // onError is not provided.
package/dist/sentry.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { nodeProfilingIntegration } from '@sentry/profiling-node';
2
- import { readEnv } from './utils.js';
2
+ import { config } from './config.js';
3
3
  export function sentryOptions(options = {}) {
4
4
  return {
5
- dsn: readEnv('SENTRY_DSN'),
5
+ dsn: config.value.SENTRY_DSN,
6
6
  tracesSampleRate: 1.0,
7
7
  profilesSampleRate: 1.0,
8
8
  integrations: [nodeProfilingIntegration()],
package/dist/slack.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createFetch } from '@devmoods/fetch';
2
+ import { config } from './config.js';
2
3
  import { getLogger } from './logger.js';
3
- import { readEnv } from './utils.js';
4
4
  const logger = getLogger('slack');
5
5
  /**
6
6
  * Send messages to Slack using Incoming Webhooks
@@ -15,7 +15,7 @@ const logger = getLogger('slack');
15
15
  *
16
16
  * See https://api.slack.com/messaging/webhooks
17
17
  */
18
- export function createSlack({ webhookUrl = readEnv('SLACK_WEBHOOK_URL', ''), dryRun = process.env.NODE_ENV !== 'production', } = {}) {
18
+ export function createSlack({ webhookUrl = config.value.SLACK_WEBHOOK_URL, dryRun = process.env.NODE_ENV !== 'production', } = {}) {
19
19
  const fetch = createFetch();
20
20
  /**
21
21
  * Post a message to Slack
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devmoods/express-extras",
3
- "version": "0.42.1",
3
+ "version": "0.44.0",
4
4
  "description": "Kubernetes-ready utilities for rapid Node.js application development",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -18,19 +18,15 @@
18
18
  ],
19
19
  "license": "ISC",
20
20
  "devDependencies": {
21
- "@devmoods/eslint-config": "^1.15.0",
21
+ "@devmoods/eslint-config": "^2.5.0",
22
22
  "@types/koa-compose": "^3.2.8",
23
23
  "@types/node": "22.10.1",
24
24
  "@types/supertest": "^6.0.2",
25
- "@typescript-eslint/eslint-plugin": "^8.17.0",
26
- "@typescript-eslint/parser": "^8.17.0",
27
25
  "@vitest/coverage-v8": "^2.1.8",
28
26
  "cookiejar": "^2.1.4",
29
- "eslint": "^8.57.0",
30
- "eslint-plugin-import": "^2.31.0",
31
- "eslint-plugin-prettier": "^5.2.1",
27
+ "eslint": "^9.16.0",
32
28
  "faktory-worker": "^4.6.0",
33
- "prettier": "3.4.1",
29
+ "prettier": "3.4.2",
34
30
  "supertest": "7.0.0",
35
31
  "ts-node": "10.9.2",
36
32
  "tsx": "^4.19.2",
@@ -40,8 +36,8 @@
40
36
  },
41
37
  "dependencies": {
42
38
  "@devmoods/fetch": "^4.0.1",
43
- "@sentry/node": "^8.42.0",
44
- "@sentry/profiling-node": "^8.42.0",
39
+ "@sentry/node": "^8.43.0",
40
+ "@sentry/profiling-node": "^8.43.0",
45
41
  "@types/bcrypt": "^5.0.2",
46
42
  "@types/body-parser": "^1.19.5",
47
43
  "@types/cookie-parser": "^1.4.8",
@@ -58,8 +54,8 @@
58
54
  "commander": "^12.1.0",
59
55
  "compose-middleware": "^5.0.1",
60
56
  "cookie-parser": "^1.4.7",
61
- "dotenv": "^16.4.6",
62
- "express": "^4.21.1",
57
+ "dotenv": "^16.4.7",
58
+ "express": "^4.21.2",
63
59
  "helmet": "^8.0.0",
64
60
  "jsonwebtoken": "^9.0.2",
65
61
  "nodemailer": "^6.9.16",
@@ -71,9 +67,6 @@
71
67
  "prettier": {
72
68
  "singleQuote": true
73
69
  },
74
- "eslintConfig": {
75
- "extends": "@devmoods/eslint-config"
76
- },
77
70
  "peerDependencies": {
78
71
  "faktory-worker": "^4.5.1"
79
72
  },
@@ -84,7 +77,7 @@
84
77
  "start": "pushd example; pnpm run start --watch; popd example;",
85
78
  "start:worker": "pushd example; pnpm run start:worker; popd example;",
86
79
  "clean": "rm -rf dist",
87
- "lint": "eslint src example/src",
80
+ "lint": "eslint",
88
81
  "test": "./test.sh",
89
82
  "build": "pnpm run clean && tsc --project tsconfig.build.json",
90
83
  "format": "prettier --write '**/*.{js,css,md,ts,tsx}'"