@perdieminc/acl
v2.2.0
Published
Shared ACL (Access Control List) bitmask helpers and constants for PerDiem
Downloads
436
Readme
@perdieminc/acl
Shared ACL (Access Control List) bitmask library for PerDiem. Used by both the API (Node.js/Hapi) and the Dashboard (Next.js/TypeScript).
How It Works
Permissions are encoded as a bitmask integer stored in a bigint column (64-bit). Each of the 15 permission groups occupies 2 bits, giving three access levels:
| Binary | Decimal | Level |
|--------|---------|-------|
| 00 | 0 | None |
| 01 | 1 | Read |
| 10 | 2 | Write |
The 15 groups are laid out sequentially in the integer:
Bits: [29-28] [27-26] [25-24] ... [3-2] [1-0]
Group: Deliver TeamMgm Settings ... Inv MenuFor example, if a user has READ on Menu Management (offset 0) and WRITE on Inventory (offset 2):
Binary: ...00 10 01
^^ ^^
| └─ Menu Management = 01 (Read)
└──── Inventory = 10 (Write)
Decimal: 9Predefined Roles
Each predefined role (super_owner, owner, general_manager, manager, employee) maps to a fixed array of 15 access levels. These are derived at runtime from ROLE_DEFAULTS — the bitmask is NOT stored in the database for predefined roles.
| Role | Description |
|------|-------------|
| super_owner | Full write access to everything (analytics is read-only) |
| owner | Same as super_owner |
| general_manager | Full write except team management (read) |
| manager | Mixed read/write on operational groups, no settings/team/features |
| employee | Read-only on core groups, no access to admin areas |
| custom | Owner-defined per-group access levels, stored in users.acl column |
Custom Role
The custom role allows owners to define exact access levels per group for a user. Unlike predefined roles where the bitmask is derived at runtime, custom roles store the bitmask integer directly in the users.acl database column.
How it works:
- The owner selects specific access levels (None / Read / Write) for each of the 15 permission groups
- The selections are encoded into a bitmask using
setPermissionorbuildMaskFromArray - The bitmask is saved to the
users.aclcolumn in the database - At runtime,
resolveAcl("custom", user.acl)returns the stored bitmask
Fallback behavior: If a custom user has no stored acl value (null/undefined), the default is all NONE (zero access). This is fail-safe — no permissions are granted until explicitly set by the owner.
Resolution Logic
resolveAcl(role, customAcl):
if customAcl is a number → use it directly
else → buildMaskForRole(role) // predefined defaults, or 0 for customInstallation
This package is hosted on GitHub (not npm). Install it via:
# Using SSH
npm install git+ssh://[email protected]:PerDiemInc/acl.git
# Using HTTPS
npm install git+https://github.com/PerDiemInc/acl.git
# Specific version/tag
npm install git+ssh://[email protected]:PerDiemInc/acl.git#v1.0.0
# Local development (file reference)
npm install ../aclIn package.json:
{
"dependencies": {
"@perdieminc/acl": "git+ssh://[email protected]:PerDiemInc/acl.git"
}
}Usage
CommonJS (API)
const {
ACL_ACCESS_LEVEL,
ACL_GROUP,
hasAccess,
resolveAcl,
setPermission,
} = require("@perdieminc/acl");
// Check if a user can write to orders
const acl = resolveAcl(user.access_level, user.acl);
if (hasAccess(acl, ACL_GROUP.ORDERS, ACL_ACCESS_LEVEL.WRITE)) {
// Allow order modification
}
// Build a custom bitmask
let mask = 0;
mask = setPermission(mask, ACL_GROUP.MENU_MANAGEMENT, ACL_ACCESS_LEVEL.WRITE);
mask = setPermission(mask, ACL_GROUP.ORDERS, ACL_ACCESS_LEVEL.READ);
// Store `mask` in users.acl columnCreating a Custom Role
import {
ACL_ACCESS_LEVEL,
ACL_GROUP,
setPermission,
buildMaskFromArray,
resolveAcl,
hasAccess,
} from "@perdieminc/acl";
const { NONE, READ, WRITE } = ACL_ACCESS_LEVEL;
// Option A: Build incrementally with setPermission
let mask = 0;
mask = setPermission(mask, ACL_GROUP.MENU_MANAGEMENT, WRITE);
mask = setPermission(mask, ACL_GROUP.ORDERS, READ);
mask = setPermission(mask, ACL_GROUP.INVENTORY, READ);
// All other groups default to NONE (no access)
// Option B: Build from a full array of 15 levels (same order as ACL_GROUP_META)
const mask2 = buildMaskFromArray([
WRITE, READ, READ, NONE, NONE, NONE, NONE, NONE,
NONE, NONE, NONE, NONE, NONE, NONE, NONE,
]);
// mask === mask2
// Save to DB: UPDATE users SET access_level = 'custom', acl = $mask WHERE id = $userId
// Later, resolve and check access
const acl = resolveAcl("custom", mask);
hasAccess(acl, ACL_GROUP.MENU_MANAGEMENT, WRITE); // true
hasAccess(acl, ACL_GROUP.ORDERS, READ); // true
hasAccess(acl, ACL_GROUP.ORDERS, WRITE); // false
hasAccess(acl, ACL_GROUP.CUSTOMERS, READ); // false (not granted)TypeScript / ESM (Dashboard)
import {
ACL_ACCESS_LEVEL,
ACL_GROUP,
ACL_GROUP_META,
getPermission,
hasAccess,
resolveAcl,
type ACLAccessLevel,
type ACLGroupKey,
} from "@perdieminc/acl";
// Iterate over all groups
ACL_GROUP_META.forEach((group) => {
const level: ACLAccessLevel = getPermission(userAcl, group.bitOffset);
console.log(`${group.label}: ${level}`);
});Permission Groups
| # | Group Key | Label | Bit Offset |
|---|-----------|-------|------------|
| 0 | MENU_MANAGEMENT | Menu Management | 0 |
| 1 | INVENTORY | Inventory & Stock | 2 |
| 2 | ORDERS | Orders & Catering | 4 |
| 3 | CUSTOMERS | Customers & Store Credit | 6 |
| 4 | LOYALTY | Loyalty, Subscriptions & Rewards | 8 |
| 5 | PROMOTIONS | Promotions & Coupons | 10 |
| 6 | NOTIFICATIONS | Notifications & Emails | 12 |
| 7 | POSTS | Posts & Content | 14 |
| 8 | LOCATIONS | Locations & Business Hours | 16 |
| 9 | ORDER_PLACEMENT | Order Placement | 18 |
| 10 | APP_CUSTOMIZATION | App Customization & Branding | 20 |
| 11 | ANALYTICS | Analytics & Reporting | 22 |
| 12 | SETTINGS | Settings & Payments | 24 |
| 13 | TEAM_MANAGEMENT | Team Management | 26 |
| 14 | DELIVERY | Delivery | 28 |
Adding New Permissions
New permission groups must be appended at the end. Here is how:
- In
src/groups.ts, add a new entry toACL_GROUPwith the next bit offset (current max is 28, so the next would be 30):
export const ACL_GROUP = Object.freeze({
// ... existing groups ...
DELIVERY: 28 as const,
NEW_GROUP: 30 as const, // ← append here
});- In the same file, add a matching entry to
ACL_GROUP_META:
{ key: "NEW_GROUP", label: "New Group", description: "Description", bitOffset: ACL_GROUP.NEW_GROUP },In
src/types.ts, add the new key toACLGroupKeyand the new offset toACLGroupBitOffset.In
src/roles.ts, add a default level for each role inROLE_DEFAULTS(append to each array):
export const ROLE_DEFAULTS: RoleDefaultsMap = Object.freeze({
super_owner: [...existing, WRITE],
owner: [...existing, WRITE],
general_manager: [...existing, READ],
manager: [...existing, NONE],
employee: [...existing, NONE],
});- Bump the version in
package.jsonand update consumers.
Maximum: 32 groups (bigint / 64-bit, 2 bits each). Currently using 15/32 slots.
Why Existing Permissions Cannot Be Changed
The bit offset for each group is its position in the bitmask. Changing an existing group's offset would break every stored ACL integer in the database:
- If you move
ORDERSfrom offset 4 to offset 6, every user's stored bitmask would now interpret theirCUSTOMERSpermissions asORDERSand vice versa. - All existing data would be silently corrupted without any error.
Similarly, you cannot insert a new group between existing ones or reorder them. The bit layout is a contract between the code and the stored data, similar to a protobuf schema where field numbers are immutable once assigned.
What you can do:
- Append new groups at the end (next available offset)
- Deprecate a group by ignoring its bits (set to NONE in defaults), but its offset is permanently reserved
- Rename a group's label/description (the key and offset stay the same)
API Reference
Constants
ACL_ACCESS_LEVEL-{ NONE: 0, READ: 1, WRITE: 2 }ACL_GROUP- Bit offset for each of the 15 permission groupsACL_GROUP_META- Array of{ key, label, description, bitOffset }metadataROLE_DEFAULTS- Default access levels for each predefined role
Functions
| Function | Description |
|----------|-------------|
| getPermission(acl, bitOffset) | Extract 2-bit access level for a group |
| setPermission(acl, bitOffset, level) | Set 2-bit access level for a group |
| buildMaskFromArray(levels) | Build bitmask from array of 15 levels |
| buildMaskForRole(role) | Build bitmask for a predefined role |
| maskToArray(acl) | Decompose bitmask into array of 15 levels |
| hasAccess(acl, bitOffset, requiredLevel) | Check if user meets minimum level |
| resolveAcl(role, customAcl) | Resolve effective ACL (custom or role default) |
| accessLevelLabel(level) | Get human-readable label ("None"/"Read"/"Write") |
Project Structure
src/
├── types.ts # Type definitions (ACLAccessLevel, ACLGroupKey, etc.)
├── access-levels.ts # ACL_ACCESS_LEVEL constant (None/Read/Write)
├── groups.ts # ACL_GROUP bit offsets + ACL_GROUP_META metadata
├── roles.ts # ROLE_DEFAULTS for predefined roles
├── helpers.ts # Bitmask helper functions
└── index.ts # Barrel re-export
test/
└── index.test.ts # Full test suiteRunning Tests
# Run tests (uses tsx + Node.js built-in test runner)
npm test
# Build TypeScript to dist/
npm run build
# Lint with biome
npm run lint
# Format with biome
npm run format
# Check all (lint + format)
npm run checkDevelopment
# Clone
git clone [email protected]:PerDiemInc/acl.git
cd acl
# Install dev dependencies
npm install
# Build
npm run build
# Run tests
npm test
# Lint & format
npm run checkDevelopment & Release
Creating a New Release
When you're ready to release a new version:
Update version and create tag (automatically):
npm version patch # For bug fixes (4.0.1 -> 4.0.2) npm version minor # For new features (4.0.1 -> 4.1.0) npm version major # For breaking changes (4.0.1 -> 5.0.0)This automatically updates
package.json, creates a commit, and creates a git tag.Push the tag (triggers automatic publish):
git push origin master --follow-tagsGitHub Actions automatically:
- ✅ Runs all tests
- ✅ Publishes to npm
- ✅ Creates a GitHub Release with auto-generated notes
Manual Release (if needed)
If you need to publish manually without the automation:
npm publish --access publicDelete a Tag (if you made a mistake)
# Delete local tag
git tag -d v4.0.2
# Delete remote tag
git push origin --delete v4.0.2