@devmoods/express-extras 0.59.0 → 0.60.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,12 +1,12 @@
1
1
  import argon2 from 'argon2';
2
2
  import bcrypt from 'bcrypt';
3
+ import {} from 'express';
3
4
  import { createTokenResponse, tokenSchema } from './token.js';
4
5
  import {} from './types.js';
5
6
  import { Unauthorized } from '../errors.js';
6
7
  import { getLogger } from '../logger.js';
7
- import { route } from '../middleware.js';
8
8
  import { startTimer } from '../utils.js';
9
- import { validateBody } from '../validate.js';
9
+ import { jsonSchema } from '../validate.js';
10
10
  const passwordSchema = { type: 'string', minLength: 4 };
11
11
  const emailSchema = { type: 'string', minLength: 4 };
12
12
  const logger = getLogger('auth');
@@ -14,14 +14,16 @@ export function EmailAndPasswordAuthProvider(options) {
14
14
  const skipPasswordCheck = async () => '';
15
15
  return {
16
16
  setupRoutes(router, context) {
17
- router.post('/login', validateBody({
18
- type: 'object',
19
- properties: {
20
- email: emailSchema,
21
- password: passwordSchema,
22
- },
23
- required: ['email', 'password'],
24
- }), route(async (req, res) => {
17
+ router.post('/login', {
18
+ request: jsonSchema({
19
+ type: 'object',
20
+ properties: {
21
+ email: emailSchema,
22
+ password: passwordSchema,
23
+ },
24
+ required: ['email', 'password'],
25
+ }),
26
+ }, async (req, res) => {
25
27
  const { email, password } = req.body;
26
28
  const timer = startTimer();
27
29
  try {
@@ -36,12 +38,14 @@ export function EmailAndPasswordAuthProvider(options) {
36
38
  await context.jitter(timer.getDuration());
37
39
  throw Unauthorized(error.message);
38
40
  }
39
- }));
40
- router.post('/change-password', validateBody({
41
- type: 'object',
42
- properties: { token: tokenSchema, password: passwordSchema },
43
- required: ['token', 'password'],
44
- }), route(async (req) => {
41
+ });
42
+ router.post('/change-password', {
43
+ request: jsonSchema({
44
+ type: 'object',
45
+ properties: { token: tokenSchema, password: passwordSchema },
46
+ required: ['token', 'password'],
47
+ }),
48
+ }, async (req) => {
45
49
  const { password, token } = req.body;
46
50
  const decoded = context.decodeTemporaryToken(token);
47
51
  const user = await context.getUserById(decoded.payload.uid);
@@ -58,12 +62,14 @@ export function EmailAndPasswordAuthProvider(options) {
58
62
  });
59
63
  logger.info('user changed password');
60
64
  return 200;
61
- }));
62
- router.post('/verify-email-request', validateBody({
63
- type: 'object',
64
- properties: { email: emailSchema },
65
- required: ['email'],
66
- }), route(async (req) => {
65
+ });
66
+ router.post('/verify-email-request', {
67
+ request: jsonSchema({
68
+ type: 'object',
69
+ properties: { email: emailSchema },
70
+ required: ['email'],
71
+ }),
72
+ }, async (req) => {
67
73
  const { email } = req.body;
68
74
  const timer = startTimer();
69
75
  const user = await options.login(email, skipPasswordCheck);
@@ -71,12 +77,14 @@ export function EmailAndPasswordAuthProvider(options) {
71
77
  await options.onEmailVerificationRequest?.(user, temporaryToken);
72
78
  await context.jitter(timer.getDuration());
73
79
  return 202;
74
- }));
75
- router.post('/verify-email', validateBody({
76
- type: 'object',
77
- properties: { token: tokenSchema, email: emailSchema },
78
- required: ['token', 'email'],
79
- }), route(async (req) => {
80
+ });
81
+ router.post('/verify-email', {
82
+ request: jsonSchema({
83
+ type: 'object',
84
+ properties: { token: tokenSchema, email: emailSchema },
85
+ required: ['token', 'email'],
86
+ }),
87
+ }, async (req) => {
80
88
  const { token, email } = req.body;
81
89
  const user = await options.login(email, skipPasswordCheck);
82
90
  try {
@@ -94,12 +102,14 @@ export function EmailAndPasswordAuthProvider(options) {
94
102
  await options.onEmailVerified?.(user);
95
103
  logger.info('user verified');
96
104
  return 200;
97
- }));
98
- router.post('/reset', context.middleware(false), validateBody({
99
- type: 'object',
100
- properties: { email: emailSchema },
101
- required: ['email'],
102
- }), route(async (req) => {
105
+ });
106
+ router.post('/reset', {
107
+ request: jsonSchema({
108
+ type: 'object',
109
+ properties: { email: emailSchema },
110
+ required: ['email'],
111
+ }),
112
+ }, context.middleware(false), async (req) => {
103
113
  const { email } = req.body;
104
114
  const timer = startTimer();
105
115
  try {
@@ -113,7 +123,7 @@ export function EmailAndPasswordAuthProvider(options) {
113
123
  logger.error(error, 'auth reset failed');
114
124
  }
115
125
  return 202;
116
- }));
126
+ });
117
127
  },
118
128
  };
119
129
  }
@@ -0,0 +1,13 @@
1
+ import { type AnyUser, type AuthProvider } from './types.js';
2
+ interface GoogleProfile {
3
+ email: string;
4
+ fullName: string;
5
+ }
6
+ interface GoogleAuthProviderOptions<UserModel = AnyUser> {
7
+ clientId: string;
8
+ clientSecret: string;
9
+ redirectUri: string;
10
+ getOrCreateUserFromUserIdentifier(userIdentifier: string, profile: GoogleProfile): Promise<UserModel>;
11
+ }
12
+ export declare function GoogleAuthProvider<UserModel extends AnyUser>(options: GoogleAuthProviderOptions<UserModel>): AuthProvider<UserModel>;
13
+ export {};
@@ -0,0 +1,94 @@
1
+ import { type } from 'arktype';
2
+ import {} from 'express';
3
+ import { OAuth2Client } from 'google-auth-library';
4
+ import { BadRequest, Unauthorized } from '../errors.js';
5
+ import { getLogger } from '../logger.js';
6
+ import { createTokenResponse } from './token.js';
7
+ import {} from './types.js';
8
+ const logger = getLogger('auth');
9
+ export function GoogleAuthProvider(options) {
10
+ const client = new OAuth2Client({
11
+ clientId: options.clientId,
12
+ clientSecret: options.clientSecret,
13
+ redirectUri: options.redirectUri,
14
+ });
15
+ const verifyAuthorizationCode = async (inputCode) => {
16
+ try {
17
+ const { tokens } = await client.getToken(inputCode);
18
+ return { idToken: tokens.id_token };
19
+ }
20
+ catch (e) {
21
+ logger.error(e);
22
+ throw Unauthorized('Invalid authorization code');
23
+ }
24
+ };
25
+ const verifyIdToken = async (token) => {
26
+ const ticket = await client.verifyIdToken({
27
+ idToken: token,
28
+ audience: options.clientId,
29
+ });
30
+ const payload = ticket.getPayload();
31
+ if (!payload) {
32
+ throw Unauthorized('Token from Google contains invalid payload');
33
+ }
34
+ return payload;
35
+ };
36
+ const requiredScopes = ['openid', 'email', 'profile'];
37
+ return {
38
+ setupRoutes(router, context) {
39
+ const googleUser = { id: 'google' };
40
+ router.get('/google/login', {}, async (req, res) => {
41
+ const state = await context.createTemporaryToken(googleUser);
42
+ const params = new URLSearchParams();
43
+ params.set('client_id', options.clientId);
44
+ params.set('redirect_uri', options.redirectUri);
45
+ params.set('response_type', 'code');
46
+ params.set('scope', requiredScopes.join(' '));
47
+ params.set('access_type', 'offline');
48
+ params.set('prompt', 'select_account');
49
+ params.set('state', state);
50
+ res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`);
51
+ });
52
+ router.get('/google/callback', {
53
+ query: type({
54
+ code: 'string?',
55
+ error: 'string?',
56
+ scope: 'string?',
57
+ state: 'string?',
58
+ }),
59
+ }, async (req, res) => {
60
+ const { code, error, scope, state } = req.query;
61
+ if (error) {
62
+ logger.error(new Error(`Google callback error: ${error}`));
63
+ return res.redirect('/');
64
+ }
65
+ if (!code || !scope || !state) {
66
+ throw BadRequest('Missing code, scope or state');
67
+ }
68
+ if (!requiredScopes.every((s) => scope.includes(s))) {
69
+ throw BadRequest(`Missing required scopes: ${requiredScopes.join(', ')}`);
70
+ }
71
+ try {
72
+ await context.verifyTemporaryToken(state, googleUser, true);
73
+ }
74
+ catch (e) {
75
+ logger.error(e);
76
+ throw Unauthorized('Invalid state');
77
+ }
78
+ const { idToken } = await verifyAuthorizationCode(code);
79
+ if (!idToken) {
80
+ throw Unauthorized('Invalid id token');
81
+ }
82
+ const payload = await verifyIdToken(idToken);
83
+ logger.info('google payload', { payload });
84
+ const user = await options.getOrCreateUserFromUserIdentifier(payload?.sub, { email: payload.email, fullName: payload.name });
85
+ const accessToken = await context.login(req, res, user);
86
+ logger.info('google-login', { email: user.email });
87
+ if (req.accepts('html')) {
88
+ return res.redirect('/');
89
+ }
90
+ return createTokenResponse(accessToken);
91
+ });
92
+ },
93
+ };
94
+ }
@@ -1,9 +1,9 @@
1
+ import {} from 'express';
1
2
  import { createTokenResponse } from './token.js';
2
3
  import {} from './types.js';
3
4
  import { getLogger } from '../logger.js';
4
- import { route } from '../middleware.js';
5
5
  import { DoesNotExistError } from '../postgres.js';
6
- import { validateBody } from '../validate.js';
6
+ import { jsonSchema } from '../validate.js';
7
7
  const logger = getLogger('auth');
8
8
  export function PasswordlessAuthProvider(options) {
9
9
  const getUserOrNull = async (email) => {
@@ -19,15 +19,17 @@ export function PasswordlessAuthProvider(options) {
19
19
  };
20
20
  return {
21
21
  setupRoutes(router, context) {
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) => {
22
+ router.post('/passwordless/login', {
23
+ request: jsonSchema({
24
+ type: 'object',
25
+ properties: {
26
+ type: { type: 'string', enum: ['otp', 'token'] },
27
+ otp: { type: 'string' },
28
+ token: { type: 'string' },
29
+ },
30
+ required: ['type'],
31
+ }),
32
+ }, async (req, res) => {
31
33
  const { token: temporaryToken, otp, type: tokenType } = req.body;
32
34
  const verify = tokenType === 'otp'
33
35
  ? context.verifyOtp
@@ -42,17 +44,19 @@ export function PasswordlessAuthProvider(options) {
42
44
  return res.redirect('/');
43
45
  }
44
46
  return createTokenResponse(accessToken);
45
- }));
47
+ });
46
48
  /**
47
49
  * Request a one-time password for login.
48
50
  */
49
- router.post('/passwordless/request-token', validateBody({
50
- type: 'object',
51
- properties: {
52
- email: { type: 'string' },
53
- },
54
- required: ['email'],
55
- }), route(async (req) => {
51
+ router.post('/passwordless/request-token', {
52
+ request: jsonSchema({
53
+ type: 'object',
54
+ properties: {
55
+ email: { type: 'string' },
56
+ },
57
+ required: ['email'],
58
+ }),
59
+ }, async (req) => {
56
60
  const { email } = req.body;
57
61
  const user = await getUserOrNull(email);
58
62
  if (!user) {
@@ -64,7 +68,7 @@ export function PasswordlessAuthProvider(options) {
64
68
  await options.onLoginRequest(user, token, otp);
65
69
  logger.info('passwordless login requested', { email });
66
70
  return 202;
67
- }));
71
+ });
68
72
  },
69
73
  };
70
74
  }
@@ -1,5 +1,6 @@
1
- import { type RequestHandler, type Router, type Request, type Response } from 'express';
1
+ import { type RequestHandler, type Request, type Response } from 'express';
2
2
  import { type AccessToken } from './token.js';
3
+ import { type TypedRouter } from '../router.js';
3
4
  export interface AnyUser {
4
5
  id: string | number;
5
6
  [key: string]: any;
@@ -45,7 +46,7 @@ export interface AuthProviderContext<UserModel extends AnyUser> {
45
46
  jitter(duration?: number): Promise<void>;
46
47
  }
47
48
  export interface AuthProvider<UserModel extends AnyUser> {
48
- setupRoutes(router: Router, context: AuthProviderContext<UserModel>): void;
49
+ setupRoutes(router: TypedRouter, context: AuthProviderContext<UserModel>): void;
49
50
  }
50
51
  export type AuthMiddlewareOptions<UserModel extends AnyUser> = false | {
51
52
  isAuthorized?: (user: UserModel) => Promise<boolean> | boolean;
@@ -1,4 +1,5 @@
1
1
  import {} from 'express';
2
2
  import {} from './token.js';
3
+ import {} from '../router.js';
3
4
  export class TokenStorage {
4
5
  }
package/dist/auth.d.ts CHANGED
@@ -1,10 +1,11 @@
1
- import { type RequestHandler, type Response, type Request, Router } from 'express';
1
+ import { type RequestHandler, type Response, type Request, type Router } from 'express';
2
2
  import { TokenStorage, type AnyUser, type AuthMiddlewareOptions, type AuthOptions, type DecodedTemporaryToken } from './auth/types.js';
3
3
  import { type RedisClient } from './redis.js';
4
4
  export * from './auth/types.js';
5
5
  export * from './auth/emailAndPassword.js';
6
6
  export * from './auth/passwordless.js';
7
7
  export * from './auth/token.js';
8
+ export * from './auth/google.js';
8
9
  export interface CreateAuth<UserModel extends AnyUser> {
9
10
  createRouter: () => Router;
10
11
  useCurrentUser: () => UserModel | undefined;
package/dist/auth.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as crypto from 'node:crypto';
2
- import { Router, } from 'express';
2
+ import {} from 'express';
3
3
  import { InvalidToken, PermissionDenied } from './auth/errors.js';
4
4
  import { createAccessToken, decodeAccessToken, extractAccessTokenFromRequest, setAccessTokenCookie, tokenSchema, } from './auth/token.js';
5
5
  import { TokenStorage, } from './auth/types.js';
@@ -7,14 +7,15 @@ import { config } from './config.js';
7
7
  import { setContext, useContext } from './context.js';
8
8
  import { Forbidden, NotFound, Unauthorized } from './errors.js';
9
9
  import { getLogger } from './logger.js';
10
- import { route } from './middleware.js';
11
10
  import {} from './redis.js';
12
- import { validateParams } from './validate.js';
11
+ import { createRouter } from './router.js';
12
+ import { jsonSchema } from './validate.js';
13
13
  const logger = getLogger('auth');
14
14
  export * from './auth/types.js';
15
15
  export * from './auth/emailAndPassword.js';
16
16
  export * from './auth/passwordless.js';
17
17
  export * from './auth/token.js';
18
+ export * from './auth/google.js';
18
19
  export class RedisTokenStorage extends TokenStorage {
19
20
  redis;
20
21
  keyPrefix;
@@ -80,24 +81,26 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
80
81
  getUserById,
81
82
  jitter,
82
83
  };
83
- const createRouter = () => {
84
- const router = Router();
85
- router.get('/me', middleware(), route(() => {
84
+ const createAuthRouter = () => {
85
+ const router = createRouter();
86
+ router.get('/me', {}, middleware(), () => {
86
87
  const user = useCurrentUser();
87
88
  return filterUser(user);
88
- }));
89
- router.get('/logout', route(async (req, res) => {
89
+ });
90
+ router.get('/logout', {}, async (req, res) => {
90
91
  await logout(req, res);
91
92
  res.redirect(loginUrl);
92
- }));
93
+ });
93
94
  /**
94
95
  * Check that a reset token is valid.
95
96
  */
96
- router.get('/reset/:token', validateParams({
97
- type: 'object',
98
- properties: { token: tokenSchema },
99
- required: ['token'],
100
- }), route(async (req) => {
97
+ router.get('/reset/:token', {
98
+ params: jsonSchema({
99
+ type: 'object',
100
+ properties: { token: tokenSchema },
101
+ required: ['token'],
102
+ }),
103
+ }, async (req) => {
101
104
  const { token } = req.params;
102
105
  const value = await tokenStorage.get(token);
103
106
  if (!value) {
@@ -105,8 +108,8 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
105
108
  throw NotFound();
106
109
  }
107
110
  return 200;
108
- }));
109
- router.get('/token/refresh', middleware(false), route(async (req, res) => {
111
+ });
112
+ router.get('/token/refresh', {}, middleware(false), async (req, res) => {
110
113
  const user = useCurrentUser();
111
114
  if (!user) {
112
115
  setAccessTokenCookie(res, null);
@@ -115,7 +118,7 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
115
118
  const { token } = await providerContext.login(req, res, user, true);
116
119
  logger.info('refreshed token');
117
120
  return { token, user: filterUser(user) };
118
- }));
121
+ });
119
122
  for (const provider of providers) {
120
123
  provider.setupRoutes(router, providerContext);
121
124
  }
@@ -213,18 +216,19 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
213
216
  */
214
217
  async function createTemporaryToken(user) {
215
218
  try {
219
+ const uid = user === null ? 'anonymous' : user.id;
216
220
  const randomBytes = crypto.randomBytes(32).toString('hex');
217
- const payload = { v: '1', uid: user.id, r: randomBytes };
221
+ const payload = { v: '1', uid: uid, r: randomBytes };
218
222
  const signature = crypto
219
223
  .createHmac('sha256', config.value.AUTH_SECRET)
220
224
  .update(JSON.stringify(payload))
221
225
  .digest('base64')
222
226
  .slice(0, 12);
223
- const temporaryToken = Buffer.from(JSON.stringify({ payload, signature })).toString('base64url');
224
- if (!user.id) {
227
+ if (!uid) {
225
228
  throw new Error('User ID is required to create a token');
226
229
  }
227
- await tokenStorage.set(temporaryToken, String(user.id), config.value.AUTH_TEMP_TOKEN_EXPIRES_IN);
230
+ const temporaryToken = Buffer.from(JSON.stringify({ payload, signature })).toString('base64url');
231
+ await tokenStorage.set(temporaryToken, String(uid), config.value.AUTH_TEMP_TOKEN_EXPIRES_IN);
228
232
  return temporaryToken;
229
233
  }
230
234
  catch (error) {
@@ -245,14 +249,14 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
245
249
  if (expectedSignature !== decoded.signature) {
246
250
  throw new InvalidToken('Invalid signature');
247
251
  }
248
- if (user && decoded.payload.uid !== user.id) {
252
+ if (user !== null && decoded.payload.uid !== user.id) {
249
253
  throw new InvalidToken(`Invalid user ${user.id}`);
250
254
  }
251
255
  const storedUid = await tokenStorage.get(token);
252
256
  if (!storedUid) {
253
257
  throw new InvalidToken('Invalid token not found');
254
258
  }
255
- if (user && storedUid !== user.id) {
259
+ if (user !== null && storedUid !== user.id) {
256
260
  throw new InvalidToken(`Invalid user ${user.id}`);
257
261
  }
258
262
  if (consume) {
@@ -263,7 +267,7 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
263
267
  return {
264
268
  before: () => middleware(false),
265
269
  isAuthorized: (fn) => middleware({ isAuthorized: fn || (() => true) }),
266
- createRouter,
270
+ createRouter: () => createAuthRouter().getRouter(),
267
271
  useCurrentUser,
268
272
  middleware,
269
273
  createTemporaryToken,
@@ -1,24 +1,25 @@
1
- import { Router } from 'express';
2
1
  import { getLogger } from './logger.js';
3
- import { route } from './middleware.js';
4
- import { validateBody } from './validate.js';
2
+ import { createRouter } from './router.js';
3
+ import { jsonSchema } from './validate.js';
5
4
  const logger = getLogger();
6
5
  export function cookieConsent({ onConsent, }) {
7
- const router = Router();
8
- router.get('/', route(() => {
6
+ const router = createRouter();
7
+ router.get('/', {}, async () => {
9
8
  return {
10
9
  consents: [],
11
10
  };
12
- }));
13
- router.post('/', validateBody({
14
- type: 'object',
15
- properties: {
16
- consents: {
17
- type: 'array',
18
- items: { type: 'string' },
11
+ });
12
+ router.post('/', {
13
+ request: jsonSchema({
14
+ type: 'object',
15
+ properties: {
16
+ consents: {
17
+ type: 'array',
18
+ items: { type: 'string' },
19
+ },
19
20
  },
20
- },
21
- }), route(async (req, res) => {
21
+ }),
22
+ }, async (req, res) => {
22
23
  const { consents } = req.body;
23
24
  res.cookie('cookie_consent', consents.join(',') || '1', {
24
25
  expires: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
@@ -28,6 +29,6 @@ export function cookieConsent({ onConsent, }) {
28
29
  await onConsent?.(consents);
29
30
  logger.info('cookie consent');
30
31
  return { consents };
31
- }));
32
- return router;
32
+ });
33
+ return router.getRouter();
33
34
  }
package/dist/manage.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { resolve } from 'node:path';
2
+ import { createClient } from '@hey-api/openapi-ts';
1
3
  import { program } from 'commander';
2
4
  import {} from 'express';
3
5
  import { hashPassword } from './auth.js';
@@ -59,4 +61,34 @@ export function registerDefaultCommands(app) {
59
61
  }
60
62
  }));
61
63
  });
64
+ registerManagementCommand((command, cli) => {
65
+ command('generate-api')
66
+ .option('--output <string>', 'Output directory', 'src/types/api-gen')
67
+ .action(cli.wrap(async (options) => {
68
+ const openApiFile = resolve('./openapi.json');
69
+ await createClient({
70
+ input: {
71
+ path: openApiFile,
72
+ },
73
+ output: {
74
+ format: 'prettier',
75
+ path: options.output,
76
+ lint: 'eslint',
77
+ tsConfigPath: resolve('./tsconfig.json'),
78
+ },
79
+ plugins: [
80
+ {
81
+ enums: 'javascript',
82
+ name: '@hey-api/typescript',
83
+ },
84
+ {
85
+ asClass: true,
86
+ classStructure: 'off',
87
+ classNameBuilder: (name) => `${name}Service`,
88
+ name: '@hey-api/sdk',
89
+ },
90
+ ],
91
+ });
92
+ }));
93
+ });
62
94
  }
package/dist/router.d.ts CHANGED
@@ -12,25 +12,25 @@ export type ExtractParams<Path extends string> = Prettify<Path extends `${string
12
12
  } & ExtractParams<`/${Rest}`> : Path extends `${string}:${infer Param}` ? {
13
13
  [K in Param extends `${infer P}?` ? P : Param]: string;
14
14
  } : {}>;
15
- export type TypedRequestHandler<Params, ResponseBodySchema extends StandardSchemaV1 | undefined, RequestBodySchema extends StandardSchemaV1 | undefined, RequestQuery extends StandardSchemaV1 | undefined> = (req: Request<Params, never, RequestBodySchema extends StandardSchemaV1<any> ? StandardSchemaV1.InferOutput<RequestBodySchema> : unknown, RequestQuery extends StandardSchemaV1<any> ? StandardSchemaV1.InferOutput<RequestQuery> : Record<string, string | string[]>>, res: Response<ResponseBodySchema extends StandardSchemaV1<any> ? StandardSchemaV1.InferOutput<ResponseBodySchema> : unknown>, next: NextFunction) => ResponseWrapper<ResponseBodySchema extends StandardSchemaV1<any> ? StandardSchemaV1.InferOutput<ResponseBodySchema> : unknown> | Promise<ResponseWrapper<ResponseBodySchema extends StandardSchemaV1<any> ? StandardSchemaV1.InferOutput<ResponseBodySchema> : unknown>>;
15
+ export type TypedRequestHandler<Params, ResponseBodySchema extends StandardSchemaV1 | undefined, RequestBodySchema extends StandardSchemaV1 | undefined, RequestQuery extends StandardSchemaV1 | undefined, RequestParamsSchema extends StandardSchemaV1 | undefined = undefined> = (req: Request<Params, never, RequestBodySchema extends StandardSchemaV1<any> ? StandardSchemaV1.InferOutput<RequestBodySchema> : unknown, RequestQuery extends StandardSchemaV1<any> ? StandardSchemaV1.InferOutput<RequestQuery> : Record<string, string | string[]>>, res: Response<ResponseBodySchema extends StandardSchemaV1<any> ? StandardSchemaV1.InferOutput<ResponseBodySchema> : unknown>, next: NextFunction) => ResponseWrapper<ResponseBodySchema extends StandardSchemaV1<any> ? StandardSchemaV1.InferOutput<ResponseBodySchema> : unknown> | Promise<ResponseWrapper<ResponseBodySchema extends StandardSchemaV1<any> ? StandardSchemaV1.InferOutput<ResponseBodySchema> : unknown>>;
16
16
  export declare class TypedRouter {
17
17
  #private;
18
18
  router: import("express-serve-static-core").Router;
19
- get<Path extends string, RequestBodySchema extends StandardSchemaV1<any> | undefined = undefined, ResponseBodySchema extends StandardSchemaV1<any> | undefined = undefined, RequestQuerySchema extends StandardSchemaV1<any> | undefined = undefined>(path: Path, validation: ValidationSpec<RequestBodySchema, ResponseBodySchema, RequestQuerySchema>, ...middleware: [
19
+ get<Path extends string, RequestBodySchema extends StandardSchemaV1<any> | undefined = undefined, ResponseBodySchema extends StandardSchemaV1<any> | undefined = undefined, RequestQuerySchema extends StandardSchemaV1<any> | undefined = undefined, RequestParamsSchema extends StandardSchemaV1<any> | undefined = undefined>(path: Path, validation: ValidationSpec<RequestBodySchema, ResponseBodySchema, RequestQuerySchema, RequestParamsSchema>, ...middleware: [
20
20
  ...RequestHandler[],
21
- TypedRequestHandler<ExtractParams<Path>, ResponseBodySchema, RequestBodySchema, RequestQuerySchema>
21
+ TypedRequestHandler<ExtractParams<Path>, ResponseBodySchema, RequestBodySchema, RequestQuerySchema, RequestParamsSchema>
22
22
  ]): this;
23
- post<Path extends string, RequestBodySchema extends StandardSchemaV1<any> | undefined = undefined, ResponseBodySchema extends StandardSchemaV1<any> | undefined = undefined, RequestQuerySchema extends StandardSchemaV1<any> | undefined = undefined>(path: Path, validation: ValidationSpec<RequestBodySchema, ResponseBodySchema, RequestQuerySchema>, ...middleware: [
23
+ post<Path extends string, RequestBodySchema extends StandardSchemaV1<any> | undefined = undefined, ResponseBodySchema extends StandardSchemaV1<any> | undefined = undefined, RequestQuerySchema extends StandardSchemaV1<any> | undefined = undefined, RequestParamsSchema extends StandardSchemaV1<any> | undefined = undefined>(path: Path, validation: ValidationSpec<RequestBodySchema, ResponseBodySchema, RequestQuerySchema, RequestParamsSchema>, ...middleware: [
24
24
  ...RequestHandler[],
25
- TypedRequestHandler<ExtractParams<Path>, ResponseBodySchema, RequestBodySchema, RequestQuerySchema>
25
+ TypedRequestHandler<ExtractParams<Path>, ResponseBodySchema, RequestBodySchema, RequestQuerySchema, RequestParamsSchema>
26
26
  ]): this;
27
- put<Path extends string, RequestBodySchema extends StandardSchemaV1<any> | undefined = undefined, ResponseBodySchema extends StandardSchemaV1<any> | undefined = undefined, RequestQuerySchema extends StandardSchemaV1<any> | undefined = undefined>(path: Path, validation: ValidationSpec<RequestBodySchema, ResponseBodySchema, RequestQuerySchema>, ...middleware: [
27
+ put<Path extends string, RequestBodySchema extends StandardSchemaV1<any> | undefined = undefined, ResponseBodySchema extends StandardSchemaV1<any> | undefined = undefined, RequestQuerySchema extends StandardSchemaV1<any> | undefined = undefined, RequestParamsSchema extends StandardSchemaV1<any> | undefined = undefined>(path: Path, validation: ValidationSpec<RequestBodySchema, ResponseBodySchema, RequestQuerySchema, RequestParamsSchema>, ...middleware: [
28
28
  ...RequestHandler[],
29
29
  TypedRequestHandler<ExtractParams<Path>, ResponseBodySchema, RequestBodySchema, RequestQuerySchema>
30
30
  ]): this;
31
- delete<Path extends string, RequestBodySchema extends StandardSchemaV1<any> | undefined = undefined, ResponseBodySchema extends StandardSchemaV1<any> | undefined = undefined, RequestQuerySchema extends StandardSchemaV1<any> | undefined = undefined>(path: Path, validation: ValidationSpec<RequestBodySchema, ResponseBodySchema, RequestQuerySchema>, ...middleware: [
31
+ delete<Path extends string, RequestBodySchema extends StandardSchemaV1<any> | undefined = undefined, ResponseBodySchema extends StandardSchemaV1<any> | undefined = undefined, RequestQuerySchema extends StandardSchemaV1<any> | undefined = undefined, RequestParamsSchema extends StandardSchemaV1<any> | undefined = undefined>(path: Path, validation: ValidationSpec<RequestBodySchema, ResponseBodySchema, RequestQuerySchema>, ...middleware: [
32
32
  ...RequestHandler[],
33
- TypedRequestHandler<ExtractParams<Path>, ResponseBodySchema, RequestBodySchema, RequestQuerySchema>
33
+ TypedRequestHandler<ExtractParams<Path>, ResponseBodySchema, RequestBodySchema, RequestQuerySchema, RequestParamsSchema>
34
34
  ]): this;
35
35
  use(...middleware: RequestHandler[]): this;
36
36
  use(...middleware: [string, ...RequestHandler[]]): this;
package/dist/routes.d.ts CHANGED
@@ -32,5 +32,8 @@ export declare function toOpenAPISpec(app: Express, info?: {
32
32
  title: string;
33
33
  version: string;
34
34
  };
35
+ tags: {
36
+ name: string;
37
+ }[];
35
38
  paths: Record<string, any>;
36
39
  };
package/dist/routes.js CHANGED
@@ -143,14 +143,54 @@ const mapKeysToPath = (layerRegexPath, keys) => {
143
143
  .replace(/\\/gi, '')
144
144
  .replace(/\/{2,}/gi, '/');
145
145
  };
146
+ function extractTagsFromPath(path) {
147
+ const segments = path
148
+ .replace(/^\/api\/v\d+\//, '') // Remove /api/v1, /api/v2, etc.
149
+ .split('/')
150
+ .filter(Boolean);
151
+ if (segments.length === 0) {
152
+ return ['Default'];
153
+ }
154
+ // Use the first segment as the primary tag
155
+ const primaryTag = segments[0].charAt(0).toUpperCase() + segments[0].slice(1);
156
+ return [primaryTag];
157
+ }
158
+ function convertPathToOperationId(method, path) {
159
+ // Clean the path and split into segments
160
+ const segments = path
161
+ .replace(/[{}]/g, '') // Remove OpenAPI parameter braces
162
+ .replace(/[^a-zA-Z0-9/-]/g, '') // Keep hyphens, remove other non-alphanumeric chars
163
+ .replace(/^\/api\/v\d+\//, '') // Remove /api/v1, /api/v2, etc. prefixes
164
+ .split('/') // Split by slashes
165
+ .filter(Boolean) // Remove empty strings
166
+ .map((segment) => {
167
+ // Handle kebab-case segments by splitting on hyphens and camelizing
168
+ return segment
169
+ .split('-')
170
+ .map((word, index) => index === 0
171
+ ? word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
172
+ : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
173
+ .join('');
174
+ }); // Capitalize each segment
175
+ const camelizedPath = segments.join('');
176
+ return `${method.toLowerCase()}${camelizedPath}`;
177
+ }
146
178
  export function toOpenAPISpec(app, info = {
147
179
  title: 'API Documentation',
148
180
  version: '1.0.0',
149
181
  }) {
150
182
  const routes = parseRoutes(app);
151
183
  const paths = {};
184
+ const allTags = new Set();
152
185
  for (const routeInfo of routes) {
153
186
  const { method, path, params, query, body, response } = routeInfo;
187
+ if (!path.startsWith('/api/')) {
188
+ continue;
189
+ }
190
+ const tags = extractTagsFromPath(path);
191
+ for (const tag of tags) {
192
+ allTags.add(tag);
193
+ }
154
194
  // Convert Express path params to OpenAPI path params
155
195
  const openApiPath = path.replace(/:([^/]+)/g, '{$1}');
156
196
  if (!paths[openApiPath]) {
@@ -178,6 +218,8 @@ export function toOpenAPISpec(app, info = {
178
218
  })));
179
219
  }
180
220
  paths[openApiPath][method.toLowerCase()] = {
221
+ operationId: convertPathToOperationId(method, path), // Use original path, not openApiPath
222
+ tags,
181
223
  parameters,
182
224
  ...(body && {
183
225
  requestBody: {
@@ -232,6 +274,7 @@ export function toOpenAPISpec(app, info = {
232
274
  version: info.version,
233
275
  ...(info.description && { description: info.description }),
234
276
  },
277
+ tags: Array.from(allTags).map((tag) => ({ name: tag })),
235
278
  paths,
236
279
  };
237
280
  }
@@ -39,10 +39,11 @@ export type ValidationResult = {
39
39
  isValid: false;
40
40
  errors: ValidationErrors;
41
41
  };
42
- export interface ValidationSpec<RequestBodySchema extends StandardSchemaV1<any> | undefined = undefined, ResponseBodySchema extends StandardSchemaV1<any> | undefined = undefined, RequestQuerySchema extends StandardSchemaV1<any> | undefined = undefined> {
42
+ export interface ValidationSpec<RequestBodySchema extends StandardSchemaV1<any> | undefined = undefined, ResponseBodySchema extends StandardSchemaV1<any> | undefined = undefined, RequestQuerySchema extends StandardSchemaV1<any> | undefined = undefined, RequestParamsSchema extends StandardSchemaV1<any> | undefined = undefined> {
43
43
  request?: RequestBodySchema;
44
44
  response?: ResponseBodySchema;
45
45
  query?: RequestQuerySchema;
46
+ params?: RequestParamsSchema;
46
47
  }
47
48
  export declare function validateSchema(data: unknown, schema: StandardSchemaV1<any>): Promise<ValidationResult>;
48
49
  export declare function validateStandardSchema(schemas: {
@@ -5,6 +5,13 @@ import Sonda from 'sonda/vite';
5
5
  import { defineConfig } from 'vite';
6
6
  import { readEnv } from '../utils.js';
7
7
  export function defineViteConfig({ base = '', reactCompiler = false, ssr = false, analyze = readEnv.boolean('VITE_ANALYZE', false), }) {
8
+ const allowedHosts = [
9
+ 'localhost',
10
+ '127.0.0.1',
11
+ // Support puma-dev by default
12
+ `${process.cwd().split('/').pop()}.local`,
13
+ `${process.cwd().split('/').pop()}.test`,
14
+ ];
8
15
  const port = readEnv.number('VITE_PORT', 5173);
9
16
  return defineConfig({
10
17
  plugins: [
@@ -53,14 +60,9 @@ export function defineViteConfig({ base = '', reactCompiler = false, ssr = false
53
60
  '/api': `http://localhost:${readEnv.number('PORT', 4000)}`,
54
61
  '/analytics.js': `http://localhost:${readEnv.number('PORT', 4000)}`,
55
62
  },
56
- allowedHosts: [
57
- 'localhost',
58
- '127.0.0.1',
59
- // Support puma-dev by default
60
- `${process.cwd().split('/').pop()}.local`,
61
- ],
63
+ allowedHosts,
62
64
  }
63
- : {},
65
+ : { allowedHosts },
64
66
  ssr: ssr
65
67
  ? {
66
68
  noExternal: ['react-helmet-async'],
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devmoods/express-extras",
3
- "version": "0.59.0",
3
+ "version": "0.60.0",
4
4
  "description": "Kubernetes-ready utilities for rapid Node.js application development",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -44,35 +44,36 @@
44
44
  ],
45
45
  "license": "ISC",
46
46
  "devDependencies": {
47
- "@devmoods/eslint-config": "^2.10.0",
47
+ "@devmoods/eslint-config": "^2.12.0",
48
48
  "@types/koa-compose": "^3.2.8",
49
49
  "@types/node": "24.3.0",
50
50
  "@types/supertest": "^6.0.3",
51
- "@vitejs/plugin-react": "^5.0.0",
51
+ "@vitejs/plugin-react": "^5.0.2",
52
52
  "@vitest/coverage-v8": "^3.2.4",
53
53
  "arktype": "^2.1.20",
54
54
  "cookiejar": "^2.1.4",
55
- "eslint": "^9.33.0",
55
+ "eslint": "^9.34.0",
56
56
  "faktory-worker": "^4.7.1",
57
57
  "prettier": "3.6.2",
58
58
  "supertest": "7.1.4",
59
59
  "ts-node": "10.9.2",
60
- "tsx": "^4.20.4",
60
+ "tsx": "^4.20.5",
61
61
  "typescript": "5.9.2",
62
- "vite": "^7.1.2",
62
+ "vite": "^7.1.3",
63
63
  "vitest": "^3.2.4",
64
64
  "vitest-fail-on-console": "^0.9.0"
65
65
  },
66
66
  "dependencies": {
67
67
  "@devmoods/fetch": "^4.1.0",
68
- "@sentry/node": "^10.5.0",
69
- "@sentry/profiling-node": "^10.5.0",
68
+ "@hey-api/openapi-ts": "^0.82.1",
69
+ "@sentry/node": "^10.8.0",
70
+ "@sentry/profiling-node": "^10.8.0",
70
71
  "@standard-schema/spec": "^1.0.0",
71
72
  "@types/bcrypt": "^6.0.0",
72
73
  "@types/cookie-parser": "^1.4.9",
73
74
  "@types/express": "^5.0.3",
74
75
  "@types/jsonwebtoken": "^9.0.10",
75
- "@types/nodemailer": "^7.0.0",
76
+ "@types/nodemailer": "^7.0.1",
76
77
  "@types/pg": "^8.15.5",
77
78
  "@types/qs": "^6.14.0",
78
79
  "@types/superagent": "^8.1.9",
@@ -80,17 +81,18 @@
80
81
  "ajv": "^8.17.1",
81
82
  "argon2": "^0.44.0",
82
83
  "bcrypt": "^6.0.0",
83
- "chalk": "^5.5.0",
84
+ "chalk": "^5.6.0",
84
85
  "commander": "^14.0.0",
85
86
  "compose-middleware": "^5.0.1",
86
87
  "cookie-parser": "^1.4.7",
87
88
  "dotenv": "^17.2.1",
88
89
  "express": "^4.21.2",
90
+ "google-auth-library": "^10.3.0",
89
91
  "helmet": "^8.1.0",
90
92
  "jsonwebtoken": "^9.0.2",
91
- "nodemailer": "^7.0.5",
93
+ "nodemailer": "^7.0.6",
92
94
  "pg": "^8.16.3",
93
- "redis": "^5.8.1",
95
+ "redis": "^5.8.2",
94
96
  "sonda": "^0.9.0",
95
97
  "sql-formatter": "^15.6.6",
96
98
  "uuid": "^11.1.0"