├── .commitlintrc.json ├── .editorconfig ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── biome.json ├── package.json ├── pnpm-lock.yaml ├── src ├── constants │ ├── bitmask.ts │ └── permission.ts ├── index.ts ├── integrations │ ├── README.md │ └── express │ │ ├── express.middleware.test.ts │ │ ├── express.middleware.ts │ │ ├── index.ts │ │ └── options.ts ├── permask.test.ts ├── permask.ts ├── types │ └── utils.ts └── utils │ ├── bitmask.test.ts │ ├── bitmask.ts │ ├── object.test.ts │ ├── object.ts │ ├── pack.test.ts │ └── pack.ts ├── tsconfig.json ├── vite.config.ts └── vitest.config.ts /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | tab_width = 2 7 | indent_size = 2 8 | max_line_length = 120 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | permissions: 8 | contents: write # to be able to publish a GitHub release 9 | issues: write # to be able to comment on released issues 10 | pull-requests: write # to be able to comment on released pull requests 11 | id-token: write # to enable use of OIDC for npm provenance 12 | 13 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 14 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: false 18 | 19 | jobs: 20 | release: 21 | name: Release 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | - name: Install pnpm 28 | uses: pnpm/action-setup@v4 29 | with: 30 | version: 10 31 | - name: Use Node.js 32 | uses: actions/setup-node@v4 33 | with: 34 | node-version: 22 35 | cache: 'pnpm' 36 | - name: Install dependencies 37 | run: pnpm install 38 | - name: Build 39 | run: pnpm build 40 | - name: Release 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 44 | run: pnpm dlx semantic-release 45 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | test: 9 | name: Test 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [ 22 ] 14 | 15 | permissions: 16 | # Required to checkout the code 17 | contents: read 18 | # Required to put a comment into the pull-request 19 | pull-requests: write 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Install pnpm 24 | uses: pnpm/action-setup@v4 25 | with: 26 | version: 10 27 | - name: Use Node.js ${{ matrix.node-version }} 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | cache: 'pnpm' 32 | - name: Install dependencies 33 | run: pnpm install 34 | - name: 'Test' 35 | run: pnpm test:coverage 36 | - name: 'Report Coverage' 37 | if: always() # Also generate the report if tests are failing 38 | uses: davelosert/vitest-coverage-report-action@v2 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | coverage 13 | docs 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | ### JetBrains template 27 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 28 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 29 | 30 | # User-specific stuff 31 | .idea/**/workspace.xml 32 | .idea/**/tasks.xml 33 | .idea/**/usage.statistics.xml 34 | .idea/**/dictionaries 35 | .idea/**/shelf 36 | 37 | # AWS User-specific 38 | .idea/**/aws.xml 39 | 40 | # Generated files 41 | .idea/**/contentModel.xml 42 | 43 | # Sensitive or high-churn files 44 | .idea/**/dataSources/ 45 | .idea/**/dataSources.ids 46 | .idea/**/dataSources.local.xml 47 | .idea/**/sqlDataSources.xml 48 | .idea/**/dynamic.xml 49 | .idea/**/uiDesigner.xml 50 | .idea/**/dbnavigator.xml 51 | 52 | # Gradle 53 | .idea/**/gradle.xml 54 | .idea/**/libraries 55 | 56 | # Gradle and Maven with auto-import 57 | # When using Gradle or Maven with auto-import, you should exclude module files, 58 | # since they will be recreated, and may cause churn. Uncomment if using 59 | # auto-import. 60 | # .idea/artifacts 61 | # .idea/compiler.xml 62 | # .idea/jarRepositories.xml 63 | # .idea/modules.xml 64 | # .idea/*.iml 65 | # .idea/modules 66 | # *.iml 67 | # *.ipr 68 | 69 | # CMake 70 | cmake-build-*/ 71 | 72 | # Mongo Explorer plugin 73 | .idea/**/mongoSettings.xml 74 | 75 | # File-based project format 76 | *.iws 77 | 78 | # IntelliJ 79 | out/ 80 | 81 | # mpeltonen/sbt-idea plugin 82 | .idea_modules/ 83 | 84 | # JIRA plugin 85 | atlassian-ide-plugin.xml 86 | 87 | # Cursive Clojure plugin 88 | .idea/replstate.xml 89 | 90 | # SonarLint plugin 91 | .idea/sonarlint/ 92 | 93 | # Crashlytics plugin (for Android Studio and IntelliJ) 94 | com_crashlytics_export_strings.xml 95 | crashlytics.properties 96 | crashlytics-build.properties 97 | fabric.properties 98 | 99 | # Editor-based Rest Client 100 | .idea/httpRequests 101 | 102 | # Android studio 3.1+ serialized cache file 103 | .idea/caches/build_file_checksums.ser 104 | 105 | ### Node template 106 | # Logs 107 | logs 108 | *.log 109 | npm-debug.log* 110 | yarn-debug.log* 111 | yarn-error.log* 112 | lerna-debug.log* 113 | .pnpm-debug.log* 114 | 115 | # Diagnostic reports (https://nodejs.org/api/report.html) 116 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 117 | 118 | # Runtime data 119 | pids 120 | *.pid 121 | *.seed 122 | *.pid.lock 123 | 124 | # Directory for instrumented libs generated by jscoverage/JSCover 125 | lib-cov 126 | 127 | # Coverage directory used by tools like istanbul 128 | coverage 129 | *.lcov 130 | 131 | # nyc test coverage 132 | .nyc_output 133 | 134 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 135 | .grunt 136 | 137 | # Bower dependency directory (https://bower.io/) 138 | bower_components 139 | 140 | # node-waf configuration 141 | .lock-wscript 142 | 143 | # Compiled binary addons (https://nodejs.org/api/addons.html) 144 | build/Release 145 | 146 | # Dependency directories 147 | node_modules/ 148 | jspm_packages/ 149 | 150 | # Snowpack dependency directory (https://snowpack.dev/) 151 | web_modules/ 152 | 153 | # TypeScript cache 154 | *.tsbuildinfo 155 | 156 | # Optional npm cache directory 157 | .npm 158 | 159 | # Optional eslint cache 160 | .eslintcache 161 | 162 | # Optional stylelint cache 163 | .stylelintcache 164 | 165 | # Microbundle cache 166 | .rpt2_cache/ 167 | .rts2_cache_cjs/ 168 | .rts2_cache_es/ 169 | .rts2_cache_umd/ 170 | 171 | # Optional REPL history 172 | .node_repl_history 173 | 174 | # Output of 'npm pack' 175 | *.tgz 176 | 177 | # Yarn Integrity file 178 | .yarn-integrity 179 | 180 | # dotenv environment variable files 181 | .env 182 | .env.development.local 183 | .env.test.local 184 | .env.production.local 185 | .env.local 186 | 187 | # parcel-bundler cache (https://parceljs.org/) 188 | .cache 189 | .parcel-cache 190 | 191 | # Next.js build output 192 | .next 193 | out 194 | 195 | # Nuxt.js build / generate output 196 | .nuxt 197 | dist 198 | 199 | # Gatsby files 200 | .cache/ 201 | # Comment in the public line in if your project uses Gatsby and not Next.js 202 | # https://nextjs.org/blog/next-9-1#public-directory-support 203 | # public 204 | 205 | # vuepress build output 206 | .vuepress/dist 207 | 208 | # vuepress v2.x temp and cache directory 209 | .temp 210 | .cache 211 | 212 | # Docusaurus cache and generated files 213 | .docusaurus 214 | 215 | # Serverless directories 216 | .serverless/ 217 | 218 | # FuseBox cache 219 | .fusebox/ 220 | 221 | # DynamoDB Local files 222 | .dynamodb/ 223 | 224 | # TernJS port file 225 | .tern-port 226 | 227 | # Stores VSCode versions used for testing VSCode extensions 228 | .vscode-test 229 | 230 | # yarn v2 231 | .yarn/cache 232 | .yarn/unplugged 233 | .yarn/build-state.yml 234 | .yarn/install-state.gz 235 | .pnp.* 236 | SimpleWebAuthn 237 | .dev.vars 238 | .wrangler 239 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.0.1](https://github.com/dschewchenko/permask/compare/v2.0.0...v2.0.1) (2025-04-01) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * initialize result array with zeros in unpackBitmasks function ([#22](https://github.com/dschewchenko/permask/issues/22)) ([aaea417](https://github.com/dschewchenko/permask/commit/aaea4176e1f95b7a099a01135e131eaad3b0e333)) 7 | 8 | # [2.0.0](https://github.com/dschewchenko/permask/compare/v1.1.0...v2.0.0) (2025-03-29) 9 | 10 | 11 | ### Features 12 | 13 | * extend permission model to include update access - 4 bits ([#16](https://github.com/dschewchenko/permask/issues/16)) ([1a0bc02](https://github.com/dschewchenko/permask/commit/1a0bc022af622b3b699c60b008dd2b75be80abbd)) 14 | 15 | 16 | ### BREAKING CHANGES 17 | 18 | * size and naming changed for access part to fully match crud operations 19 | 20 | # [1.1.0](https://github.com/dschewchenko/permask/compare/v1.0.0...v1.1.0) (2025-03-16) 21 | 22 | 23 | ### Features 24 | 25 | * add pack and unpack functions ([cab3faf](https://github.com/dschewchenko/permask/commit/cab3faf15337849f757865ecea0cef131add3683)) 26 | 27 | # 1.0.0 (2025-01-17) 28 | 29 | 30 | ### Bug Fixes 31 | 32 | * entry files and mistype ([#3](https://github.com/dschewchenko/permask/issues/3)) ([197f7b3](https://github.com/dschewchenko/permask/commit/197f7b3eee49a5078df42c1f7aff2b143e2247c5)) 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 dschewchenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # permask [![npm](https://img.shields.io/npm/v/permask.svg)](https://www.npmjs.com/package/permask) [![build status](https://github.com/dschewchenko/permask/actions/workflows/release.yml/badge.svg?branch=main)](https://github.com/dschewchenko/permask/actions/workflows/release.yml) [![Download](https://img.shields.io/npm/dm/permask)](https://www.npmjs.com/package/permask) 2 | 3 | 4 | A lightweight TypeScript library for managing permissions using bitmasks. 5 | Using just utility functions will be even smaller with tree-shaking. 6 | 7 | ## What are bitmasks? 8 | 9 | Bitmasks are a way to store multiple boolean values in a single integer. 10 | They are useful for managing permissions, flags or groups. 11 | 12 | For example in UNIX file systems, bitmasks are used to manage file permissions (read, write, execute) for users, groups and others. Like 777 for full access to everyone, 755 for read and execute access to everyone, but full access to the owner. 13 | 14 | 15 | ## Why use bitmasks? 16 | 17 | - Fast: Bitwise operations (&, |) are faster than comparing strings. 18 | - Compact: Combine multiple permissions in a single integer (e.g., 0b1111 for read, create, update, delete). 19 | Just 4 bits for access control. For groups, you can use any number of bits, 20 | - Flexible: Easy to check, add, or remove permissions. 21 | 22 | Example of using bitmasks: 23 | 24 | ```ts 25 | const READ = 1; // 0b0001 26 | const CREATE = 2; // 0b0010 27 | const UPDATE = 4; // 0b0100 28 | const DELETE = 8; // 0b1000 29 | 30 | const userPermissions = READ | CREATE | UPDATE; // 0b0111 31 | const canRead = (userPermissions & READ) === READ; // true 32 | const canCreate = (userPermissions & CREATE) === CREATE; // true 33 | const canUpdate = (userPermissions & UPDATE) === UPDATE; // true 34 | const canDelete = (userPermissions & DELETE) === DELETE; // false 35 | ``` 36 | 37 | ## Bitmask in permask structure 38 | 39 | ``` 40 | [ Group (0–29 bits) | Permissions (4 bits) ] 41 | 42 | 0b0001_0111 = 23 43 | \__/ \__/ 44 | / \ 45 | Group(1) Permissions(read, create, update) 46 | ``` 47 | 48 | 49 | ## Installation 50 | 51 | Install `permask`: 52 | 53 | ```bash 54 | # npm 55 | npm install permask 56 | # pnpm 57 | pnpm add permask 58 | # yarn 59 | yarn add permask 60 | ``` 61 | 62 | 63 | ## How to use `permask`? 64 | 65 | ### 1. Define groups of permissions: 66 | 67 | ```ts 68 | // examples 69 | // with object 70 | const PermissionGroup = { 71 | POST: 1, 72 | COMMENT: 2, 73 | LIKE: 3 74 | } as const; 75 | 76 | ``` 77 | 78 | ### 2. Initialize permask 79 | 80 | ```ts 81 | import { createPermask } from "permask"; 82 | import { PermissionGroup } from "./permission-group"; // your defined groups 83 | 84 | const permask = createPermask(PermissionGroup); 85 | ``` 86 | 87 | ### 3. Use it 88 | 89 | - #### create a bitmask from an object: 90 | ```ts 91 | const bitmask2 = permask.create({ 92 | group: GroupEnum.LIKE, 93 | read: true, 94 | create: false, 95 | update: true, 96 | delete: false 97 | }); 98 | console.log(bitmask2); // 53 (0b110101) 99 | ``` 100 | 101 | - #### parse a bitmask to an object: 102 | ```ts 103 | const parsed = permask.parse(31); // 0b11111 104 | console.log(parsed); // { group: 1, read: true, create: true, update: true, delete: true } 105 | ``` 106 | 107 | - #### check if a bitmask has a specific group: 108 | 109 | ```ts 110 | const hasGroup = permask.hasGroup(23, PermissionGroup.LIKE); 111 | console.log(hasGroup); // true 112 | ``` 113 | 114 | - #### check if a bitmask has a specific permission: 115 | ```ts 116 | const canRead = permask.canRead(17); 117 | const canCreate = permask.canCreate(17); 118 | const canDelete = permask.canDelete(17); 119 | const canUpdate = permask.canUpdate(17); 120 | console.log(canRead, canCreate, canDelete, canUpdate); // true, false, false, false 121 | ``` 122 | 123 | - #### get group name from bitmask: 124 | ```ts 125 | const groupName = permask.getGroupName(23); 126 | console.log(groupName); // "LIKE" 127 | const groupName2 = permask.getGroupName(29); 128 | console.log(groupName2); // undefined 129 | ``` 130 | 131 | 132 | ## Bonus: 133 | 134 | You can use `permask` just with bitmask utility functions. 135 | 136 | *But it will be without some types dependent on your groups.* 137 | 138 | ### Use bitmask utilities: 139 | 140 | **Functions:** 141 | - `createBitmask({ group: number, read: boolean, create: boolean, delete: boolean, update: boolean }): number` - creates a bitmask from an options. 142 | - `parseBitmask(bitmask: number): { group: number, read: boolean, create: boolean, delete: boolean, update: boolean }` - parses a bitmask and returns an object. 143 | - `getPermissionGroup(bitmask: number): number` - returns a group number from a bitmask. 144 | - `getPermissionAccess(bitmask: number): number` - returns an access number from a bitmask. 145 | - `hasPermissionGroup(bitmask: number, group: number): boolean` - checks if a bitmask has a specific group. 146 | - `hasPermissionAccess(bitmask: number, access: number): boolean` - checks if a bitmask has a specific access. 147 | 148 | useful functions: 149 | - `canRead(bitmask: number): boolean` 150 | - `canCreate(bitmask: number): boolean` 151 | - `canDelete(bitmask: number): boolean` 152 | - `canUpdate(bitmask: number): boolean` 153 | - `setPermissionGroup(bitmask: number, group: number): number` - sets a group in a bitmask (will overwrite the previous group). 154 | - `setPermissionAccess(bitmask: number, access: number): number` - sets access in a bitmask (will overwrite the previous access). 155 | - `getPermissionBitmask(group: number, access: number): number` - creates a bitmask from a group and access. 156 | - `packBitbasks(bitmasks: number[], urlSafe?: boolean): string` - packs bitmasks to base64 string. (more compact than JSON.stringify) 157 | - `unpackBitmasks(base64: string, urlSafe?: boolean): number[]` - unpacks bitmasks from a base64 string. 158 | 159 | **Constants:** 160 | - `PermissionAccess` - an enum-like object with access types. 161 | ```ts 162 | const PermissionAccess = { 163 | READ: 1, // 0b0001 164 | CREATE: 2, // 0b0010 165 | UPDATE: 4, // 0b0100 166 | DELETE: 8 // 0b1000 167 | } as const; 168 | ``` 169 | - `PermissionAccessBitmasks` - full access bitmask for usual cases. 170 | ```ts 171 | const PermissionAccessBitmasks = { 172 | FULL: 0b1111, // read, create, update, delete 173 | CREATE: 0b0011, // read, create 174 | READ: 0b0001 // read-only 175 | } as const; 176 | ``` 177 | 178 | ## [Integration with frameworks](https://github.com/dschewchenko/permask/blob/main/integrations/README.md) 179 | 180 | ## How I'm using it? 181 | 182 | I'm using `permask` in my projects to manage permissions for users. It's easy to use and understand. And that's why I decided to share it with you. 183 | 184 | For example, I'm storing bitmask permissions array in access tokens for users. It's easy to check if user has access to a specific functionality or group. 185 | 186 | It's possible to store ~820 bitmask permissions(1 group + 3 access) in 1kB. In JS - 128 bitmasks, because each number in JS weights 4bytes 187 | 188 | With strings like `Posts.Read`, `Users.Create` it will be just ~35 permissions (1 group + 1 access) 189 | 190 | 191 | ## Enjoy! 192 | 193 | If you have any questions or suggestions, feel free to open an issue or pull request. 194 | 195 | 196 | ## Roadmap 197 | 198 | - [x] Create a library 199 | - [x] Add tests 200 | - [x] Add documentation 201 | - [ ] Add easy-to-use integration with frameworks 202 | - [x] Express 203 | - [ ] Fastify 204 | - [ ] H3 205 | - [ ] Nitro 206 | - [ ] NestJS 207 | - [ ] Hono 208 | - [ ] Koa 209 | - [ ] itty-router 210 | 211 | 212 | ## License 213 | 214 | [MIT](https://opensource.org/licenses/MIT) 215 | 216 | Copyright (c) 2025 by [Dmytro Shevchenko](https://github.com/dschewchenko) 217 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "files": { 4 | "ignore": [ 5 | "packages/*/dist/**", 6 | "packages/*/node_modules/**" 7 | ] 8 | }, 9 | "formatter": { 10 | "enabled": true, 11 | "formatWithErrors": false, 12 | "indentStyle": "space", 13 | "indentWidth": 2, 14 | "lineEnding": "lf", 15 | "lineWidth": 120, 16 | "attributePosition": "auto" 17 | }, 18 | "organizeImports": { "enabled": true }, 19 | "linter": { "enabled": true, "rules": { "recommended": true } }, 20 | "javascript": { 21 | "formatter": { 22 | "jsxQuoteStyle": "double", 23 | "quoteProperties": "asNeeded", 24 | "trailingCommas": "none", 25 | "semicolons": "always", 26 | "arrowParentheses": "always", 27 | "bracketSpacing": true, 28 | "bracketSameLine": false, 29 | "quoteStyle": "double", 30 | "attributePosition": "auto" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "permask", 3 | "version": "2.0.1", 4 | "type": "module", 5 | "description": "A lightweight utility library for managing permission bitmasks with groups and access levels", 6 | "main": "./dist/permask.cjs", 7 | "module": "./dist/permask.js", 8 | "types": "./dist/index.d.ts", 9 | "sideEffects": false, 10 | "scripts": { 11 | "build": "vite build", 12 | "test": "vitest", 13 | "test:coverage": "vitest --coverage", 14 | "lint": "biome check", 15 | "lint:fix": "pnpm run lint -- --write", 16 | "prepare": "husky", 17 | "husky:pre-commit": "pnpm run lint", 18 | "prepublishOnly": "pnpm run build" 19 | }, 20 | "devDependencies": { 21 | "@anolilab/semantic-release-pnpm": "^1.1.10", 22 | "@biomejs/biome": "^1.9.4", 23 | "@commitlint/cli": "^19.8.0", 24 | "@commitlint/config-conventional": "^19.8.0", 25 | "@semantic-release/changelog": "^6.0.3", 26 | "@semantic-release/git": "^10.0.1", 27 | "@semantic-release/github": "^11.0.1", 28 | "@types/express": "^5.0.0", 29 | "@vitest/coverage-v8": "^3.0.8", 30 | "biome": "^0.3.3", 31 | "husky": "^9.1.7", 32 | "is-ci": "^4.1.0", 33 | "semantic-release": "^24.2.3", 34 | "typescript": "^5.8.2", 35 | "vite": "^6.2.2", 36 | "vite-plugin-dts": "^4.5.3", 37 | "vitest": "^3.0.8" 38 | }, 39 | "files": [ 40 | "dist/*", 41 | "package.json", 42 | "README.md", 43 | "LICENSE", 44 | "CHANGELOG.md" 45 | ], 46 | "keywords": [ 47 | "bitmask", 48 | "access", 49 | "permissions", 50 | "authorization", 51 | "auth", 52 | "hono", 53 | "nest", 54 | "express", 55 | "itty-router", 56 | "express" 57 | ], 58 | "author": "dschewchenko", 59 | "license": "MIT", 60 | "bugs": { 61 | "url": "https://github.com/dschewchenko/permask/issues" 62 | }, 63 | "homepage": "https://github.com/dschewchenko/permask#readme", 64 | "repository": { 65 | "type": "git", 66 | "url": "https://github.com/dschewchenko/permask.git" 67 | }, 68 | "release": { 69 | "plugins": [ 70 | "@semantic-release/commit-analyzer", 71 | "@semantic-release/release-notes-generator", 72 | "@semantic-release/changelog", 73 | "@anolilab/semantic-release-pnpm", 74 | "@semantic-release/github", 75 | "@semantic-release/git" 76 | ], 77 | "branches": [ 78 | "main" 79 | ] 80 | }, 81 | "engines": { 82 | "node": ">=20.9.0", 83 | "pnpm": ">=10.0.0" 84 | }, 85 | "pnpm": { 86 | "onlyBuiltDependencies": [ 87 | "@biomejs/biome", 88 | "core-js", 89 | "esbuild" 90 | ] 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/constants/bitmask.ts: -------------------------------------------------------------------------------- 1 | // Max bits count for access 2 | export const ACCESS_BITS = 4; 3 | // Mask for access bits 4 | export const ACCESS_MASK = (1 << ACCESS_BITS) - 1; 5 | -------------------------------------------------------------------------------- /src/constants/permission.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Permission access enum. 4 bits. 3 | */ 4 | export const PermissionAccess = { 5 | READ: 1, // 0b0001 6 | CREATE: 2, // 0b0010 7 | UPDATE: 4, // 0b0100 8 | DELETE: 8, // 0b1000 9 | } as const; 10 | 11 | export type PermissionAccessType = keyof typeof PermissionAccess; 12 | 13 | /** 14 | * Predefined permission access bitmasks. 15 | */ 16 | export const PermissionAccessBitmasks = { 17 | FULL: 15, // 0b1111 - read, create, update, delete 18 | CREATE: 3, // 0b0011 - read, create 19 | READ: 1 // 0b0001 - read-only 20 | } as const; 21 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./permask"; 2 | export * from "./utils/bitmask"; 3 | export * from "./utils/pack"; 4 | export * from "./constants/permission"; 5 | export * from "./integrations/express"; 6 | -------------------------------------------------------------------------------- /src/integrations/README.md: -------------------------------------------------------------------------------- 1 | # permask integrations 2 | 3 | ## Examples: 4 | 5 | Here is a list of integrations permask with popular frameworks. 6 | 7 | ### Express.js Middleware 8 | 9 | ```ts 10 | import {permaskExpress, PermissionAccess} from "permask"; 11 | import {groups} from "./permission-group"; // your defined groups 12 | 13 | // Create a middleware 14 | // Options object is optional, just for customization 15 | const checkPermission = permaskExpress(groups, { 16 | /** 17 | * Use to setup where you store permissions in the request object. 18 | * Don't use if you're overring getPermissions function 19 | */ 20 | permissionsKey: "user.permissions", 21 | /** 22 | * Use it, if you want custom loogic to get permissions array 23 | */ 24 | getPermissions: ({permissionsKey}, req) => { 25 | return req.session?.user?.permissions || []; 26 | }, 27 | /** 28 | * Use it, if you want to customize the response when the user doesn't have permission 29 | */ 30 | forbiddenResponse: (res) => { 31 | res.status(403).send("You don't have permission to access this route"); 32 | } 33 | }); 34 | 35 | // to check permission for a route 36 | app.get("/protected", checkPermissions(groups.admin, PermissionAccess.READ), (req, res) => { 37 | res.send("protected route"); 38 | }); 39 | ``` 40 | 41 | -------------------------------------------------------------------------------- /src/integrations/express/express.middleware.test.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction, Request, Response } from "express"; 2 | import { beforeEach, describe, expect, it, vi } from "vitest"; 3 | import { PermissionAccess } from "../../constants/permission"; 4 | import { createBitmask } from "../../utils/bitmask"; 5 | import { defaultPermaskMiddlewareOptions, permaskExpress } from "./"; 6 | 7 | const groups = { 8 | POSTS: 1, 9 | COMMENTS: 2 10 | }; 11 | 12 | describe("permask Express Middleware", () => { 13 | const mockRequest = (permissions: number[]) => 14 | ({ 15 | user: { permissions } 16 | }) as unknown as Request; 17 | 18 | const mockResponse = () => { 19 | const res = {} as Response; 20 | res.status = vi.fn().mockReturnValue(res); 21 | res.json = vi.fn().mockReturnValue(res); 22 | res.send = vi.fn().mockReturnValue(res); 23 | 24 | return res; 25 | }; 26 | 27 | const mockNext = vi.fn() as NextFunction; 28 | 29 | beforeEach(() => { 30 | vi.clearAllMocks(); 31 | }); 32 | 33 | it("should allow access if the user has the required permission", () => { 34 | const checkPermission = permaskExpress(groups); 35 | const req = mockRequest([createBitmask({ group: groups.POSTS, read: true })]); 36 | const res = mockResponse(); 37 | 38 | checkPermission(groups.POSTS, PermissionAccess.READ)(req, res, mockNext); 39 | 40 | expect(mockNext).toHaveBeenCalled(); 41 | expect(res.status).not.toHaveBeenCalled(); 42 | expect(res.json).not.toHaveBeenCalled(); 43 | }); 44 | 45 | it("should deny access if the user does not have the required permission", () => { 46 | const checkPermission = permaskExpress(groups); 47 | const req = mockRequest([createBitmask({ group: groups.POSTS, read: true })]); 48 | const res = mockResponse(); 49 | 50 | checkPermission(groups.POSTS, PermissionAccess.CREATE)(req, res, mockNext); 51 | 52 | expect(mockNext).not.toHaveBeenCalled(); 53 | expect(res.status).toHaveBeenCalledWith(403); 54 | expect(res.json).toHaveBeenCalledWith({ 55 | error: "Access denied" 56 | }); 57 | }); 58 | 59 | it("should handle missing permissions gracefully", () => { 60 | const checkPermission = permaskExpress(groups); 61 | const req = mockRequest([]); 62 | const res = mockResponse(); 63 | 64 | checkPermission(groups.POSTS, PermissionAccess.DELETE)(req, res, mockNext); 65 | 66 | expect(mockNext).not.toHaveBeenCalled(); 67 | expect(res.status).toHaveBeenCalledWith(403); 68 | expect(res.json).toHaveBeenCalledWith({ 69 | error: "Access denied" 70 | }); 71 | }); 72 | 73 | it("should return 500 if an error occurs during permission checking", () => { 74 | const checkPermission = permaskExpress(groups, { 75 | ...defaultPermaskMiddlewareOptions, 76 | getPermissions: () => { 77 | throw new Error("Test error"); // Емітуємо помилку 78 | } 79 | }); 80 | 81 | const req = mockRequest([createBitmask({ group: groups.POSTS, read: true })]); 82 | const res = mockResponse(); 83 | 84 | checkPermission(groups.POSTS, PermissionAccess.READ)(req, res, mockNext); 85 | expect(mockNext).not.toHaveBeenCalled(); 86 | expect(res.status).toHaveBeenCalledWith(500); 87 | expect(res.json).toHaveBeenCalledWith({ 88 | error: "Internal server error", 89 | details: "Test error" 90 | }); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /src/integrations/express/express.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextFunction, Request, Response } from "express"; 2 | import { hasRequiredPermission } from "../../utils/bitmask"; 3 | import { type PermaskMiddlewareOptionsType, defaultPermaskMiddlewareOptions } from "./options"; 4 | 5 | /** 6 | * Create Express middleware for Permask. 7 | * 8 | * @example 9 | * 10 | * ```ts 11 | * import express from "express"; 12 | * import { permaskExpress, PermissionAccess } from "permask"; 13 | * 14 | * const app = express(); 15 | * 16 | * const groups = { 17 | * admin: 1, 18 | * user: 2, 19 | * // ... 20 | * } as const; 21 | * 22 | * const checkPermission = permaskExpress(groups); 23 | * 24 | * // to check permission for a route 25 | * app.get("/protected", checkPermissions(groups.admin, PermissionAccess.READ), (req, res) => { 26 | * res.send("protected route"); 27 | * }); 28 | */ 29 | export function permaskExpress>( 30 | groups: Groups, 31 | opts?: PermaskMiddlewareOptionsType 32 | ) { 33 | const options = { ...defaultPermaskMiddlewareOptions, ...opts }; 34 | 35 | return function checkPermissions(group: Groups[keyof Groups], access: number) { 36 | return (req: Request, res: Response, next: NextFunction) => { 37 | try { 38 | const bitmasks = options.getPermissions(options, req); 39 | 40 | if (hasRequiredPermission(bitmasks, group as number, access)) { 41 | return next(); 42 | } 43 | 44 | return options.forbiddenResponse(res); 45 | } catch (error) { 46 | // @ts-ignore 47 | return res.status(500).json({ error: "Internal server error", details: error.message }); 48 | } 49 | }; 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/integrations/express/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./options"; 2 | export * from "./express.middleware"; 3 | -------------------------------------------------------------------------------- /src/integrations/express/options.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from "express"; 2 | import { get } from "../../utils/object"; 3 | 4 | export type PermaskMiddlewareOptionsType = { 5 | /** 6 | * Request property, where to get user permissions. 7 | * 8 | * @default "user.permissions" 9 | */ 10 | permissionsKey?: string; 11 | 12 | /** 13 | * Callback to get permissions from request and store them in the request property. 14 | * 15 | * @default ({ property, permissionsKey }: Required, req: Record) => get(req, permissionsKey) 16 | */ 17 | getPermissions?: (opts: Required, req: Request) => number[]; 18 | 19 | /** 20 | * Callback to send a forbidden response. 21 | * 22 | * @default (res) => res.status(403).json({ error: "Access denied" }) 23 | */ 24 | forbiddenResponse?: (res: Response) => void; 25 | }; 26 | 27 | export const defaultPermaskMiddlewareOptions: Required = { 28 | permissionsKey: "user.permissions", 29 | getPermissions: ({ permissionsKey }: Required, req: Request) => 30 | get(req as unknown as Record, permissionsKey, []) as number[], 31 | forbiddenResponse: (res: Response) => res.status(403).json({ error: "Access denied" }) 32 | }; 33 | -------------------------------------------------------------------------------- /src/permask.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { createPermask } from "./"; 3 | 4 | enum PermissionGroup { 5 | POSTS = 1, 6 | LIKES = 2, 7 | COMMENTS = 3 8 | } 9 | 10 | const postsReadDeleteUpdate = 0b10000 | 0b1001; // 25 = (POSTS << 4) | (READ | DELETE) 11 | const invalidGroup = 0b1010000 | 0b0111; // Group 5 (not in enum) with some permissions 12 | 13 | describe("Permask", () => { 14 | describe("createPermask", () => { 15 | const permask = createPermask(PermissionGroup); 16 | 17 | it("should create a bitmask correctly", () => { 18 | const bitmask = permask.create({ 19 | group: PermissionGroup.POSTS, 20 | read: true, 21 | create: false, 22 | delete: true, 23 | update: false 24 | }); 25 | 26 | expect(bitmask).toBe(postsReadDeleteUpdate); 27 | }); 28 | 29 | it("should parse a bitmask correctly", () => { 30 | const parsed = permask.parse(postsReadDeleteUpdate); 31 | expect(parsed).toEqual({ 32 | group: PermissionGroup.POSTS, 33 | read: true, 34 | create: false, 35 | delete: true, 36 | update: false 37 | }); 38 | }); 39 | 40 | it("should return group name from bitmask", () => { 41 | const groupName = permask.getGroupName(postsReadDeleteUpdate); 42 | expect(groupName).toBe("POSTS"); 43 | 44 | const undefinedGroup = permask.getGroupName(invalidGroup); 45 | expect(undefinedGroup).toBeUndefined(); 46 | }); 47 | 48 | it("should check group presence in a bitmask", () => { 49 | const hasGroup = permask.hasGroup(postsReadDeleteUpdate, PermissionGroup.POSTS); 50 | expect(hasGroup).toBe(true); 51 | 52 | const noGroup = permask.hasGroup(invalidGroup, PermissionGroup.LIKES); 53 | expect(noGroup).toBe(false); 54 | }); 55 | 56 | it("should check read, create, delete, and update access", () => { 57 | const bitmask = permask.create({ 58 | group: PermissionGroup.LIKES, 59 | read: true, 60 | create: true, 61 | delete: false, 62 | update: true 63 | }); 64 | 65 | expect(permask.hasGroup(bitmask, PermissionGroup.LIKES)).toBe(true); 66 | expect(permask.canRead(bitmask)).toBe(true); 67 | expect(permask.canCreate(bitmask)).toBe(true); 68 | expect(permask.canDelete(bitmask)).toBe(false); 69 | expect(permask.canUpdate(bitmask)).toBe(true); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/permask.ts: -------------------------------------------------------------------------------- 1 | import type { EnumOrObjectType, StringKeysType } from "./types/utils"; 2 | import { 3 | canDelete, 4 | canRead, 5 | canUpdate, 6 | canCreate, 7 | createBitmask, 8 | getPermissionGroup, 9 | hasPermissionGroup, 10 | parseBitmask 11 | } from "./utils/bitmask"; 12 | 13 | /** 14 | * Construct bitmask functions with defined permission groups. 15 | */ 16 | export function createPermask< 17 | Groups extends Record, 18 | EnumOrObj extends EnumOrObjectType 19 | >(groups: Groups) { 20 | const groupEntries = Object.entries(groups).filter(([, value]) => typeof value === "number") as [ 21 | keyof EnumOrObj, 22 | EnumOrObj[keyof EnumOrObj] 23 | ][]; 24 | 25 | /** 26 | * Get group name from bitmask. 27 | */ 28 | function getGroupName(bitmask: Bitmask) { 29 | const groupValue = getPermissionGroup(bitmask); 30 | const entry = groupEntries.find(([, value]) => value === groupValue); 31 | return entry?.[0] as StringKeysType | undefined; 32 | } 33 | 34 | return { 35 | parse: parseBitmask, 36 | create: createBitmask, 37 | hasGroup: hasPermissionGroup, 38 | getGroupName, 39 | canRead, 40 | canCreate, 41 | canDelete, 42 | canUpdate 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/types/utils.ts: -------------------------------------------------------------------------------- 1 | export type NumberValuesType = { 2 | [K in keyof T]: T[K] extends number ? T[K] : never; 3 | }[keyof T]; 4 | 5 | export type StringKeysType = Extract; 6 | export type EnumOrObjectType = Record>; 7 | -------------------------------------------------------------------------------- /src/utils/bitmask.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { 3 | canDelete, 4 | canRead, 5 | canUpdate, 6 | canCreate, 7 | createBitmask, 8 | getPermissionAccess, 9 | getPermissionBitmask, 10 | getPermissionGroup, 11 | hasPermissionAccess, 12 | hasPermissionGroup, 13 | hasRequiredPermission, 14 | parseBitmask, 15 | setPermissionAccess, 16 | setPermissionGroup 17 | } from "../"; 18 | import { PermissionAccess, PermissionAccessBitmasks } from "../constants/permission"; 19 | 20 | const TestPermissionGroup = { 21 | USER: 0, 22 | POST: 1, 23 | COMMENT: 2, 24 | LIKE: 3 25 | } as const; 26 | 27 | // With 4-bit system: group bits shifted left by 4, not 3 28 | const bitmask0 = 0b00000; // 0 User, no access 29 | const bitmask1 = 0b10000 | 0b1111; // 31 = POST (1) << 4 | FULL ACCESS (15) 30 | const bitmask2 = 0b100000 | 0b0001; // 33 = COMMENT (2) << 4 | READ (1) 31 | const bitmask3 = 0b110000 | 0b1111; // 63 = LIKE (3) << 4 | FULL ACCESS (15) 32 | const bitmaskWithUpdate = 0b10000 | 0b0101; // 21 = POST (1) << 4 | READ+UPDATE (5) 33 | 34 | describe("Permission Bitmask Utilities", () => { 35 | describe("createBitmask", () => { 36 | it("should create correct bitmask", () => { 37 | expect(createBitmask({ group: TestPermissionGroup.USER, read: false, create: false, delete: false, update: false })).toBe( 38 | bitmask0 39 | ); 40 | expect(createBitmask({ group: TestPermissionGroup.POST, read: true, create: true, delete: true, update: true })).toBe(bitmask1); 41 | expect(createBitmask({ group: TestPermissionGroup.COMMENT, read: true, create: false, delete: false, update: false })).toBe( 42 | bitmask2 43 | ); 44 | expect(createBitmask({ group: TestPermissionGroup.LIKE, read: true, create: true, delete: true, update: true })).toBe(bitmask3); 45 | expect(createBitmask({ group: TestPermissionGroup.POST, read: true, create: false, delete: false, update: true })).toBe( 46 | bitmaskWithUpdate 47 | ); 48 | }); 49 | }); 50 | 51 | describe("parseBitmask", () => { 52 | it("should parse bitmask to configuration", () => { 53 | expect(parseBitmask(bitmask0)).toEqual({ 54 | group: TestPermissionGroup.USER, 55 | read: false, 56 | create: false, 57 | delete: false, 58 | update: false 59 | }); 60 | expect(parseBitmask(bitmask1)).toEqual({ 61 | group: TestPermissionGroup.POST, 62 | read: true, 63 | create: true, 64 | delete: true, 65 | update: true 66 | }); 67 | expect(parseBitmask(bitmask2)).toEqual({ 68 | group: TestPermissionGroup.COMMENT, 69 | read: true, 70 | create: false, 71 | delete: false, 72 | update: false 73 | }); 74 | expect(parseBitmask(bitmask3)).toEqual({ 75 | group: TestPermissionGroup.LIKE, 76 | read: true, 77 | create: true, 78 | delete: true, 79 | update: true 80 | }); 81 | expect(parseBitmask(bitmaskWithUpdate)).toEqual({ 82 | group: TestPermissionGroup.POST, 83 | read: true, 84 | create: false, 85 | delete: false, 86 | update: true 87 | }); 88 | }); 89 | }); 90 | 91 | describe("getPermissionGroup", () => { 92 | it("should return correct group from bitmask", () => { 93 | expect(getPermissionGroup(bitmask0)).toBe(TestPermissionGroup.USER); 94 | expect(getPermissionGroup(bitmask1)).toBe(TestPermissionGroup.POST); 95 | expect(getPermissionGroup(bitmask2)).toBe(TestPermissionGroup.COMMENT); 96 | expect(getPermissionGroup(bitmask3)).toBe(TestPermissionGroup.LIKE); 97 | expect(getPermissionGroup(bitmaskWithUpdate)).toBe(TestPermissionGroup.POST); 98 | }); 99 | }); 100 | 101 | describe("hasPermissionAccess", () => { 102 | it("should correctly check access presence", () => { 103 | expect(hasPermissionAccess(bitmask0, PermissionAccess.READ)).toBe(false); 104 | expect(hasPermissionAccess(bitmask1, PermissionAccess.CREATE)).toBe(true); 105 | expect(hasPermissionAccess(bitmask2, PermissionAccess.DELETE)).toBe(false); 106 | expect(hasPermissionAccess(bitmask3, PermissionAccess.READ)).toBe(true); 107 | expect(hasPermissionAccess(bitmaskWithUpdate, PermissionAccess.UPDATE)).toBe(true); 108 | expect(hasPermissionAccess(bitmaskWithUpdate, PermissionAccess.CREATE)).toBe(false); 109 | }); 110 | }); 111 | 112 | describe("hasPermissionGroup", () => { 113 | it("should correctly check group presence", () => { 114 | expect(hasPermissionGroup(bitmask0, TestPermissionGroup.USER)).toBe(true); 115 | expect(hasPermissionGroup(bitmask2, TestPermissionGroup.COMMENT)).toBe(true); 116 | expect(hasPermissionGroup(bitmask1, TestPermissionGroup.LIKE)).toBe(false); 117 | expect(hasPermissionGroup(bitmaskWithUpdate, TestPermissionGroup.POST)).toBe(true); 118 | }); 119 | }); 120 | 121 | describe("getPermissionAccess", () => { 122 | it("should return correct access flags from bitmask", () => { 123 | expect(getPermissionAccess(bitmask0)).toBe(0b0000); 124 | expect(getPermissionAccess(bitmask1)).toBe(0b1111); 125 | expect(getPermissionAccess(bitmask2)).toBe(0b0001); 126 | expect(getPermissionAccess(bitmask3)).toBe(0b1111); 127 | expect(getPermissionAccess(bitmaskWithUpdate)).toBe(0b0101); 128 | }); 129 | }); 130 | 131 | describe("access checking utilities", () => { 132 | it("should verify read access", () => { 133 | expect(canRead(bitmask2)).toBe(true); 134 | expect(canRead(bitmask0)).toBe(false); 135 | }); 136 | 137 | it("should verify create access", () => { 138 | expect(canCreate(bitmask1)).toBe(true); 139 | expect(canCreate(bitmask2)).toBe(false); 140 | expect(canCreate(bitmaskWithUpdate)).toBe(false); 141 | }); 142 | 143 | it("should verify create access", () => { 144 | expect(canCreate(bitmask1)).toBe(true); 145 | expect(canCreate(bitmask2)).toBe(false); 146 | expect(canCreate(bitmaskWithUpdate)).toBe(false); 147 | }); 148 | 149 | it("should verify delete access", () => { 150 | expect(canDelete(bitmask1)).toBe(true); 151 | expect(canDelete(bitmask0)).toBe(false); 152 | expect(canDelete(bitmaskWithUpdate)).toBe(false); 153 | }); 154 | 155 | it("should verify update access", () => { 156 | expect(canUpdate(bitmask1)).toBe(true); 157 | expect(canUpdate(bitmask2)).toBe(false); 158 | expect(canUpdate(bitmaskWithUpdate)).toBe(true); 159 | }); 160 | }); 161 | 162 | describe("setPermissionGroup", () => { 163 | it("should update group in bitmask", () => { 164 | const updated = setPermissionGroup(bitmask1, TestPermissionGroup.LIKE); 165 | expect(getPermissionGroup(updated)).toBe(TestPermissionGroup.LIKE); 166 | }); 167 | }); 168 | 169 | describe("setPermissionAccess", () => { 170 | it("should update access in bitmask", () => { 171 | const updated = setPermissionAccess(bitmask0, 0b1111); // Full access 172 | expect(canRead(updated)).toBe(true); 173 | expect(canCreate(updated)).toBe(true); 174 | expect(canDelete(updated)).toBe(true); 175 | expect(canUpdate(updated)).toBe(true); 176 | }); 177 | }); 178 | 179 | describe("hasRequiredPermission", () => { 180 | it("should return true if one of the bitmasks contains the required group and access", () => { 181 | const bitmasks = [bitmask0, bitmask1]; 182 | expect(hasRequiredPermission(bitmasks, TestPermissionGroup.POST, PermissionAccess.CREATE)).toBe(true); 183 | expect(hasRequiredPermission(bitmasks, TestPermissionGroup.POST, PermissionAccess.UPDATE)).toBe(true); 184 | }); 185 | 186 | it("should return false if no bitmask contains the required group and access", () => { 187 | const bitmasks = [bitmask0, bitmask2]; 188 | expect(hasRequiredPermission(bitmasks, TestPermissionGroup.POST, PermissionAccess.CREATE)).toBe(false); 189 | expect(hasRequiredPermission(bitmasks, TestPermissionGroup.POST, PermissionAccess.UPDATE)).toBe(false); 190 | }); 191 | 192 | it("should handle empty bitmask array", () => { 193 | const bitmasks: number[] = []; 194 | expect(hasRequiredPermission(bitmasks, TestPermissionGroup.POST, PermissionAccess.CREATE)).toBe(false); 195 | expect(hasRequiredPermission(bitmasks, TestPermissionGroup.POST, PermissionAccess.UPDATE)).toBe(false); 196 | }); 197 | }); 198 | 199 | describe("getPermissionBitmask", () => { 200 | it("should create a bitmask from group and access", () => { 201 | expect(getPermissionBitmask(TestPermissionGroup.COMMENT, PermissionAccessBitmasks.READ)).toBe(bitmask2); 202 | expect(getPermissionBitmask(TestPermissionGroup.LIKE, PermissionAccessBitmasks.FULL)).toBe(bitmask3); 203 | expect(getPermissionBitmask(TestPermissionGroup.USER, 0b0000)).toBe(bitmask0); 204 | }); 205 | }); 206 | }); 207 | -------------------------------------------------------------------------------- /src/utils/bitmask.ts: -------------------------------------------------------------------------------- 1 | import { ACCESS_BITS, ACCESS_MASK } from "../constants/bitmask"; 2 | import { PermissionAccess } from "../constants/permission"; 3 | 4 | /** 5 | * Permission mask: 6 | * - 3 bits for access 7 | * - any number of bits for group 8 | * {group(0,...)}{access(3)} 9 | */ 10 | 11 | /** 12 | * Set permission access in bitmask. 13 | * Use to override access. 14 | */ 15 | export function setPermissionAccess(bitmask: number, access: number) { 16 | return (bitmask & ~ACCESS_MASK) | access; 17 | } 18 | 19 | /** 20 | * Add permission access to bitmask. 21 | * Will not override existing access. 22 | */ 23 | export function addPermissionAccess(bitmask: number, access: number) { 24 | return bitmask | access; 25 | } 26 | 27 | /** 28 | * Set permission group in bitmask. 29 | * Use to override group. 30 | */ 31 | export function setPermissionGroup(bitmask: number, group: number) { 32 | return getPermissionAccess(bitmask) | (group << ACCESS_BITS); 33 | } 34 | 35 | /** 36 | * Get permission group from mask. 37 | */ 38 | export function getPermissionGroup(bitmask: number): number { 39 | return bitmask >> ACCESS_BITS; 40 | } 41 | 42 | /** 43 | * Get permission access from bitmask. 44 | */ 45 | export function getPermissionAccess(bitmask: number): number { 46 | return bitmask & ACCESS_MASK; 47 | } 48 | 49 | /** 50 | * Check if permission bitmask has given group. 51 | */ 52 | export function hasPermissionGroup(bitmask: number, group: number) { 53 | return getPermissionGroup(bitmask) === group; 54 | } 55 | 56 | /** 57 | * Check if permission bitmask has given access. 58 | */ 59 | export function hasPermissionAccess(bitmask: number, access: number) { 60 | return (getPermissionAccess(bitmask) & access) !== 0; 61 | } 62 | 63 | /** 64 | * Get permission bitmask from given group and access. {group(1,...)}{access(010)} 65 | */ 66 | export function getPermissionBitmask(group: number, access: number) { 67 | return (group << ACCESS_BITS) | access; 68 | } 69 | 70 | /** 71 | * Check if permission bitmask has read, create or delete access. 72 | * Just fancy way to check permission. 73 | */ 74 | export function canRead(bitmask: number) { 75 | return hasPermissionAccess(bitmask, PermissionAccess.READ); 76 | } 77 | 78 | export function canCreate(bitmask: number) { 79 | return hasPermissionAccess(bitmask, PermissionAccess.CREATE); 80 | } 81 | 82 | export function canDelete(bitmask: number) { 83 | return hasPermissionAccess(bitmask, PermissionAccess.DELETE); 84 | } 85 | 86 | /** 87 | * Checks if a bitmask has update permission. 88 | */ 89 | export function canUpdate(bitmask: number): boolean { 90 | return hasPermissionAccess(bitmask, PermissionAccess.UPDATE); 91 | } 92 | 93 | /** 94 | * Array methods for checking permission. 95 | * 96 | * @example 97 | * const bitmask = [0b1111, 0b11101, 0b011]; 98 | * 99 | * const canReadPosts = hasRequiredPermission(bitmasks, PermissionGroup.Post, PermissionAccess.READ); 100 | */ 101 | export function hasRequiredPermission(bitmasks: number[], group: number, access: number) { 102 | return bitmasks.some((bitmask) => hasPermissionGroup(bitmask, group) && hasPermissionAccess(bitmask, access)); 103 | } 104 | 105 | /** 106 | * Return object with permission group and access from bitmask. 107 | */ 108 | export function parseBitmask(bitmask: number): { 109 | group: number; 110 | read: boolean; 111 | create: boolean; 112 | delete: boolean; 113 | update: boolean; 114 | } { 115 | const group = getPermissionGroup(bitmask); 116 | const access = getPermissionAccess(bitmask); 117 | return { 118 | group, 119 | read: (access & PermissionAccess.READ) === PermissionAccess.READ, 120 | create: (access & PermissionAccess.CREATE) === PermissionAccess.CREATE, 121 | delete: (access & PermissionAccess.DELETE) === PermissionAccess.DELETE, 122 | update: (access & PermissionAccess.UPDATE) === PermissionAccess.UPDATE 123 | }; 124 | } 125 | 126 | /** 127 | * Create permission bitmask from group and access. 128 | */ 129 | export function createBitmask({ 130 | group, 131 | read = false, 132 | create = false, 133 | delete: del = false, 134 | update = false 135 | }: { 136 | group: number; 137 | read?: boolean; 138 | create?: boolean; 139 | delete?: boolean; 140 | update?: boolean; 141 | }) { 142 | let bitmask = 0; 143 | if (read) bitmask = addPermissionAccess(bitmask, PermissionAccess.READ); 144 | if (create) bitmask = addPermissionAccess(bitmask, PermissionAccess.CREATE); 145 | if (del) bitmask = addPermissionAccess(bitmask, PermissionAccess.DELETE); 146 | if (update) bitmask = addPermissionAccess(bitmask, PermissionAccess.UPDATE); 147 | bitmask = setPermissionGroup(bitmask, group); 148 | 149 | return bitmask; 150 | } 151 | -------------------------------------------------------------------------------- /src/utils/object.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { get } from "./object"; 3 | 4 | const testObject = { 5 | a: { 6 | b: { 7 | c: "value" 8 | }, 9 | d: null, 10 | e: 0 11 | } 12 | }; 13 | 14 | describe("Object Utilities", () => { 15 | describe("get", () => { 16 | it("should return the value at a single level path", () => { 17 | expect(get(testObject, "a")).toEqual(testObject.a); 18 | }); 19 | 20 | it("should return the value at a two-level path", () => { 21 | expect(get(testObject, "a.b")).toEqual(testObject.a.b); 22 | }); 23 | 24 | it("should return the value at a three-level path", () => { 25 | expect(get(testObject, "a.b.c")).toBe("value"); 26 | }); 27 | 28 | it("should return undefined if the path does not exist", () => { 29 | expect(get(testObject, "a.x")).toBeUndefined(); 30 | }); 31 | 32 | it("should return the default value if the path does not exist", () => { 33 | expect(get(testObject, "a.x", "default")).toBe("default"); 34 | }); 35 | 36 | it("should handle null values correctly", () => { 37 | expect(get(testObject, "a.d")).toBeNull(); 38 | }); 39 | 40 | it("should handle falsy values correctly", () => { 41 | expect(get(testObject, "a.e")).toBe(0); 42 | }); 43 | 44 | it("should return the object itself if the path is empty", () => { 45 | expect(get(testObject, "")).toEqual(testObject); 46 | }); 47 | 48 | it("should return the default value if the object is null or undefined", () => { 49 | expect(get(null, "a", "default")).toBe("default"); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/utils/object.ts: -------------------------------------------------------------------------------- 1 | export const get = (obj: Record | null, path: string, defaultValue?: unknown): unknown => { 2 | if (!path) return obj; 3 | if (!obj) return defaultValue; 4 | const [head, ...tail] = path.split("."); 5 | 6 | if (obj[head] === undefined) return defaultValue; 7 | 8 | return get(obj[head] as Record, tail.join("."), defaultValue) as (typeof obj)[typeof head]; 9 | }; 10 | -------------------------------------------------------------------------------- /src/utils/pack.test.ts: -------------------------------------------------------------------------------- 1 | import {beforeAll, describe, expect, it } from "vitest"; 2 | import { packBitmasks, unpackBitmasks } from "../" 3 | 4 | const generateBitmasks = () => { 5 | const bitmasks = new Set(); 6 | 7 | // Add some key test cases 8 | bitmasks.add(0); 9 | bitmasks.add(1); 10 | bitmasks.add(0xFF); // 8 bits 11 | bitmasks.add(0xFFFF); // 16 bits 12 | bitmasks.add(0xFFFFFF); // 24 bits 13 | bitmasks.add(0xFFFFFFFF); // 32 bits 14 | 15 | for (let i = 0; i < 31; i++) { 16 | bitmasks.add(1 << i); 17 | } 18 | 19 | bitmasks.add(0x12345678); 20 | bitmasks.add(0x55555555); 21 | bitmasks.add(0xAAAAAAAA); 22 | bitmasks.add(0x33333333); 23 | bitmasks.add(0xCCCCCCCC); 24 | 25 | return Array.from(bitmasks) 26 | }; 27 | 28 | describe("Pack/Unpack Bitmasks", () => { 29 | let bitmasks: number[]; 30 | beforeAll(() => { 31 | bitmasks = generateBitmasks(); 32 | }); 33 | 34 | describe("packBitmasks", () => { 35 | it("should handle empty bitmasks array in packBitmasks", () => { 36 | const empty: number[] = []; 37 | const packed = packBitmasks(empty); 38 | 39 | expect(packed).toBe(""); 40 | }); 41 | 42 | it("should handle undefined bitmasks in packBitmasks", () => { 43 | 44 | const packed = packBitmasks(undefined); 45 | 46 | expect(packed).toBe(""); 47 | }); 48 | 49 | it("should pack bitmasks correctly", () => { 50 | const packed = packBitmasks(bitmasks); 51 | 52 | expect(packed).toBeTypeOf("string"); 53 | expect(packed.length).toBeGreaterThan(0); 54 | // valid base64 55 | expect(packed).toMatch(/^[A-Za-z0-9+/]+={0,2}$/); 56 | }); 57 | 58 | it("should pack bitmasks to url safe base64", () => { 59 | const packed = packBitmasks(bitmasks, true); 60 | 61 | expect(packed).toBeTypeOf("string"); 62 | expect(packed.length).toBeGreaterThan(0); 63 | // valid url-safe base64 64 | expect(packed).toMatch(/^[A-Za-z0-9_-]+$/); 65 | }); 66 | }); 67 | 68 | describe("unpackBitmasks", () => { 69 | it("should handle invalid base64", () => { 70 | const invalid = "!!!invalid-base64!!!"; 71 | const result = unpackBitmasks(invalid); 72 | 73 | expect(result).toEqual([]); 74 | 75 | // Also test with urlSafe=true 76 | const resultUrlSafe = unpackBitmasks(invalid, true); 77 | 78 | expect(resultUrlSafe).toEqual([]); 79 | }); 80 | 81 | it("should handle empty string in unpackBitmasks", () => { 82 | const result = unpackBitmasks(""); 83 | expect(result).toEqual([]); 84 | }); 85 | 86 | it("should unpack bitmasks correctly", () => { 87 | const packed = packBitmasks(bitmasks); 88 | const unpacked = unpackBitmasks(packed); 89 | expect(unpacked).toBeTypeOf("object"); 90 | expect(unpacked).toBeInstanceOf(Array); 91 | expect(unpacked.length).toBe(bitmasks.length); 92 | expect(unpacked[0]).toBeTypeOf("number"); 93 | expect(unpacked).toEqual(bitmasks); 94 | }); 95 | 96 | it("should unpack bitmasks from url safe base64", () => { 97 | const packed = packBitmasks(bitmasks, true); 98 | const unpacked = unpackBitmasks(packed, true); 99 | 100 | expect(unpacked).toBeTypeOf("object"); 101 | expect(unpacked).toBeInstanceOf(Array); 102 | expect(unpacked.length).toBe(bitmasks.length); 103 | expect(unpacked[0]).toBeTypeOf("number"); 104 | expect(unpacked).toEqual(bitmasks); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/utils/pack.ts: -------------------------------------------------------------------------------- 1 | export const base64ToUrlSafe = (base64: string) => base64.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); 2 | 3 | export const urlSafeToBase64 = (base64: string) => base64.replace(/-/g, "+").replace(/_/g, "/"); 4 | 5 | /** 6 | * Convert bitmasks to base64 7 | * Also it will be more compact than using JSON.stringify 8 | * 9 | * @param bitmasks 10 | * @param urlSafe - if true, will use url safe base64 11 | * 12 | * @returns {string} - base64 string 13 | * 14 | * @example 15 | * 16 | * const bitmasks = [1, 2, 3, 4, 5]; 17 | * const packed = packBitmasks(bitmasks); 18 | * console.log(packed); // "AQIDBAU=" 19 | */ 20 | export function packBitmasks(bitmasks: number[], urlSafe = false): string { 21 | if (!bitmasks?.length) { 22 | return ""; 23 | } 24 | 25 | const buffer = new ArrayBuffer(bitmasks.length * 4); 26 | const uint32View = new Uint32Array(buffer); 27 | 28 | for (let i = 0; i < bitmasks.length; i++) { 29 | uint32View[i] = bitmasks[i]; 30 | } 31 | 32 | const uint8View = new Uint8Array(buffer); 33 | const binary = String.fromCharCode.apply(null, uint8View as unknown as number[]); 34 | 35 | const base64String = btoa(binary); 36 | return urlSafe ? base64ToUrlSafe(base64String) : base64String; 37 | } 38 | 39 | /** 40 | * Convert base64 to bitmasks 41 | * 42 | * @param packed 43 | * @param urlSafe - if true, will use url safe base64 44 | * 45 | * @returns {number[]} - array of bitmasks 46 | * 47 | * @example 48 | * 49 | * const packed = "AQIDBAU="; 50 | * const bitmasks = unpackBitmasks(packed); 51 | * console.log(bitmasks); // [1, 2, 3, 4, 5] 52 | */ 53 | export function unpackBitmasks(packed: string, urlSafe = false): number[] { 54 | try { 55 | const base64String = urlSafe ? urlSafeToBase64(packed) : packed; 56 | const binary = atob(base64String); 57 | 58 | const count = binary.length >>> 2; 59 | const result = new Array(count).fill(0); 60 | 61 | for (let i = 0, offset = 0; i < count; i++, offset += 4) { 62 | result[i] = 63 | ( 64 | binary.charCodeAt(offset) | 65 | (binary.charCodeAt(offset + 1) << 8) | 66 | (binary.charCodeAt(offset + 2) << 16) | 67 | (binary.charCodeAt(offset + 3) << 24) 68 | ) >>> 0; 69 | } 70 | 71 | return result; 72 | } catch (e) { 73 | console.error("Error unpacking bitmasks:", e); 74 | return []; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "rootDir": "src", 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleResolution": "bundler", 8 | "lib": ["ESNext"], 9 | "declaration": true, 10 | "strict": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true 13 | }, 14 | "include": ["src"], 15 | "exclude": ["node_modules", "**/*.test.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { defineConfig } from "vite"; 4 | import dts from "vite-plugin-dts"; 5 | 6 | export default defineConfig({ 7 | build: { 8 | lib: { 9 | entry: "./src/index.ts", 10 | name: "Permask", 11 | formats: ["cjs", "es", "umd"] 12 | }, 13 | rollupOptions: { 14 | external: [] 15 | }, 16 | sourcemap: true, 17 | minify: true 18 | }, 19 | plugins: [ 20 | dts({ 21 | insertTypesEntry: true 22 | }) 23 | ] 24 | }); 25 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"], 6 | coverage: { 7 | provider: "v8", 8 | reporter: ["text", "json-summary", "json"], 9 | include: ["src/**/*.ts"], 10 | reportOnFailure: true 11 | }, 12 | } 13 | }); 14 | --------------------------------------------------------------------------------