@astralibx/staff-engine
v0.2.3
Published
Staff management engine with JWT authentication, runtime-configurable permissions, and CRUD operations
Maintainers
Readme
@astralibx/staff-engine
Staff management backend with JWT authentication, role-based token expiry, IP-based rate limiting, runtime-configurable permission groups, and a REST admin API. No hardcoded permissions -- all groups and entries are defined at runtime via API.
Install
npm install @astralibx/staff-enginePeer Dependencies
| Package | Required |
|---------|----------|
| express | Yes |
| mongoose | Yes |
npm install express mongooseQuick Start
import { createStaffEngine } from '@astralibx/staff-engine';
import mongoose from 'mongoose';
import bcrypt from 'bcryptjs';
import express from 'express';
const app = express();
app.use(express.json());
const connection = mongoose.createConnection('mongodb://localhost:27017/myapp');
const engine = createStaffEngine({
db: { connection },
auth: {
jwtSecret: process.env.JWT_SECRET!,
},
adapters: {
hashPassword: (plain) => bcrypt.hash(plain, 12),
comparePassword: (plain, hash) => bcrypt.compare(plain, hash),
},
});
app.use('/api/staff', engine.routes);
app.listen(3000);Features
Authentication
- JWT with role-based expiry -- login returns a JWT signed with
jwtSecret. Owners getownerTokenExpiry(default30d); staff members getstaffTokenExpiry(default24h). Both are configurable. - Login with IP rate limiting --
POST /logintracks failed attempts per IP using Redis sorted sets or in-memory fallback. Locks out aftermaxAttemptsfailures withinwindowMs(default: 5 attempts / 15 min). ReturnsSTAFF_RATE_LIMITEDon lockout. - Setup route auto-locks --
POST /setupcreates the initial owner account and then permanently locks itself. Any subsequent call returnsSTAFF_SETUP_ALREADY_COMPLETE. This route is always public and never requires a token. - Token distinguishes expired vs invalid --
verifyTokenmiddleware checksTokenExpiredErrorseparately from all other JWT errors and returnsSTAFF_TOKEN_EXPIREDvsSTAFF_TOKEN_INVALIDso clients can show the correct message.
Staff Management
- Create staff with permissions -- owner creates staff with name, email, password hash (via adapter), optional initial permissions, and optional
externalUserIdfor linking to external identity systems. - Email uniqueness -- duplicate email rejected with
STAFF_EMAIL_EXISTS. Can be disabled viarequireEmailUniqueness: false. - Paginated list with filters --
GET /supportsstatus,role,page, andlimitquery params. Returnsdata[]+paginationwith total counts. - Owner-only access -- all staff CRUD routes (
GET /,POST /,PUT /:id,PUT /:id/permissions,PUT /:id/status,PUT /:id/password) require owner role.GET /meandPUT /me/passwordare staff-accessible. - No-delete policy -- staff records are never hard-deleted. Deactivate to revoke access. Inactive staff tokens are rejected on every authenticated request even before JWT expiry.
Permissions
- Runtime-configurable groups via API --
POST /permission-groupscreates a named group with permission entries. Groups havegroupId,label,sortOrder, and an array of entries (key, label, type). No redeploy required to define new permissions.
Note: Permission groups use
labelfor display text andgroupIdfor the unique identifier -- notname.
await engine.permissions.createGroup({
groupId: 'chat-management', // unique identifier, kebab-case
label: 'Chat Management', // display text shown in UI
permissions: [
{ key: 'chat:view', label: 'View chats', type: 'view' },
{ key: 'chat:edit', label: 'Edit chats', type: 'edit' },
],
sortOrder: 1,
});- Edit-to-view cascade -- when granting a permission ending in
.edit, the corresponding.viewkey is automatically required. The engine validates this onPUT /:id/permissions. - Permission cache (Redis or in-memory) -- each staff member's resolved permission list is cached after the first lookup. TTL is
permissionCacheTtlMs(default 5 min). Cache is invalidated immediately onupdatePermissionsorupdateStatus. - Owner bypasses all checks --
requirePermissionmiddleware skips the permission check entirely whenreq.user.role === 'owner'.
Security
- Rate limiting (configurable window/max) --
windowMsandmaxAttemptsare configurable at engine creation time. Uses RedisZADD/ZREMRANGEBYSCOREfor accurate per-IP tracking across multiple processes. - Last-owner guard --
PUT /:id/statuswithstatus: 'inactive'checks that at least one other active owner exists before allowing the change. ReturnsSTAFF_LAST_OWNER_GUARDif blocked. - Inactive token rejection -- every call through
verifyTokenre-reads staff status from MongoDB (with optional tenant filter). Inactive or pending accounts are rejected withSTAFF_TOKEN_INVALIDeven with an otherwise valid JWT. - Status checks on every request --
verifyTokenloads status and role fresh on each request. There is no session store; the database is the source of truth.
Integration
resolveStafffor programmatic token resolution --engine.auth.resolveStaff(token)returns{ staffId, role, permissions }ornullwithout sending an HTTP response. Useful for WebSocket authentication or programmatic access in other modules.requirePermissionmiddleware for consumer routes --engine.auth.requirePermission('contacts.view', 'contacts.edit')returns an Express middleware that checks the current user's permissions. Owners always pass. Non-owners are rejected withSTAFF_INSUFFICIENT_PERMISSIONSand a list of missing keys.requireRolemiddleware --engine.auth.requireRole('owner', 'staff')checks the token's resolved role against the provided list.
Routes
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | /setup | Public | Create initial owner account (auto-locks after first use) |
| POST | /login | Public | Authenticate and receive JWT token |
| GET | /me | Staff | Get current staff profile and resolved permissions |
| PUT | /me/password | Staff | Change own password (only when allowSelfPasswordChange: true) |
| GET | / | Owner | List staff with pagination and status/role filters |
| POST | / | Owner | Create a new staff member |
| PUT | /:staffId | Owner | Update staff name, email, metadata |
| PUT | /:staffId/permissions | Owner | Replace staff permission set |
| PUT | /:staffId/status | Owner | Activate or deactivate a staff member |
| PUT | /:staffId/password | Owner | Reset a staff member's password |
| GET | /permission-groups | Staff | List all permission groups |
| POST | /permission-groups | Owner | Create a new permission group |
| PUT | /permission-groups/:groupId | Owner | Update a permission group's entries or label |
| DELETE | /permission-groups/:groupId | Owner | Delete a permission group |
Architecture
The factory function returns a single StaffEngine object:
| Export | Purpose |
|--------|---------|
| engine.routes | Express router -- mount at /api/staff or similar |
| engine.auth.verifyToken | Middleware to authenticate any route |
| engine.auth.requirePermission(...keys) | Middleware for permission-gated routes |
| engine.auth.ownerOnly | Middleware to restrict to owner role |
| engine.auth.resolveStaff(token) | Programmatic token resolution (no HTTP response) |
| engine.staff | Direct access to StaffService for programmatic use |
| engine.permissions | Direct access to PermissionService |
| engine.models | Mongoose models (Staff, PermissionGroup) |
| engine.destroy() | Flush permission cache and clean up resources |
Seeding Data
Schema factory functions are exported so you can seed data or run scripts without creating a full engine instance:
import { createStaffModel, createPermissionGroupModel } from '@astralibx/staff-engine';
const Staff = createStaffModel(connection);
const PermissionGroup = createPermissionGroupModel(connection);
await PermissionGroup.create({
groupId: 'admin',
label: 'Admin',
permissions: [
{ key: 'chat:view', label: 'View chats', type: 'view' },
{ key: 'chat:edit', label: 'Edit chats', type: 'edit' },
],
sortOrder: 1,
});Redis Key Prefix (Required for Multi-Project Deployments)
WARNING: If multiple projects share the same Redis server, you MUST set a unique
keyPrefixper project. Without this, rate limiter state and permission cache entries will collide across projects.
const engine = createStaffEngine({
redis: {
connection: redis,
keyPrefix: 'myproject:staff:', // REQUIRED if sharing Redis
},
// ...
});Links
License
MIT
