@loomcore/api 0.1.37 → 0.1.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@ This library provides a foundational structure for creating robust APIs, with a
8
8
 
9
9
  - **Generic API Controller**: A base `ApiController` that automatically scaffolds a full suite of RESTful endpoints for any data model. This includes support for CRUD operations, batch updates, pagination, and counting.
10
10
  - **Built-in Multi-tenancy**: Optional, configuration-driven multi-tenancy. When enabled, all database operations are automatically scoped to the current user's organization, ensuring strict data isolation.
11
- - **Authentication & Authorization**: Integrated JWT-based authentication middleware (`isAuthenticated`) to easily secure endpoints and support for OAuth 2.0 Code Flow, including the use of refresh tokens.
11
+ - **Authentication & Authorization**: Integrated JWT-based authentication middleware (`isAuthorized`) to easily secure endpoints and support for OAuth 2.0 Code Flow, including the use of refresh tokens.
12
12
  - **User & Organization Management**: Pre-built services and controllers for common user and organization management tasks.
13
13
  - **Password Management**: Includes services for handling password reset requests and workflows.
14
14
  - **Email Service Integration**: A ready-to-use service for sending transactional emails (e.g., for password resets or welcome messages).
@@ -131,17 +131,8 @@ async function simulateloginWithTestUser() {
131
131
  return `Bearer ${loginResponse.tokens.accessToken}`;
132
132
  }
133
133
  function getAuthToken() {
134
- const metaOrgUser = getTestMetaOrgUser();
135
- const metaOrg = getTestMetaOrg();
136
- const payload = {
137
- user: {
138
- _id: metaOrgUser._id,
139
- email: metaOrgUser.email
140
- },
141
- organization: metaOrg,
142
- _orgId: metaOrgUser._orgId
143
- };
144
- const token = JwtService.sign(payload, JWT_SECRET, { expiresIn: 3600 });
134
+ const userContext = getTestMetaOrgUserContext();
135
+ const token = JwtService.sign(userContext, JWT_SECRET, { expiresIn: 3600 });
145
136
  return `Bearer ${token}`;
146
137
  }
147
138
  function verifyToken(token) {
@@ -1,7 +1,7 @@
1
1
  import { BadRequestError } from '../errors/index.js';
2
2
  import { entityUtils } from '@loomcore/common/utils';
3
- import { isAuthenticated } from '../middleware/index.js';
4
3
  import { apiUtils } from '../utils/index.js';
4
+ import { isAuthorized } from '../middleware/index.js';
5
5
  export class ApiController {
6
6
  app;
7
7
  service;
@@ -19,15 +19,15 @@ export class ApiController {
19
19
  this.mapRoutes(app);
20
20
  }
21
21
  mapRoutes(app) {
22
- app.get(`/api/${this.slug}`, isAuthenticated, this.get.bind(this));
23
- app.get(`/api/${this.slug}/all`, isAuthenticated, this.getAll.bind(this));
24
- app.get(`/api/${this.slug}/count`, isAuthenticated, this.getCount.bind(this));
25
- app.get(`/api/${this.slug}/:id`, isAuthenticated, this.getById.bind(this));
26
- app.post(`/api/${this.slug}`, isAuthenticated, this.create.bind(this));
27
- app.patch(`/api/${this.slug}/batch`, isAuthenticated, this.batchUpdate.bind(this));
28
- app.put(`/api/${this.slug}/:id`, isAuthenticated, this.fullUpdateById.bind(this));
29
- app.patch(`/api/${this.slug}/:id`, isAuthenticated, this.partialUpdateById.bind(this));
30
- app.delete(`/api/${this.slug}/:id`, isAuthenticated, this.deleteById.bind(this));
22
+ app.get(`/api/${this.slug}`, isAuthorized(), this.get.bind(this));
23
+ app.get(`/api/${this.slug}/all`, isAuthorized(), this.getAll.bind(this));
24
+ app.get(`/api/${this.slug}/count`, isAuthorized(), this.getCount.bind(this));
25
+ app.get(`/api/${this.slug}/:id`, isAuthorized(), this.getById.bind(this));
26
+ app.post(`/api/${this.slug}`, isAuthorized(), this.create.bind(this));
27
+ app.patch(`/api/${this.slug}/batch`, isAuthorized(), this.batchUpdate.bind(this));
28
+ app.put(`/api/${this.slug}/:id`, isAuthorized(), this.fullUpdateById.bind(this));
29
+ app.patch(`/api/${this.slug}/:id`, isAuthorized(), this.partialUpdateById.bind(this));
30
+ app.delete(`/api/${this.slug}/:id`, isAuthorized(), this.deleteById.bind(this));
31
31
  }
32
32
  validate(entity, isPartial = false) {
33
33
  const validationErrors = this.service.validate(entity, isPartial);
@@ -1,9 +1,9 @@
1
1
  import { LoginResponseSpec, TokenResponseSpec, UserSpec, PublicUserSpec, passwordValidator, PublicUserContextSpec, } from '@loomcore/common/models';
2
2
  import { entityUtils } from '@loomcore/common/utils';
3
3
  import { BadRequestError, UnauthenticatedError } from '../errors/index.js';
4
- import { isAuthenticated } from '../middleware/index.js';
5
4
  import { apiUtils } from '../utils/index.js';
6
5
  import { AuthService } from '../services/index.js';
6
+ import { isAuthorized } from '../middleware/index.js';
7
7
  export class AuthController {
8
8
  authService;
9
9
  constructor(app, database) {
@@ -13,10 +13,10 @@ export class AuthController {
13
13
  }
14
14
  mapRoutes(app) {
15
15
  app.post(`/api/auth/login`, this.login.bind(this), this.afterAuth.bind(this));
16
- app.post(`/api/auth/register`, isAuthenticated, this.registerUser.bind(this));
16
+ app.post(`/api/auth/register`, isAuthorized(), this.registerUser.bind(this));
17
17
  app.get(`/api/auth/refresh`, this.requestTokenUsingRefreshToken.bind(this));
18
- app.get(`/api/auth/get-user-context`, isAuthenticated, this.getUserContext.bind(this));
19
- app.patch(`/api/auth/change-password`, isAuthenticated, this.changePassword.bind(this));
18
+ app.get(`/api/auth/get-user-context`, isAuthorized(), this.getUserContext.bind(this));
19
+ app.patch(`/api/auth/change-password`, isAuthorized(), this.changePassword.bind(this));
20
20
  app.post(`/api/auth/forgot-password`, this.forgotPassword.bind(this));
21
21
  app.post(`/api/auth/reset-password`, this.resetPassword.bind(this));
22
22
  }
@@ -1,8 +1,8 @@
1
1
  import { ApiController } from './api.controller.js';
2
- import { isAuthenticated } from '../middleware/index.js';
3
2
  import { apiUtils } from '../utils/index.js';
4
3
  import { BadRequestError } from '../errors/index.js';
5
4
  import { OrganizationService } from '../services/index.js';
5
+ import { isAuthorized } from '../middleware/index.js';
6
6
  export class OrganizationsController extends ApiController {
7
7
  orgService;
8
8
  constructor(app, database) {
@@ -12,8 +12,8 @@ export class OrganizationsController extends ApiController {
12
12
  }
13
13
  mapRoutes(app) {
14
14
  super.mapRoutes(app);
15
- app.get(`/api/${this.slug}/get-by-name/:name`, isAuthenticated, this.getByName.bind(this));
16
- app.get(`/api/${this.slug}/get-by-code/:code`, isAuthenticated, this.getByCode.bind(this));
15
+ app.get(`/api/${this.slug}/get-by-name/:name`, isAuthorized(), this.getByName.bind(this));
16
+ app.get(`/api/${this.slug}/get-by-code/:code`, isAuthorized(), this.getByCode.bind(this));
17
17
  }
18
18
  async getByName(req, res, next) {
19
19
  console.log('in OrganizationController.getByName');
@@ -1,6 +1,6 @@
1
1
  import { UserSpec, PublicUserSpec } from '@loomcore/common/models';
2
2
  import { ApiController } from './api.controller.js';
3
- import { isAuthenticated } from '../middleware/index.js';
3
+ import { isAuthorized } from '../middleware/index.js';
4
4
  import { UserService } from '../services/index.js';
5
5
  export class UsersController extends ApiController {
6
6
  userService;
@@ -10,13 +10,13 @@ export class UsersController extends ApiController {
10
10
  this.userService = userService;
11
11
  }
12
12
  mapRoutes(app) {
13
- app.get(`/api/${this.slug}`, isAuthenticated, this.get.bind(this));
14
- app.get(`/api/${this.slug}/all`, isAuthenticated, this.getAll.bind(this));
15
- app.get(`/api/${this.slug}/find`, isAuthenticated, this.get.bind(this));
16
- app.get(`/api/${this.slug}/count`, isAuthenticated, this.getCount.bind(this));
17
- app.get(`/api/${this.slug}/:id`, isAuthenticated, this.getById.bind(this));
18
- app.post(`/api/${this.slug}`, isAuthenticated, this.create.bind(this));
19
- app.patch(`/api/${this.slug}/:id`, isAuthenticated, this.partialUpdateById.bind(this));
20
- app.delete(`/api/${this.slug}/:id`, isAuthenticated, this.deleteById.bind(this));
13
+ app.get(`/api/${this.slug}`, isAuthorized(), this.get.bind(this));
14
+ app.get(`/api/${this.slug}/all`, isAuthorized(), this.getAll.bind(this));
15
+ app.get(`/api/${this.slug}/find`, isAuthorized(), this.get.bind(this));
16
+ app.get(`/api/${this.slug}/count`, isAuthorized(), this.getCount.bind(this));
17
+ app.get(`/api/${this.slug}/:id`, isAuthorized(), this.getById.bind(this));
18
+ app.post(`/api/${this.slug}`, isAuthorized(), this.create.bind(this));
19
+ app.patch(`/api/${this.slug}/:id`, isAuthorized(), this.partialUpdateById.bind(this));
20
+ app.delete(`/api/${this.slug}/:id`, isAuthorized(), this.deleteById.bind(this));
21
21
  }
22
22
  }
@@ -0,0 +1,15 @@
1
+ export type DbType = 'postgres' | 'mongo';
2
+ export interface MigrationConfig {
3
+ dbType: DbType;
4
+ dbUrl: string;
5
+ migrationsDir: string;
6
+ }
7
+ export declare class MigrationRunner {
8
+ private config;
9
+ constructor(config: MigrationConfig);
10
+ private parseSql;
11
+ private getMigrator;
12
+ private wipeDatabase;
13
+ run(command: 'up' | 'down' | 'reset'): Promise<void>;
14
+ private closeConnection;
15
+ }
@@ -0,0 +1,118 @@
1
+ import { Umzug, MongoDBStorage } from 'umzug';
2
+ import { Pool } from 'pg';
3
+ import { MongoClient } from 'mongodb';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ export class MigrationRunner {
7
+ config;
8
+ constructor(config) {
9
+ this.config = config;
10
+ }
11
+ parseSql(content) {
12
+ const upMatch = content.match(/--\s*up\s([\s\S]+?)(?=--\s*down|$)/i);
13
+ const downMatch = content.match(/--\s*down\s([\s\S]+)/i);
14
+ return {
15
+ up: upMatch ? upMatch[1].trim() : '',
16
+ down: downMatch ? downMatch[1].trim() : ''
17
+ };
18
+ }
19
+ async getMigrator() {
20
+ const { dbType, dbUrl, migrationsDir } = this.config;
21
+ if (dbType === 'postgres') {
22
+ const pool = new Pool({ connectionString: dbUrl });
23
+ return new Umzug({
24
+ migrations: {
25
+ glob: path.join(migrationsDir, '*.sql'),
26
+ resolve: ({ name, path: filePath, context }) => {
27
+ const content = fs.readFileSync(filePath, 'utf8');
28
+ const { up, down } = this.parseSql(content);
29
+ return {
30
+ name,
31
+ up: async () => context.query(up),
32
+ down: async () => context.query(down)
33
+ };
34
+ }
35
+ },
36
+ context: pool,
37
+ storage: {
38
+ async executed({ context }) {
39
+ await context.query(`CREATE TABLE IF NOT EXISTS migrations (name text)`);
40
+ const result = await context.query(`SELECT name FROM migrations`);
41
+ return result.rows.map((r) => r.name);
42
+ },
43
+ async logMigration({ name, context }) {
44
+ await context.query(`INSERT INTO migrations (name) VALUES ($1)`, [name]);
45
+ },
46
+ async unlogMigration({ name, context }) {
47
+ await context.query(`DELETE FROM migrations WHERE name = $1`, [name]);
48
+ }
49
+ },
50
+ logger: console,
51
+ });
52
+ }
53
+ else if (dbType === 'mongo') {
54
+ const client = await MongoClient.connect(dbUrl);
55
+ const db = client.db();
56
+ return new Umzug({
57
+ migrations: { glob: path.join(migrationsDir, '*.ts') },
58
+ context: db,
59
+ storage: new MongoDBStorage({ collection: db.collection('migrations') }),
60
+ logger: console,
61
+ });
62
+ }
63
+ throw new Error(`Unsupported DB_TYPE: ${dbType}`);
64
+ }
65
+ async wipeDatabase() {
66
+ const { dbType, dbUrl } = this.config;
67
+ console.log(`⚠️ Wiping ${dbType} database...`);
68
+ if (dbType === 'postgres') {
69
+ const pool = new Pool({ connectionString: dbUrl });
70
+ try {
71
+ await pool.query('DROP SCHEMA public CASCADE; CREATE SCHEMA public;');
72
+ }
73
+ finally {
74
+ await pool.end();
75
+ }
76
+ }
77
+ else if (dbType === 'mongo') {
78
+ const client = await MongoClient.connect(dbUrl);
79
+ await client.db().dropDatabase();
80
+ await client.close();
81
+ }
82
+ console.log('✅ Database wiped.');
83
+ }
84
+ async run(command) {
85
+ try {
86
+ if (command === 'reset') {
87
+ await this.wipeDatabase();
88
+ console.log('🚀 Restarting migrations...');
89
+ const migrator = await this.getMigrator();
90
+ await migrator.up();
91
+ await this.closeConnection(migrator);
92
+ console.log('✅ Reset complete.');
93
+ return;
94
+ }
95
+ const migrator = await this.getMigrator();
96
+ switch (command) {
97
+ case 'up':
98
+ await migrator.up();
99
+ console.log('✅ Migrations up to date.');
100
+ break;
101
+ case 'down':
102
+ await migrator.down();
103
+ console.log('✅ Reverted last migration.');
104
+ break;
105
+ }
106
+ await this.closeConnection(migrator);
107
+ }
108
+ catch (err) {
109
+ console.error('❌ Migration failed:', err);
110
+ process.exit(1);
111
+ }
112
+ }
113
+ async closeConnection(migrator) {
114
+ if (this.config.dbType === 'postgres') {
115
+ await migrator.context.end();
116
+ }
117
+ }
118
+ }
@@ -1,3 +1,3 @@
1
1
  export * from './error-handler.js';
2
- export * from './is-authenticated.js';
2
+ export * from './is-authorized.js';
3
3
  export * from './ensure-user-context.js';
@@ -1,3 +1,3 @@
1
1
  export * from './error-handler.js';
2
- export * from './is-authenticated.js';
2
+ export * from './is-authorized.js';
3
3
  export * from './ensure-user-context.js';
@@ -0,0 +1,3 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+ declare const isAuthorized: (allowedFeatures?: string[]) => (req: Request, res: Response, next: NextFunction) => void;
3
+ export { isAuthorized };
@@ -0,0 +1,40 @@
1
+ import { UserContextSpec } from '@loomcore/common/models';
2
+ import { UnauthenticatedError, UnauthorizedError } from '../errors/index.js';
3
+ import { JwtService } from '../services/index.js';
4
+ import { config } from '../config/index.js';
5
+ const isAuthorized = (allowedFeatures) => {
6
+ return (req, res, next) => {
7
+ let token = null;
8
+ if (req.headers?.authorization) {
9
+ let authHeader = req.headers.authorization;
10
+ const authHeaderArray = authHeader.split('Bearer ');
11
+ if (authHeaderArray?.length > 1) {
12
+ token = authHeaderArray[1];
13
+ }
14
+ }
15
+ if (!token) {
16
+ throw new UnauthenticatedError();
17
+ }
18
+ try {
19
+ const rawPayload = JwtService.verify(token, config.clientSecret);
20
+ const userContext = UserContextSpec.decode(rawPayload);
21
+ req.userContext = userContext;
22
+ if (userContext.authorizations.some(authorization => authorization.feature === 'admin')) {
23
+ next();
24
+ }
25
+ else if (allowedFeatures && allowedFeatures.length) {
26
+ if (!userContext.authorizations.some(authorization => allowedFeatures?.includes(authorization.feature))) {
27
+ throw new UnauthorizedError();
28
+ }
29
+ next();
30
+ }
31
+ else {
32
+ next();
33
+ }
34
+ }
35
+ catch (err) {
36
+ throw new UnauthenticatedError();
37
+ }
38
+ };
39
+ };
40
+ export { isAuthorized };
@@ -70,20 +70,19 @@ export class AuthService extends MultiTenantApiService {
70
70
  if (!rawUser) {
71
71
  return null;
72
72
  }
73
- const dbPostprocessed = this.database.postprocessEntity(rawUser, this.modelSpec.fullSchema);
74
- return dbPostprocessed;
73
+ return this.database.postprocessEntity(rawUser, this.modelSpec.fullSchema);
75
74
  }
76
75
  async createUser(userContext, user) {
77
- if (userContext.user?._id === 'system') {
76
+ if (userContext.user._id === 'system') {
77
+ if (config.app.isMultiTenant && userContext.organization?._id !== user._orgId) {
78
+ throw new BadRequestError('User is not authorized to create a user in this organization');
79
+ }
78
80
  if (user._orgId) {
79
81
  const org = await this.organizationService.findOne(userContext, { filters: { _id: { eq: user._orgId } } });
80
82
  if (!org) {
81
83
  throw new BadRequestError('The specified organization does not exist');
82
84
  }
83
85
  }
84
- if (config.app.isMultiTenant && userContext.organization?._id !== user._orgId) {
85
- throw new BadRequestError('User is not authorized to create a user in this organization');
86
- }
87
86
  }
88
87
  if (user.email) {
89
88
  const existingUser = await this.getUserByEmail(user.email);
@@ -1,11 +1,8 @@
1
- import { IUser, IUserContext, IPagedResult, IQueryOptions } from '@loomcore/common/models';
1
+ import { IUser, IUserContext } from '@loomcore/common/models';
2
2
  import { MultiTenantApiService } from './index.js';
3
3
  import { IDatabase } from '../databases/models/index.js';
4
4
  export declare class UserService extends MultiTenantApiService<IUser> {
5
5
  constructor(database: IDatabase);
6
6
  fullUpdateById(userContext: IUserContext, id: string, entity: IUser): Promise<IUser>;
7
- getById(userContext: IUserContext, id: string): Promise<IUser>;
8
- get(userContext: IUserContext, queryOptions: IQueryOptions): Promise<IPagedResult<IUser>>;
9
- getAll(userContext: IUserContext): Promise<IUser[]>;
10
7
  preprocessEntity(userContext: IUserContext, entity: Partial<IUser>, isCreate: boolean, allowId?: boolean): Promise<Partial<IUser>>;
11
8
  }
@@ -1,7 +1,7 @@
1
1
  import { Value } from '@sinclair/typebox/value';
2
2
  import { UserSpec, PublicUserSchema } from '@loomcore/common/models';
3
3
  import { MultiTenantApiService } from './index.js';
4
- import { IdNotFoundError, ServerError } from '../errors/index.js';
4
+ import { ServerError } from '../errors/index.js';
5
5
  export class UserService extends MultiTenantApiService {
6
6
  constructor(database) {
7
7
  super(database, 'users', 'user', UserSpec);
@@ -9,28 +9,6 @@ export class UserService extends MultiTenantApiService {
9
9
  async fullUpdateById(userContext, id, entity) {
10
10
  throw new ServerError('Cannot full update a user. Either use PATCH or /auth/change-password to update password.');
11
11
  }
12
- async getById(userContext, id) {
13
- const { operations, queryObject } = this.prepareQuery(userContext, {}, []);
14
- const user = await this.database.getById(operations, queryObject, id, this.pluralResourceName);
15
- if (!user) {
16
- throw new IdNotFoundError();
17
- }
18
- return this.postprocessEntity(userContext, user);
19
- }
20
- async get(userContext, queryOptions) {
21
- const { operations, queryObject } = this.prepareQuery(userContext, queryOptions, []);
22
- const pagedResult = await this.database.get(operations, queryObject, this.modelSpec, this.pluralResourceName);
23
- const transformedEntities = (pagedResult.entities || []).map(entity => this.postprocessEntity(userContext, entity));
24
- return {
25
- ...pagedResult,
26
- entities: transformedEntities
27
- };
28
- }
29
- async getAll(userContext) {
30
- const { operations } = this.prepareQuery(userContext, {}, []);
31
- const users = await this.database.getAll(operations, this.pluralResourceName);
32
- return users.map(user => this.postprocessEntity(userContext, user));
33
- }
34
12
  async preprocessEntity(userContext, entity, isCreate, allowId = false) {
35
13
  const preparedEntity = await super.preprocessEntity(userContext, entity, isCreate);
36
14
  if (preparedEntity.email) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loomcore/api",
3
- "version": "0.1.37",
3
+ "version": "0.1.39",
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": {
@@ -62,7 +62,8 @@
62
62
  "moment": "^2.30.1",
63
63
  "mongodb": "^6.16.0",
64
64
  "pg": "^8.15.6",
65
- "rxjs": "^7.8.0"
65
+ "rxjs": "^7.8.0",
66
+ "umzug": "^3.8.2"
66
67
  },
67
68
  "devDependencies": {
68
69
  "@types/cookie-parser": "^1.4.7",
@@ -1,2 +0,0 @@
1
- import { Request, Response, NextFunction } from 'express';
2
- export declare const isAuthenticated: (req: Request, res: Response, next: NextFunction) => void;
@@ -1,27 +0,0 @@
1
- import { UserContextSpec } from '@loomcore/common/models';
2
- import { UnauthenticatedError } from '../errors/index.js';
3
- import { JwtService } from '../services/index.js';
4
- import { config } from '../config/index.js';
5
- export const isAuthenticated = (req, res, next) => {
6
- let token = null;
7
- if (req.headers?.authorization) {
8
- let authHeader = req.headers.authorization;
9
- const authHeaderArray = authHeader.split('Bearer ');
10
- if (authHeaderArray?.length > 1) {
11
- token = authHeaderArray[1];
12
- }
13
- }
14
- if (token) {
15
- try {
16
- const rawPayload = JwtService.verify(token, config.clientSecret);
17
- req.userContext = UserContextSpec.decode(rawPayload);
18
- next();
19
- }
20
- catch (err) {
21
- throw new UnauthenticatedError();
22
- }
23
- }
24
- else {
25
- throw new UnauthenticatedError();
26
- }
27
- };