@devmoods/express-extras 0.52.3 → 0.54.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,6 +1,6 @@
1
1
  import { type AnyUser, type AuthProvider } from './types.js';
2
2
  export interface PasswordlessAuthProviderOptions<UserModel = AnyUser> {
3
3
  getUserByEmail(email: string): Promise<UserModel | null>;
4
- onLoginRequest(user: UserModel, temporaryToken: string): Promise<void>;
4
+ onLoginRequest(user: UserModel, temporaryToken: string, otp: string): Promise<void>;
5
5
  }
6
6
  export declare function PasswordlessAuthProvider<UserModel extends AnyUser>(options: PasswordlessAuthProviderOptions<UserModel>): AuthProvider<UserModel>;
@@ -1,6 +1,5 @@
1
1
  import { createTokenResponse } from './token.js';
2
2
  import {} from './types.js';
3
- import { Unauthorized } from '../errors.js';
4
3
  import { getLogger } from '../logger.js';
5
4
  import { route } from '../middleware.js';
6
5
  import { DoesNotExistError } from '../postgres.js';
@@ -20,19 +19,25 @@ export function PasswordlessAuthProvider(options) {
20
19
  };
21
20
  return {
22
21
  setupRoutes(router, context) {
23
- router.post('/passwordless/login/:token', route(async (req, res) => {
24
- const { token: temporaryToken } = req.params;
25
- const decoded = context.decodeTemporaryToken(temporaryToken);
26
- const user = await context.getUserById(decoded.payload.uid);
27
- try {
28
- await context.verifyTemporaryToken(temporaryToken, user);
29
- }
30
- catch {
31
- logger.warn('passwordless: invalid temp token', { uid: user.id });
32
- throw Unauthorized('Invalid token');
33
- }
22
+ router.post('/passwordless/login', validateBody({
23
+ type: 'object',
24
+ properties: {
25
+ type: { type: 'string', enum: ['otp', 'token'] },
26
+ otp: { type: 'string' },
27
+ token: { type: 'string' },
28
+ },
29
+ required: ['type'],
30
+ }), route(async (req, res) => {
31
+ const { token: temporaryToken, otp, type: tokenType } = req.body;
32
+ const verify = tokenType === 'otp'
33
+ ? context.verifyOtp
34
+ : context.verifyTemporaryToken;
35
+ const tokenValue = tokenType === 'otp' ? otp : temporaryToken;
36
+ const uid = await verify(tokenValue, null, false);
37
+ const user = await context.getUserById(uid);
38
+ await verify(tokenValue, user);
34
39
  const accessToken = await context.login(req, res, user);
35
- logger.info('passwordless login', { uid: user.id });
40
+ logger.info('passwordless login', { tokenType, uid: user.id });
36
41
  if (req.accepts('html')) {
37
42
  return res.redirect('/');
38
43
  }
@@ -55,7 +60,8 @@ export function PasswordlessAuthProvider(options) {
55
60
  return 202;
56
61
  }
57
62
  const token = await context.createTemporaryToken(user);
58
- await options.onLoginRequest(user, token);
63
+ const otp = await context.createOtp(user);
64
+ await options.onLoginRequest(user, token, otp);
59
65
  logger.info('passwordless login requested', { email });
60
66
  return 202;
61
67
  }));
@@ -35,8 +35,10 @@ export interface AuthProviderContext<UserModel extends AnyUser> {
35
35
  tokenStorage: AuthOptions<UserModel>['tokenStorage'];
36
36
  setCurrentUser: (user: UserModel) => void;
37
37
  createTemporaryToken(user: UserModel): Promise<string>;
38
- verifyTemporaryToken(token: string, user: UserModel): Promise<void>;
38
+ verifyTemporaryToken(token: string, user: UserModel | null, consume?: boolean): Promise<string>;
39
39
  decodeTemporaryToken(token: string): DecodedTemporaryToken;
40
+ createOtp(user: UserModel): Promise<string>;
41
+ verifyOtp(otp: string, user: UserModel | null, consume?: boolean): Promise<string>;
40
42
  middleware: (options?: AuthMiddlewareOptions<UserModel>) => RequestHandler;
41
43
  login: (req: Request, res: Response, user: UserModel, isRefresh?: boolean) => Promise<AccessToken>;
42
44
  getUserById(id: string): Promise<UserModel>;
package/dist/auth.d.ts CHANGED
@@ -12,8 +12,10 @@ export interface CreateAuth<UserModel extends AnyUser> {
12
12
  before: () => RequestHandler;
13
13
  isAuthorized: (fn?: (user: UserModel) => boolean) => RequestHandler;
14
14
  createTemporaryToken(user: UserModel): Promise<string>;
15
- verifyTemporaryToken(token: string, user: UserModel): Promise<void>;
15
+ verifyTemporaryToken(token: string, user: UserModel | null): Promise<string>;
16
16
  decodeTemporaryToken(token: string): DecodedTemporaryToken;
17
+ createOtp(user: UserModel): Promise<string>;
18
+ verifyOtp(otp: string, user: UserModel | null): Promise<string>;
17
19
  logout: (req: Request, res: Response) => Promise<void>;
18
20
  }
19
21
  export declare class RedisTokenStorage extends TokenStorage {
package/dist/auth.js CHANGED
@@ -71,6 +71,8 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
71
71
  createTemporaryToken,
72
72
  verifyTemporaryToken,
73
73
  decodeTemporaryToken,
74
+ createOtp,
75
+ verifyOtp,
74
76
  middleware,
75
77
  login,
76
78
  getUserById,
@@ -185,6 +187,24 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
185
187
  }
186
188
  };
187
189
  }
190
+ async function createOtp(user) {
191
+ const otp = crypto.randomInt(100000, 999999).toString();
192
+ await tokenStorage.set(otp, String(user.id), config.value.AUTH_OTP_EXPIRES_IN);
193
+ return otp;
194
+ }
195
+ async function verifyOtp(otp, user, consume = true) {
196
+ const storedUid = await tokenStorage.get(otp);
197
+ if (!storedUid) {
198
+ throw new InvalidToken('Invalid OTP');
199
+ }
200
+ if (user && storedUid !== user.id) {
201
+ throw new InvalidToken('Invalid OTP');
202
+ }
203
+ if (consume) {
204
+ await tokenStorage.invalidate(otp);
205
+ }
206
+ return storedUid;
207
+ }
188
208
  /**
189
209
  * Create a short-lived token tied to a user that can be
190
210
  * used for different kinds of verification.
@@ -213,7 +233,7 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
213
233
  function decodeTemporaryToken(token) {
214
234
  return JSON.parse(Buffer.from(token, 'base64url').toString('utf-8'));
215
235
  }
216
- async function verifyTemporaryToken(token, user) {
236
+ async function verifyTemporaryToken(token, user, consume = true) {
217
237
  const decoded = decodeTemporaryToken(token);
218
238
  const expectedSignature = crypto
219
239
  .createHmac('sha256', config.value.AUTH_SECRET)
@@ -223,14 +243,20 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
223
243
  if (expectedSignature !== decoded.signature) {
224
244
  throw new InvalidToken('Invalid signature');
225
245
  }
226
- if (decoded.payload.uid !== user.id) {
246
+ if (user && decoded.payload.uid !== user.id) {
227
247
  throw new InvalidToken(`Invalid user ${user.id}`);
228
248
  }
229
249
  const storedUid = await tokenStorage.get(token);
230
- if (storedUid !== user.id) {
250
+ if (!storedUid) {
251
+ throw new InvalidToken('Invalid token not found');
252
+ }
253
+ if (user && storedUid !== user.id) {
231
254
  throw new InvalidToken(`Invalid user ${user.id}`);
232
255
  }
233
- await tokenStorage.invalidate(token);
256
+ if (consume) {
257
+ await tokenStorage.invalidate(token);
258
+ }
259
+ return storedUid;
234
260
  }
235
261
  return {
236
262
  before: () => middleware(false),
@@ -241,6 +267,8 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
241
267
  createTemporaryToken,
242
268
  verifyTemporaryToken,
243
269
  decodeTemporaryToken,
270
+ createOtp,
271
+ verifyOtp,
244
272
  logout,
245
273
  };
246
274
  }
package/dist/config.d.ts CHANGED
@@ -5,6 +5,7 @@ export declare const config: {
5
5
  PUBLIC_URL: string;
6
6
  AUTH_TOKEN_EXPIRES_IN: number;
7
7
  AUTH_TEMP_TOKEN_EXPIRES_IN: number;
8
+ AUTH_OTP_EXPIRES_IN: number;
8
9
  AUTH_SECRET: string;
9
10
  AUTH_COOKIE_SAME_SITE: string;
10
11
  REDIS_URL: string;
@@ -37,6 +38,7 @@ export declare function extendConfig<T extends Record<string, unknown>>(appConfi
37
38
  PUBLIC_URL: string;
38
39
  AUTH_TOKEN_EXPIRES_IN: number;
39
40
  AUTH_TEMP_TOKEN_EXPIRES_IN: number;
41
+ AUTH_OTP_EXPIRES_IN: number;
40
42
  AUTH_SECRET: string;
41
43
  AUTH_COOKIE_SAME_SITE: string;
42
44
  REDIS_URL: string;
package/dist/config.js CHANGED
@@ -8,6 +8,7 @@ const baseConfig = {
8
8
  PUBLIC_URL: readEnv('PUBLIC_URL', ''),
9
9
  AUTH_TOKEN_EXPIRES_IN: readEnv.number('AUTH_TOKEN_EXPIRES', 3600 * 24 * 30), // 30 days
10
10
  AUTH_TEMP_TOKEN_EXPIRES_IN: readEnv.number('AUTH_TEMP_TOKEN_EXPIRES', 3600),
11
+ AUTH_OTP_EXPIRES_IN: readEnv.number('AUTH_OTP_EXPIRES', 5 * 60),
11
12
  AUTH_SECRET: readEnv('AUTH_JWT_SECRET', ''),
12
13
  AUTH_COOKIE_SAME_SITE: readEnv('AUTH_COOKIE_SAME_SITE', 'strict'),
13
14
  REDIS_URL: readEnv('REDIS_URL', ''),
package/dist/errors.js CHANGED
@@ -2,6 +2,7 @@ import { STATUS_CODES } from 'node:http';
2
2
  import * as Sentry from '@sentry/node';
3
3
  import { errors } from 'compose-middleware';
4
4
  import {} from 'express';
5
+ import { InvalidToken } from './auth/errors.js';
5
6
  import { getLogger } from './logger.js';
6
7
  import { DoesNotExistError, IntegrityError } from './postgres.js';
7
8
  export class ApiError extends Error {
@@ -48,6 +49,9 @@ function convertToAPIError(error) {
48
49
  if (error instanceof DoesNotExistError) {
49
50
  return NotFound(error.message);
50
51
  }
52
+ if (error instanceof InvalidToken) {
53
+ return Unauthorized(error.message);
54
+ }
51
55
  return error;
52
56
  }
53
57
  function _errorHandler({ onError }) {
package/dist/index.d.ts CHANGED
@@ -14,10 +14,12 @@ export * from './logger.js';
14
14
  export * from './manage.js';
15
15
  export * from './middleware.js';
16
16
  export * from './postgres.js';
17
+ export * from './rate-limit.js';
17
18
  export * from './redis.js';
18
19
  export * from './routes.js';
19
20
  export * from './sentry.js';
20
21
  export * from './slack.js';
22
+ export * from './sql.js';
21
23
  export * from './utils.js';
22
24
  export * from './validate.js';
23
25
  export * from './webapi.js';
package/dist/index.js CHANGED
@@ -15,10 +15,12 @@ export * from './logger.js';
15
15
  export * from './manage.js';
16
16
  export * from './middleware.js';
17
17
  export * from './postgres.js';
18
+ export * from './rate-limit.js';
18
19
  export * from './redis.js';
19
20
  export * from './routes.js';
20
21
  export * from './sentry.js';
21
22
  export * from './slack.js';
23
+ export * from './sql.js';
22
24
  export * from './utils.js';
23
25
  export * from './validate.js';
24
26
  export * from './webapi.js';
@@ -68,8 +68,17 @@ export function before({ cookies = false, helmet: useHelmet = true, json: useJso
68
68
  ].filter(Boolean));
69
69
  }
70
70
  function logRequestDuration() {
71
+ const FILTERED_PATHS = [
72
+ '/src',
73
+ '/node_modules',
74
+ '/@fs',
75
+ '/@vite',
76
+ '/@react-refresh',
77
+ '/@id',
78
+ '/analytics.js',
79
+ ];
71
80
  return (req, res, next) => {
72
- if (req.path.startsWith('/src')) {
81
+ if (FILTERED_PATHS.some((p) => req.path.startsWith(p))) {
73
82
  return next();
74
83
  }
75
84
  const timer = startTimer();
package/dist/migrate.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type Command } from 'commander';
2
2
  import { type CLI } from './cli.js';
3
- import { type Connection, createPostgres } from './postgres.js';
3
+ import { createPostgres } from './postgres.js';
4
4
  interface Migration {
5
5
  name: string;
6
6
  filename: string;
@@ -33,8 +33,8 @@ export declare class Migrator {
33
33
  log(...args: any[]): void;
34
34
  executeMigration(migrations: Migration[], currentVersion: number, direction: 'up' | 'down'): Promise<number>;
35
35
  readMigrationFile(filename: string): Promise<MigrationFileAPI>;
36
- migrate(connection: Connection, migrator: MigrationFileAPI, direction: 'up' | 'down'): Promise<void>;
37
- updateCurrentVersion(connection: Connection, version: number): Promise<import("pg").QueryResult<import("pg").QueryResultRow>>;
36
+ migrate(migrator: MigrationFileAPI, direction: 'up' | 'down'): Promise<void>;
37
+ updateCurrentVersion(version: number): Promise<import("pg").QueryResult<import("pg").QueryResultRow>>;
38
38
  getCurrentVersion(): Promise<number>;
39
39
  getMigrations(): Promise<{
40
40
  filename: string;
package/dist/migrate.js CHANGED
@@ -3,7 +3,8 @@ import * as path from 'node:path';
3
3
  import chalk from 'chalk';
4
4
  import {} from 'commander';
5
5
  import {} from './cli.js';
6
- import { createPostgres, formatSql, sql } from './postgres.js';
6
+ import { createPostgres } from './postgres.js';
7
+ import { formatSql, sql } from './sql.js';
7
8
  export const asCli = (command, cli) => {
8
9
  command.command('up').action(cli.wrap(async () => {
9
10
  const migrator = new Migrator();
@@ -138,15 +139,15 @@ export class Migrator {
138
139
  this.log('Running migration', migration.filename);
139
140
  const migrator = await this.readMigrationFile(migration.filename);
140
141
  if (migrator.useTransaction !== false) {
141
- await this.postgres.transaction(async (tx) => {
142
- await this.migrate(tx, migrator, direction);
143
- await this.updateCurrentVersion(tx, migration.version);
142
+ await this.postgres.transaction(async () => {
143
+ await this.migrate(migrator, direction);
144
+ await this.updateCurrentVersion(migration.version);
144
145
  });
145
146
  }
146
147
  else {
147
- await this.postgres.withConnection(async (tx) => {
148
- await this.migrate(tx, migrator, direction);
149
- await this.updateCurrentVersion(tx, migration.version);
148
+ await this.postgres.withConnection(async () => {
149
+ await this.migrate(migrator, direction);
150
+ await this.updateCurrentVersion(migration.version);
150
151
  });
151
152
  }
152
153
  }
@@ -165,15 +166,15 @@ export class Migrator {
165
166
  this.log('Rolling back migration', migrationToRollback.filename);
166
167
  const migrator = await this.readMigrationFile(migrationToRollback.filename);
167
168
  if (migrator.useTransaction !== false) {
168
- await this.postgres.transaction(async (tx) => {
169
- await this.migrate(tx, migrator, 'down');
170
- await this.updateCurrentVersion(tx, updatedVersion);
169
+ await this.postgres.transaction(async () => {
170
+ await this.migrate(migrator, 'down');
171
+ await this.updateCurrentVersion(updatedVersion);
171
172
  });
172
173
  }
173
174
  else {
174
- await this.postgres.withConnection(async (tx) => {
175
- await this.migrate(tx, migrator, 'down');
176
- await this.updateCurrentVersion(tx, updatedVersion);
175
+ await this.postgres.withConnection(async () => {
176
+ await this.migrate(migrator, 'down');
177
+ await this.updateCurrentVersion(updatedVersion);
177
178
  });
178
179
  }
179
180
  return await this.getCurrentVersion();
@@ -197,13 +198,13 @@ export class Migrator {
197
198
  },
198
199
  };
199
200
  }
200
- async migrate(connection, migrator, direction) {
201
+ async migrate(migrator, direction) {
201
202
  try {
202
203
  const queryWithLog = (statement) => {
203
204
  this.log(chalk.yellow(formatSql(statement)));
204
- return connection.query(statement);
205
+ return this.postgres.query(statement);
205
206
  };
206
- const returnValue = await migrator[direction](queryWithLog.bind(connection));
207
+ const returnValue = await migrator[direction](queryWithLog.bind(this.postgres));
207
208
  if (typeof returnValue === 'string') {
208
209
  if (returnValue !== '') {
209
210
  await queryWithLog(returnValue);
@@ -218,8 +219,8 @@ export class Migrator {
218
219
  throw new Error(`Migration failed: ${error.message}`);
219
220
  }
220
221
  }
221
- updateCurrentVersion(connection, version) {
222
- return connection.query(`INSERT INTO ${this.migrationTableName} (version, run_at) VALUES ($1, $2)`, [version, new Date()]);
222
+ updateCurrentVersion(version) {
223
+ return this.postgres.query(`INSERT INTO ${this.migrationTableName} (version, run_at) VALUES ($1, $2)`, [version, new Date()]);
223
224
  }
224
225
  async getCurrentVersion() {
225
226
  const result = await this.postgres.first(`SELECT version FROM ${this.migrationTableName} ORDER BY run_at DESC LIMIT 1`);
@@ -1,10 +1,8 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
1
2
  import pg, { type PoolClient, type PoolConfig, type QueryResult, type QueryResultRow } from 'pg';
3
+ import { type SqlStatement } from './sql.js';
2
4
  export declare const escapePostgresIdentifier: typeof pg.escapeIdentifier;
3
5
  export declare const escapePostgresLiteral: typeof pg.escapeLiteral;
4
- /**
5
- * Returns a pretty-printed SQL statement.
6
- */
7
- export declare const formatSql: (statement: SqlStatement | string) => string;
8
6
  export interface PostgresOptions extends PoolConfig, ConnectionOptions {
9
7
  onError?: (error: Error, client: any) => void;
10
8
  url?: PoolConfig['connectionString'];
@@ -22,6 +20,13 @@ type Value = null | string | number | Date;
22
20
  export interface ConnectionOptions {
23
21
  onLogStatement?: (statement: SqlStatement | string, values: unknown[] | undefined, duration: number) => void;
24
22
  }
23
+ export interface TransactionContext {
24
+ connection: Connection;
25
+ savepoints: string[];
26
+ isCommitted: boolean;
27
+ isRolledBack: boolean;
28
+ }
29
+ export declare const transactionStorage: AsyncLocalStorage<TransactionContext>;
25
30
  export declare class Connection {
26
31
  private options;
27
32
  client: PoolClient;
@@ -34,6 +39,12 @@ export declare class Connection {
34
39
  get<T extends QueryResultRow>(text: string | SqlStatement, values?: Value[]): Promise<T>;
35
40
  }
36
41
  export declare class Transaction extends Connection {
42
+ savepoints: string[];
43
+ isCommitted: boolean;
44
+ isRolledBack: boolean;
45
+ createSavepoint(): Promise<string>;
46
+ rollbackToSavepoint(savepointName: string): Promise<void>;
47
+ releaseSavepoint(savepointName: string): Promise<void>;
37
48
  }
38
49
  /**
39
50
  * Create a new Postgres connection pool with
@@ -49,37 +60,10 @@ export declare function createPostgres({ onError, url, ...postgresOptions }?: Po
49
60
  all: <T extends QueryResultRow>(text: string | SqlStatement, values?: Value[]) => Promise<T[]>;
50
61
  get: <T extends QueryResultRow>(text: string | SqlStatement, values?: Value[]) => Promise<T>;
51
62
  withConnection: <T>(callback: (connection: Connection) => Promise<T>) => Promise<T>;
52
- transaction: <T>(callback: (connection: Transaction) => Promise<T> | T) => Promise<T>;
63
+ transaction: <T>(callback: () => Promise<T> | T) => Promise<T>;
64
+ getCurrentTransaction: () => Transaction | null;
53
65
  instance: pg.Pool;
54
66
  clear: (force?: boolean) => Promise<void>;
55
67
  end(): Promise<void>;
56
68
  };
57
- export declare class SqlStatement {
58
- stringsAndValues: [string, unknown | undefined][];
59
- constructor(strings: string[], values: unknown[]);
60
- _extract(strings: string[], values: unknown[]): void;
61
- append(statement: SqlStatement | string): this;
62
- get values(): ({} | null)[];
63
- get text(): string;
64
- }
65
- export declare const sql: {
66
- (strings: TemplateStringsArray, ...values: unknown[]): SqlStatement;
67
- identifier(name: string): {
68
- name: string;
69
- raw: boolean;
70
- };
71
- raw(name: string): {
72
- name: string;
73
- raw: boolean;
74
- };
75
- literal(name: string | null): {
76
- name: string;
77
- raw: boolean;
78
- };
79
- spreadUpdate: typeof spreadUpdate;
80
- spreadInsert: typeof spreadInsert;
81
- };
82
- declare function spreadUpdate(data: Record<string, any>): SqlStatement;
83
- declare function spreadInsert<TData extends Record<string, any>>(data: TData | TData[]): SqlStatement;
84
- export declare function joinStatements(statements: SqlStatement[], separator: string): SqlStatement;
85
69
  export {};
package/dist/postgres.js CHANGED
@@ -1,21 +1,10 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
1
2
  import pg, {} from 'pg';
2
- import { format } from 'sql-formatter';
3
3
  import { config } from './config.js';
4
+ import { formatSql } from './sql.js';
4
5
  import { startTimer } from './utils.js';
5
6
  export const escapePostgresIdentifier = pg.escapeIdentifier;
6
7
  export const escapePostgresLiteral = pg.escapeLiteral;
7
- /**
8
- * Returns a pretty-printed SQL statement.
9
- */
10
- export const formatSql = (statement) => {
11
- const value = statement instanceof SqlStatement ? statement.text : statement;
12
- return format(value, {
13
- language: 'postgresql',
14
- keywordCase: 'upper',
15
- tabWidth: 2,
16
- useTabs: false,
17
- });
18
- };
19
8
  export class DoesNotExistError extends Error {
20
9
  constructor(message) {
21
10
  super(message);
@@ -34,6 +23,7 @@ export class IntegrityError extends Error {
34
23
  this.name = 'IntegrityError';
35
24
  }
36
25
  }
26
+ export const transactionStorage = new AsyncLocalStorage();
37
27
  export class Connection {
38
28
  constructor(client, options) {
39
29
  this.options = options;
@@ -89,6 +79,28 @@ export class Connection {
89
79
  }
90
80
  }
91
81
  export class Transaction extends Connection {
82
+ constructor() {
83
+ super(...arguments);
84
+ this.savepoints = [];
85
+ this.isCommitted = false;
86
+ this.isRolledBack = false;
87
+ }
88
+ async createSavepoint() {
89
+ const savepointName = `sp_${this.savepoints.length + 1}`;
90
+ await this.query(`SAVEPOINT ${savepointName}`);
91
+ this.savepoints.push(savepointName);
92
+ return savepointName;
93
+ }
94
+ async rollbackToSavepoint(savepointName) {
95
+ await this.query(`ROLLBACK TO SAVEPOINT ${savepointName}`);
96
+ }
97
+ async releaseSavepoint(savepointName) {
98
+ await this.query(`RELEASE SAVEPOINT ${savepointName}`);
99
+ const index = this.savepoints.indexOf(savepointName);
100
+ if (index !== -1) {
101
+ this.savepoints.splice(index, 1);
102
+ }
103
+ }
92
104
  }
93
105
  /**
94
106
  * Create a new Postgres connection pool with
@@ -103,6 +115,11 @@ export function createPostgres({ onError, url = config.value.POSTGRES_URL, ...po
103
115
  onError?.(e, client);
104
116
  });
105
117
  async function withConnection(callback) {
118
+ // Check if we're already in a transaction context
119
+ const txContext = transactionStorage.getStore();
120
+ if (txContext) {
121
+ return callback(txContext.connection);
122
+ }
106
123
  const client = await pool.connect();
107
124
  try {
108
125
  const result = await callback(new Connection(client, {
@@ -128,21 +145,50 @@ export function createPostgres({ onError, url = config.value.POSTGRES_URL, ...po
128
145
  }
129
146
  /**
130
147
  * Run queries inside a BEGIN, COMMIT / ROLLBACK transaction.
148
+ * Supports nested transactions using SAVEPOINT.
131
149
  */
132
150
  async function transaction(callback) {
151
+ // Check if we're already in a transaction context
152
+ const existingTxContext = transactionStorage.getStore();
153
+ if (existingTxContext) {
154
+ // We're already in a transaction, create a savepoint for nested transaction
155
+ const txConnection = existingTxContext.connection;
156
+ const savepointName = await txConnection.createSavepoint();
157
+ try {
158
+ const result = await callback();
159
+ await txConnection.releaseSavepoint(savepointName);
160
+ return result;
161
+ }
162
+ catch (error) {
163
+ await txConnection.rollbackToSavepoint(savepointName);
164
+ throw error;
165
+ }
166
+ }
167
+ // Start a new top-level transaction
133
168
  const client = await pool.connect();
134
169
  try {
135
170
  await client.query('BEGIN');
136
- const result = await callback(new Transaction(client, {
171
+ const txConnection = new Transaction(client, {
137
172
  onLogStatement: postgresOptions.onLogStatement,
138
- }));
173
+ });
174
+ // Create transaction context
175
+ const txContext = {
176
+ connection: txConnection,
177
+ savepoints: [],
178
+ isCommitted: false,
179
+ isRolledBack: false,
180
+ };
181
+ // Run the callback within the transaction context
182
+ const result = await transactionStorage.run(txContext, async () => {
183
+ return await callback();
184
+ });
139
185
  await client.query('COMMIT');
186
+ txContext.isCommitted = true;
140
187
  return result;
141
188
  }
142
189
  catch (error) {
143
190
  await client.query('ROLLBACK');
144
191
  throw error;
145
- /* c8 ignore next 3 */
146
192
  }
147
193
  finally {
148
194
  client.release();
@@ -173,6 +219,14 @@ export function createPostgres({ onError, url = config.value.POSTGRES_URL, ...po
173
219
  return connection.get(...args);
174
220
  });
175
221
  };
222
+ /**
223
+ * Get the current transaction from the AsyncLocalStorage context.
224
+ * Returns null if not in a transaction.
225
+ */
226
+ function getCurrentTransaction() {
227
+ const txContext = transactionStorage.getStore();
228
+ return txContext ? txContext.connection : null;
229
+ }
176
230
  return {
177
231
  insert,
178
232
  query,
@@ -181,6 +235,7 @@ export function createPostgres({ onError, url = config.value.POSTGRES_URL, ...po
181
235
  get,
182
236
  withConnection,
183
237
  transaction,
238
+ getCurrentTransaction,
184
239
  instance: pool,
185
240
  clear,
186
241
  end() {
@@ -188,123 +243,3 @@ export function createPostgres({ onError, url = config.value.POSTGRES_URL, ...po
188
243
  },
189
244
  };
190
245
  }
191
- const isRaw = (value) => {
192
- return (typeof value === 'object' && value !== null && 'raw' in value && !!value.raw);
193
- };
194
- export class SqlStatement {
195
- constructor(strings, values) {
196
- this.stringsAndValues = [];
197
- this._extract(strings, values);
198
- }
199
- _extract(strings, values) {
200
- for (let i = 0; i < strings.length; i++) {
201
- const string = strings[i];
202
- const value = values[i];
203
- if (isRaw(value)) {
204
- this.stringsAndValues.push([string + value.name, undefined]);
205
- }
206
- else if (value instanceof SqlStatement) {
207
- this.stringsAndValues.push([string, undefined]);
208
- this.stringsAndValues.push(...value.stringsAndValues);
209
- }
210
- else {
211
- this.stringsAndValues.push([string, value]);
212
- }
213
- }
214
- }
215
- append(statement) {
216
- if (statement instanceof SqlStatement) {
217
- this.stringsAndValues.push(...statement.stringsAndValues);
218
- }
219
- else {
220
- this.stringsAndValues.push([statement, undefined]);
221
- }
222
- return this;
223
- }
224
- get values() {
225
- return this.stringsAndValues
226
- .map(([, value]) => value)
227
- .filter((x) => x !== undefined);
228
- }
229
- get text() {
230
- let i = 1;
231
- let result = '';
232
- for (const [string, value] of this.stringsAndValues) {
233
- if (value == null) {
234
- result += string;
235
- }
236
- else {
237
- result += `${string}$${i}`;
238
- i += 1;
239
- }
240
- }
241
- return result;
242
- }
243
- }
244
- export const sql = (strings, ...values) => {
245
- return new SqlStatement(Array.from(strings), values);
246
- };
247
- function spreadUpdate(data) {
248
- const entries = Object.entries(data);
249
- if (entries.length === 0) {
250
- return sql ``;
251
- }
252
- return joinStatements(entries
253
- .filter(([, value]) => typeof value !== 'undefined')
254
- .map(([key, value]) => sql `${sql.identifier(key)} = ${value == null ? sql.raw('NULL') : value}`), ', ');
255
- }
256
- function spreadInsert(data) {
257
- const rows = Array.isArray(data) ? data : [data];
258
- if (rows.length === 0) {
259
- return sql ``;
260
- }
261
- const statement = sql `(`;
262
- statement.append(Object.keys(rows[0])
263
- .map((k) => escapePostgresIdentifier(k))
264
- .join(', '));
265
- statement.append(sql `) VALUES `);
266
- const toInsert = [];
267
- for (const row of rows) {
268
- const values = Object.values(row);
269
- if (values.length === 0) {
270
- continue;
271
- }
272
- toInsert.push(sql `(${joinStatements(values.map((value) => sql `${value ?? sql.raw('NULL')}`), ', ')})`);
273
- }
274
- statement.append(joinStatements(toInsert, ', '));
275
- return statement;
276
- }
277
- /**
278
- * Inject an identifier into the template string
279
- */
280
- sql.identifier = (name) => {
281
- return { name: escapePostgresIdentifier(name), raw: true };
282
- };
283
- /**
284
- * Inject a raw value into the template string
285
- */
286
- sql.raw = (name) => {
287
- return { name, raw: true };
288
- };
289
- /**
290
- * Inject a literal value into the template string
291
- */
292
- sql.literal = (name) => {
293
- if (name === null) {
294
- return { name: 'NULL', raw: true };
295
- }
296
- return { name: escapePostgresLiteral(name), raw: true };
297
- };
298
- sql.spreadUpdate = spreadUpdate;
299
- sql.spreadInsert = spreadInsert;
300
- export function joinStatements(statements, separator) {
301
- let i = 0;
302
- const statement = sql ``;
303
- for (const o of statements) {
304
- if (i++ !== 0) {
305
- statement.append(separator);
306
- }
307
- statement.append(o);
308
- }
309
- return statement;
310
- }
@@ -0,0 +1,22 @@
1
+ import { type RequestHandler, type Request } from 'express';
2
+ import { type RedisClient } from './redis.js';
3
+ export interface RateLimitOptions {
4
+ /**
5
+ * Maximum number of requests allowed within the window
6
+ * @default 100
7
+ */
8
+ max?: number;
9
+ /**
10
+ * Time window in seconds
11
+ * @default 60
12
+ */
13
+ windowSizeInSeconds?: number;
14
+ /**
15
+ * Function to generate a key for rate limiting
16
+ * @default IP-based rate limiting
17
+ */
18
+ getKey?: (req: Request) => string;
19
+ }
20
+ export declare function rateLimit(redis: RedisClient, { max, windowSizeInSeconds, getKey, }?: RateLimitOptions): RequestHandler<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>> & {
21
+ clear: (key: string) => Promise<void>;
22
+ };
@@ -0,0 +1,35 @@
1
+ import {} from 'express';
2
+ import { config } from './config.js';
3
+ import { makeError } from './errors.js';
4
+ import {} from './redis.js';
5
+ export function rateLimit(redis, { max = 100, windowSizeInSeconds = 60, getKey = (req) => req.ip || 'unknown', } = {}) {
6
+ const handler = async (req, res, next) => {
7
+ try {
8
+ const key = `${config.value.REDIS_KEY_PREFIX}rate-limit:${getKey(req)}`;
9
+ // Use Redis to track requests
10
+ const currentCount = await redis.get(key);
11
+ const count = currentCount ? parseInt(currentCount, 10) : 0;
12
+ // Set headers for rate limiting info
13
+ res.setHeader('X-RateLimit-Limit', max.toString());
14
+ res.setHeader('X-RateLimit-Remaining', Math.max(0, max - count - 1).toString());
15
+ if (count >= max) {
16
+ throw makeError(429, 'Rate limit exceeded');
17
+ }
18
+ if (count === 0) {
19
+ await redis.set(key, '1', { EX: windowSizeInSeconds });
20
+ }
21
+ else {
22
+ await redis.incr(key);
23
+ }
24
+ next();
25
+ }
26
+ catch (error) {
27
+ next(error);
28
+ }
29
+ };
30
+ const clear = async (key) => {
31
+ await redis.del(`rate-limit:${key}`);
32
+ };
33
+ handler.clear = clear;
34
+ return handler;
35
+ }
package/dist/sql.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ export declare class SqlStatement {
2
+ stringsAndValues: [string, unknown | undefined][];
3
+ constructor(strings: string[], values: unknown[]);
4
+ _extract(strings: string[], values: unknown[]): void;
5
+ append(statement: SqlStatement | string): this;
6
+ get values(): ({} | null)[];
7
+ get text(): string;
8
+ }
9
+ export declare const sql: {
10
+ (strings: TemplateStringsArray, ...values: unknown[]): SqlStatement;
11
+ identifier(name: string): {
12
+ name: string;
13
+ raw: boolean;
14
+ };
15
+ raw(name: string): {
16
+ name: string;
17
+ raw: boolean;
18
+ };
19
+ literal(name: string | null): {
20
+ name: string;
21
+ raw: boolean;
22
+ };
23
+ spreadUpdate: typeof spreadUpdate;
24
+ spreadInsert: typeof spreadInsert;
25
+ };
26
+ declare function spreadUpdate(data: Record<string, any>): SqlStatement;
27
+ declare function spreadInsert<TData extends Record<string, any>>(data: TData | TData[]): SqlStatement;
28
+ export declare function joinStatements(statements: SqlStatement[], separator: string): SqlStatement;
29
+ /**
30
+ * Returns a pretty-printed SQL statement.
31
+ */
32
+ export declare const formatSql: (statement: SqlStatement | string) => string;
33
+ export {};
package/dist/sql.js ADDED
@@ -0,0 +1,134 @@
1
+ import { format } from 'sql-formatter';
2
+ import { escapePostgresIdentifier, escapePostgresLiteral } from './postgres.js';
3
+ const isRaw = (value) => {
4
+ return (typeof value === 'object' && value !== null && 'raw' in value && !!value.raw);
5
+ };
6
+ export class SqlStatement {
7
+ constructor(strings, values) {
8
+ this.stringsAndValues = [];
9
+ this._extract(strings, values);
10
+ }
11
+ _extract(strings, values) {
12
+ for (let i = 0; i < strings.length; i++) {
13
+ const string = strings[i];
14
+ const value = values[i];
15
+ if (isRaw(value)) {
16
+ this.stringsAndValues.push([string + value.name, undefined]);
17
+ }
18
+ else if (value instanceof SqlStatement) {
19
+ this.stringsAndValues.push([string, undefined]);
20
+ this.stringsAndValues.push(...value.stringsAndValues);
21
+ }
22
+ else {
23
+ this.stringsAndValues.push([string, value]);
24
+ }
25
+ }
26
+ }
27
+ append(statement) {
28
+ if (statement instanceof SqlStatement) {
29
+ this.stringsAndValues.push(...statement.stringsAndValues);
30
+ }
31
+ else {
32
+ this.stringsAndValues.push([statement, undefined]);
33
+ }
34
+ return this;
35
+ }
36
+ get values() {
37
+ return this.stringsAndValues
38
+ .map(([, value]) => value)
39
+ .filter((x) => x !== undefined);
40
+ }
41
+ get text() {
42
+ let i = 1;
43
+ let result = '';
44
+ for (const [string, value] of this.stringsAndValues) {
45
+ if (value == null) {
46
+ result += string;
47
+ }
48
+ else {
49
+ result += `${string}$${i}`;
50
+ i += 1;
51
+ }
52
+ }
53
+ return result;
54
+ }
55
+ }
56
+ export const sql = (strings, ...values) => {
57
+ return new SqlStatement(Array.from(strings), values);
58
+ };
59
+ function spreadUpdate(data) {
60
+ const entries = Object.entries(data);
61
+ if (entries.length === 0) {
62
+ return sql ``;
63
+ }
64
+ return joinStatements(entries
65
+ .filter(([, value]) => typeof value !== 'undefined')
66
+ .map(([key, value]) => sql `${sql.identifier(key)} = ${value == null ? sql.raw('NULL') : value}`), ', ');
67
+ }
68
+ function spreadInsert(data) {
69
+ const rows = Array.isArray(data) ? data : [data];
70
+ if (rows.length === 0) {
71
+ return sql ``;
72
+ }
73
+ const statement = sql `(`;
74
+ statement.append(Object.keys(rows[0])
75
+ .map((k) => escapePostgresIdentifier(k))
76
+ .join(', '));
77
+ statement.append(sql `) VALUES `);
78
+ const toInsert = [];
79
+ for (const row of rows) {
80
+ const values = Object.values(row);
81
+ if (values.length === 0) {
82
+ continue;
83
+ }
84
+ toInsert.push(sql `(${joinStatements(values.map((value) => sql `${value ?? sql.raw('NULL')}`), ', ')})`);
85
+ }
86
+ statement.append(joinStatements(toInsert, ', '));
87
+ return statement;
88
+ }
89
+ /**
90
+ * Inject an identifier into the template string
91
+ */
92
+ sql.identifier = (name) => {
93
+ return { name: escapePostgresIdentifier(name), raw: true };
94
+ };
95
+ /**
96
+ * Inject a raw value into the template string
97
+ */
98
+ sql.raw = (name) => {
99
+ return { name, raw: true };
100
+ };
101
+ /**
102
+ * Inject a literal value into the template string
103
+ */
104
+ sql.literal = (name) => {
105
+ if (name === null) {
106
+ return { name: 'NULL', raw: true };
107
+ }
108
+ return { name: escapePostgresLiteral(name), raw: true };
109
+ };
110
+ sql.spreadUpdate = spreadUpdate;
111
+ sql.spreadInsert = spreadInsert;
112
+ export function joinStatements(statements, separator) {
113
+ let i = 0;
114
+ const statement = sql ``;
115
+ for (const o of statements) {
116
+ if (i++ !== 0) {
117
+ statement.append(separator);
118
+ }
119
+ statement.append(o);
120
+ }
121
+ return statement;
122
+ }
123
+ /**
124
+ * Returns a pretty-printed SQL statement.
125
+ */
126
+ export const formatSql = (statement) => {
127
+ const value = statement instanceof SqlStatement ? statement.text : statement;
128
+ return format(value, {
129
+ language: 'postgresql',
130
+ keywordCase: 'upper',
131
+ tabWidth: 2,
132
+ useTabs: false,
133
+ });
134
+ };
package/dist/validate.js CHANGED
@@ -56,6 +56,7 @@ function createAjv(options) {
56
56
  });
57
57
  ajv.addKeyword('$enumNames');
58
58
  ajv.addKeyword('$bitiforms');
59
+ ajv.addKeyword('$forms');
59
60
  return ajv;
60
61
  }
61
62
  export function compileValidateSchema(schema, options = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devmoods/express-extras",
3
- "version": "0.52.3",
3
+ "version": "0.54.0",
4
4
  "description": "Kubernetes-ready utilities for rapid Node.js application development",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -46,31 +46,31 @@
46
46
  "devDependencies": {
47
47
  "@devmoods/eslint-config": "^2.6.0",
48
48
  "@types/koa-compose": "^3.2.8",
49
- "@types/node": "22.13.4",
49
+ "@types/node": "22.13.5",
50
50
  "@types/supertest": "^6.0.2",
51
51
  "@vitejs/plugin-react": "^4.3.4",
52
- "@vitest/coverage-v8": "^3.0.5",
53
- "arktype": "^2.0.4",
52
+ "@vitest/coverage-v8": "^3.0.7",
53
+ "arktype": "^2.1.0",
54
54
  "cookiejar": "^2.1.4",
55
- "eslint": "^9.20.1",
55
+ "eslint": "^9.21.0",
56
56
  "faktory-worker": "^4.6.0",
57
- "prettier": "3.5.1",
57
+ "prettier": "3.5.2",
58
58
  "supertest": "7.0.0",
59
59
  "ts-node": "10.9.2",
60
- "tsx": "^4.19.2",
60
+ "tsx": "^4.19.3",
61
61
  "typescript": "5.7.3",
62
- "vite": "^6.1.0",
63
- "vitest": "^3.0.5",
62
+ "vite": "^6.1.1",
63
+ "vitest": "^3.0.7",
64
64
  "vitest-fail-on-console": "^0.7.1"
65
65
  },
66
66
  "dependencies": {
67
67
  "@devmoods/fetch": "^4.0.1",
68
- "@sentry/node": "^9.1.0",
69
- "@sentry/profiling-node": "^9.1.0",
68
+ "@sentry/node": "^9.2.0",
69
+ "@sentry/profiling-node": "^9.2.0",
70
70
  "@types/bcrypt": "^5.0.2",
71
71
  "@types/cookie-parser": "^1.4.8",
72
72
  "@types/express": "^5.0.0",
73
- "@types/jsonwebtoken": "^9.0.8",
73
+ "@types/jsonwebtoken": "^9.0.9",
74
74
  "@types/nodemailer": "^6.4.17",
75
75
  "@types/pg": "^8.11.11",
76
76
  "@types/superagent": "^8.1.9",
@@ -90,8 +90,8 @@
90
90
  "pg": "^8.13.3",
91
91
  "redis": "^4.7.0",
92
92
  "sonda": "^0.7.1",
93
- "sql-formatter": "^15.4.10",
94
- "uuid": "^11.0.5"
93
+ "sql-formatter": "^15.4.11",
94
+ "uuid": "^11.1.0"
95
95
  },
96
96
  "prettier": {
97
97
  "singleQuote": true