@astralibx/staff-engine 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +147 -0
- package/dist/index.cjs +1087 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.mts +262 -0
- package/dist/index.d.ts +262 -0
- package/dist/index.mjs +1049 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +65 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { AlxError, sendError, noopLogger, sendSuccess } from '@astralibx/core';
|
|
3
|
+
export { sendSuccess } from '@astralibx/core';
|
|
4
|
+
import { PERMISSION_TYPE_VALUES, STAFF_STATUS, STAFF_STATUS_VALUES, STAFF_ROLE_VALUES, PERMISSION_TYPE, STAFF_ROLE, DEFAULT_OPTIONS } from '@astralibx/staff-types';
|
|
5
|
+
export { DEFAULT_OPTIONS } from '@astralibx/staff-types';
|
|
6
|
+
import { Schema } from 'mongoose';
|
|
7
|
+
import jwt2 from 'jsonwebtoken';
|
|
8
|
+
import { Router } from 'express';
|
|
9
|
+
|
|
10
|
+
// src/index.ts
|
|
11
|
+
function createStaffModel(connection, prefix) {
|
|
12
|
+
const schema = new Schema(
|
|
13
|
+
{
|
|
14
|
+
name: { type: String, required: true, trim: true },
|
|
15
|
+
email: { type: String, required: true, trim: true, lowercase: true },
|
|
16
|
+
password: { type: String, required: true, select: false },
|
|
17
|
+
role: { type: String, enum: STAFF_ROLE_VALUES, default: "staff" },
|
|
18
|
+
status: { type: String, enum: STAFF_STATUS_VALUES, default: STAFF_STATUS.Pending },
|
|
19
|
+
permissions: { type: [String], default: [] },
|
|
20
|
+
externalUserId: { type: String, sparse: true },
|
|
21
|
+
lastLoginAt: { type: Date },
|
|
22
|
+
lastLoginIp: { type: String },
|
|
23
|
+
metadata: { type: Schema.Types.Mixed },
|
|
24
|
+
tenantId: { type: String, sparse: true }
|
|
25
|
+
},
|
|
26
|
+
{ timestamps: true }
|
|
27
|
+
);
|
|
28
|
+
schema.index({ email: 1, tenantId: 1 }, { unique: true });
|
|
29
|
+
schema.index({ status: 1 });
|
|
30
|
+
schema.index({ role: 1 });
|
|
31
|
+
const collectionName = prefix ? `${prefix}_staff` : "staff";
|
|
32
|
+
return connection.model("Staff", schema, collectionName);
|
|
33
|
+
}
|
|
34
|
+
var permissionEntrySchema = new Schema(
|
|
35
|
+
{
|
|
36
|
+
key: { type: String, required: true },
|
|
37
|
+
label: { type: String, required: true },
|
|
38
|
+
type: { type: String, enum: PERMISSION_TYPE_VALUES, required: true }
|
|
39
|
+
},
|
|
40
|
+
{ _id: false }
|
|
41
|
+
);
|
|
42
|
+
function createPermissionGroupModel(connection, prefix) {
|
|
43
|
+
const schema = new Schema(
|
|
44
|
+
{
|
|
45
|
+
groupId: { type: String, required: true },
|
|
46
|
+
label: { type: String, required: true, trim: true },
|
|
47
|
+
permissions: { type: [permissionEntrySchema], default: [] },
|
|
48
|
+
sortOrder: { type: Number, default: 0 },
|
|
49
|
+
tenantId: { type: String, index: true, sparse: true }
|
|
50
|
+
},
|
|
51
|
+
{ timestamps: true }
|
|
52
|
+
);
|
|
53
|
+
schema.index({ groupId: 1, tenantId: 1 }, { unique: true });
|
|
54
|
+
schema.index({ sortOrder: 1 });
|
|
55
|
+
const collectionName = prefix ? `${prefix}_permission_groups` : "permission_groups";
|
|
56
|
+
return connection.model("PermissionGroup", schema, collectionName);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/services/rate-limiter.service.ts
|
|
60
|
+
var RateLimiterService = class {
|
|
61
|
+
constructor(windowMs, maxAttempts, redis, keyPrefix, logger) {
|
|
62
|
+
this.windowMs = windowMs;
|
|
63
|
+
this.maxAttempts = maxAttempts;
|
|
64
|
+
this.redis = redis;
|
|
65
|
+
this.keyPrefix = keyPrefix;
|
|
66
|
+
this.logger = logger;
|
|
67
|
+
}
|
|
68
|
+
memoryStore = /* @__PURE__ */ new Map();
|
|
69
|
+
async checkLimit(key) {
|
|
70
|
+
if (this.redis) {
|
|
71
|
+
return this.checkLimitRedis(key);
|
|
72
|
+
}
|
|
73
|
+
return this.checkLimitMemory(key);
|
|
74
|
+
}
|
|
75
|
+
async recordAttempt(key) {
|
|
76
|
+
if (this.redis) {
|
|
77
|
+
return this.recordAttemptRedis(key);
|
|
78
|
+
}
|
|
79
|
+
this.recordAttemptMemory(key);
|
|
80
|
+
}
|
|
81
|
+
async reset(key) {
|
|
82
|
+
if (this.redis) {
|
|
83
|
+
await this.redis.del(`${this.keyPrefix}rate:${key}`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.memoryStore.delete(key);
|
|
87
|
+
}
|
|
88
|
+
checkLimitMemory(key) {
|
|
89
|
+
const entry = this.memoryStore.get(key);
|
|
90
|
+
if (!entry || Date.now() > entry.expiresAt) {
|
|
91
|
+
return { allowed: true, remaining: this.maxAttempts };
|
|
92
|
+
}
|
|
93
|
+
if (entry.count >= this.maxAttempts) {
|
|
94
|
+
return { allowed: false, remaining: 0, retryAfterMs: entry.expiresAt - Date.now() };
|
|
95
|
+
}
|
|
96
|
+
return { allowed: true, remaining: this.maxAttempts - entry.count };
|
|
97
|
+
}
|
|
98
|
+
recordAttemptMemory(key) {
|
|
99
|
+
const entry = this.memoryStore.get(key);
|
|
100
|
+
if (!entry || Date.now() > entry.expiresAt) {
|
|
101
|
+
this.memoryStore.set(key, { count: 1, expiresAt: Date.now() + this.windowMs });
|
|
102
|
+
} else {
|
|
103
|
+
entry.count++;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async checkLimitRedis(key) {
|
|
107
|
+
const redisKey = `${this.keyPrefix}rate:${key}`;
|
|
108
|
+
const redis = this.redis;
|
|
109
|
+
const count = await redis.get(redisKey);
|
|
110
|
+
const current = count ? parseInt(count, 10) : 0;
|
|
111
|
+
if (current >= this.maxAttempts) {
|
|
112
|
+
const ttl = await redis.pttl(redisKey);
|
|
113
|
+
return { allowed: false, remaining: 0, retryAfterMs: ttl > 0 ? ttl : this.windowMs };
|
|
114
|
+
}
|
|
115
|
+
return { allowed: true, remaining: this.maxAttempts - current };
|
|
116
|
+
}
|
|
117
|
+
async recordAttemptRedis(key) {
|
|
118
|
+
const redisKey = `${this.keyPrefix}rate:${key}`;
|
|
119
|
+
const redis = this.redis;
|
|
120
|
+
const count = await redis.incr(redisKey);
|
|
121
|
+
if (count === 1) {
|
|
122
|
+
await redis.pexpire(redisKey, this.windowMs);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// src/services/permission-cache.service.ts
|
|
128
|
+
var PermissionCacheService = class {
|
|
129
|
+
constructor(StaffModel, ttlMs, redis, keyPrefix, logger, tenantId) {
|
|
130
|
+
this.StaffModel = StaffModel;
|
|
131
|
+
this.ttlMs = ttlMs;
|
|
132
|
+
this.redis = redis;
|
|
133
|
+
this.keyPrefix = keyPrefix;
|
|
134
|
+
this.logger = logger;
|
|
135
|
+
this.tenantId = tenantId;
|
|
136
|
+
}
|
|
137
|
+
memoryCache = /* @__PURE__ */ new Map();
|
|
138
|
+
async get(staffId) {
|
|
139
|
+
if (this.redis) {
|
|
140
|
+
return this.getRedis(staffId);
|
|
141
|
+
}
|
|
142
|
+
return this.getMemory(staffId);
|
|
143
|
+
}
|
|
144
|
+
async invalidate(staffId) {
|
|
145
|
+
if (this.redis) {
|
|
146
|
+
await this.redis.del(`${this.keyPrefix}perms:${staffId}`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
this.memoryCache.delete(staffId);
|
|
150
|
+
}
|
|
151
|
+
async invalidateAll() {
|
|
152
|
+
if (this.redis) {
|
|
153
|
+
const redis = this.redis;
|
|
154
|
+
const keys = await redis.keys(`${this.keyPrefix}perms:*`);
|
|
155
|
+
if (keys.length > 0) {
|
|
156
|
+
await redis.del(...keys);
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
this.memoryCache.clear();
|
|
161
|
+
}
|
|
162
|
+
async getMemory(staffId) {
|
|
163
|
+
const cached = this.memoryCache.get(staffId);
|
|
164
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
165
|
+
return cached.permissions;
|
|
166
|
+
}
|
|
167
|
+
const permissions = await this.fetchFromDb(staffId);
|
|
168
|
+
this.memoryCache.set(staffId, { permissions, expiresAt: Date.now() + this.ttlMs });
|
|
169
|
+
return permissions;
|
|
170
|
+
}
|
|
171
|
+
async getRedis(staffId) {
|
|
172
|
+
const redisKey = `${this.keyPrefix}perms:${staffId}`;
|
|
173
|
+
const redis = this.redis;
|
|
174
|
+
const cached = await redis.get(redisKey);
|
|
175
|
+
if (cached) {
|
|
176
|
+
return JSON.parse(cached);
|
|
177
|
+
}
|
|
178
|
+
const permissions = await this.fetchFromDb(staffId);
|
|
179
|
+
await redis.set(redisKey, JSON.stringify(permissions), "PX", this.ttlMs);
|
|
180
|
+
return permissions;
|
|
181
|
+
}
|
|
182
|
+
async fetchFromDb(staffId) {
|
|
183
|
+
const filter = { _id: staffId };
|
|
184
|
+
if (this.tenantId) filter.tenantId = this.tenantId;
|
|
185
|
+
const staff = await this.StaffModel.findOne(filter).select("permissions").lean();
|
|
186
|
+
return staff?.permissions ?? [];
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// src/constants/index.ts
|
|
191
|
+
var ERROR_CODE = {
|
|
192
|
+
// Auth
|
|
193
|
+
InvalidCredentials: "STAFF_INVALID_CREDENTIALS",
|
|
194
|
+
AccountInactive: "STAFF_ACCOUNT_INACTIVE",
|
|
195
|
+
AccountPending: "STAFF_ACCOUNT_PENDING",
|
|
196
|
+
RateLimited: "STAFF_RATE_LIMITED",
|
|
197
|
+
TokenExpired: "STAFF_TOKEN_EXPIRED",
|
|
198
|
+
TokenInvalid: "STAFF_TOKEN_INVALID",
|
|
199
|
+
InsufficientPermissions: "STAFF_INSUFFICIENT_PERMISSIONS",
|
|
200
|
+
OwnerOnly: "STAFF_OWNER_ONLY",
|
|
201
|
+
// CRUD
|
|
202
|
+
StaffNotFound: "STAFF_NOT_FOUND",
|
|
203
|
+
EmailExists: "STAFF_EMAIL_EXISTS",
|
|
204
|
+
SetupAlreadyComplete: "STAFF_SETUP_ALREADY_COMPLETE",
|
|
205
|
+
LastOwnerGuard: "STAFF_LAST_OWNER_GUARD",
|
|
206
|
+
InvalidPermissions: "STAFF_INVALID_PERMISSIONS",
|
|
207
|
+
// Permission Groups
|
|
208
|
+
GroupNotFound: "STAFF_GROUP_NOT_FOUND",
|
|
209
|
+
GroupIdExists: "STAFF_GROUP_ID_EXISTS",
|
|
210
|
+
// Config
|
|
211
|
+
InvalidConfig: "STAFF_INVALID_CONFIG"
|
|
212
|
+
};
|
|
213
|
+
var ERROR_MESSAGE = {
|
|
214
|
+
InvalidCredentials: "Invalid email or password",
|
|
215
|
+
AccountInactive: "Account is deactivated",
|
|
216
|
+
AccountPending: "Account is pending activation",
|
|
217
|
+
RateLimited: "Too many login attempts. Please try again later.",
|
|
218
|
+
TokenExpired: "Token has expired",
|
|
219
|
+
TokenInvalid: "Invalid token",
|
|
220
|
+
InsufficientPermissions: "Insufficient permissions",
|
|
221
|
+
OwnerOnly: "This action requires owner privileges",
|
|
222
|
+
StaffNotFound: "Staff member not found",
|
|
223
|
+
EmailExists: "A staff member with this email already exists",
|
|
224
|
+
SetupAlreadyComplete: "Initial setup has already been completed",
|
|
225
|
+
LastOwnerGuard: "Cannot deactivate the last active owner",
|
|
226
|
+
InvalidPermissions: "Edit permissions require corresponding view permissions",
|
|
227
|
+
GroupNotFound: "Permission group not found",
|
|
228
|
+
GroupIdExists: "A permission group with this ID already exists",
|
|
229
|
+
InvalidConfig: "Invalid engine configuration"
|
|
230
|
+
};
|
|
231
|
+
var DEFAULTS = {
|
|
232
|
+
ListPageSize: 20,
|
|
233
|
+
MaxListPageSize: 100,
|
|
234
|
+
PermissionCacheTtlMs: 5 * 60 * 1e3
|
|
235
|
+
};
|
|
236
|
+
var DEFAULT_AUTH = {
|
|
237
|
+
staffTokenExpiry: "24h",
|
|
238
|
+
ownerTokenExpiry: "30d",
|
|
239
|
+
permissionCacheTtlMs: 5 * 60 * 1e3
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// src/errors/index.ts
|
|
243
|
+
var AlxStaffError = class extends AlxError {
|
|
244
|
+
constructor(message, code, context) {
|
|
245
|
+
super(message, code);
|
|
246
|
+
this.context = context;
|
|
247
|
+
this.name = "AlxStaffError";
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
var AuthenticationError = class extends AlxStaffError {
|
|
251
|
+
constructor(code = ERROR_CODE.InvalidCredentials, message) {
|
|
252
|
+
super(message || ERROR_MESSAGE.InvalidCredentials, code);
|
|
253
|
+
this.name = "AuthenticationError";
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
var AuthorizationError = class extends AlxStaffError {
|
|
257
|
+
constructor(code = ERROR_CODE.InsufficientPermissions, message) {
|
|
258
|
+
super(message || ERROR_MESSAGE.InsufficientPermissions, code);
|
|
259
|
+
this.name = "AuthorizationError";
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
var RateLimitError = class extends AlxStaffError {
|
|
263
|
+
constructor(retryAfterMs) {
|
|
264
|
+
super(ERROR_MESSAGE.RateLimited, ERROR_CODE.RateLimited, { retryAfterMs });
|
|
265
|
+
this.retryAfterMs = retryAfterMs;
|
|
266
|
+
this.name = "RateLimitError";
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
var TokenError = class extends AlxStaffError {
|
|
270
|
+
constructor(code = ERROR_CODE.TokenInvalid, message) {
|
|
271
|
+
super(message || ERROR_MESSAGE.TokenInvalid, code);
|
|
272
|
+
this.name = "TokenError";
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
var StaffNotFoundError = class extends AlxStaffError {
|
|
276
|
+
constructor(staffId) {
|
|
277
|
+
super(ERROR_MESSAGE.StaffNotFound, ERROR_CODE.StaffNotFound, { staffId });
|
|
278
|
+
this.staffId = staffId;
|
|
279
|
+
this.name = "StaffNotFoundError";
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
var DuplicateError = class extends AlxStaffError {
|
|
283
|
+
constructor(code, message, context) {
|
|
284
|
+
super(message, code, context);
|
|
285
|
+
this.name = "DuplicateError";
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
var SetupError = class extends AlxStaffError {
|
|
289
|
+
constructor() {
|
|
290
|
+
super(ERROR_MESSAGE.SetupAlreadyComplete, ERROR_CODE.SetupAlreadyComplete);
|
|
291
|
+
this.name = "SetupError";
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
var LastOwnerError = class extends AlxStaffError {
|
|
295
|
+
constructor(staffId) {
|
|
296
|
+
super(ERROR_MESSAGE.LastOwnerGuard, ERROR_CODE.LastOwnerGuard, { staffId });
|
|
297
|
+
this.staffId = staffId;
|
|
298
|
+
this.name = "LastOwnerError";
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
var InvalidPermissionError = class extends AlxStaffError {
|
|
302
|
+
constructor(missingViewKeys) {
|
|
303
|
+
super(ERROR_MESSAGE.InvalidPermissions, ERROR_CODE.InvalidPermissions, { missingViewKeys });
|
|
304
|
+
this.missingViewKeys = missingViewKeys;
|
|
305
|
+
this.name = "InvalidPermissionError";
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
var GroupNotFoundError = class extends AlxStaffError {
|
|
309
|
+
constructor(groupId) {
|
|
310
|
+
super(ERROR_MESSAGE.GroupNotFound, ERROR_CODE.GroupNotFound, { groupId });
|
|
311
|
+
this.groupId = groupId;
|
|
312
|
+
this.name = "GroupNotFoundError";
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
var InvalidConfigError = class extends AlxStaffError {
|
|
316
|
+
constructor(field, reason) {
|
|
317
|
+
super(`Invalid config for "${field}": ${reason}`, ERROR_CODE.InvalidConfig, { field, reason });
|
|
318
|
+
this.field = field;
|
|
319
|
+
this.reason = reason;
|
|
320
|
+
this.name = "InvalidConfigError";
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// src/services/permission.service.ts
|
|
325
|
+
var PermissionService = class {
|
|
326
|
+
constructor(PermissionGroup, permissionCache, logger, tenantId) {
|
|
327
|
+
this.PermissionGroup = PermissionGroup;
|
|
328
|
+
this.permissionCache = permissionCache;
|
|
329
|
+
this.logger = logger;
|
|
330
|
+
this.tenantId = tenantId;
|
|
331
|
+
}
|
|
332
|
+
get tenantFilter() {
|
|
333
|
+
return this.tenantId ? { tenantId: this.tenantId } : {};
|
|
334
|
+
}
|
|
335
|
+
async listGroups() {
|
|
336
|
+
return this.PermissionGroup.find(this.tenantFilter).sort({ sortOrder: 1 }).lean();
|
|
337
|
+
}
|
|
338
|
+
async createGroup(data) {
|
|
339
|
+
const existing = await this.PermissionGroup.findOne({
|
|
340
|
+
groupId: data.groupId,
|
|
341
|
+
...this.tenantFilter
|
|
342
|
+
});
|
|
343
|
+
if (existing) {
|
|
344
|
+
throw new DuplicateError(
|
|
345
|
+
ERROR_CODE.GroupIdExists,
|
|
346
|
+
ERROR_MESSAGE.GroupIdExists,
|
|
347
|
+
{ groupId: data.groupId }
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
const group = await this.PermissionGroup.create({
|
|
351
|
+
...data,
|
|
352
|
+
sortOrder: data.sortOrder ?? 0,
|
|
353
|
+
...this.tenantFilter
|
|
354
|
+
});
|
|
355
|
+
this.logger.info("Permission group created", { groupId: data.groupId });
|
|
356
|
+
return group.toObject();
|
|
357
|
+
}
|
|
358
|
+
async updateGroup(groupId, data) {
|
|
359
|
+
const group = await this.PermissionGroup.findOneAndUpdate(
|
|
360
|
+
{ groupId, ...this.tenantFilter },
|
|
361
|
+
{ $set: data },
|
|
362
|
+
{ new: true }
|
|
363
|
+
).lean();
|
|
364
|
+
if (!group) {
|
|
365
|
+
throw new GroupNotFoundError(groupId);
|
|
366
|
+
}
|
|
367
|
+
await this.permissionCache.invalidateAll();
|
|
368
|
+
this.logger.info("Permission group updated", { groupId, fields: Object.keys(data) });
|
|
369
|
+
return group;
|
|
370
|
+
}
|
|
371
|
+
async deleteGroup(groupId) {
|
|
372
|
+
const result = await this.PermissionGroup.deleteOne({ groupId, ...this.tenantFilter });
|
|
373
|
+
if (result.deletedCount === 0) {
|
|
374
|
+
throw new GroupNotFoundError(groupId);
|
|
375
|
+
}
|
|
376
|
+
await this.permissionCache.invalidateAll();
|
|
377
|
+
this.logger.info("Permission group deleted", { groupId });
|
|
378
|
+
}
|
|
379
|
+
async getAllPermissionKeys() {
|
|
380
|
+
const groups = await this.listGroups();
|
|
381
|
+
return groups.flatMap((g) => g.permissions.map((p) => p.key));
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
function validatePermissionPairs(permissions, allGroups) {
|
|
385
|
+
const allEntries = allGroups.flatMap((g) => g.permissions);
|
|
386
|
+
const editKeys = allEntries.filter((e) => e.type === PERMISSION_TYPE.Edit).map((e) => e.key);
|
|
387
|
+
const permissionSet = new Set(permissions);
|
|
388
|
+
const missingViewKeys = [];
|
|
389
|
+
for (const editKey of editKeys) {
|
|
390
|
+
if (!permissionSet.has(editKey)) continue;
|
|
391
|
+
const prefix = editKey.substring(0, editKey.lastIndexOf(":"));
|
|
392
|
+
const viewKey = `${prefix}:view`;
|
|
393
|
+
const viewEntry = allEntries.find((e) => e.key === viewKey && e.type === PERMISSION_TYPE.View);
|
|
394
|
+
if (viewEntry && !permissionSet.has(viewKey)) {
|
|
395
|
+
missingViewKeys.push(viewKey);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (missingViewKeys.length > 0) {
|
|
399
|
+
throw new InvalidPermissionError(missingViewKeys);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/services/staff.service.ts
|
|
404
|
+
var StaffService = class {
|
|
405
|
+
Staff;
|
|
406
|
+
PermissionGroup;
|
|
407
|
+
adapters;
|
|
408
|
+
hooks;
|
|
409
|
+
permissionCache;
|
|
410
|
+
rateLimiter;
|
|
411
|
+
logger;
|
|
412
|
+
tenantId;
|
|
413
|
+
jwtSecret;
|
|
414
|
+
staffTokenExpiry;
|
|
415
|
+
ownerTokenExpiry;
|
|
416
|
+
requireEmailUniqueness;
|
|
417
|
+
allowSelfPasswordChange;
|
|
418
|
+
constructor(deps) {
|
|
419
|
+
this.Staff = deps.Staff;
|
|
420
|
+
this.PermissionGroup = deps.PermissionGroup;
|
|
421
|
+
this.adapters = deps.adapters;
|
|
422
|
+
this.hooks = deps.hooks;
|
|
423
|
+
this.permissionCache = deps.permissionCache;
|
|
424
|
+
this.rateLimiter = deps.rateLimiter;
|
|
425
|
+
this.logger = deps.logger;
|
|
426
|
+
this.tenantId = deps.tenantId;
|
|
427
|
+
this.jwtSecret = deps.jwtSecret;
|
|
428
|
+
this.staffTokenExpiry = deps.staffTokenExpiry;
|
|
429
|
+
this.ownerTokenExpiry = deps.ownerTokenExpiry;
|
|
430
|
+
this.requireEmailUniqueness = deps.requireEmailUniqueness;
|
|
431
|
+
this.allowSelfPasswordChange = deps.allowSelfPasswordChange;
|
|
432
|
+
}
|
|
433
|
+
get tenantFilter() {
|
|
434
|
+
return this.tenantId ? { tenantId: this.tenantId } : {};
|
|
435
|
+
}
|
|
436
|
+
generateToken(staffId, role) {
|
|
437
|
+
const expiresIn = role === STAFF_ROLE.Owner ? this.ownerTokenExpiry : this.staffTokenExpiry;
|
|
438
|
+
return jwt2.sign({ staffId, role }, this.jwtSecret, { expiresIn });
|
|
439
|
+
}
|
|
440
|
+
async setupOwner(data) {
|
|
441
|
+
const count = await this.Staff.countDocuments(this.tenantFilter);
|
|
442
|
+
if (count > 0) throw new SetupError();
|
|
443
|
+
const hashedPassword = await this.adapters.hashPassword(data.password);
|
|
444
|
+
let staff;
|
|
445
|
+
try {
|
|
446
|
+
const doc = await this.Staff.create({
|
|
447
|
+
name: data.name,
|
|
448
|
+
email: data.email.toLowerCase().trim(),
|
|
449
|
+
password: hashedPassword,
|
|
450
|
+
role: STAFF_ROLE.Owner,
|
|
451
|
+
status: STAFF_STATUS.Active,
|
|
452
|
+
permissions: [],
|
|
453
|
+
...this.tenantFilter
|
|
454
|
+
});
|
|
455
|
+
staff = doc.toObject();
|
|
456
|
+
} catch (err) {
|
|
457
|
+
if (err && typeof err === "object" && "code" in err && err.code === 11e3) {
|
|
458
|
+
throw new SetupError();
|
|
459
|
+
}
|
|
460
|
+
throw err;
|
|
461
|
+
}
|
|
462
|
+
const token = this.generateToken(staff._id.toString(), STAFF_ROLE.Owner);
|
|
463
|
+
this.logger.info("Owner setup complete", { staffId: staff._id.toString() });
|
|
464
|
+
this.hooks.onStaffCreated?.(staff);
|
|
465
|
+
this.hooks.onMetric?.({ name: "staff_setup_complete", value: 1 });
|
|
466
|
+
return { staff, token };
|
|
467
|
+
}
|
|
468
|
+
async login(email, password, ip) {
|
|
469
|
+
if (ip) {
|
|
470
|
+
const limit = await this.rateLimiter.checkLimit(ip);
|
|
471
|
+
if (!limit.allowed) {
|
|
472
|
+
this.hooks.onLoginFailed?.(email, ip);
|
|
473
|
+
throw new RateLimitError(limit.retryAfterMs);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const staff = await this.Staff.findOne({
|
|
477
|
+
email: email.toLowerCase().trim(),
|
|
478
|
+
...this.tenantFilter
|
|
479
|
+
}).select("+password");
|
|
480
|
+
if (!staff) {
|
|
481
|
+
if (ip) await this.rateLimiter.recordAttempt(ip);
|
|
482
|
+
this.hooks.onLoginFailed?.(email, ip);
|
|
483
|
+
throw new AuthenticationError(ERROR_CODE.InvalidCredentials);
|
|
484
|
+
}
|
|
485
|
+
const valid = await this.adapters.comparePassword(password, staff.password);
|
|
486
|
+
if (!valid) {
|
|
487
|
+
if (ip) await this.rateLimiter.recordAttempt(ip);
|
|
488
|
+
this.hooks.onLoginFailed?.(email, ip);
|
|
489
|
+
throw new AuthenticationError(ERROR_CODE.InvalidCredentials);
|
|
490
|
+
}
|
|
491
|
+
if (staff.status === STAFF_STATUS.Inactive) {
|
|
492
|
+
throw new AuthenticationError(ERROR_CODE.AccountInactive, ERROR_MESSAGE.AccountInactive);
|
|
493
|
+
}
|
|
494
|
+
if (staff.status === STAFF_STATUS.Pending) {
|
|
495
|
+
throw new AuthenticationError(ERROR_CODE.AccountPending, ERROR_MESSAGE.AccountPending);
|
|
496
|
+
}
|
|
497
|
+
staff.lastLoginAt = /* @__PURE__ */ new Date();
|
|
498
|
+
if (ip) staff.lastLoginIp = ip;
|
|
499
|
+
await staff.save();
|
|
500
|
+
if (ip) await this.rateLimiter.reset(ip);
|
|
501
|
+
const token = this.generateToken(staff._id.toString(), staff.role);
|
|
502
|
+
this.hooks.onLogin?.(staff.toObject(), ip);
|
|
503
|
+
this.hooks.onMetric?.({ name: "staff_login", value: 1, labels: { role: staff.role } });
|
|
504
|
+
this.logger.info("Staff login", { staffId: staff._id.toString() });
|
|
505
|
+
const staffObj = staff.toObject();
|
|
506
|
+
delete staffObj.password;
|
|
507
|
+
return { staff: staffObj, token };
|
|
508
|
+
}
|
|
509
|
+
async create(data) {
|
|
510
|
+
if (this.requireEmailUniqueness) {
|
|
511
|
+
const existing = await this.Staff.findOne({
|
|
512
|
+
email: data.email.toLowerCase().trim(),
|
|
513
|
+
...this.tenantFilter
|
|
514
|
+
});
|
|
515
|
+
if (existing) {
|
|
516
|
+
throw new DuplicateError(ERROR_CODE.EmailExists, ERROR_MESSAGE.EmailExists, { email: data.email });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const hashedPassword = await this.adapters.hashPassword(data.password);
|
|
520
|
+
const staff = await this.Staff.create({
|
|
521
|
+
...data,
|
|
522
|
+
email: data.email.toLowerCase().trim(),
|
|
523
|
+
password: hashedPassword,
|
|
524
|
+
role: data.role ?? STAFF_ROLE.Staff,
|
|
525
|
+
status: data.status ?? STAFF_STATUS.Pending,
|
|
526
|
+
permissions: data.permissions ?? [],
|
|
527
|
+
...this.tenantFilter
|
|
528
|
+
});
|
|
529
|
+
this.logger.info("Staff created", { staffId: staff._id.toString() });
|
|
530
|
+
this.hooks.onStaffCreated?.(staff.toObject());
|
|
531
|
+
return staff.toObject();
|
|
532
|
+
}
|
|
533
|
+
async list(filters = {}) {
|
|
534
|
+
const page = Math.max(1, filters.page ?? 1);
|
|
535
|
+
const limit = Math.min(filters.limit ?? DEFAULTS.ListPageSize, DEFAULTS.MaxListPageSize);
|
|
536
|
+
const query = { ...this.tenantFilter };
|
|
537
|
+
if (filters.status) query.status = filters.status;
|
|
538
|
+
if (filters.role) query.role = filters.role;
|
|
539
|
+
const [data, total] = await Promise.all([
|
|
540
|
+
this.Staff.find(query).sort({ createdAt: -1 }).skip((page - 1) * limit).limit(limit).lean(),
|
|
541
|
+
this.Staff.countDocuments(query)
|
|
542
|
+
]);
|
|
543
|
+
return {
|
|
544
|
+
data,
|
|
545
|
+
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
async getById(staffId) {
|
|
549
|
+
const staff = await this.Staff.findOne({ _id: staffId, ...this.tenantFilter }).lean();
|
|
550
|
+
if (!staff) throw new StaffNotFoundError(staffId);
|
|
551
|
+
return staff;
|
|
552
|
+
}
|
|
553
|
+
async update(staffId, data) {
|
|
554
|
+
const updateData = {};
|
|
555
|
+
if (data.name !== void 0) updateData.name = data.name;
|
|
556
|
+
if (data.email !== void 0) updateData.email = data.email.toLowerCase().trim();
|
|
557
|
+
if (data.metadata !== void 0) updateData.metadata = data.metadata;
|
|
558
|
+
if (data.email && this.requireEmailUniqueness) {
|
|
559
|
+
const existing = await this.Staff.findOne({
|
|
560
|
+
email: data.email.toLowerCase().trim(),
|
|
561
|
+
_id: { $ne: staffId },
|
|
562
|
+
...this.tenantFilter
|
|
563
|
+
});
|
|
564
|
+
if (existing) {
|
|
565
|
+
throw new DuplicateError(ERROR_CODE.EmailExists, ERROR_MESSAGE.EmailExists, { email: data.email });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const staff = await this.Staff.findOneAndUpdate(
|
|
569
|
+
{ _id: staffId, ...this.tenantFilter },
|
|
570
|
+
{ $set: updateData },
|
|
571
|
+
{ new: true }
|
|
572
|
+
).lean();
|
|
573
|
+
if (!staff) throw new StaffNotFoundError(staffId);
|
|
574
|
+
this.logger.info("Staff updated", { staffId, fields: Object.keys(updateData) });
|
|
575
|
+
return staff;
|
|
576
|
+
}
|
|
577
|
+
async updatePermissions(staffId, permissions) {
|
|
578
|
+
const groups = await this.PermissionGroup.find(this.tenantFilter).lean();
|
|
579
|
+
validatePermissionPairs(permissions, groups);
|
|
580
|
+
const staff = await this.Staff.findOne({ _id: staffId, ...this.tenantFilter });
|
|
581
|
+
if (!staff) throw new StaffNotFoundError(staffId);
|
|
582
|
+
const oldPerms = [...staff.permissions];
|
|
583
|
+
staff.permissions = permissions;
|
|
584
|
+
await staff.save();
|
|
585
|
+
await this.permissionCache.invalidate(staffId);
|
|
586
|
+
this.hooks.onPermissionsChanged?.(staffId, oldPerms, permissions);
|
|
587
|
+
this.logger.info("Staff permissions updated", { staffId, count: permissions.length });
|
|
588
|
+
return staff.toObject();
|
|
589
|
+
}
|
|
590
|
+
async updateStatus(staffId, status) {
|
|
591
|
+
const staff = await this.Staff.findOne({ _id: staffId, ...this.tenantFilter });
|
|
592
|
+
if (!staff) throw new StaffNotFoundError(staffId);
|
|
593
|
+
if (status === STAFF_STATUS.Inactive && staff.role === STAFF_ROLE.Owner) {
|
|
594
|
+
const activeOwnerCount = await this.Staff.countDocuments({
|
|
595
|
+
role: STAFF_ROLE.Owner,
|
|
596
|
+
status: STAFF_STATUS.Active,
|
|
597
|
+
...this.tenantFilter
|
|
598
|
+
});
|
|
599
|
+
if (activeOwnerCount <= 1) throw new LastOwnerError(staffId);
|
|
600
|
+
}
|
|
601
|
+
const oldStatus = staff.status;
|
|
602
|
+
staff.status = status;
|
|
603
|
+
await staff.save();
|
|
604
|
+
await this.permissionCache.invalidate(staffId);
|
|
605
|
+
this.hooks.onStatusChanged?.(staffId, oldStatus, status);
|
|
606
|
+
this.logger.info("Staff status updated", { staffId, oldStatus, newStatus: status });
|
|
607
|
+
return staff.toObject();
|
|
608
|
+
}
|
|
609
|
+
async resetPassword(staffId, newPassword) {
|
|
610
|
+
const staff = await this.Staff.findOne({ _id: staffId, ...this.tenantFilter });
|
|
611
|
+
if (!staff) throw new StaffNotFoundError(staffId);
|
|
612
|
+
staff.password = await this.adapters.hashPassword(newPassword);
|
|
613
|
+
await staff.save();
|
|
614
|
+
this.logger.info("Staff password reset", { staffId });
|
|
615
|
+
}
|
|
616
|
+
async changeOwnPassword(staffId, oldPassword, newPassword) {
|
|
617
|
+
if (!this.allowSelfPasswordChange) {
|
|
618
|
+
throw new AuthenticationError(ERROR_CODE.InsufficientPermissions, "Self password change is disabled");
|
|
619
|
+
}
|
|
620
|
+
const staff = await this.Staff.findOne({ _id: staffId, ...this.tenantFilter }).select("+password");
|
|
621
|
+
if (!staff) throw new StaffNotFoundError(staffId);
|
|
622
|
+
const valid = await this.adapters.comparePassword(oldPassword, staff.password);
|
|
623
|
+
if (!valid) throw new AuthenticationError(ERROR_CODE.InvalidCredentials, "Current password is incorrect");
|
|
624
|
+
staff.password = await this.adapters.hashPassword(newPassword);
|
|
625
|
+
await staff.save();
|
|
626
|
+
this.logger.info("Staff changed own password", { staffId });
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
function createAuthMiddleware(jwtSecret, permissionCache, StaffModel, logger, tenantId) {
|
|
630
|
+
async function resolveStaffFromToken(token) {
|
|
631
|
+
try {
|
|
632
|
+
const payload = jwt2.verify(token, jwtSecret);
|
|
633
|
+
if (!payload.staffId || !payload.role) return null;
|
|
634
|
+
const filter = { _id: payload.staffId };
|
|
635
|
+
if (tenantId) filter.tenantId = tenantId;
|
|
636
|
+
const staff = await StaffModel.findOne(filter).select("status role").lean();
|
|
637
|
+
if (!staff) return null;
|
|
638
|
+
if (staff.status !== STAFF_STATUS.Active) return null;
|
|
639
|
+
const permissions = await permissionCache.get(payload.staffId);
|
|
640
|
+
return { staffId: payload.staffId, role: staff.role, permissions };
|
|
641
|
+
} catch {
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
const verifyToken = async (req, res, next) => {
|
|
646
|
+
const authHeader = req.headers.authorization;
|
|
647
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
648
|
+
res.status(401).json({ success: false, error: ERROR_MESSAGE.TokenInvalid, code: ERROR_CODE.TokenInvalid });
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const token = authHeader.slice(7);
|
|
652
|
+
try {
|
|
653
|
+
jwt2.verify(token, jwtSecret);
|
|
654
|
+
} catch (err) {
|
|
655
|
+
if (err instanceof jwt2.TokenExpiredError) {
|
|
656
|
+
res.status(401).json({ success: false, error: ERROR_MESSAGE.TokenExpired, code: ERROR_CODE.TokenExpired });
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
res.status(401).json({ success: false, error: ERROR_MESSAGE.TokenInvalid, code: ERROR_CODE.TokenInvalid });
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const user = await resolveStaffFromToken(token);
|
|
663
|
+
if (!user) {
|
|
664
|
+
res.status(401).json({ success: false, error: ERROR_MESSAGE.TokenInvalid, code: ERROR_CODE.TokenInvalid });
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
req.user = user;
|
|
668
|
+
next();
|
|
669
|
+
};
|
|
670
|
+
function requirePermission(...keys) {
|
|
671
|
+
return (req, res, next) => {
|
|
672
|
+
const user = req.user;
|
|
673
|
+
if (!user) {
|
|
674
|
+
res.status(401).json({ success: false, error: ERROR_MESSAGE.TokenInvalid, code: ERROR_CODE.TokenInvalid });
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if (user.role === STAFF_ROLE.Owner) {
|
|
678
|
+
next();
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const permSet = new Set(user.permissions);
|
|
682
|
+
const missing = [];
|
|
683
|
+
for (const key of keys) {
|
|
684
|
+
if (!permSet.has(key)) {
|
|
685
|
+
missing.push(key);
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
if (key.endsWith(":edit")) {
|
|
689
|
+
const prefix = key.substring(0, key.lastIndexOf(":"));
|
|
690
|
+
const viewKey = `${prefix}:view`;
|
|
691
|
+
if (!permSet.has(viewKey)) {
|
|
692
|
+
missing.push(viewKey);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (missing.length > 0) {
|
|
697
|
+
res.status(403).json({
|
|
698
|
+
success: false,
|
|
699
|
+
error: ERROR_MESSAGE.InsufficientPermissions,
|
|
700
|
+
code: ERROR_CODE.InsufficientPermissions,
|
|
701
|
+
missing
|
|
702
|
+
});
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
next();
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
const ownerOnly = (req, res, next) => {
|
|
709
|
+
const user = req.user;
|
|
710
|
+
if (!user || user.role !== STAFF_ROLE.Owner) {
|
|
711
|
+
res.status(403).json({ success: false, error: ERROR_MESSAGE.OwnerOnly, code: ERROR_CODE.OwnerOnly });
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
next();
|
|
715
|
+
};
|
|
716
|
+
function requireRole(...roles) {
|
|
717
|
+
return (req, res, next) => {
|
|
718
|
+
const user = req.user;
|
|
719
|
+
if (!user || !roles.includes(user.role)) {
|
|
720
|
+
res.status(403).json({ success: false, error: ERROR_MESSAGE.InsufficientPermissions, code: ERROR_CODE.InsufficientPermissions });
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
next();
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
return {
|
|
727
|
+
verifyToken,
|
|
728
|
+
resolveStaff: resolveStaffFromToken,
|
|
729
|
+
requirePermission,
|
|
730
|
+
ownerOnly,
|
|
731
|
+
requireRole
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
function sendStaffError(res, error, status) {
|
|
735
|
+
res.status(status).json({ success: false, error: error.message, code: error.code });
|
|
736
|
+
}
|
|
737
|
+
function handleStaffError(res, error, logger) {
|
|
738
|
+
if (error instanceof RateLimitError) {
|
|
739
|
+
res.set("Retry-After", String(Math.ceil(error.retryAfterMs / 1e3)));
|
|
740
|
+
sendStaffError(res, error, 429);
|
|
741
|
+
} else if (error instanceof AuthenticationError || error instanceof TokenError) {
|
|
742
|
+
sendStaffError(res, error, 401);
|
|
743
|
+
} else if (error instanceof AuthorizationError || error instanceof SetupError) {
|
|
744
|
+
sendStaffError(res, error, 403);
|
|
745
|
+
} else if (error instanceof StaffNotFoundError || error instanceof GroupNotFoundError) {
|
|
746
|
+
sendStaffError(res, error, 404);
|
|
747
|
+
} else if (error instanceof DuplicateError) {
|
|
748
|
+
sendStaffError(res, error, 409);
|
|
749
|
+
} else if (error instanceof LastOwnerError || error instanceof InvalidPermissionError) {
|
|
750
|
+
sendStaffError(res, error, 400);
|
|
751
|
+
} else if (error instanceof AlxStaffError) {
|
|
752
|
+
sendStaffError(res, error, 400);
|
|
753
|
+
} else {
|
|
754
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
755
|
+
logger.error("Unexpected error", { error: message });
|
|
756
|
+
sendError(res, message, 500);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// src/routes/auth.routes.ts
|
|
761
|
+
function createAuthRoutes(staffService, auth, logger, allowSelfPasswordChange) {
|
|
762
|
+
const router = Router();
|
|
763
|
+
router.post("/setup", async (req, res) => {
|
|
764
|
+
try {
|
|
765
|
+
const result = await staffService.setupOwner(req.body);
|
|
766
|
+
sendSuccess(res, result, 201);
|
|
767
|
+
} catch (error) {
|
|
768
|
+
handleStaffError(res, error, logger);
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
router.post("/login", async (req, res) => {
|
|
772
|
+
try {
|
|
773
|
+
const { email, password } = req.body;
|
|
774
|
+
const ip = req.ip || req.socket.remoteAddress || "";
|
|
775
|
+
const result = await staffService.login(email, password, ip);
|
|
776
|
+
sendSuccess(res, result);
|
|
777
|
+
} catch (error) {
|
|
778
|
+
handleStaffError(res, error, logger);
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
router.get("/me", auth.verifyToken, async (req, res) => {
|
|
782
|
+
try {
|
|
783
|
+
const user = req.user;
|
|
784
|
+
const staff = await staffService.getById(user.staffId);
|
|
785
|
+
sendSuccess(res, { staff, permissions: user.permissions });
|
|
786
|
+
} catch (error) {
|
|
787
|
+
handleStaffError(res, error, logger);
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
if (allowSelfPasswordChange) {
|
|
791
|
+
router.put("/me/password", auth.verifyToken, async (req, res) => {
|
|
792
|
+
try {
|
|
793
|
+
const user = req.user;
|
|
794
|
+
const { oldPassword, newPassword } = req.body;
|
|
795
|
+
await staffService.changeOwnPassword(user.staffId, oldPassword, newPassword);
|
|
796
|
+
sendSuccess(res, { message: "Password changed successfully" });
|
|
797
|
+
} catch (error) {
|
|
798
|
+
handleStaffError(res, error, logger);
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
return router;
|
|
803
|
+
}
|
|
804
|
+
function createStaffRoutes(staffService, logger) {
|
|
805
|
+
const router = Router();
|
|
806
|
+
router.get("/", async (req, res) => {
|
|
807
|
+
try {
|
|
808
|
+
const query = req.query;
|
|
809
|
+
const filters = {};
|
|
810
|
+
if (query["status"]) filters["status"] = query["status"];
|
|
811
|
+
if (query["role"]) filters["role"] = query["role"];
|
|
812
|
+
if (query["page"]) filters["page"] = parseInt(query["page"], 10);
|
|
813
|
+
if (query["limit"]) filters["limit"] = parseInt(query["limit"], 10);
|
|
814
|
+
const result = await staffService.list(filters);
|
|
815
|
+
sendSuccess(res, result);
|
|
816
|
+
} catch (error) {
|
|
817
|
+
handleStaffError(res, error, logger);
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
router.post("/", async (req, res) => {
|
|
821
|
+
try {
|
|
822
|
+
const result = await staffService.create(req.body);
|
|
823
|
+
sendSuccess(res, result, 201);
|
|
824
|
+
} catch (error) {
|
|
825
|
+
handleStaffError(res, error, logger);
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
router.put("/:staffId", async (req, res) => {
|
|
829
|
+
try {
|
|
830
|
+
const result = await staffService.update(req.params["staffId"], req.body);
|
|
831
|
+
sendSuccess(res, result);
|
|
832
|
+
} catch (error) {
|
|
833
|
+
handleStaffError(res, error, logger);
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
router.put("/:staffId/permissions", async (req, res) => {
|
|
837
|
+
try {
|
|
838
|
+
const result = await staffService.updatePermissions(
|
|
839
|
+
req.params["staffId"],
|
|
840
|
+
req.body.permissions
|
|
841
|
+
);
|
|
842
|
+
sendSuccess(res, result);
|
|
843
|
+
} catch (error) {
|
|
844
|
+
handleStaffError(res, error, logger);
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
router.put("/:staffId/status", async (req, res) => {
|
|
848
|
+
try {
|
|
849
|
+
const result = await staffService.updateStatus(
|
|
850
|
+
req.params["staffId"],
|
|
851
|
+
req.body.status
|
|
852
|
+
);
|
|
853
|
+
sendSuccess(res, result);
|
|
854
|
+
} catch (error) {
|
|
855
|
+
handleStaffError(res, error, logger);
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
router.put("/:staffId/password", async (req, res) => {
|
|
859
|
+
try {
|
|
860
|
+
await staffService.resetPassword(req.params["staffId"], req.body.password);
|
|
861
|
+
sendSuccess(res, { message: "Password reset successfully" });
|
|
862
|
+
} catch (error) {
|
|
863
|
+
handleStaffError(res, error, logger);
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
return router;
|
|
867
|
+
}
|
|
868
|
+
function createPermissionGroupRoutes(permissionService, auth, logger) {
|
|
869
|
+
const router = Router();
|
|
870
|
+
router.get("/", auth.verifyToken, async (req, res) => {
|
|
871
|
+
try {
|
|
872
|
+
const result = await permissionService.listGroups();
|
|
873
|
+
sendSuccess(res, result);
|
|
874
|
+
} catch (error) {
|
|
875
|
+
handleStaffError(res, error, logger);
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
router.post("/", auth.verifyToken, auth.ownerOnly, async (req, res) => {
|
|
879
|
+
try {
|
|
880
|
+
const result = await permissionService.createGroup(req.body);
|
|
881
|
+
sendSuccess(res, result, 201);
|
|
882
|
+
} catch (error) {
|
|
883
|
+
handleStaffError(res, error, logger);
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
router.put("/:groupId", auth.verifyToken, auth.ownerOnly, async (req, res) => {
|
|
887
|
+
try {
|
|
888
|
+
const result = await permissionService.updateGroup(req.params["groupId"], req.body);
|
|
889
|
+
sendSuccess(res, result);
|
|
890
|
+
} catch (error) {
|
|
891
|
+
handleStaffError(res, error, logger);
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
router.delete("/:groupId", auth.verifyToken, auth.ownerOnly, async (req, res) => {
|
|
895
|
+
try {
|
|
896
|
+
await permissionService.deleteGroup(req.params["groupId"]);
|
|
897
|
+
sendSuccess(res, { message: "Group deleted successfully" });
|
|
898
|
+
} catch (error) {
|
|
899
|
+
handleStaffError(res, error, logger);
|
|
900
|
+
}
|
|
901
|
+
});
|
|
902
|
+
return router;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// src/routes/index.ts
|
|
906
|
+
function createRoutes(services, auth, logger, allowSelfPasswordChange) {
|
|
907
|
+
const router = Router();
|
|
908
|
+
router.use("/", createAuthRoutes(services.staff, auth, logger, allowSelfPasswordChange));
|
|
909
|
+
router.use("/", auth.verifyToken, auth.ownerOnly, createStaffRoutes(services.staff, logger));
|
|
910
|
+
router.use("/permission-groups", createPermissionGroupRoutes(services.permissions, auth, logger));
|
|
911
|
+
return router;
|
|
912
|
+
}
|
|
913
|
+
var StaffEngineConfigSchema = z.object({
|
|
914
|
+
db: z.object({
|
|
915
|
+
connection: z.unknown().refine((v) => v !== void 0 && v !== null, {
|
|
916
|
+
message: "db.connection is required"
|
|
917
|
+
}),
|
|
918
|
+
collectionPrefix: z.string().optional()
|
|
919
|
+
}),
|
|
920
|
+
redis: z.object({
|
|
921
|
+
connection: z.unknown(),
|
|
922
|
+
keyPrefix: z.string().optional()
|
|
923
|
+
}).optional(),
|
|
924
|
+
logger: z.object({
|
|
925
|
+
info: z.function(),
|
|
926
|
+
warn: z.function(),
|
|
927
|
+
error: z.function()
|
|
928
|
+
}).optional(),
|
|
929
|
+
tenantId: z.string().optional(),
|
|
930
|
+
auth: z.object({
|
|
931
|
+
jwtSecret: z.string().min(1),
|
|
932
|
+
staffTokenExpiry: z.string().optional(),
|
|
933
|
+
ownerTokenExpiry: z.string().optional(),
|
|
934
|
+
permissionCacheTtlMs: z.number().int().positive().optional()
|
|
935
|
+
}),
|
|
936
|
+
adapters: z.object({
|
|
937
|
+
hashPassword: z.function(),
|
|
938
|
+
comparePassword: z.function()
|
|
939
|
+
}),
|
|
940
|
+
hooks: z.object({
|
|
941
|
+
onStaffCreated: z.function().optional(),
|
|
942
|
+
onLogin: z.function().optional(),
|
|
943
|
+
onLoginFailed: z.function().optional(),
|
|
944
|
+
onPermissionsChanged: z.function().optional(),
|
|
945
|
+
onStatusChanged: z.function().optional(),
|
|
946
|
+
onMetric: z.function().optional()
|
|
947
|
+
}).optional(),
|
|
948
|
+
options: z.object({
|
|
949
|
+
requireEmailUniqueness: z.boolean().optional(),
|
|
950
|
+
allowSelfPasswordChange: z.boolean().optional(),
|
|
951
|
+
rateLimiter: z.object({
|
|
952
|
+
windowMs: z.number().int().positive().optional(),
|
|
953
|
+
maxAttempts: z.number().int().positive().optional()
|
|
954
|
+
}).optional()
|
|
955
|
+
}).optional()
|
|
956
|
+
});
|
|
957
|
+
function createStaffEngine(config) {
|
|
958
|
+
const parseResult = StaffEngineConfigSchema.safeParse(config);
|
|
959
|
+
if (!parseResult.success) {
|
|
960
|
+
const issues = parseResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ");
|
|
961
|
+
throw new InvalidConfigError("config", issues);
|
|
962
|
+
}
|
|
963
|
+
const resolvedOptions = {
|
|
964
|
+
requireEmailUniqueness: config.options?.requireEmailUniqueness ?? DEFAULT_OPTIONS.requireEmailUniqueness,
|
|
965
|
+
allowSelfPasswordChange: config.options?.allowSelfPasswordChange ?? DEFAULT_OPTIONS.allowSelfPasswordChange,
|
|
966
|
+
rateLimiter: {
|
|
967
|
+
windowMs: config.options?.rateLimiter?.windowMs ?? DEFAULT_OPTIONS.rateLimiter.windowMs,
|
|
968
|
+
maxAttempts: config.options?.rateLimiter?.maxAttempts ?? DEFAULT_OPTIONS.rateLimiter.maxAttempts
|
|
969
|
+
}
|
|
970
|
+
};
|
|
971
|
+
const resolvedAuth = {
|
|
972
|
+
jwtSecret: config.auth.jwtSecret,
|
|
973
|
+
staffTokenExpiry: config.auth.staffTokenExpiry ?? DEFAULT_AUTH.staffTokenExpiry,
|
|
974
|
+
ownerTokenExpiry: config.auth.ownerTokenExpiry ?? DEFAULT_AUTH.ownerTokenExpiry,
|
|
975
|
+
permissionCacheTtlMs: config.auth.permissionCacheTtlMs ?? DEFAULT_AUTH.permissionCacheTtlMs
|
|
976
|
+
};
|
|
977
|
+
const logger = config.logger ?? noopLogger;
|
|
978
|
+
const conn = config.db.connection;
|
|
979
|
+
const prefix = config.db.collectionPrefix;
|
|
980
|
+
const StaffModel = createStaffModel(conn, prefix);
|
|
981
|
+
const PermissionGroupModel = createPermissionGroupModel(conn, prefix);
|
|
982
|
+
const redis = config.redis?.connection ?? null;
|
|
983
|
+
const keyPrefix = config.redis?.keyPrefix ?? "staff:";
|
|
984
|
+
const rateLimiter = new RateLimiterService(
|
|
985
|
+
resolvedOptions.rateLimiter.windowMs,
|
|
986
|
+
resolvedOptions.rateLimiter.maxAttempts,
|
|
987
|
+
redis,
|
|
988
|
+
keyPrefix,
|
|
989
|
+
logger
|
|
990
|
+
);
|
|
991
|
+
const permissionCache = new PermissionCacheService(
|
|
992
|
+
StaffModel,
|
|
993
|
+
resolvedAuth.permissionCacheTtlMs,
|
|
994
|
+
redis,
|
|
995
|
+
keyPrefix,
|
|
996
|
+
logger,
|
|
997
|
+
config.tenantId
|
|
998
|
+
);
|
|
999
|
+
const permissionService = new PermissionService(
|
|
1000
|
+
PermissionGroupModel,
|
|
1001
|
+
permissionCache,
|
|
1002
|
+
logger,
|
|
1003
|
+
config.tenantId
|
|
1004
|
+
);
|
|
1005
|
+
const staffService = new StaffService({
|
|
1006
|
+
Staff: StaffModel,
|
|
1007
|
+
PermissionGroup: PermissionGroupModel,
|
|
1008
|
+
adapters: config.adapters,
|
|
1009
|
+
hooks: config.hooks ?? {},
|
|
1010
|
+
permissionCache,
|
|
1011
|
+
rateLimiter,
|
|
1012
|
+
logger,
|
|
1013
|
+
tenantId: config.tenantId,
|
|
1014
|
+
jwtSecret: resolvedAuth.jwtSecret,
|
|
1015
|
+
staffTokenExpiry: resolvedAuth.staffTokenExpiry,
|
|
1016
|
+
ownerTokenExpiry: resolvedAuth.ownerTokenExpiry,
|
|
1017
|
+
requireEmailUniqueness: resolvedOptions.requireEmailUniqueness,
|
|
1018
|
+
allowSelfPasswordChange: resolvedOptions.allowSelfPasswordChange
|
|
1019
|
+
});
|
|
1020
|
+
const auth = createAuthMiddleware(
|
|
1021
|
+
resolvedAuth.jwtSecret,
|
|
1022
|
+
permissionCache,
|
|
1023
|
+
StaffModel,
|
|
1024
|
+
logger,
|
|
1025
|
+
config.tenantId
|
|
1026
|
+
);
|
|
1027
|
+
const routes = createRoutes(
|
|
1028
|
+
{ staff: staffService, permissions: permissionService },
|
|
1029
|
+
auth,
|
|
1030
|
+
logger,
|
|
1031
|
+
resolvedOptions.allowSelfPasswordChange
|
|
1032
|
+
);
|
|
1033
|
+
async function destroy() {
|
|
1034
|
+
await permissionCache.invalidateAll();
|
|
1035
|
+
logger.info("StaffEngine destroyed");
|
|
1036
|
+
}
|
|
1037
|
+
return {
|
|
1038
|
+
routes,
|
|
1039
|
+
auth,
|
|
1040
|
+
staff: staffService,
|
|
1041
|
+
permissions: permissionService,
|
|
1042
|
+
models: { Staff: StaffModel, PermissionGroup: PermissionGroupModel },
|
|
1043
|
+
destroy
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
export { AlxStaffError, AuthenticationError, AuthorizationError, DEFAULTS, DEFAULT_AUTH, DuplicateError, ERROR_CODE, ERROR_MESSAGE, GroupNotFoundError, InvalidConfigError, InvalidPermissionError, LastOwnerError, PermissionCacheService, PermissionService, RateLimitError, RateLimiterService, SetupError, StaffNotFoundError, StaffService, TokenError, createAuthMiddleware, createPermissionGroupModel, createRoutes, createStaffEngine, createStaffModel, handleStaffError, validatePermissionPairs };
|
|
1048
|
+
//# sourceMappingURL=index.mjs.map
|
|
1049
|
+
//# sourceMappingURL=index.mjs.map
|