@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/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