@loomcore/api 0.1.57 → 0.1.58

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.
@@ -7,6 +7,7 @@ import { GenericApiService } from '../services/index.js';
7
7
  import { IDatabase } from '../databases/models/index.js';
8
8
  import { ICategory } from './models/category.model.js';
9
9
  import { IProduct } from './models/product.model.js';
10
+ import { DbType } from '../databases/db-type.type.js';
10
11
  declare function initialize(database: IDatabase): void;
11
12
  declare function getRandomId(): string;
12
13
  declare function isMongoDatabase(database: IDatabase): boolean;
@@ -28,7 +29,7 @@ export declare class CategoryService extends GenericApiService<ICategory> {
28
29
  export declare class CategoryController extends ApiController<ICategory> {
29
30
  constructor(app: Application, database: IDatabase);
30
31
  }
31
- export declare function setupTestConfig(isMultiTenant?: boolean): void;
32
+ export declare function setupTestConfig(isMultiTenant: boolean | undefined, dbType: DbType): void;
32
33
  export declare class ProductService extends GenericApiService<IProduct> {
33
34
  private db;
34
35
  constructor(database: IDatabase);
@@ -174,47 +174,47 @@ export class CategoryController extends ApiController {
174
174
  super('categories', app, categoryService, 'category', CategorySpec);
175
175
  }
176
176
  }
177
- export function setupTestConfig(isMultiTenant = true) {
177
+ export function setupTestConfig(isMultiTenant = true, dbType) {
178
178
  setBaseApiConfig({
179
- env: 'test',
180
- hostName: 'localhost',
181
- appName: 'test-app',
182
- clientSecret: 'test-secret',
183
- database: {
184
- name: 'test-db',
185
- },
186
- externalPort: 4000,
187
- internalPort: 8083,
188
- corsAllowedOrigins: ['*'],
189
- saltWorkFactor: 10,
190
- jobTypes: '',
191
- deployedBranch: '',
192
- debug: {
193
- showErrors: false
194
- },
195
179
  app: {
196
- isMultiTenant: isMultiTenant,
180
+ isMultiTenant: isMultiTenant, name: 'test-app', dbType: dbType,
197
181
  ...(isMultiTenant && {
198
182
  metaOrgName: 'Test Meta Organization',
199
183
  metaOrgCode: 'TEST_META_ORG'
200
184
  })
201
185
  },
202
186
  auth: {
187
+ adminUser: {
188
+ email: 'admin@test.com',
189
+ password: 'admin-password'
190
+ },
191
+ clientSecret: 'test-secret',
192
+ saltWorkFactor: 10,
203
193
  jwtExpirationInSeconds: 3600,
204
194
  refreshTokenExpirationInDays: 7,
205
195
  deviceIdCookieMaxAgeInDays: 730,
206
196
  passwordResetTokenExpirationInMinutes: 20
207
197
  },
198
+ database: {
199
+ name: 'test-db',
200
+ host: 'localhost',
201
+ password: 'test-password',
202
+ port: 27017,
203
+ username: 'test-user'
204
+ },
205
+ env: 'test',
208
206
  email: {
209
207
  emailApiKey: 'WeDontHaveAKeyYet',
210
208
  emailApiSecret: 'WeDontHaveASecretYet',
211
209
  fromAddress: 'test@test.com',
212
210
  systemEmailAddress: 'system@test.com'
213
211
  },
214
- adminUser: {
215
- email: 'admin@test.com',
216
- password: 'admin-password'
217
- }
212
+ network: {
213
+ hostName: 'localhost',
214
+ internalPort: 8083,
215
+ externalPort: 4000,
216
+ corsAllowedOrigins: ['*']
217
+ },
218
218
  });
219
219
  }
220
220
  export class ProductService extends GenericApiService {
@@ -26,7 +26,7 @@ export class TestExpressApp {
26
26
  return this.initPromise;
27
27
  }
28
28
  static async _performInit(useMongoDb) {
29
- setupTestConfig();
29
+ setupTestConfig(true, useMongoDb ? 'mongodb' : 'postgres');
30
30
  initializeTypeBox();
31
31
  if (!this.database) {
32
32
  if (useMongoDb) {
@@ -1,6 +1,7 @@
1
1
  import { Umzug, MongoDBStorage } from 'umzug';
2
2
  import { Pool } from 'pg';
3
3
  import { MongoClient } from 'mongodb';
4
+ import { setBaseApiConfig } from '../../config/index.js';
4
5
  import fs from 'fs';
5
6
  import path from 'path';
6
7
  import { buildMongoUrl } from '../mongo-db/utils/build-mongo-url.util.js';
@@ -15,6 +16,7 @@ export class MigrationRunner {
15
16
  primaryTimezone;
16
17
  dbConnection;
17
18
  constructor(config) {
19
+ setBaseApiConfig(config);
18
20
  this.config = config;
19
21
  this.dbType = config.app.dbType || 'mongodb';
20
22
  this.dbUrl = this.dbType === 'postgres' ? buildPostgresUrl(config) : buildMongoUrl(config);
@@ -146,11 +146,11 @@ export const getMongoInitialSchema = (config) => {
146
146
  }
147
147
  });
148
148
  }
149
- if (config.adminUser?.email && config.adminUser?.password) {
149
+ if (config.auth && config.auth.adminUser) {
150
150
  migrations.push({
151
151
  name: '00000000000009_data-admin-user',
152
152
  up: async ({ context: db }) => {
153
- if (!config.adminUser?.email || !config.adminUser?.password) {
153
+ if (config.auth?.adminUser?.email || !config.auth?.adminUser?.password) {
154
154
  throw new Error('Admin user email and password must be provided in config');
155
155
  }
156
156
  const database = new MongoDBDatabase(db);
@@ -160,25 +160,25 @@ export const getMongoInitialSchema = (config) => {
160
160
  await authService.createUser(systemUserContext, {
161
161
  _id: _id,
162
162
  _orgId: systemUserContext.organization?._id,
163
- email: config.adminUser.email,
164
- password: config.adminUser.password,
163
+ email: config.auth?.adminUser?.email,
164
+ password: config.auth?.adminUser?.password,
165
165
  firstName: 'Admin',
166
166
  lastName: 'User',
167
167
  displayName: 'Admin User',
168
168
  });
169
169
  },
170
170
  down: async ({ context: db }) => {
171
- if (!config.adminUser?.email)
171
+ if (!config.auth?.adminUser?.email)
172
172
  return;
173
- await db.collection('users').deleteOne({ email: config.adminUser.email });
173
+ await db.collection('users').deleteOne({ email: config.auth?.adminUser?.email });
174
174
  }
175
175
  });
176
176
  }
177
- if (config.adminUser?.email && isMultiTenant) {
177
+ if (config.auth?.adminUser?.email && isMultiTenant) {
178
178
  migrations.push({
179
179
  name: '00000000000010_data-admin-authorizations',
180
180
  up: async ({ context: db }) => {
181
- if (!config.adminUser?.email) {
181
+ if (!config.auth?.adminUser?.email) {
182
182
  throw new Error('Admin user email not found in config');
183
183
  }
184
184
  const database = new MongoDBDatabase(db);
@@ -188,7 +188,7 @@ export const getMongoInitialSchema = (config) => {
188
188
  if (!metaOrg) {
189
189
  throw new Error('Meta organization not found. Ensure meta-org migration ran successfully.');
190
190
  }
191
- const adminUser = await authService.getUserByEmail(config.adminUser.email);
191
+ const adminUser = await authService.getUserByEmail(config.auth?.adminUser?.email);
192
192
  if (!adminUser) {
193
193
  throw new Error('Admin user not found. Ensure admin-user migration ran successfully.');
194
194
  }
@@ -3,11 +3,11 @@ export function buildMongoUrl(config) {
3
3
  if (!database) {
4
4
  throw new Error("Database configuration is required to build the MongoDB URL.");
5
5
  }
6
- const { user, password, host, port, name } = database;
7
- if (!user || !password || !host || !port || !name) {
6
+ const { username, password, host, port, name } = database;
7
+ if (!username || !password || !host || !port || !name) {
8
8
  throw new Error("Database configuration must include user, password, host, port, and name to build the MongoDB URL.");
9
9
  }
10
- const encodedUser = encodeURIComponent(user);
10
+ const encodedUsername = encodeURIComponent(username);
11
11
  const encodedPassword = encodeURIComponent(password);
12
- return `mongodb://${encodedUser}:${encodedPassword}@${host}:${port}/${name}`;
12
+ return `mongodb://${encodedUsername}:${encodedPassword}@${host}:${port}/${name}`;
13
13
  }
@@ -207,11 +207,11 @@ export const getPostgresInitialSchema = (config) => {
207
207
  }
208
208
  });
209
209
  }
210
- if (config.adminUser?.email && config.adminUser?.password) {
210
+ if (config.auth?.adminUser?.email && config.auth?.adminUser?.password) {
211
211
  migrations.push({
212
212
  name: '00000000000009_data-admin-user',
213
213
  up: async ({ context: pool }) => {
214
- if (!config.adminUser?.email || !config.adminUser?.password) {
214
+ if (!config.auth?.adminUser?.email || !config.auth?.adminUser?.password) {
215
215
  throw new Error('Admin user email and password must be provided in config');
216
216
  }
217
217
  const client = await pool.connect();
@@ -236,8 +236,8 @@ export const getPostgresInitialSchema = (config) => {
236
236
  }
237
237
  }
238
238
  const userData = {
239
- email: config.adminUser.email,
240
- password: config.adminUser.password,
239
+ email: config.auth?.adminUser?.email,
240
+ password: config.auth?.adminUser?.password,
241
241
  firstName: 'Admin',
242
242
  lastName: 'User',
243
243
  displayName: 'Admin User',
@@ -252,17 +252,17 @@ export const getPostgresInitialSchema = (config) => {
252
252
  }
253
253
  },
254
254
  down: async ({ context: pool }) => {
255
- if (!config.adminUser?.email)
255
+ if (!config.auth?.adminUser?.email)
256
256
  return;
257
- const result = await pool.query(`DELETE FROM "users" WHERE "email" = $1`, [config.adminUser.email]);
257
+ const result = await pool.query(`DELETE FROM "users" WHERE "email" = $1`, [config.auth?.adminUser?.email]);
258
258
  }
259
259
  });
260
260
  }
261
- if (config.adminUser?.email) {
261
+ if (config.auth?.adminUser?.email) {
262
262
  migrations.push({
263
263
  name: '00000000000010_data-admin-authorizations',
264
264
  up: async ({ context: pool }) => {
265
- if (!config.adminUser?.email) {
265
+ if (!config.auth?.adminUser?.email) {
266
266
  throw new Error('Admin user email not found in config');
267
267
  }
268
268
  const client = await pool.connect();
@@ -274,7 +274,7 @@ export const getPostgresInitialSchema = (config) => {
274
274
  if (isMultiTenant && !metaOrg) {
275
275
  throw new Error('Meta organization not found. Ensure meta-org migration ran successfully.');
276
276
  }
277
- const adminUser = await authService.getUserByEmail(config.adminUser.email);
277
+ const adminUser = await authService.getUserByEmail(config.auth?.adminUser?.email);
278
278
  if (!adminUser) {
279
279
  throw new Error('Admin user not found. Ensure admin-user migration ran successfully.');
280
280
  }
@@ -3,11 +3,11 @@ export function buildPostgresUrl(config) {
3
3
  if (!database) {
4
4
  throw new Error("Database configuration is required to build the PostgreSQL URL.");
5
5
  }
6
- const { user, password, host, port, name } = database;
7
- if (!user || !password || !host || !port || !name) {
6
+ const { username, password, host, port, name } = database;
7
+ if (!username || !password || !host || !port || !name) {
8
8
  throw new Error("Database configuration must include user, password, host, port, and name to build the PostgreSQL URL.");
9
9
  }
10
- const encodedUser = encodeURIComponent(user);
10
+ const encodedUsername = encodeURIComponent(username);
11
11
  const encodedPassword = encodeURIComponent(password);
12
- return `postgresql://${encodedUser}:${encodedPassword}@${host}:${port}/${name}`;
12
+ return `postgresql://${encodedUsername}:${encodedPassword}@${host}:${port}/${name}`;
13
13
  }
@@ -16,7 +16,7 @@ const isAuthorized = (allowedFeatures) => {
16
16
  throw new UnauthenticatedError();
17
17
  }
18
18
  try {
19
- const rawPayload = JwtService.verify(token, config.clientSecret);
19
+ const rawPayload = JwtService.verify(token, config.auth?.clientSecret || '');
20
20
  const userContext = UserContextSpec.decode(rawPayload);
21
21
  req.userContext = userContext;
22
22
  if (userContext.authorizations.some(authorization => authorization.feature === 'admin')) {
@@ -0,0 +1,12 @@
1
+ export interface IAuthConfig {
2
+ adminUser: {
3
+ email: string;
4
+ password: string;
5
+ };
6
+ clientSecret: string;
7
+ saltWorkFactor: number;
8
+ deviceIdCookieMaxAgeInDays: number;
9
+ jwtExpirationInSeconds: number;
10
+ passwordResetTokenExpirationInMinutes: number;
11
+ refreshTokenExpirationInDays: number;
12
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,41 +1,24 @@
1
1
  import { DbType } from "../databases/db-type.type.js";
2
+ import { IAuthConfig } from "./auth-config.interface.js";
2
3
  export interface IBaseApiConfig {
3
- appName: string;
4
- env: string;
5
- hostName: string;
6
- clientSecret: string;
7
- database: {
8
- name?: string;
9
- host?: string;
10
- port?: number;
11
- user?: string;
12
- password?: string;
13
- };
14
- externalPort?: number;
15
- internalPort?: number;
16
- corsAllowedOrigins: string[];
17
- saltWorkFactor?: number;
18
- jobTypes?: string;
19
- deployedBranch?: string;
20
- debug?: {
21
- showErrors?: boolean;
22
- };
23
4
  app: {
5
+ dbType?: DbType;
24
6
  isMultiTenant: boolean;
25
- metaOrgName?: string;
26
7
  metaOrgCode?: string;
27
- dbType?: DbType;
8
+ metaOrgName?: string;
9
+ name: string;
28
10
  primaryTimezone?: string;
29
11
  };
30
- adminUser?: {
31
- email: string;
12
+ auth?: IAuthConfig;
13
+ database?: {
14
+ host: string;
15
+ name: string;
32
16
  password: string;
17
+ port: number;
18
+ username: string;
33
19
  };
34
- auth: {
35
- jwtExpirationInSeconds: number;
36
- refreshTokenExpirationInDays: number;
37
- deviceIdCookieMaxAgeInDays: number;
38
- passwordResetTokenExpirationInMinutes: number;
20
+ debug?: {
21
+ showErrors?: boolean;
39
22
  };
40
23
  email?: {
41
24
  emailApiKey: string;
@@ -43,4 +26,11 @@ export interface IBaseApiConfig {
43
26
  fromAddress: string;
44
27
  systemEmailAddress: string;
45
28
  };
29
+ env: string;
30
+ network: {
31
+ corsAllowedOrigins: string[];
32
+ externalPort?: number;
33
+ hostName: string;
34
+ internalPort?: number;
35
+ };
46
36
  }
@@ -10,6 +10,7 @@ export declare class AuthService extends MultiTenantApiService<IUser> {
10
10
  private passwordResetTokenService;
11
11
  private emailService;
12
12
  private organizationService;
13
+ private authConfig;
13
14
  constructor(database: IDatabase);
14
15
  attemptLogin(req: Request, res: Response, email: string, password: string): Promise<ILoginResponse | null>;
15
16
  logUserIn(userContext: IUserContext, deviceId: string): Promise<{
@@ -17,12 +17,17 @@ export class AuthService extends MultiTenantApiService {
17
17
  passwordResetTokenService;
18
18
  emailService;
19
19
  organizationService;
20
+ authConfig;
20
21
  constructor(database) {
21
22
  super(database, 'users', 'user', UserSpec);
22
23
  this.refreshTokenService = new GenericApiService(database, 'refreshTokens', 'refreshToken', refreshTokenModelSpec);
23
24
  this.passwordResetTokenService = new PasswordResetTokenService(database);
24
25
  this.emailService = new EmailService();
25
26
  this.organizationService = new OrganizationService(database);
27
+ if (!config.auth) {
28
+ throw new ServerError('Auth configuration is not set');
29
+ }
30
+ this.authConfig = config.auth;
26
31
  }
27
32
  async attemptLogin(req, res, email, password) {
28
33
  const lowerCaseEmail = email.toLowerCase();
@@ -49,7 +54,7 @@ export class AuthService extends MultiTenantApiService {
49
54
  const payload = userContext;
50
55
  const accessToken = this.generateJwt(payload);
51
56
  const refreshTokenObject = await this.createNewRefreshToken(userContext.user._id, deviceId, userContext.organization?._id);
52
- const accessTokenExpiresOn = this.getExpiresOnFromSeconds(config.auth.jwtExpirationInSeconds);
57
+ const accessTokenExpiresOn = this.getExpiresOnFromSeconds(this.authConfig.jwtExpirationInSeconds);
53
58
  let loginResponse = null;
54
59
  if (refreshTokenObject) {
55
60
  const tokenResponse = {
@@ -126,7 +131,7 @@ export class AuthService extends MultiTenantApiService {
126
131
  async createNewTokens(userContext, activeRefreshToken) {
127
132
  const payload = userContext;
128
133
  const accessToken = this.generateJwt(payload);
129
- const accessTokenExpiresOn = this.getExpiresOnFromSeconds(config.auth.jwtExpirationInSeconds);
134
+ const accessTokenExpiresOn = this.getExpiresOnFromSeconds(this.authConfig.jwtExpirationInSeconds);
130
135
  const tokenResponse = {
131
136
  accessToken,
132
137
  refreshToken: activeRefreshToken.token,
@@ -147,7 +152,7 @@ export class AuthService extends MultiTenantApiService {
147
152
  return activeRefreshToken;
148
153
  }
149
154
  async createNewRefreshToken(userId, deviceId, orgId) {
150
- const expiresOn = this.getExpiresOnFromDays(config.auth.refreshTokenExpirationInDays);
155
+ const expiresOn = this.getExpiresOnFromDays(this.authConfig.refreshTokenExpirationInDays);
151
156
  const newRefreshToken = {
152
157
  _orgId: orgId,
153
158
  token: this.generateRefreshToken(),
@@ -162,17 +167,16 @@ export class AuthService extends MultiTenantApiService {
162
167
  return insertResult;
163
168
  }
164
169
  async sendResetPasswordEmail(emailAddress) {
165
- const expiresOn = this.getExpiresOnFromMinutes(config.auth.passwordResetTokenExpirationInMinutes);
170
+ const expiresOn = this.getExpiresOnFromMinutes(this.authConfig.passwordResetTokenExpirationInMinutes);
166
171
  const passwordResetToken = await this.passwordResetTokenService.createPasswordResetToken(emailAddress, expiresOn);
167
172
  if (!passwordResetToken) {
168
173
  throw new ServerError(`Failed to create password reset token for email: ${emailAddress}`);
169
174
  }
170
175
  const httpOrHttps = config.env === 'local' ? 'http' : 'https';
171
176
  const urlEncodedEmail = encodeURIComponent(emailAddress);
172
- const clientUrl = config.hostName;
173
- const resetPasswordLink = `${httpOrHttps}://${clientUrl}/reset-password/${passwordResetToken.token}/${urlEncodedEmail}`;
177
+ const resetPasswordLink = `${httpOrHttps}://${config.network.hostName}/reset-password/${passwordResetToken.token}/${urlEncodedEmail}`;
174
178
  const htmlEmailBody = `<strong><a href="${resetPasswordLink}">Reset Password</a></strong>`;
175
- await this.emailService.sendHtmlEmail(emailAddress, `Reset Password for ${config.appName}`, htmlEmailBody);
179
+ await this.emailService.sendHtmlEmail(emailAddress, `Reset Password for ${config.app.name}`, htmlEmailBody);
176
180
  }
177
181
  async resetPassword(email, passwordResetToken, password) {
178
182
  const lowerCaseEmail = email.toLowerCase();
@@ -195,9 +199,9 @@ export class AuthService extends MultiTenantApiService {
195
199
  return this.refreshTokenService.deleteMany(EmptyUserContext, { filters: { deviceId: { eq: deviceId } } });
196
200
  }
197
201
  generateJwt(userContext) {
198
- const jwtExpiryConfig = config.auth.jwtExpirationInSeconds;
202
+ const jwtExpiryConfig = this.authConfig.jwtExpirationInSeconds;
199
203
  const jwtExpirationInSeconds = (typeof jwtExpiryConfig === 'string') ? parseInt(jwtExpiryConfig) : jwtExpiryConfig;
200
- const accessToken = JwtService.sign(userContext, config.clientSecret, {
204
+ const accessToken = JwtService.sign(userContext, this.authConfig.clientSecret, {
201
205
  expiresIn: jwtExpirationInSeconds
202
206
  });
203
207
  return accessToken;
@@ -222,7 +226,7 @@ export class AuthService extends MultiTenantApiService {
222
226
  }
223
227
  if (isNewDeviceId) {
224
228
  const cookieOptions = {
225
- maxAge: config.auth.deviceIdCookieMaxAgeInDays * 24 * 60 * 60 * 1000,
229
+ maxAge: this.authConfig.deviceIdCookieMaxAgeInDays * 24 * 60 * 60 * 1000,
226
230
  httpOnly: true
227
231
  };
228
232
  res.cookie('deviceId', deviceId, cookieOptions);
@@ -2,23 +2,28 @@ import * as Mailjet from 'node-mailjet';
2
2
  import { ServerError } from '../errors/index.js';
3
3
  import { config } from '../config/index.js';
4
4
  export class EmailService {
5
- mailjet;
5
+ mailjet = null;
6
6
  constructor() {
7
- this.mailjet = new Mailjet.default({
8
- apiKey: config.email?.emailApiKey || '',
9
- apiSecret: config.email?.emailApiSecret || ''
10
- });
7
+ if (config && config.email?.emailApiKey && config.email?.emailApiSecret) {
8
+ this.mailjet = new Mailjet.default({
9
+ apiKey: config.email.emailApiKey,
10
+ apiSecret: config.email.emailApiSecret
11
+ });
12
+ }
11
13
  }
12
14
  async sendHtmlEmail(emailAddress, subject, body) {
13
- if (!config.email?.fromAddress) {
14
- throw new ServerError('From address is not set in the config');
15
+ if (!config || !config.email?.fromAddress) {
16
+ throw new ServerError('Email configuration is not available. From address is not set in the config.');
17
+ }
18
+ if (!this.mailjet) {
19
+ throw new ServerError('Email service is not configured. Email API credentials are not set in the config.');
15
20
  }
16
21
  const messageData = {
17
22
  Messages: [
18
23
  {
19
24
  From: {
20
25
  Email: config.email?.fromAddress,
21
- Name: config.appName || 'Application'
26
+ Name: config.app.name || 'Application'
22
27
  },
23
28
  To: [
24
29
  {
@@ -20,7 +20,7 @@ function setupExpressApp(database, config, setupRoutes) {
20
20
  app.use(bodyParser.json());
21
21
  app.use(cookieParser());
22
22
  app.use(cors({
23
- origin: config.corsAllowedOrigins,
23
+ origin: config.network.corsAllowedOrigins,
24
24
  credentials: true
25
25
  }));
26
26
  app.use(ensureUserContext);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loomcore/api",
3
- "version": "0.1.57",
3
+ "version": "0.1.58",
4
4
  "private": false,
5
5
  "description": "Loom Core Api - An opinionated Node.js api using Typescript, Express, and MongoDb or PostgreSQL",
6
6
  "scripts": {
@@ -50,7 +50,7 @@
50
50
  "dependencies": {
51
51
  "jsonwebtoken": "^9.0.2",
52
52
  "node-mailjet": "^6.0.8",
53
- "qs": "^6.14.0"
53
+ "qs": "^6.14.1"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "@loomcore/common": "^0.0.43",