npm package: @perdieminc/acl
Description: Shared ACL (Access Control List) bitmask helpers and constants for PerDiem
Author: Per Diem INC
Latest version: 2.2.0

# @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   Menu
```

For 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: 9
```

### Predefined 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:**

1. The owner selects specific access levels (None / Read / Write) for each of the 15 permission groups
2. The selections are encoded into a bitmask using `setPermission` or `buildMaskFromArray`
3. The bitmask is saved to the `users.acl` column in the database
4. 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 custom
```

## Installation

This package is hosted on GitHub (not npm). Install it via:

```bash
# Using SSH
npm install git+ssh://git@github.com:PerDiemInc/acl.git

# Using HTTPS
npm install git+https://github.com/PerDiemInc/acl.git

# Specific version/tag
npm install git+ssh://git@github.com:PerDiemInc/acl.git#v1.0.0

# Local development (file reference)
npm install ../acl
```

In `package.json`:
```json
{
  "dependencies": {
    "@perdieminc/acl": "git+ssh://git@github.com:PerDiemInc/acl.git"
  }
}
```

## Usage

### CommonJS (API)

```js
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 column
```

### Creating a Custom Role

```ts
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)

```ts
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:

1. In `src/groups.ts`, add a new entry to `ACL_GROUP` with the next bit offset (current max is 28, so the next would be **30**):

```ts
export const ACL_GROUP = Object.freeze({
  // ... existing groups ...
  DELIVERY: 28 as const,
  NEW_GROUP: 30 as const,  // ← append here
});
```

2. In the same file, add a matching entry to `ACL_GROUP_META`:

```ts
{ key: "NEW_GROUP", label: "New Group", description: "Description", bitOffset: ACL_GROUP.NEW_GROUP },
```

3. In `src/types.ts`, add the new key to `ACLGroupKey` and the new offset to `ACLGroupBitOffset`.

4. In `src/roles.ts`, add a default level for each role in `ROLE_DEFAULTS` (append to each array):

```ts
export const ROLE_DEFAULTS: RoleDefaultsMap = Object.freeze({
  super_owner:     [...existing, WRITE],
  owner:           [...existing, WRITE],
  general_manager: [...existing, READ],
  manager:         [...existing, NONE],
  employee:        [...existing, NONE],
});
```

5. Bump the version in `package.json` and 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 `ORDERS` from offset 4 to offset 6, every user's stored bitmask would now interpret their `CUSTOMERS` permissions as `ORDERS` and 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 groups
- `ACL_GROUP_META` - Array of `{ key, label, description, bitOffset }` metadata
- `ROLE_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 suite
```

## Running Tests

```bash
# 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 check
```

## Development

```bash
# Clone
git clone git@github.com:PerDiemInc/acl.git
cd acl

# Install dev dependencies
npm install

# Build
npm run build

# Run tests
npm test

# Lint & format
npm run check
```

## Development & Release

### Creating a New Release

When you're ready to release a new version:

1. **Update version and create tag** (automatically):
   ```bash
   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.

2. **Push the tag** (triggers automatic publish):
   ```bash
   git push origin master --follow-tags
   ```

3. **GitHub 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:

```bash
npm publish --access public
```

#### Delete a Tag (if you made a mistake)

```bash
# Delete local tag
git tag -d v4.0.2

# Delete remote tag
git push origin --delete v4.0.2
```