├── .changeset ├── README.md ├── config.json └── fuzzy-windows-reply.md ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── publish-commit.yaml │ ├── publish.yaml │ └── pull-request.yaml ├── .gitignore ├── .vscode └── settings.json ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── SECURITY.MD ├── biome.json ├── lefthook.yml ├── package-lock.json ├── package.json ├── src ├── index.test.ts └── index.ts ├── test-apps ├── remix-vite-cjs │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── README.md │ ├── app │ │ ├── entry.client.tsx │ │ ├── entry.server.tsx │ │ ├── policies.ts │ │ ├── root.tsx │ │ └── routes │ │ │ └── _index.tsx │ ├── package.json │ ├── public │ │ └── favicon.ico │ ├── tsconfig.json │ └── vite.config.ts └── remix-vite │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── README.md │ ├── app │ ├── entry.client.tsx │ ├── entry.server.tsx │ ├── policies.ts │ ├── root.tsx │ └── routes │ │ └── _index.tsx │ ├── package.json │ ├── public │ └── favicon.ico │ ├── tsconfig.json │ └── vite.config.ts ├── tests └── setup.ts ├── tsconfig.json ├── tsup.config.ts ├── tsup.dev.config.ts └── vitest.config.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": true, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.changeset/fuzzy-windows-reply.md: -------------------------------------------------------------------------------- 1 | --- 2 | "open-source-stack": minor 3 | --- 4 | 5 | Added changesets to the project 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug Report 2 | description: Create a bug report to help us improve 3 | title: "bug: " 4 | labels: ["🐞 unconfirmed bug"] 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Provide environment information 9 | description: | 10 | Run this command in your project root and paste the results: 11 | ```bash 12 | npx envinfo --system --binaries 13 | ``` 14 | 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Describe the bug 20 | description: A clear and concise description of the bug, as well as what you expected to happen when encountering it. 21 | validations: 22 | required: true 23 | - type: input 24 | attributes: 25 | label: Reproduction repo 26 | description: If applicable, please provide a link to a reproduction repo or a Stackblitz / CodeSandbox project. Your issue may be closed if this is not provided and we are unable to reproduce the issue. 27 | - type: textarea 28 | attributes: 29 | label: Expected behavior 30 | description: A clear and concise description of what you expected to happen. 31 | validations: 32 | required: true 33 | - type: textarea 34 | attributes: 35 | label: Actual behavior 36 | description: A clear and concise description of what actually happened. 37 | validations: 38 | required: true 39 | - type: textarea 40 | attributes: 41 | label: Steps to reproduce 42 | description: Steps to reproduce the behavior. 43 | placeholder: | 44 | 1. Go to '...' 45 | 2. Click on '....' 46 | 3. Scroll down to '....' 47 | 4. See error 48 | validations: 49 | required: true 50 | - type: textarea 51 | attributes: 52 | label: Screenshots 53 | description: If applicable, add screenshots to help explain your problem. 54 | - type: textarea 55 | attributes: 56 | label: Additional context 57 | description: Add any other context about the problem here. 58 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Ask a Question 4 | url: https://discord.gg/uZ39dxhj 5 | about: Ask questions and discuss with other community members 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | title: "feat: " 4 | labels: ["🌟 enhancement"] 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Is your feature request related to a problem? Please describe. 9 | description: A clear and concise description of what the problem is. 10 | validations: 11 | required: true 12 | - type: textarea 13 | attributes: 14 | label: Describe the solution you'd like to see 15 | description: A clear and concise description of what you want to happen. 16 | validations: 17 | required: true 18 | - type: textarea 19 | attributes: 20 | label: Describe alternate solutions 21 | description: A clear and concise description of any alternative solutions or features you've considered. 22 | validations: 23 | required: true 24 | - type: textarea 25 | attributes: 26 | label: Additional information 27 | description: Add any other information related to the feature here. If your feature request is related to any issues or discussions, link them here. 28 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Fixes # 2 | 3 | # Description 4 | 5 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. 6 | List any dependencies that are required for this change. 7 | 8 | ## Type of change 9 | 10 | Please mark relevant options with an `x` in the brackets. 11 | 12 | - [ ] Bug fix (non-breaking change which fixes an issue) 13 | - [ ] New feature (non-breaking change which adds functionality) 14 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 15 | - [ ] This change requires a documentation update 16 | - [ ] Algorithm update - updates algorithm documentation/questions/answers etc. 17 | - [ ] Other (please describe): 18 | 19 | # How Has This Been Tested? 20 | 21 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also 22 | list any relevant details for your test configuration 23 | 24 | - [ ] Integration tests 25 | - [ ] Unit tests 26 | - [ ] Manual tests 27 | - [ ] No tests required 28 | 29 | # Reviewer checklist 30 | 31 | Mark everything that needs to be checked before merging the PR. 32 | 33 | - [ ] Check if the UI is working as expected and is satisfactory 34 | - [ ] Check if the code is well documented 35 | - [ ] Check if the behavior is what is expected 36 | - [ ] Check if the code is well tested 37 | - [ ] Check if the code is readable and well formatted 38 | - [ ] Additional checks (document below if any) 39 | 40 | # Screenshots (if appropriate): 41 | 42 | # Questions (if appropriate): 43 | -------------------------------------------------------------------------------- /.github/workflows/publish-commit.yaml: -------------------------------------------------------------------------------- 1 | name: 🚀 pkg-pr-new 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - name: Checkout code 10 | uses: actions/checkout@v2 11 | 12 | - run: corepack enable 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | cache: "npm" 17 | 18 | - name: Install dependencies 19 | run: npm install 20 | 21 | - name: Build 22 | run: npm run build 23 | 24 | - run: npx pkg-pr-new publish 25 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | on: 3 | release: 4 | types: [published] 5 | workflow_dispatch: 6 | jobs: 7 | npm-publish: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | # Setup .npmrc file to publish to npm 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: "20.x" 15 | registry-url: "https://registry.npmjs.org" 16 | - run: npm ci 17 | - run: npm publish 18 | env: 19 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yaml: -------------------------------------------------------------------------------- 1 | name: 🚀 PR 2 | 3 | concurrency: 4 | group: ${{ github.repository }}-${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: [pull_request] # Run only on pull_request, to also get status updates in PRs. We omit push because this would run the steps two times (for push and pull_request). 8 | 9 | permissions: 10 | actions: write 11 | contents: read 12 | # Required to put a comment into the pull-request 13 | pull-requests: write 14 | 15 | jobs: 16 | lint: 17 | name: ⬣ Linting 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: biomejs/setup-biome@v2 22 | - run: biome ci . --reporter=github 23 | 24 | typecheck: 25 | name: 🔎 Type check 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: 🛑 Cancel Previous Runs 29 | uses: styfle/cancel-workflow-action@0.12.1 30 | 31 | - name: ⬇️ Checkout repo 32 | uses: actions/checkout@v4 33 | 34 | - name: ⎔ Setup node 35 | uses: actions/setup-node@v4 36 | with: 37 | node-version-file: "package.json" 38 | 39 | - name: 📥 Download deps 40 | uses: bahmutov/npm-install@v1 41 | with: 42 | useLockFile: false 43 | 44 | - name: 🔎 Type check 45 | run: npm run typecheck 46 | 47 | vitest: 48 | name: ⚡ Unit Tests 49 | runs-on: ubuntu-latest 50 | steps: 51 | - name: 🛑 Cancel Previous Runs 52 | uses: styfle/cancel-workflow-action@0.12.1 53 | 54 | - name: ⬇️ Checkout repo 55 | uses: actions/checkout@v4 56 | 57 | - name: ⎔ Setup node 58 | uses: actions/setup-node@v4 59 | with: 60 | node-version-file: "package.json" 61 | 62 | - name: 📥 Download deps 63 | uses: bahmutov/npm-install@v1 64 | with: 65 | useLockFile: false 66 | 67 | - name: ⚡ Run vitest 68 | run: npm run test:cov 69 | 70 | - name: "Report Coverage" 71 | # Only works if you set `reportOnFailure: true` in your vite config as specified above 72 | if: always() 73 | uses: davelosert/vitest-coverage-report-action@v2 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | /dist 132 | .history -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "biome.enabled": true, 3 | "editor.defaultFormatter": "biomejs.biome", 4 | "editor.formatOnSave": true, 5 | "javascript.format.enable": false, 6 | "javascript.suggest.autoImports": true, 7 | "javascript.suggest.paths": true, 8 | "typescript.format.enable": false, 9 | "typescript.suggest.paths": true, 10 | "typescript.suggest.autoImports": true, 11 | "editor.renderWhitespace": "all", 12 | "editor.rulers": [120, 160], 13 | "editor.codeActionsOnSave": { 14 | "source.fixAll": "always", 15 | "source.organizeImports": "never", 16 | "source.organizeImports.biome": "always", 17 | "quickfix.biome": "always" 18 | }, 19 | "editor.insertSpaces": false, 20 | "editor.detectIndentation": true, 21 | "editor.trimAutoWhitespace": true, 22 | "files.trimTrailingWhitespace": true, 23 | "files.trimTrailingWhitespaceInRegexAndStrings": true, 24 | "files.trimFinalNewlines": true, 25 | "explorer.fileNesting.patterns": { 26 | "*.ts": "${basename}.*.${extname}", 27 | ".env": ".env.*", 28 | "*.tsx": "${basename}.*.${extname},${basename}.*.ts", 29 | "package.json": "*.json, *.yml, *.config.js, *.config.ts, *.yaml" 30 | }, 31 | "eslint.enable": false, 32 | "eslint.format.enable": false, 33 | "[typescript]": { 34 | "editor.defaultFormatter": "biomejs.biome" 35 | }, 36 | "[markdown]": { 37 | "editor.defaultFormatter": "yzhang.markdown-all-in-one" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 rphlmr 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 | # Comply 2 | 3 | ![GitHub Repo stars](https://img.shields.io/github/stars/rphlmr/comply?style=social) 4 | ![npm](https://img.shields.io/npm/v/comply?style=plastic) 5 | ![GitHub](https://img.shields.io/github/license/rphlmr/comply?style=plastic) 6 | ![npm](https://img.shields.io/npm/dy/comply?style=plastic) 7 | ![npm](https://img.shields.io/npm/dw/comply?style=plastic) 8 | ![GitHub top language](https://img.shields.io/github/languages/top/rphlmr/comply?style=plastic) 9 | 10 | This library provides a simple way to define and enforce policies within your application. Policies are defined as a set of rules that determine whether a specific action can be performed based on a given context. 11 | 12 | The API surface is small: 13 | - `definePolicy`: Define a policy, the core primitive 14 | - `definePolicies`: Define a policy set (a collection of policies created with `definePolicy`) 15 | - `check`: Test a policy condition 16 | - `assert`: Assert a policy condition (throws if condition is not met) 17 | 18 | ## TLDR 19 | ### Define policies 20 | ```typescript 21 | // somewhere in your app 22 | type MyContext = { userId: string; rolesByOrg: Record }; 23 | ``` 24 | ```typescript 25 | // define all your policies 26 | import { assert, check, definePolicies, definePolicy, matchSchema, notNull, or } from "comply"; 27 | import { z } from "zod"; 28 | 29 | const OrgPolicies = definePolicies((context: MyContext) => (orgId: string) => { 30 | const currentUserOrgRole = context.rolesByOrg[orgId]; 31 | 32 | return [ 33 | definePolicy("can administrate", or(currentUserOrgRole === "admin", currentUserOrgRole === "superadmin")), 34 | definePolicy("is superadmin", currentUserOrgRole === "superadmin"), 35 | ]; 36 | }); 37 | 38 | const UserPolicies = definePolicies((context: MyContext) => (userId: string) => [ 39 | definePolicy("can edit profile", context.userId === userId), 40 | ]); 41 | 42 | // create and export a 'guard' that contains all your policies, scoped by domain 43 | export const Guard = (context: MyContext) => ({ 44 | org: OrgPolicies(context), 45 | user: UserPolicies(context), 46 | }); 47 | ``` 48 | 49 | ### Use policies 50 | ```typescript 51 | // use - example with Remix Run 52 | import { matchSchema, notNull, definePolicy, definePolicies, check, assert } from 'comply'; 53 | 54 | // route: /orgs/:orgId 55 | const ParamsSchema = z.object({ orgId: z.string() }); 56 | 57 | export function loader({ request, context, params }: LoaderFunctionArgs) { 58 | const guard = Guard(context); 59 | 60 | // define an implicit policy on the fly! 61 | assert("params are valid", matchSchema(ParamsSchema), params); 62 | // params is now typed as { orgId: string } 63 | 64 | 65 | // 👇 type-safe 👇 type-safe 66 | if (check(guard.org(params.orgId).policy("can administrate"))) { 67 | console.log("User can administrate the IT department."); 68 | } else { 69 | console.log("User cannot administrate the IT department."); 70 | } 71 | 72 | assert(guard.org(params.orgId).policy("can administrate")); 73 | // context.rolesByOrg[params.orgId] === "admin" 74 | // otherwise it throws an error 75 | } 76 | ``` 77 | 78 | ### Type-safe all the way 79 | 80 | Accessing policies by name from policy sets is type-safe. 81 | 82 | For example, with `guard.org(params.orgId).policy("can administrate")`, `"can administrate"` will be suggested by Typescript. 83 | 84 | If the condition requires a parameter, `assert` and `check` will require it. 85 | 86 | Finally, if the condition is a type guard, the parameter you pass will be inferred automatically. 87 | 88 | ## Defining Policies 89 | To define policies, you create a policy set using the `definePolicies` function. 90 | Each policy definition is created using the `definePolicy` function, which takes a policy name and a callback that defines the policy logic (or a boolean value). 91 | 92 | > [!IMPORTANT] 93 | > For convenience, the condition can be a boolean value but **you will lose type inference** 94 | > 95 | > If you want TS to infer something (not null, union, etc), use a condition function 96 | 97 | The callback logic can receive a unique parameter (scalar or object) and return a boolean value or a a type predicate. 98 | 99 | You can also provide an error factory to the policy (3rd argument) to customize the error message. 100 | 101 | `definePolicies` returns a policy set (a collection of policies you can invoke with `.policy("name")`) or a policy set factory (a function that takes a parameter and returns a policy set). 102 | 103 | You can then use this set to check if a condition is met and/or assert it with `check` and `assert`. 104 | 105 | ### Simple policy set 106 | `definePolicies` accepts an array of policies created with `definePolicy`. 107 | 108 | _Primary use case_: simple policies that can be defined inline and are 'self-contained' (don't need a context or a factory). 109 | 110 | ```typescript 111 | const policies = definePolicies([ 112 | // built-in type guards 113 | definePolicy("is not null", notNull), 114 | definePolicy('params are valid', matchSchema(z.object({ name: z.string() }))), 115 | // type guard 116 | definePolicy("is a string", (v: unknown): v is string => typeof v === "string"), 117 | ]); 118 | ``` 119 | 120 | ### Advanced policy set 121 | `definePolicies` can take a callback that receives a context (whatever you want to pass to your policies) and returns a policy set or a policy set factory. 122 | 123 | A policy set factory is a function that takes a parameter (scalar or object) and returns a policy set. 124 | 125 | The primary purpose of this is to simplify the definition of policies that depend on a parameter (e.g. a userId, orgId, etc.). 126 | 127 | Here's a quick example: 128 | ```typescript 129 | // 1️⃣ 130 | type Context = { userId: string; rolesByOrg: Record; appRole: "admin" | "user" }; 131 | 132 | const AdminPolicies = definePolicies((context: Context) => [ 133 | definePolicy("has app admin role", context.appRole === "admin") 134 | ]); 135 | 136 | // 2️⃣ 137 | const OrgPolicies = definePolicies((context: MyContext) => (orgId: string) => { 138 | const adminGuard = AdminPolicies(context); 139 | const currentUserOrgRole = context.rolesByOrg[orgId]; 140 | 141 | return [ 142 | definePolicy("can administrate", () => 143 | or( 144 | () => currentUserOrgRole === "admin", 145 | () => currentUserOrgRole === "superadmin", 146 | () => check(adminGuard.policy("has app admin role")) 147 | ), 148 | definePolicy("is superadmin", () => currentUserOrgRole === "superadmin"), 149 | ]; 150 | }); 151 | 152 | // other policies... 153 | 154 | // 3️⃣ 155 | // create and export a 'guard' that contains all your policies, scoped by domain 156 | export const Guard = (context: Context) => ({ 157 | org: OrgPolicies(context), 158 | }); 159 | ``` 160 | Let's break it down: 161 | 162 | ### 1️⃣ 163 | We define a context type that includes the necessary information for our policies. 164 | 165 | It's up to you what you put in it, depending on what framework you're using and what information you need in your policies. 166 | 167 | ### 2️⃣ 168 | We create a policy set factory that takes a `orgId` and returns a policy set. 169 | 170 | This way, we can 'scope' our policies to a specific organization and benefit from the closure feature (all policies share the same `currentUserOrgRole` variable). 171 | 172 | We also can invoke other policy sets factories (e.g. `AdminPolicies`) and compose new policies. 173 | 174 | ### 3️⃣ 175 | We create and export a `Guard` function (arbitrary name) that takes a context and returns an object containing all our policies. 176 | 177 | We choose to scope our policies by domain (e.g. `org`, `user`, `params`, etc.) to avoid conflicts and make the code more organized. 178 | 179 | 180 | ## Using Policies 181 | 182 | To use your policies, invoke the `Guard` factory with the context and then use the returned object to access your policies. 183 | 184 | Here's an example with a Remix Run loader but it works the same with any other framework. 185 | ```typescript 186 | import { matchSchema, notNull, definePolicy, definePolicies, check, assert } from 'comply'; 187 | 188 | // route: /orgs/:orgId 189 | const ParamsSchema = z.object({ orgId: z.string() }); 190 | 191 | export function loader({ request, context, params }: LoaderFunctionArgs) { 192 | const guard = Guard(context); 193 | 194 | // 1️⃣ define an implicit policy on the fly! 195 | assert("params are valid", matchSchema(ParamsSchema), params) 196 | // params is now typed as { orgId: string } 197 | 198 | // 2️⃣ 👇 type-safe 👇 type-safe 199 | if (check(guard.org(params.orgId).policy("can administrate"))) { 200 | console.log("User can administrate the IT department."); 201 | } else { 202 | console.log("User cannot administrate the IT department."); 203 | } 204 | 205 | // 3️⃣ 206 | assert(guard.org(params.orgId).policy("can administrate")) 207 | // context.rolesByOrg[params.orgId] === "admin" 208 | // otherwise it throws an error 209 | } 210 | ``` 211 | Let's break it down: 212 | 213 | ### 1️⃣ 214 | Just to demonstrate that we can, we define an implicit policy on the fly! 215 | 216 | It's a quick way to name an assert/check in your code flow. 217 | 218 | It works the same for `check` and it's equivalent to defining a policy with `definePolicy`. 219 | 220 | ### 2️⃣ 221 | We use `check` and pass it the policy we want to evaluate. We are telling a story here: "check if the user can administrate this specific organization". 222 | 223 | ### 3️⃣ 224 | We use `assert` to assert a policy condition. It passes or it throws an error. 225 | 226 | 227 | ## Async Policy Evaluation 228 | 229 | The library does not support async policy evaluation because TypeScript does not support async type guards. (https://github.com/microsoft/TypeScript/issues/37681). 230 | 231 | Of course, we can use async check but not directly in policy conditions. 232 | 233 | Here's an example: 234 | ```typescript 235 | type Context = { userId: string; rolesByOrg: Record }; 236 | 237 | const OrgPolicies = definePolicies((context: Context) => (orgId: string) => [ 238 | definePolicy("can administrate org", (stillOrgAdmin: boolean) => 239 | and(context.rolesByOrg[orgId] === "admin", stillOrgAdmin) 240 | ), 241 | ]); 242 | 243 | // fake server check 244 | async function checkIfStillOrgAdmin(orgId: string, userId: string) { 245 | // ... 246 | } 247 | 248 | // route: /orgs/:orgId 249 | const ParamsSchema = z.object({ orgId: z.string() }); 250 | 251 | export async function loader({ request, context, params }: LoaderFunctionArgs) { 252 | const guard = Guard(context); 253 | 254 | assert("params are valid", matchSchema(ParamsSchema), params) 255 | 256 | assert(guard.org(params.orgId).policy("can administrate org"), await checkIfStillOrgAdmin(params.orgId, context.userId)) 257 | } 258 | ``` 259 | In this example, our policy condition requires a parameter (`stillOrgAdmin`, boolean, but can be any type). 260 | 261 | Then we use inversion of control to pass the parameter to the policy condition. 262 | 263 | This is not what I really want, but it's a temporary limitation we have to live with until TypeScript implements async type guards. 264 | 265 | I prefer to preserve the type guard/inference benefits of `assert` and `check` instead of supporting async policy conditions. 266 | 267 | ## API 268 | ### `definePolicy` 269 | Core primitive to define a policy. 270 | 271 | ```typescript 272 | type PolicyError = Error; 273 | type PolicyErrorFactory = (arg: unknown) => T; 274 | type PolicyConditionWithArg = (arg: T) => boolean; 275 | type PolicyConditionArg

= P extends PolicyConditionWithArg ? T : never; 276 | type PolicyConditionTypeGuard = (arg: T) => arg is U; 277 | type PolicyConditionTypeGuardResult

= P extends PolicyConditionTypeGuard 278 | ? U 279 | : PolicyConditionArg

; 280 | type PolicyConditionNoArg = (() => boolean) | boolean; 281 | type PolicyCondition = 282 | | PolicyConditionTypeGuard 283 | | PolicyConditionWithArg 284 | | PolicyConditionNoArg; 285 | 286 | function definePolicy(name: string, condition: PolicyCondition, errorFactory?: PolicyErrorFactory) 287 | ``` 288 | 289 | Example: 290 | ```ts 291 | const postHasCommentsPolicy = definePolicy( 292 | "post has comments", 293 | (post: Post) => post.comments.length > 0, 294 | () => new Error("Post has no comments") 295 | ); 296 | ``` 297 | 298 | ### `definePolicies` 299 | Core primitive to define a policy set (collection of policies). 300 | 301 | 302 | ```typescript 303 | function definePolicies(policies: T): PolicySet; 304 | 305 | type AnyPolicy = Policy; 306 | type AnyPolicies = AnyPolicy[]; 307 | type PolicyFactory = (...args: any[]) => AnyPolicies; 308 | type PoliciesOrFactory = AnyPolicies | PolicyFactory; 309 | type PolicySetOrFactory = T extends AnyPolicies 310 | ? PolicySet 311 | : T extends PolicyFactory 312 | ? (...args: Parameters) => PolicySet> 313 | : never; 314 | type WithRequiredContext = T extends (arg: infer A) => any ? (unknown extends A ? never : T) : never; 315 | 316 | function definePolicies( 317 | define: WithRequiredContext<(context: Context) => T> 318 | ): (context: Context) => PolicySetOrFactory; 319 | ``` 320 | Example: 321 | ```ts 322 | const PostPolicies = definePolicies((context: Context) => [ 323 | definePolicy("post has comments", (post: Post) => post.comments.length > 0), 324 | definePolicy("is author", (post: Post) => post.authorId === context.userId) 325 | ]); 326 | 327 | export const Guard = (context: Context) => ({ 328 | post: PostPolicies(context), 329 | }); 330 | ``` 331 | 332 | ### `assert` 333 | ```typescript 334 | function assert(name: string, condition: PolicyConditionNoArg): void; 335 | 336 | function assert | PolicyConditionWithArg>( 337 | name: string, 338 | condition: TPolicyCondition, 339 | arg: TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionArg 340 | ): asserts arg is TPolicyCondition extends PolicyConditionNoArg 341 | ? never 342 | : PolicyConditionTypeGuardResult; 343 | 344 | function assert( 345 | policy: Policy 346 | ): void; 347 | 348 | function assert | PolicyConditionWithArg>( 349 | policy: Policy, 350 | arg: TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionArg 351 | ): asserts arg is TPolicyCondition extends PolicyConditionNoArg 352 | ? never 353 | : PolicyConditionTypeGuardResult; 354 | ``` 355 | 356 | Example: 357 | ```ts 358 | const guard = Guard(context); 359 | 360 | const post = await fetchPost(id); 361 | 362 | assert(guard.post.policy("is author"), post); 363 | // post.authorId === context.userId 364 | ``` 365 | 366 | ### `check` 367 | ```typescript 368 | function check(name: string, condition: PolicyConditionNoArg): boolean; 369 | 370 | function check | PolicyConditionWithArg>( 371 | name: string, 372 | condition: TPolicyCondition, 373 | arg: TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionArg 374 | ): arg is TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionTypeGuardResult; 375 | 376 | function check( 377 | policy: Policy 378 | ): boolean; 379 | 380 | function check | PolicyConditionWithArg>( 381 | policy: Policy, 382 | arg: TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionArg 383 | ): arg is TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionTypeGuardResult; 384 | ``` 385 | 386 | Example: 387 | ```ts 388 | const guard = Guard(context); 389 | 390 | const post = await fetchPost(id); 391 | 392 | if (check(guard.post.policy("post has comments"), post)) { 393 | console.log("Post has comments"); 394 | } 395 | ``` 396 | 397 | ### `checkAllSettle` 398 | Evaluates all the policies with `check` and returns a snapshot with the results. 399 | 400 | It's useful to serialize policies. 401 | 402 | It takes an array of policies. If a policy does not take an argument, it can be passed as is. Policies that take an argument have to be passed as a tuple with the argument. 403 | 404 | ```ts 405 | type PolicyTuple = 406 | | Policy 407 | | readonly [string, PolicyConditionNoArg] 408 | | readonly [Policy] 409 | | readonly [Policy, any]; 410 | type InferPolicyName = TPolicyTuple extends readonly [infer name, any] 411 | ? name extends Policy 412 | ? Name 413 | : name extends string 414 | ? name 415 | : never 416 | : TPolicyTuple extends readonly [Policy] 417 | ? Name 418 | : TPolicyTuple extends Policy 419 | ? Name 420 | : never; 421 | type PoliciesSnapshot = { [K in TPolicyName]: boolean }; 422 | 423 | export function checkAllSettle< 424 | const TPolicies extends readonly PolicyTuple[], 425 | TPolicyTuple extends TPolicies[number], 426 | TPolicyName extends InferPolicyName, 427 | >(policies: TPolicies): PoliciesSnapshot 428 | ``` 429 | 430 | Example: 431 | ```ts 432 | // TLDR 433 | const snapshot = checkAllSettle([ 434 | [guard.post.policy("is my post"), post], // Policy with argument 435 | ["post has comments", post.comments.length > 0], // Implicit policy with no argument 436 | definePolicy("post has likes", post.likes.length > 0), // Policy without argument 437 | ]); 438 | 439 | // Example 440 | const PostPolicies = definePolicies((context: Context) => { 441 | const myPostPolicy = definePolicy( 442 | "is my post", 443 | (post: Post) => post.userId === context.userId, 444 | () => new Error("Not the author") 445 | ); 446 | 447 | return [ 448 | myPostPolicy, 449 | definePolicy("published post or mine", (post: Post) => 450 | or(check(myPostPolicy, post), post.status === "published") 451 | ), 452 | ]; 453 | }); 454 | 455 | const guard = { 456 | post: PostPolicies(context), 457 | }; 458 | 459 | const snapshot = checkAllSettle([ 460 | [guard.post.policy("is my post"), post], 461 | ["post has comments", post.comments.length > 0], 462 | definePolicy("post has likes", post.likes.length > 0), 463 | ]); 464 | 465 | console.log(snapshot); // { "is my post": boolean; "post has comments": boolean; "post has likes": boolean } 466 | console.log(snapshot["is my post"]) // boolean 467 | ``` 468 | 469 | ### Condition helpers 470 | #### `or` 471 | Logical OR operator for policy conditions. 472 | 473 | ```typescript 474 | function or(...conditions: (() => Policy> | boolean)[]) 475 | ``` 476 | 477 | Example: 478 | ```ts 479 | const PostPolicies = definePolicies((context: Context) => { 480 | const myPostPolicy = definePolicy( 481 | "my post", 482 | (post: Post) => post.userId === context.userId, 483 | () => new Error("Not the author") 484 | ); 485 | 486 | return [ 487 | myPostPolicy, 488 | definePolicy("all published posts or mine", (post: Post) => 489 | or( 490 | () => check(myPostPolicy, post), 491 | () => post.status === "published" 492 | ) 493 | ), 494 | definePolicy("[lazy] all published posts or mine", (post: Post) => 495 | or(check(myPostPolicy, post), post.status === "published") 496 | ), 497 | ]; 498 | }); 499 | ``` 500 | 501 | #### `and` 502 | Logical AND operator for policy conditions. 503 | 504 | 505 | ```typescript 506 | function and( 507 | ...conditions: (() => Policy> | boolean)[] 508 | ) 509 | ``` 510 | 511 | Example: 512 | ```ts 513 | const PostPolicies = definePolicies((context: Context) => { 514 | const myPostPolicy = definePolicy( 515 | "my post", 516 | (post: Post) => post.userId === context.userId, 517 | () => new Error("Not the author") 518 | ); 519 | 520 | return [ 521 | myPostPolicy, 522 | definePolicy("my published post", (post: Post) => 523 | and( 524 | () => check(myPostPolicy, post), 525 | () => post.status === "published" 526 | ) 527 | ), 528 | definePolicy("[lazy] my published post", (post: Post) => 529 | and(check(myPostPolicy, post), post.status === "published") 530 | ), 531 | ]; 532 | }); 533 | ``` 534 | -------------------------------------------------------------------------------- /SECURITY.MD: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 1.0.x | :white_check_mark: | 8 | 9 | ## Reporting a Vulnerability 10 | 11 | In case of a vulnerability please reach out to active maintainers of the project and report it to them. -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "vcs": { 4 | "enabled": true, 5 | "clientKind": "git", 6 | "defaultBranch": "main", 7 | "useIgnoreFile": true 8 | }, 9 | "formatter": { 10 | "enabled": true, 11 | "formatWithErrors": true, 12 | "indentStyle": "space", 13 | "lineEnding": "lf", 14 | "lineWidth": 120 15 | }, 16 | "organizeImports": { 17 | "enabled": true 18 | }, 19 | "linter": { 20 | "ignore": ["test-apps"], 21 | "enabled": true, 22 | "rules": { 23 | "recommended": true, 24 | "suspicious": { 25 | "recommended": true, 26 | "noExplicitAny": "off", 27 | "noConfusingVoidType": "off" 28 | }, 29 | "style": { 30 | "recommended": true, 31 | "noParameterAssign": "info" 32 | }, 33 | "complexity": { 34 | "recommended": true 35 | }, 36 | "security": { 37 | "recommended": true 38 | }, 39 | "performance": { 40 | "recommended": true 41 | }, 42 | "correctness": { 43 | "recommended": true 44 | }, 45 | "a11y": { 46 | "recommended": true 47 | }, 48 | "nursery": { 49 | "recommended": true 50 | } 51 | } 52 | }, 53 | "javascript": { 54 | "formatter": { 55 | "semicolons": "always", 56 | "trailingCommas": "es5" 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | parallel: true 3 | commands: 4 | check: 5 | run: npm run check -- --staged --fix --no-errors-on-unmatched 6 | stage_fixed: true 7 | typecheck: 8 | run: npm run typecheck 9 | test: 10 | run: npm run test 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "comply", 3 | "version": "0.4.0", 4 | "description": "Comply is a tiny library to help you define policies in your app", 5 | "main": "./dist/index.js", 6 | "module": "./dist/index.mjs", 7 | "types": "./dist/index.d.mts", 8 | "exports": { 9 | "./package.json": "./package.json", 10 | ".": { 11 | "import": { 12 | "types": "./dist/index.d.mts", 13 | "import": "./dist/index.mjs", 14 | "default": "./dist/index.mjs" 15 | }, 16 | "require": { 17 | "types": "./dist/index.d.ts", 18 | "import": "./dist/index.js", 19 | "require": "./dist/index.js" 20 | } 21 | } 22 | }, 23 | "scripts": { 24 | "test": "vitest run", 25 | "test:watch": "vitest", 26 | "test:cov": "vitest run --coverage", 27 | "postbuild": "npm run check:exports", 28 | "build": "tsup src/index.ts --config tsup.config.ts", 29 | "remix:dev": "npm run dev -w test-apps/remix-vite", 30 | "remix:cjs:dev": "npm run dev -w test-apps/remix-vite-cjs", 31 | "build:dev": "tsup src/index.ts --config tsup.dev.config.ts", 32 | "build:dev:watch": "npm run build:dev -- --watch", 33 | "build:dev:cjs:watch": "npm run build:dev -- --watch", 34 | "dev": "npm-run-all -s build:dev -p remix:dev build:dev:watch", 35 | "dev:cjs": "npm-run-all -s build:dev -p remix:cjs:dev build:dev:cjs:watch", 36 | "prepublishOnly": "npm run build", 37 | "check": "biome check .", 38 | "check:fix": "biome check --fix .", 39 | "typecheck": "tsc", 40 | "validate": "npm run check && npm run tsc && npm run test", 41 | "check:exports": "attw --pack .", 42 | "local-release": "changeset version && changeset publish" 43 | }, 44 | "author": "", 45 | "license": "MIT", 46 | "repository": { 47 | "type": "git", 48 | "url": "git+https://github.com/rphlmr/comply.git" 49 | }, 50 | "bugs": { 51 | "url": "https://github.com/rphlmr/comply/issues" 52 | }, 53 | "files": ["dist"], 54 | "workspaces": [".", "test-apps/*"], 55 | "homepage": "https://github.com/rphlmr/comply#readme", 56 | "devDependencies": { 57 | "@arethetypeswrong/cli": "^0.15.4", 58 | "@biomejs/biome": "^1.8.3", 59 | "@changesets/cli": "^2.27.7", 60 | "@types/node": "^20.12.7", 61 | "@vitest/coverage-v8": "^1.5.2", 62 | "happy-dom": "^14.7.1", 63 | "lefthook": "^1.7.2", 64 | "npm-run-all": "^4.1.5", 65 | "tsup": "^8.0.2", 66 | "typescript": "^5.4.5", 67 | "vitest": "^1.5.2", 68 | "zod": "^3.23.8" 69 | }, 70 | "engines": { 71 | "node": ">=20.0.0" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, expectTypeOf, it } from "vitest"; 2 | import { z } from "zod"; 3 | import { assert, and, check, checkAllSettle, definePolicies, definePolicy, matchSchema, notNull, or } from "."; 4 | 5 | describe("Define policy", () => { 6 | type Post = { userId: string; comments: string[] }; 7 | 8 | it("should define a policy", () => { 9 | const postHasCommentsPolicy = definePolicy( 10 | "post has comments", 11 | (post: Post) => post.comments.length > 0, 12 | () => new Error("Post has no comments") 13 | ); 14 | 15 | expect(postHasCommentsPolicy.name).toBe("post has comments"); 16 | expect(postHasCommentsPolicy.condition).toBeInstanceOf(Function); 17 | expect(postHasCommentsPolicy.errorFactory).toBeInstanceOf(Function); 18 | }); 19 | 20 | it("should check a policy", () => { 21 | const postHasCommentsPolicy = definePolicy( 22 | "post has comments", 23 | (post: Post) => post.comments.length > 0, 24 | () => new Error("Post has no comments") 25 | ); 26 | 27 | expect(postHasCommentsPolicy.check({ userId: "1", comments: [] })).toBe(false); 28 | expect(postHasCommentsPolicy.check({ userId: "1", comments: ["comment 1"] })).toBe(true); 29 | 30 | expect(check(postHasCommentsPolicy, { userId: "1", comments: [] })).toBe(false); 31 | expect(check(postHasCommentsPolicy, { userId: "1", comments: ["comment 1"] })).toBe(true); 32 | }); 33 | 34 | it("should assert a policy", () => { 35 | const postHasCommentsPolicy = definePolicy( 36 | "post has comments", 37 | (post: Post) => post.comments.length > 0, 38 | () => new Error("Post has no comments") 39 | ); 40 | 41 | expect(() => assert(postHasCommentsPolicy, { userId: "1", comments: [] })).toThrowError( 42 | new Error("Post has no comments") 43 | ); 44 | expect(() => assert(postHasCommentsPolicy, { userId: "1", comments: ["comment 1"] })).not.toThrowError(); 45 | }); 46 | 47 | it("should accept a message as error factory", () => { 48 | const postHasCommentsPolicy = definePolicy( 49 | "post has comments", 50 | (post: Post) => post.comments.length > 0, 51 | "Post has no comments" 52 | ); 53 | 54 | expect(() => assert(postHasCommentsPolicy, { userId: "1", comments: [] })).toThrowError( 55 | new Error("Post has no comments") 56 | ); 57 | 58 | try { 59 | assert(postHasCommentsPolicy, { userId: "1", comments: [] }); 60 | } catch (error) { 61 | expect((error as Error).name).toBe("PolicyRejection: [post has comments]"); 62 | } 63 | }); 64 | 65 | it("should define a policy without error factory", () => { 66 | const postHasCommentsPolicy = definePolicy("post has comments", (post: Post) => post.comments.length > 0); 67 | 68 | expect(postHasCommentsPolicy.name).toBe("post has comments"); 69 | expect(postHasCommentsPolicy.condition).toBeInstanceOf(Function); 70 | expect(postHasCommentsPolicy.errorFactory).toBeInstanceOf(Function); 71 | 72 | expect(postHasCommentsPolicy.check({ userId: "1", comments: [] })).toBe(false); 73 | expect(postHasCommentsPolicy.check({ userId: "1", comments: ["comment 1"] })).toBe(true); 74 | 75 | expect(() => assert(postHasCommentsPolicy, { userId: "1", comments: [] })).toThrowError( 76 | new Error( 77 | `[post has comments] policy is not met for the argument: ${JSON.stringify({ userId: "1", comments: [] })}` 78 | ) 79 | ); 80 | expect(() => assert(postHasCommentsPolicy, { userId: "1", comments: ["comment 1"] })).not.toThrowError(); 81 | }); 82 | 83 | it("can define a policy with a condition that takes no argument", () => { 84 | const truePolicy = definePolicy("is true", () => true); 85 | 86 | expect(truePolicy.check()).toBe(true); 87 | 88 | expect(() => assert(truePolicy)).not.toThrowError(); 89 | }); 90 | 91 | it("can define a policy with a boolean condition", () => { 92 | const truePolicy = definePolicy("is true", true); 93 | 94 | expect(truePolicy.check()).toBe(true); 95 | 96 | expect(() => assert(truePolicy)).not.toThrowError(); 97 | }); 98 | 99 | it("should allow defining a policy on the fly", () => { 100 | const params = { id: "123" }; 101 | 102 | expect(check(definePolicy("params are valid", matchSchema(z.object({ id: z.string() }))), params)).toBe(true); 103 | 104 | expect(check("params are valid", matchSchema(z.object({ id: z.string() })), params)).toBe(true); 105 | }); 106 | }); 107 | 108 | describe("Define policies", () => { 109 | type Context = { userId: string }; 110 | type Post = { userId: string; comments: string[]; status: "published" | "draft" | "archived" }; 111 | 112 | it("should define a policy set", () => { 113 | const postGuard = definePolicies([ 114 | definePolicy("post has comments", (post: Post) => post.comments.length > 0), 115 | definePolicy("post is draft", (post: Post): post is Post & { status: "draft" } => post.status === "draft"), 116 | ]); 117 | 118 | expect(postGuard.policy("post has comments").name).toBe("post has comments"); 119 | expect(postGuard.policy("post has comments").condition).toBeInstanceOf(Function); 120 | expect(postGuard.policy("post has comments").errorFactory).toBeInstanceOf(Function); 121 | }); 122 | 123 | it("should check a policy from a policy set", () => { 124 | const guard = { 125 | post: definePolicies([ 126 | definePolicy("post has comments", (post: Post) => post.comments.length > 0), 127 | definePolicy("post is draft", (post: Post) => post.status === "draft"), 128 | ]), 129 | }; 130 | 131 | expect(guard.post.policy("post has comments").check({ userId: "1", comments: [], status: "published" })).toBe( 132 | false 133 | ); 134 | expect(check(guard.post.policy("post has comments"), { userId: "1", comments: [], status: "published" })).toBe( 135 | false 136 | ); 137 | 138 | expect( 139 | guard.post.policy("post has comments").check({ userId: "1", comments: ["comment 1"], status: "published" }) 140 | ).toBe(true); 141 | expect( 142 | check(guard.post.policy("post has comments"), { userId: "1", comments: ["comment 1"], status: "published" }) 143 | ).toBe(true); 144 | 145 | expect(guard.post.policy("post is draft").check({ userId: "1", comments: [], status: "published" })).toBe(false); 146 | expect(check(guard.post.policy("post is draft"), { userId: "1", comments: [], status: "published" })).toBe(false); 147 | 148 | expect(guard.post.policy("post is draft").check({ userId: "1", comments: ["comment 1"], status: "draft" })).toBe( 149 | true 150 | ); 151 | expect(check(guard.post.policy("post is draft"), { userId: "1", comments: ["comment 1"], status: "draft" })).toBe( 152 | true 153 | ); 154 | }); 155 | 156 | it("should assert a policy from a policy set", () => { 157 | const guard = { 158 | post: definePolicies([ 159 | definePolicy( 160 | "post has comments", 161 | (post: Post) => post.comments.length > 0, 162 | () => new Error("Post has no comments") 163 | ), 164 | definePolicy("post is draft", (post: Post) => post.status === "draft"), 165 | ]), 166 | }; 167 | 168 | expect(() => 169 | assert(guard.post.policy("post has comments"), { userId: "1", comments: [], status: "published" }) 170 | ).toThrowError(new Error("Post has no comments")); 171 | expect(() => 172 | assert(guard.post.policy("post has comments"), { userId: "1", comments: ["comment 1"], status: "published" }) 173 | ).not.toThrowError(); 174 | }); 175 | 176 | it("should define a policy set with a factory function which takes a `context` argument", () => { 177 | const PostPolicies = definePolicies((context: Context) => [ 178 | definePolicy( 179 | "my post", 180 | (post: Post) => post.userId === context.userId, 181 | () => new Error("Not the author") 182 | ), 183 | ]); 184 | 185 | const context: Context = { userId: "1" }; 186 | 187 | const guard = { 188 | post: PostPolicies(context), 189 | }; 190 | 191 | expect(check(guard.post.policy("my post"), { userId: "1", comments: [], status: "published" })).toBe(true); 192 | expect(check(guard.post.policy("my post"), { userId: "2", comments: [], status: "published" })).toBe(false); 193 | 194 | expect(() => 195 | assert(guard.post.policy("my post"), { userId: "1", comments: [], status: "published" }) 196 | ).not.toThrowError(); 197 | expect(() => assert(guard.post.policy("my post"), { userId: "2", comments: [], status: "published" })).toThrowError( 198 | new Error("Not the author") 199 | ); 200 | }); 201 | 202 | it("should define a policy set with compound conditions that require every condition to be met", () => { 203 | const PostPolicies = definePolicies((context: Context) => { 204 | const myPostPolicy = definePolicy( 205 | "my post", 206 | (post: Post) => post.userId === context.userId, 207 | () => new Error("Not the author") 208 | ); 209 | 210 | return [ 211 | myPostPolicy, 212 | definePolicy("my published post", (post: Post) => 213 | and( 214 | () => check(myPostPolicy, post), 215 | () => post.status === "published" 216 | ) 217 | ), 218 | ]; 219 | }); 220 | 221 | const context: Context = { userId: "1" }; 222 | 223 | const guard = { 224 | post: PostPolicies(context), 225 | }; 226 | 227 | expect(check(guard.post.policy("my published post"), { userId: "1", comments: [], status: "published" })).toBe( 228 | true 229 | ); 230 | expect(check(guard.post.policy("my published post"), { userId: "1", comments: [], status: "draft" })).toBe(false); 231 | expect(check(guard.post.policy("my published post"), { userId: "2", comments: [], status: "published" })).toBe( 232 | false 233 | ); 234 | 235 | expect(() => 236 | assert(guard.post.policy("my published post"), { userId: "1", comments: [], status: "published" }) 237 | ).not.toThrowError(); 238 | expect(() => 239 | assert(guard.post.policy("my published post"), { userId: "1", comments: [], status: "draft" }) 240 | ).toThrowError(); 241 | expect(() => 242 | assert(guard.post.policy("my published post"), { userId: "2", comments: [], status: "published" }) 243 | ).toThrowError(); 244 | }); 245 | 246 | it("should define a policy set with compound conditions that require at least one condition to be met", () => { 247 | const PostPolicies = definePolicies((context: Context) => { 248 | const myPostPolicy = definePolicy( 249 | "my post", 250 | (post: Post) => post.userId === context.userId, 251 | () => new Error("Not the author") 252 | ); 253 | 254 | return [ 255 | myPostPolicy, 256 | definePolicy("all published posts or mine", (post: Post) => 257 | or( 258 | () => check(myPostPolicy, post), 259 | () => post.status === "published" 260 | ) 261 | ), 262 | ]; 263 | }); 264 | 265 | const context: Context = { userId: "1" }; 266 | 267 | const guard = { 268 | post: PostPolicies(context), 269 | }; 270 | 271 | expect( 272 | check(guard.post.policy("all published posts or mine"), { userId: "1", comments: [], status: "published" }) 273 | ).toBe(true); 274 | expect( 275 | check(guard.post.policy("all published posts or mine"), { userId: "1", comments: [], status: "draft" }) 276 | ).toBe(true); 277 | expect( 278 | check(guard.post.policy("all published posts or mine"), { userId: "2", comments: [], status: "published" }) 279 | ).toBe(true); 280 | expect( 281 | check(guard.post.policy("all published posts or mine"), { userId: "2", comments: [], status: "draft" }) 282 | ).toBe(false); 283 | 284 | expect(() => 285 | assert(guard.post.policy("all published posts or mine"), { userId: "1", comments: [], status: "published" }) 286 | ).not.toThrowError(); 287 | expect(() => 288 | assert(guard.post.policy("all published posts or mine"), { userId: "1", comments: [], status: "draft" }) 289 | ).not.toThrowError(); 290 | expect(() => 291 | assert(guard.post.policy("all published posts or mine"), { userId: "2", comments: [], status: "published" }) 292 | ).not.toThrowError(); 293 | expect(() => 294 | assert(guard.post.policy("all published posts or mine"), { userId: "2", comments: [], status: "draft" }) 295 | ).toThrowError(); 296 | }); 297 | 298 | it("should define a policy set that comprises other policy sets only", () => { 299 | type Context = { role: "admin" | "user" | "bot" }; 300 | 301 | const AdminPolicies = definePolicies((context: Context) => [ 302 | definePolicy("has admin role", () => context.role === "admin"), 303 | ]); 304 | 305 | const PostPolicies = definePolicies((context: Context) => { 306 | const adminGuard = AdminPolicies(context); 307 | 308 | return [ 309 | definePolicy("can moderate comments", or(context.role === "bot", check(adminGuard.policy("has admin role")))), 310 | ]; 311 | }); 312 | 313 | expect(check(PostPolicies({ role: "admin" }).policy("can moderate comments"))).toBe(true); 314 | expect(check(PostPolicies({ role: "bot" }).policy("can moderate comments"))).toBe(true); 315 | expect(check(PostPolicies({ role: "user" }).policy("can moderate comments"))).toBe(false); 316 | 317 | expect(() => assert(PostPolicies({ role: "admin" }).policy("can moderate comments"))).not.toThrowError(); 318 | expect(() => assert(PostPolicies({ role: "bot" }).policy("can moderate comments"))).not.toThrowError(); 319 | expect(() => assert(PostPolicies({ role: "user" }).policy("can moderate comments"))).toThrowError(); 320 | }); 321 | 322 | it("should define a policy set that comprises other policy sets", () => { 323 | type Context = { userId: string; role: "admin" | "user" }; 324 | 325 | const AdminPolicies = definePolicies((context: Context) => [ 326 | definePolicy("has admin role", () => context.role === "admin"), 327 | ]); 328 | 329 | const PostPolicies = definePolicies((context: Context) => { 330 | const adminGuard = AdminPolicies(context); 331 | 332 | return [ 333 | definePolicy("can edit comments", (post: Post) => 334 | or( 335 | () => post.userId === context.userId, 336 | () => check(adminGuard.policy("has admin role")) 337 | ) 338 | ), 339 | ]; 340 | }); 341 | 342 | const context: Context = { userId: "1", role: "admin" }; 343 | 344 | const guard = { 345 | admin: AdminPolicies(context), 346 | post: PostPolicies(context), 347 | }; 348 | 349 | expect(check(guard.post.policy("can edit comments"), { userId: "1", comments: [], status: "published" })).toBe( 350 | true 351 | ); 352 | expect(check(guard.post.policy("can edit comments"), { userId: "2", comments: [], status: "published" })).toBe( 353 | true 354 | ); 355 | 356 | expect(() => 357 | assert(guard.post.policy("can edit comments"), { userId: "1", comments: [], status: "published" }) 358 | ).not.toThrowError(); 359 | expect(() => 360 | assert(guard.post.policy("can edit comments"), { userId: "2", comments: [], status: "published" }) 361 | ).not.toThrowError(); 362 | }); 363 | 364 | it("should define a policy set that returns a policy set factory", () => { 365 | type Context = { userId: string; rolesByOrg: Record }; 366 | 367 | const orgPolicies = definePolicies((context: Context) => (orgId: string) => { 368 | const currentUserOrgRole = context.rolesByOrg[orgId]; 369 | 370 | return [definePolicy("can administrate org", () => currentUserOrgRole === "admin")]; 371 | }); 372 | 373 | const context: Context = { userId: "1", rolesByOrg: { "it-department": "admin", "sales-team": "user" } }; 374 | 375 | const guard = { 376 | org: orgPolicies(context), 377 | }; 378 | 379 | expect(check(guard.org("it-department").policy("can administrate org"))).toBe(true); 380 | expect(check(guard.org("sales-team").policy("can administrate org"))).toBe(false); 381 | 382 | expect(() => assert(guard.org("it-department").policy("can administrate org"))).not.toThrowError(); 383 | expect(() => assert(guard.org("sales-team").policy("can administrate org"))).toThrowError(); 384 | }); 385 | 386 | it("should work with resolved async params", async () => { 387 | // Note: This is just to demonstrate that we can mix async validation with policy conditions until TypeScript enables async type guards 388 | // https://github.com/microsoft/TypeScript/issues/37681 389 | // We need that to fully support async condition that still preserve inference 390 | type Context = { userId: string; rolesByOrg: Record }; 391 | 392 | const orgPolicies = definePolicies((context: Context) => { 393 | return (orgId: string) => [ 394 | definePolicy("can administrate org", (stillOrgAdmin: boolean) => 395 | and( 396 | () => context.rolesByOrg[orgId] === "admin", 397 | () => stillOrgAdmin 398 | ) 399 | ), 400 | ]; 401 | }); 402 | 403 | const context: Context = { userId: "1", rolesByOrg: { "it-department": "admin", "sales-team": "user" } }; 404 | 405 | const guard = { 406 | org: orgPolicies(context), 407 | }; 408 | 409 | // fake server check 410 | async function checkIfStillOrgAdmin(orgId: string) { 411 | return await Promise.resolve(orgId === "it-department"); 412 | } 413 | 414 | expect( 415 | check(guard.org("it-department").policy("can administrate org"), await checkIfStillOrgAdmin("it-department")) 416 | ).toBe(true); 417 | expect( 418 | check(guard.org("sales-team").policy("can administrate org"), await checkIfStillOrgAdmin("sales-team")) 419 | ).toBe(false); 420 | }); 421 | }); 422 | 423 | describe("Inference", () => { 424 | it("should infer scalar from policy", () => { 425 | type Label = string | null; 426 | 427 | const guard = { 428 | input: definePolicies([definePolicy("not null", notNull)]), 429 | }; 430 | 431 | const label: Label = "label"; 432 | 433 | function test(label: Label) { 434 | if (check(guard.input.policy("not null"), label)) { 435 | expect(label).not.toBeNull(); 436 | expectTypeOf(label).toEqualTypeOf(); 437 | } 438 | 439 | expect(() => { 440 | assert(guard.input.policy("not null"), label); 441 | expect(label).not.toBeNull(); 442 | expectTypeOf(label).toEqualTypeOf(); 443 | }).not.toThrowError(); 444 | } 445 | 446 | test(label); 447 | 448 | expect.assertions(3); 449 | }); 450 | 451 | it("should infer scalar from implicit policy", () => { 452 | type Label = string | null; 453 | const label: Label = "label"; 454 | 455 | function test(label: Label) { 456 | if (check("not null", notNull, label)) { 457 | expect(label).not.toBeNull(); 458 | expectTypeOf(label).toEqualTypeOf(); 459 | } 460 | 461 | expect(() => { 462 | assert("not null", notNull, label); 463 | expect(label).not.toBeNull(); 464 | expectTypeOf(label).toEqualTypeOf(); 465 | }).not.toThrowError(); 466 | } 467 | 468 | test(label); 469 | 470 | expect.assertions(3); 471 | }); 472 | 473 | it("should infer object from policy", () => { 474 | type Post = { userId: string; comments: string[]; status: "published" | "draft" | "archived" }; 475 | 476 | const guard = { 477 | post: definePolicies([ 478 | definePolicy( 479 | "published post", 480 | (post: Post): post is Post & { status: "published" } => post.status === "published" 481 | ), 482 | ]), 483 | }; 484 | 485 | const post: Post = { userId: "1", comments: [], status: "published" }; 486 | 487 | function test(post: Post) { 488 | if (check(guard.post.policy("published post"), post)) { 489 | expect(post.status).toBe("published"); 490 | expectTypeOf(post.status).toEqualTypeOf<"published">(); 491 | } 492 | 493 | expect(() => { 494 | assert(guard.post.policy("published post"), post); 495 | expect(post.status).toBe("published"); 496 | expectTypeOf(post.status).toEqualTypeOf<"published">(); 497 | }).not.toThrowError(); 498 | } 499 | 500 | test(post); 501 | 502 | expect.assertions(3); 503 | }); 504 | 505 | it("should infer object from implicit policy", () => { 506 | type Post = { userId: string; comments: string[]; status: "published" | "draft" | "archived" }; 507 | 508 | const post: Post = { userId: "1", comments: [], status: "published" }; 509 | 510 | function test(post: Post) { 511 | // type predicate 512 | if ( 513 | check( 514 | "published post", 515 | (post: Post): post is Post & { status: "published" } => post.status === "published", 516 | post 517 | ) 518 | ) { 519 | expect(post.status).toBe("published"); 520 | expectTypeOf(post.status).toEqualTypeOf<"published">(); 521 | } 522 | 523 | expect(() => { 524 | assert( 525 | "published post", 526 | (post: Post): post is Post & { status: "published" } => post.status === "published", 527 | post 528 | ); 529 | expect(post.status).toBe("published"); 530 | expectTypeOf(post.status).toEqualTypeOf<"published">(); 531 | }).not.toThrowError(); 532 | } 533 | 534 | test(post); 535 | 536 | expect.assertions(3); 537 | }); 538 | 539 | it("should infer a zod schema from a policy", () => { 540 | const PostSchema = z.object({ 541 | userId: z.string(), 542 | comments: z.array(z.string()), 543 | status: z.union([z.literal("published"), z.literal("draft"), z.literal("archived")]), 544 | }); 545 | type Post = z.infer; 546 | 547 | const guard = { 548 | post: definePolicies([ 549 | definePolicy("published post", matchSchema(PostSchema.extend({ status: z.literal("published") }))), 550 | ]), 551 | }; 552 | 553 | const post: Post = { userId: "1", comments: [], status: "published" }; 554 | 555 | function test(post: Post) { 556 | if (check(guard.post.policy("published post"), post)) { 557 | expect(post.status).toBe("published"); 558 | expectTypeOf(post.status).toEqualTypeOf<"published">(); 559 | } 560 | 561 | expect(() => { 562 | assert(guard.post.policy("published post"), post); 563 | expect(post.status).toBe("published"); 564 | expectTypeOf(post.status).toEqualTypeOf<"published">(); 565 | }).not.toThrowError(); 566 | } 567 | 568 | test(post); 569 | 570 | expect.assertions(3); 571 | }); 572 | 573 | it("should infer a zod schema from an implicit policy", () => { 574 | const PostSchema = z.object({ 575 | userId: z.string(), 576 | comments: z.array(z.string()), 577 | status: z.union([z.literal("published"), z.literal("draft"), z.literal("archived")]), 578 | }); 579 | type Post = z.infer; 580 | 581 | const post: Post = { userId: "1", comments: [], status: "published" }; 582 | 583 | function test(post: Post) { 584 | if (check("published post", matchSchema(PostSchema.extend({ status: z.literal("published") })), post)) { 585 | expect(post.status).toBe("published"); 586 | expectTypeOf(post.status).toEqualTypeOf<"published">(); 587 | } 588 | 589 | expect(() => { 590 | assert("published post", matchSchema(PostSchema.extend({ status: z.literal("published") })), post); 591 | expect(post.status).toBe("published"); 592 | expectTypeOf(post.status).toEqualTypeOf<"published">(); 593 | }).not.toThrowError(); 594 | } 595 | 596 | test(post); 597 | 598 | expect.assertions(3); 599 | }); 600 | 601 | it("should error if check is called with wrong signature", () => { 602 | // no condition arg 603 | expectTypeOf(check("policy", () => true)).toEqualTypeOf(); 604 | expectTypeOf(check(definePolicy("policy", () => true))).toEqualTypeOf(); 605 | 606 | // extra arg 607 | expectTypeOf( 608 | /** @ts-expect-error */ 609 | check("policy", () => true, {}) 610 | ).toEqualTypeOf(); 611 | expectTypeOf( 612 | /** @ts-expect-error */ 613 | check( 614 | definePolicy("policy", () => true), 615 | {} 616 | ) 617 | ).toEqualTypeOf(); 618 | 619 | // missing arg 620 | expectTypeOf( 621 | /** @ts-expect-error */ 622 | check("policy", (d: any) => d) 623 | ).toEqualTypeOf(); 624 | expectTypeOf( 625 | /** @ts-expect-error */ 626 | check(definePolicy("policy", (d: any) => d)) 627 | ).toEqualTypeOf(); 628 | 629 | // missing arg type guard 630 | expectTypeOf( 631 | /** @ts-expect-error */ 632 | check("policy", (d: string | null): d is string => d) 633 | ).toEqualTypeOf(); 634 | expectTypeOf( 635 | /** @ts-expect-error */ 636 | check(definePolicy("policy", (d: string | null): d is string => d)) 637 | ).toEqualTypeOf(); 638 | }); 639 | 640 | it("should error if assert is called with wrong signature", () => { 641 | try { 642 | // no condition arg 643 | expectTypeOf(assert("policy", () => true)).toEqualTypeOf(); 644 | expectTypeOf(assert(definePolicy("policy", () => true))).toEqualTypeOf(); 645 | 646 | // extra arg 647 | expectTypeOf( 648 | /** @ts-expect-error */ 649 | assert("policy", () => true, {}) 650 | ).toEqualTypeOf(); 651 | expectTypeOf( 652 | /** @ts-expect-error */ 653 | assert( 654 | definePolicy("policy", () => true), 655 | {} 656 | ) 657 | ).toEqualTypeOf(); 658 | 659 | // missing arg 660 | expectTypeOf( 661 | /** @ts-expect-error */ 662 | assert("policy", (d: any) => d) 663 | ).toEqualTypeOf(); 664 | expectTypeOf( 665 | /** @ts-expect-error */ 666 | assert(definePolicy("policy", (d: any) => d)) 667 | ).toEqualTypeOf(); 668 | 669 | // missing arg type guard 670 | expectTypeOf( 671 | /** @ts-expect-error */ 672 | assert("policy", (d: string | null): d is string => d) 673 | ).toEqualTypeOf(); 674 | expectTypeOf( 675 | /** @ts-expect-error */ 676 | assert(definePolicy("policy", (d: string | null): d is string => d)) 677 | ).toEqualTypeOf(); 678 | } catch (e) { 679 | // We only test types here 680 | } 681 | }); 682 | }); 683 | 684 | describe("Logical operators", () => { 685 | type Context = { userId: string }; 686 | type Post = { userId: string; comments: string[]; status: "published" | "draft" | "archived" }; 687 | 688 | it("should [or] accept predicates", () => { 689 | const PostPolicies = definePolicies((context: Context) => { 690 | const myPostPolicy = definePolicy( 691 | "my post", 692 | (post: Post) => post.userId === context.userId, 693 | () => new Error("Not the author") 694 | ); 695 | 696 | return [ 697 | myPostPolicy, 698 | definePolicy("all published posts or mine", (post: Post) => 699 | or( 700 | () => check(myPostPolicy, post), 701 | () => post.status === "published" 702 | ) 703 | ), 704 | ]; 705 | }); 706 | 707 | const guard = { 708 | post: PostPolicies({ userId: "1" }), 709 | }; 710 | 711 | expect( 712 | check(guard.post.policy("all published posts or mine"), { userId: "1", comments: [], status: "draft" }) 713 | ).toBe(true); 714 | expect( 715 | check(guard.post.policy("all published posts or mine"), { 716 | userId: "2", 717 | comments: [], 718 | status: "published", 719 | }) 720 | ).toBe(true); 721 | expect( 722 | check(guard.post.policy("all published posts or mine"), { userId: "2", comments: [], status: "draft" }) 723 | ).toBe(false); 724 | 725 | expect(() => 726 | assert(guard.post.policy("all published posts or mine"), { userId: "1", comments: [], status: "draft" }) 727 | ).not.toThrowError(); 728 | expect(() => 729 | assert(guard.post.policy("all published posts or mine"), { 730 | userId: "2", 731 | comments: [], 732 | status: "published", 733 | }) 734 | ).not.toThrowError(); 735 | expect(() => 736 | assert(guard.post.policy("all published posts or mine"), { userId: "2", comments: [], status: "draft" }) 737 | ).toThrowError(); 738 | }); 739 | 740 | it("should [or] accept booleans", () => { 741 | const PostPolicies = definePolicies((context: Context) => { 742 | const myPostPolicy = definePolicy( 743 | "my post", 744 | (post: Post) => post.userId === context.userId, 745 | () => new Error("Not the author") 746 | ); 747 | 748 | return [ 749 | myPostPolicy, 750 | definePolicy("all published posts or mine", (post: Post) => 751 | or(check(myPostPolicy, post), post.status === "published") 752 | ), 753 | ]; 754 | }); 755 | 756 | const guard = { 757 | post: PostPolicies({ userId: "1" }), 758 | }; 759 | 760 | expect( 761 | check(guard.post.policy("all published posts or mine"), { userId: "1", comments: [], status: "draft" }) 762 | ).toBe(true); 763 | expect( 764 | check(guard.post.policy("all published posts or mine"), { 765 | userId: "2", 766 | comments: [], 767 | status: "published", 768 | }) 769 | ).toBe(true); 770 | expect( 771 | check(guard.post.policy("all published posts or mine"), { userId: "2", comments: [], status: "draft" }) 772 | ).toBe(false); 773 | 774 | expect(() => 775 | assert(guard.post.policy("all published posts or mine"), { userId: "1", comments: [], status: "draft" }) 776 | ).not.toThrowError(); 777 | expect(() => 778 | assert(guard.post.policy("all published posts or mine"), { 779 | userId: "2", 780 | comments: [], 781 | status: "published", 782 | }) 783 | ).not.toThrowError(); 784 | expect(() => 785 | assert(guard.post.policy("all published posts or mine"), { userId: "2", comments: [], status: "draft" }) 786 | ).toThrowError(); 787 | }); 788 | 789 | it("should [and] accept predicates", () => { 790 | const PostPolicies = definePolicies((context: Context) => { 791 | const myPostPolicy = definePolicy( 792 | "my post", 793 | (post: Post) => post.userId === context.userId, 794 | () => new Error("Not the author") 795 | ); 796 | 797 | return [ 798 | myPostPolicy, 799 | definePolicy("all my published posts", (post: Post) => 800 | and( 801 | () => check(myPostPolicy, post), 802 | () => post.status === "published" 803 | ) 804 | ), 805 | ]; 806 | }); 807 | 808 | const guard = { 809 | post: PostPolicies({ userId: "1" }), 810 | }; 811 | 812 | expect(check(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "published" })).toBe( 813 | true 814 | ); 815 | expect(check(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "draft" })).toBe( 816 | false 817 | ); 818 | expect( 819 | check(guard.post.policy("all my published posts"), { 820 | userId: "2", 821 | comments: [], 822 | status: "published", 823 | }) 824 | ).toBe(false); 825 | 826 | expect(() => 827 | assert(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "published" }) 828 | ).not.toThrowError(); 829 | expect(() => 830 | assert(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "draft" }) 831 | ).toThrowError(); 832 | expect(() => 833 | assert(guard.post.policy("all my published posts"), { 834 | userId: "2", 835 | comments: [], 836 | status: "published", 837 | }) 838 | ).toThrowError(); 839 | }); 840 | 841 | it("should [and] accept booleans", () => { 842 | const PostPolicies = definePolicies((context: Context) => { 843 | const myPostPolicy = definePolicy( 844 | "my post", 845 | (post: Post) => post.userId === context.userId, 846 | () => new Error("Not the author") 847 | ); 848 | 849 | return [ 850 | myPostPolicy, 851 | definePolicy("all my published posts", (post: Post) => 852 | and(check(myPostPolicy, post), post.status === "published") 853 | ), 854 | ]; 855 | }); 856 | 857 | const guard = { 858 | post: PostPolicies({ userId: "1" }), 859 | }; 860 | 861 | expect(check(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "published" })).toBe( 862 | true 863 | ); 864 | expect(check(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "draft" })).toBe( 865 | false 866 | ); 867 | expect( 868 | check(guard.post.policy("all my published posts"), { 869 | userId: "2", 870 | comments: [], 871 | status: "published", 872 | }) 873 | ).toBe(false); 874 | 875 | expect(() => 876 | assert(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "published" }) 877 | ).not.toThrowError(); 878 | expect(() => 879 | assert(guard.post.policy("all my published posts"), { userId: "1", comments: [], status: "draft" }) 880 | ).toThrowError(); 881 | expect(() => 882 | assert(guard.post.policy("all my published posts"), { 883 | userId: "2", 884 | comments: [], 885 | status: "published", 886 | }) 887 | ).toThrowError(); 888 | }); 889 | }); 890 | 891 | describe("Check all settle", () => { 892 | type Context = { userId: string }; 893 | type Post = { userId: string; comments: string[]; status: "published" | "draft" | "archived" }; 894 | 895 | it("should snapshot policies", () => { 896 | const PostPolicies = definePolicies((context: Context) => { 897 | const myPostPolicy = definePolicy( 898 | "shared policy within policy set", 899 | (post: Post) => post.userId === context.userId, 900 | () => new Error("Not the author") 901 | ); 902 | 903 | return [ 904 | myPostPolicy, 905 | definePolicy("policy from policy set", (post: Post) => 906 | and(check(myPostPolicy, post), post.status === "published") 907 | ), 908 | ]; 909 | }); 910 | 911 | const guard = { 912 | post: PostPolicies({ userId: "1" }), 913 | }; 914 | 915 | const snapshot = checkAllSettle([ 916 | [definePolicy("policy with arg", notNull), "not null"], 917 | ["implicit policy with arg", notNull, "not null"], 918 | [definePolicy("policy with no arg", true)], 919 | definePolicy("policy with no arg simple version", true), 920 | ["implicit policy with boolean", true], 921 | ["implicit policy with no arg function", () => true], 922 | [guard.post.policy("shared policy within policy set"), { userId: "1", comments: [], status: "published" }], 923 | [guard.post.policy("policy from policy set"), { userId: "1", comments: [], status: "published" }], 924 | ]); 925 | 926 | expect(snapshot).toStrictEqual({ 927 | "policy with arg": true, 928 | "implicit policy with arg": true, 929 | "policy with no arg": true, 930 | "policy with no arg simple version": true, 931 | "implicit policy with boolean": true, 932 | "implicit policy with no arg function": true, 933 | "shared policy within policy set": true, 934 | "policy from policy set": true, 935 | }); 936 | 937 | expectTypeOf(snapshot).toEqualTypeOf<{ 938 | "policy with arg": boolean; 939 | "implicit policy with arg": boolean; 940 | "policy with no arg": boolean; 941 | "policy with no arg simple version": boolean; 942 | "implicit policy with boolean": boolean; 943 | "implicit policy with no arg function": boolean; 944 | "shared policy within policy set": boolean; 945 | "policy from policy set": boolean; 946 | }>(); 947 | 948 | expectTypeOf( 949 | checkAllSettle([ 950 | [ 951 | /** @ts-expect-error */ 952 | definePolicy("is not null", notNull), 953 | ], 954 | ]) 955 | ).toEqualTypeOf<{ [x: string]: boolean }>(); 956 | expectTypeOf( 957 | checkAllSettle([ 958 | [ 959 | /** @ts-expect-error */ 960 | definePolicy("is true", true), 961 | "extra arg", 962 | ], 963 | ]) 964 | ).toEqualTypeOf<{ 965 | [x: string]: boolean; 966 | }>(); 967 | expectTypeOf( 968 | checkAllSettle([ 969 | /** @ts-expect-error */ 970 | definePolicy("is true", (v: unknown) => true), 971 | ]) 972 | ).toEqualTypeOf<{ 973 | [x: string]: boolean; 974 | }>(); 975 | }); 976 | }); 977 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { z } from "zod"; 2 | 3 | /* -------------------------------------------------------------------------- */ 4 | /* Policy; */ 5 | /* -------------------------------------------------------------------------- */ 6 | 7 | type PolicyError = Error; 8 | 9 | type PolicyErrorFactory = (arg: unknown) => T; 10 | 11 | type PolicyConditionWithArg = (arg: T) => boolean; 12 | 13 | type PolicyConditionArg

= P extends PolicyConditionWithArg ? T : never; 14 | 15 | type PolicyConditionTypeGuard = (arg: T) => arg is U; 16 | 17 | type PolicyConditionTypeGuardResult

= P extends PolicyConditionTypeGuard 18 | ? U 19 | : PolicyConditionArg

; 20 | 21 | type PolicyConditionNoArg = (() => boolean) | boolean; 22 | 23 | type PolicyCondition = 24 | | PolicyConditionTypeGuard 25 | | PolicyConditionWithArg 26 | | PolicyConditionNoArg; 27 | 28 | type PolicyName = string; 29 | 30 | class Policy< 31 | TPolicyName extends PolicyName, 32 | TPolicyCondition extends PolicyCondition, 33 | TPolicyErrorFactory extends PolicyErrorFactory = PolicyErrorFactory, 34 | TPolicyConditionArg = PolicyConditionArg, 35 | TResult extends TPolicyConditionArg = PolicyConditionTypeGuardResult, 36 | > { 37 | constructor( 38 | readonly name: TPolicyName, 39 | readonly condition: TPolicyCondition, 40 | readonly errorFactory: TPolicyErrorFactory 41 | ) { 42 | this.name = name; 43 | this.condition = condition; 44 | this.errorFactory = errorFactory; 45 | } 46 | 47 | // assert( 48 | // arg: TPolicyCondition extends PolicyConditionNoArg ? void : TPolicyConditionArg 49 | // ): asserts arg is TPolicyCondition extends PolicyConditionNoArg ? void : TResult { 50 | // if (!this.condition(arg)) { 51 | // throw this.errorFactory(arg); 52 | // } 53 | // } 54 | 55 | check( 56 | arg: TPolicyCondition extends PolicyConditionNoArg ? void : TPolicyConditionArg 57 | ): arg is TPolicyCondition extends PolicyConditionNoArg ? void : TResult { 58 | return typeof this.condition === "boolean" ? this.condition : this.condition(arg); 59 | } 60 | } 61 | 62 | /** 63 | * Define a policy 64 | * 65 | * @param name - The name of the policy 66 | * @param condition - The condition that the policy checks 67 | * @param errorFactoryOrMessage - The error factory or message of the policy 68 | * 69 | * @example 70 | * ```ts 71 | const postHasCommentsPolicy = definePolicy( 72 | "post has comments", 73 | (post: Post) => post.comments.length > 0, 74 | () => new Error("Post has no comments") 75 | ); 76 | * ``` 77 | */ 78 | export function definePolicy< 79 | TPolicyName extends PolicyName, 80 | TPolicyCondition extends PolicyCondition, 81 | TPolicyErrorFactory extends PolicyErrorFactory = PolicyErrorFactory, 82 | >(name: TPolicyName, condition: TPolicyCondition, errorFactoryOrMessage?: TPolicyErrorFactory | string) { 83 | const errorFactory = 84 | typeof errorFactoryOrMessage === "function" 85 | ? errorFactoryOrMessage 86 | : (arg: unknown) => { 87 | const error = new Error( 88 | errorFactoryOrMessage || 89 | `[${name}] policy is not met for the argument: ${arg ? JSON.stringify(arg) : ""}` 90 | ); 91 | error.name = `PolicyRejection: [${name}]`; 92 | return error; 93 | }; 94 | 95 | return new Policy(name, condition, errorFactory); 96 | } 97 | 98 | /* -------------------------------------------------------------------------- */ 99 | /* Policy Set; */ 100 | /* -------------------------------------------------------------------------- */ 101 | 102 | class PolicySet< 103 | TPolicies extends Policy[], 104 | TPolicy extends TPolicies[number] = TPolicies[number], 105 | TPolicyName extends TPolicy["name"] = TPolicy["name"], 106 | > { 107 | private readonly set = {} as Record; 108 | 109 | constructor(policies: TPolicies) { 110 | for (const policy of policies) { 111 | this.set[policy.name as TPolicyName] = policy as TPolicy; 112 | } 113 | } 114 | 115 | policy(name: TName): Extract { 116 | return this.set[name] as Extract; 117 | } 118 | } 119 | 120 | type AnyPolicy = Policy; 121 | 122 | type AnyPolicies = AnyPolicy[]; 123 | 124 | type PolicyFactory = (...args: any[]) => AnyPolicies; 125 | 126 | type PoliciesOrFactory = AnyPolicies | PolicyFactory; 127 | 128 | type PolicySetOrFactory = T extends AnyPolicies 129 | ? PolicySet 130 | : T extends PolicyFactory 131 | ? (...args: Parameters) => PolicySet> 132 | : never; 133 | 134 | type WithRequiredArg = T extends (arg: infer A) => any ? (unknown extends A ? never : T) : never; 135 | 136 | /** 137 | * Create a set of policies 138 | * 139 | * @param policies - Policies to be added to the set 140 | * 141 | * @example 142 | * 143 | * ```ts 144 | // define a policy set 145 | const Guard = () => ({ 146 | post: definePolicies([ 147 | definePolicy("post has comments", (post: Post) => post.comments.length > 0), 148 | // unlock type inference with type guards 👇 149 | definePolicy("post is draft", (post: Post) => post.status === "draft"), 150 | ]), 151 | }); 152 | 153 | const guard = Guard(); 154 | 155 | // use it 156 | if (check(guard.post.policy("post has comments"), post)) { 157 | // post has comments 158 | } 159 | 160 | if (check(guard.post.policy("post is draft"), post)) { 161 | // post.status === "draft" + inferred type 162 | } 163 | 164 | assert(guard.post.policy("post has comments"), post); // throws if the condition is not met 165 | // post has comments 166 | * ``` 167 | */ 168 | export function definePolicies(policies: T): PolicySet; 169 | 170 | /** 171 | * Create a set of policies from a factory function which takes a `context` argument. 172 | * 173 | * The factory function should return a policy set or a policy set factory. 174 | * 175 | * @param define - A function that takes a context and returns a policy set or a policy set factory 176 | * 177 | * @example 178 | * **Returns a policy set** 179 | * ```ts 180 | * // define a policy set 181 | const PostPolicies = definePolicies((context: Context) => [ 182 | definePolicy( 183 | "my post", 184 | (post: Post) => post.userId === context.userId, 185 | ), 186 | ]); 187 | 188 | const Guard = () => ({ 189 | post: PostPolicies(context), 190 | }); 191 | 192 | const guard = Guard(); 193 | 194 | // use it 195 | if (check(guard.post.policy("my post"), post)) { 196 | // post.userId === context.userId 197 | } 198 | 199 | assert(guard.post.policy("my post"), post); // throws if the condition is not met 200 | // post.userId === context.userId 201 | * ``` 202 | * **Returns a policy set factory** 203 | * ```ts 204 | * // define a policy set 205 | const PostPolicies = definePolicies((context: Context) => { 206 | return (orgId: string) => [ 207 | definePolicy("can administrate org", () => context.rolesByOrg[orgId] === "admin"), 208 | ]; 209 | }); 210 | 211 | const Guard = () => ({ 212 | org: PostPolicies(context), 213 | }); 214 | 215 | // use it 216 | if (check(guard.org("it-department").policy("can administrate org"))) { 217 | // context.rolesByOrg["it-department"] === "admin" 218 | } 219 | 220 | assert(guard.org("it-department").policy("can administrate org")); // throws if the condition is not met 221 | // context.rolesByOrg["it-department"] === "admin" 222 | * ``` 223 | */ 224 | export function definePolicies( 225 | define: WithRequiredArg<(context: Context) => T> 226 | ): (context: Context) => PolicySetOrFactory; 227 | 228 | export function definePolicies(defineOrPolicies: T | ((context: Context) => T)) { 229 | if (Array.isArray(defineOrPolicies)) { 230 | return new PolicySet(defineOrPolicies); 231 | } 232 | 233 | return (context: Context) => { 234 | const policiesOrFactory = defineOrPolicies(context); 235 | 236 | if (typeof policiesOrFactory === "function") { 237 | return (...args: any[]) => new PolicySet(policiesOrFactory(...args)); 238 | } 239 | 240 | return new PolicySet(policiesOrFactory); 241 | }; 242 | } 243 | 244 | /** 245 | * Logical OR operator for policy conditions. 246 | * 247 | * At least one of the policies or conditions must be met for the result to be true 248 | * 249 | * @example 250 | * ```ts 251 | const PostPolicies = definePolicies((context: Context) => { 252 | const myPostPolicy = definePolicy( 253 | "my post", 254 | (post: Post) => post.userId === context.userId, 255 | () => new Error("Not the author") 256 | ); 257 | 258 | return [ 259 | myPostPolicy, 260 | definePolicy("all published posts or mine", (post: Post) => 261 | or( 262 | () => check(myPostPolicy, post), 263 | () => post.status === "published" 264 | ) 265 | ), 266 | ]; 267 | }); 268 | 269 | const guard = { 270 | post: PostPolicies(context), 271 | }; 272 | 273 | if (check(guard.post.policy("all published posts or mine"), post)) { 274 | // post.status === "published" || post.userId === context.userId && post.status === "published" | ... 275 | } 276 | 277 | assert(guard.post.policy("all published posts or mine"), post); // throws if the condition is not met 278 | // post.status === "published" || post.userId === context.userId && post.status === "published" | ... 279 | *``` 280 | */ 281 | export function or( 282 | ...conditions: ( 283 | | (() => Policy> | boolean) 284 | | boolean 285 | )[] 286 | ) { 287 | return conditions.some((predicate) => (typeof predicate === "function" ? predicate() : predicate)); 288 | } 289 | 290 | /** 291 | * Logical AND operator for policy conditions. 292 | * 293 | * All the policies or conditions must be met for the result to be true 294 | * 295 | * @example 296 | * ```ts 297 | const PostPolicies = definePolicies((context: Context) => { 298 | const myPostPolicy = definePolicy( 299 | "my post", 300 | (post: Post) => post.userId === context.userId, 301 | () => new Error("Not the author") 302 | ); 303 | 304 | return [ 305 | myPostPolicy, 306 | definePolicy("my published post", (post: Post) => 307 | and( 308 | () => check(myPostPolicy, post), 309 | () => post.status === "published" 310 | ) 311 | ), 312 | ]; 313 | }); 314 | 315 | const guard = { 316 | post: PostPolicies(context), 317 | }; 318 | 319 | if (check(guard.post.policy("my published post"), post)) { 320 | // post.status === "published" && post.userId === context.userId 321 | } 322 | 323 | assert(guard.post.policy("my published post"), post); // throws if the condition is not met 324 | // post.status === "published" && post.userId === context.userId 325 | *``` 326 | */ 327 | export function and( 328 | ...conditions: ( 329 | | (() => Policy> | boolean) 330 | | boolean 331 | )[] 332 | ) { 333 | return conditions.every((predicate) => (typeof predicate === "function" ? predicate() : predicate)); 334 | } 335 | 336 | /* -------------------------------------------------------------------------- */ 337 | /* Guards; */ 338 | /* -------------------------------------------------------------------------- */ 339 | 340 | /* --------------------------------- Assert; -------------------------------- */ 341 | 342 | /** 343 | * Assert an implicit policy with a no-arg condition function (lazy evaluation) or a boolean value 344 | * 345 | * @param name - The name of the policy 346 | * @param condition - The condition to assert (no-arg) or a boolean value 347 | * 348 | * @example 349 | * ```ts 350 | * const post = await getPost(id); 351 | * 352 | * // lazy evaluation 353 | * assert("post has comments", () => post.comments.length > 0); 354 | * 355 | * // boolean value 356 | * assert("post has comments", post.comments.length > 0); 357 | * ``` 358 | */ 359 | export function assert(name: string, condition: PolicyConditionNoArg): void; 360 | 361 | /** 362 | * Assert an implicit policy with a condition function that takes an argument (lazy evaluation) or a boolean value 363 | * 364 | * The condition function can be a type guard or a predicate 365 | * 366 | * @param name - The name of the policy 367 | * @param condition - The condition to assert (with arg) or a boolean value 368 | * @param arg - The argument to pass to the condition 369 | * 370 | * @example 371 | * ```ts 372 | * // lazy evaluation 373 | * assert("post has comments", (post: Post) => post.comments.length > 0, await getPost(id)); 374 | * 375 | * // type guard 376 | * assert("post is draft", (post: Post): post is Post & { status: "draft" } => post.status === "draft", await getPost(id)); 377 | * 378 | * // boolean value 379 | * assert("post has comments",(await getPost(id)).comments.length > 0); 380 | * ``` 381 | */ 382 | export function assert | PolicyConditionWithArg>( 383 | name: string, 384 | condition: TPolicyCondition, 385 | arg: TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionArg 386 | ): asserts arg is TPolicyCondition extends PolicyConditionNoArg 387 | ? never 388 | : PolicyConditionTypeGuardResult; 389 | 390 | /** 391 | * Assert a policy with a no-arg condition function (lazy evaluation) or a boolean value 392 | * 393 | * @param policy - The policy to assert or a boolean value 394 | * 395 | * @example 396 | * ```ts 397 | * const AdminPolicies = definePolicies((context: Context) => [ 398 | * definePolicy("is admin", context.role === "admin"), 399 | * // lazy evaluation 400 | * definePolicy("is admin", () => context.role === "admin"), 401 | * ]); 402 | * 403 | * const Guard = (context: Context) => ({ 404 | * admin: AdminPolicies(context), 405 | * }); 406 | * 407 | * assert(guard.admin.policy("is admin")); 408 | * ``` 409 | */ 410 | export function assert( 411 | policy: Policy 412 | ): void; 413 | 414 | /** 415 | * Assert a policy with a condition function that takes an argument 416 | * 417 | * The condition function can be a type guard or a predicate 418 | * 419 | * @param policy - The policy to assert 420 | * @param arg - The argument to pass to the condition 421 | * 422 | * @example 423 | * ```ts 424 | * const PostPolicies = definePolicies((context: Context) => [ 425 | * definePolicy("is author", (post: Post) => post.userId === context.userId), 426 | * ]); 427 | * 428 | * const Guard = (context: Context) => ({ 429 | * post: PostPolicies(context), 430 | * }); 431 | * 432 | * assert(guard.post.policy("is author"), post); 433 | * ``` 434 | */ 435 | export function assert | PolicyConditionWithArg>( 436 | policy: Policy, 437 | arg: TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionArg 438 | ): asserts arg is TPolicyCondition extends PolicyConditionNoArg 439 | ? never 440 | : PolicyConditionTypeGuardResult; 441 | 442 | /** 443 | * Implementation of the assert function 444 | */ 445 | export function assert( 446 | policyOrName: Policy | string, 447 | ...args: any[] 448 | ): void { 449 | let policy: AnyPolicy; 450 | let arg: any; 451 | 452 | if (typeof policyOrName === "string") { 453 | policy = definePolicy(policyOrName, args[0]); 454 | arg = args[1]; 455 | } else { 456 | policy = policyOrName; 457 | arg = args[0]; 458 | } 459 | 460 | if (typeof policy.condition === "boolean" ? policy.condition : policy.condition(arg)) { 461 | return; 462 | } 463 | 464 | throw policy.errorFactory(arg); 465 | } 466 | 467 | /* --------------------------------- Check; --------------------------------- */ 468 | 469 | /** 470 | * Check an implicit policy with a no-arg condition function or a boolean value 471 | * 472 | * @param name - The name of the policy 473 | * @param condition - The condition to check (no-arg) or a boolean value 474 | * 475 | * @example 476 | * ```ts 477 | * const post = await getPost(id); 478 | * 479 | * // lazy evaluation 480 | * if (check("post has comments", () => post.comments.length > 0)) { 481 | * // post has comments 482 | * } 483 | * 484 | * // boolean value 485 | * if (check("post has comments", post.comments.length > 0)) { 486 | * // post has comments 487 | * } 488 | * ``` 489 | */ 490 | export function check(name: string, condition: PolicyConditionNoArg): boolean; 491 | 492 | /** 493 | * Check an implicit policy with a condition function that takes an argument 494 | * 495 | * The condition function can be a type guard or a predicate 496 | * 497 | * @param name - The name of the policy 498 | * @param condition - The condition to check (with arg) 499 | * @param arg - The argument to pass to the condition 500 | * 501 | * @example 502 | * ```ts 503 | * if (check("post has comments", (post: Post) => post.comments.length > 0, post)) { 504 | * // post has comments 505 | * } 506 | * 507 | * // type guard 508 | * if (check("post is draft", (post: Post): post is Post & { status: "draft" } => post.status === "draft", post)) { 509 | * // post.status === "draft" 510 | * } 511 | * ``` 512 | */ 513 | export function check | PolicyConditionWithArg>( 514 | name: string, 515 | condition: TPolicyCondition, 516 | arg: TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionArg 517 | ): arg is TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionTypeGuardResult; 518 | 519 | /** 520 | * Check a policy with a no-arg condition function 521 | * 522 | * @param policy - The policy to check 523 | * 524 | * @example 525 | * ```ts 526 | * const AdminPolicies = definePolicies((context: Context) => [ 527 | * definePolicy("is admin", () => context.role === "admin"), 528 | * ]); 529 | * 530 | * const Guard = (context: Context) => ({ 531 | * admin: AdminPolicies(context), 532 | * }); 533 | * 534 | * if (check(guard.admin.policy("is admin"))) { 535 | * // ... 536 | * } 537 | * ``` 538 | */ 539 | export function check( 540 | policy: Policy 541 | ): boolean; 542 | 543 | /** 544 | * Check a policy with a condition function that takes an argument 545 | * 546 | * The condition function can be a type guard or a predicate 547 | * 548 | * @param policy - The policy to check 549 | * @param arg - The argument to pass to the condition 550 | * 551 | * @example 552 | * ```ts 553 | * const PostPolicies = definePolicies((context: Context) => [ 554 | * definePolicy("is author", (post: Post) => post.userId === context.userId), 555 | * ]); 556 | * 557 | * const Guard = (context: Context) => ({ 558 | * post: PostPolicies(context), 559 | * }); 560 | * 561 | * if (check(guard.post.policy("is author"), post)) { 562 | * // ... 563 | * } 564 | * ``` 565 | */ 566 | export function check | PolicyConditionWithArg>( 567 | policy: Policy, 568 | arg: TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionArg 569 | ): arg is TPolicyCondition extends PolicyConditionNoArg ? never : PolicyConditionTypeGuardResult; 570 | 571 | /** 572 | * Implementation of the check function 573 | */ 574 | export function check( 575 | policyOrName: Policy | string, 576 | ...args: any[] 577 | ): boolean { 578 | let policy: AnyPolicy; 579 | let arg: any; 580 | 581 | if (typeof policyOrName === "string") { 582 | policy = definePolicy(policyOrName, args[0]); 583 | arg = args[1]; 584 | } else { 585 | policy = policyOrName; 586 | arg = args[0]; 587 | } 588 | 589 | return typeof policy.condition === "boolean" ? policy.condition : policy.condition(arg); 590 | } 591 | 592 | type PolicyTuple = 593 | | Policy 594 | | readonly [string, PolicyConditionNoArg] 595 | | readonly [string, PolicyConditionWithArg, any] 596 | | readonly [Policy] 597 | | readonly [Policy, any]; 598 | 599 | type InferPolicyName = TPolicyTuple extends readonly [infer NameOrPolicy, ...any[]] 600 | ? NameOrPolicy extends Policy 601 | ? Name 602 | : NameOrPolicy extends string 603 | ? NameOrPolicy 604 | : never 605 | : TPolicyTuple extends readonly [Policy] 606 | ? Name 607 | : TPolicyTuple extends Policy 608 | ? Name 609 | : never; 610 | 611 | type PoliciesSnapshot = { [K in TPolicyName]: boolean }; 612 | 613 | /** 614 | * Create a snapshot of policies and their evaluation results 615 | * 616 | * It evaluates all the policies with `check` 617 | * 618 | * If a policy does not take an argument, it can be passed as is. Policies that take an argument have to be passed as a tuple with the argument. 619 | * 620 | * @param policies - A tuple of policies and their arguments (if needed) 621 | * 622 | * @example 623 | * ```ts 624 | * // TLDR 625 | const snapshot = checkAllSettle([ 626 | [guard.post.policy("is my post"), post], // Policy with argument 627 | ["post has comments", post.comments.length > 0], // Implicit policy with no argument 628 | definePolicy("post has likes", post.likes.length > 0), // Policy without argument. Can be used as is 629 | ]); 630 | 631 | // Example 632 | const PostPolicies = definePolicies((context: Context) => { 633 | const myPostPolicy = definePolicy( 634 | "is my post", 635 | (post: Post) => post.userId === context.userId, 636 | () => new Error("Not the author") 637 | ); 638 | 639 | return [ 640 | myPostPolicy, 641 | definePolicy("published post or mine", (post: Post) => 642 | or(check(myPostPolicy, post), post.status === "published") 643 | ), 644 | ]; 645 | }); 646 | 647 | const guard = { 648 | post: PostPolicies(context), 649 | }; 650 | 651 | const snapshot = checkAllSettle([ 652 | [guard.post.policy("is my post"), post], // A policy with an argument 653 | ["post has comments", post.comments.length > 0], // An implicit policy with no argument 654 | definePolicy("post has likes", post.likes.length > 0), // A policy without argument. Can be used as is 655 | ]); 656 | 657 | console.log(snapshot); // { "is my post": boolean; "post has comments": boolean; "post has likes": boolean } 658 | console.log(snapshot["is my post"]) // boolean 659 | * ``` 660 | */ 661 | export function checkAllSettle< 662 | const TPolicies extends readonly PolicyTuple[], 663 | TPolicyTuple extends TPolicies[number], 664 | TPolicyName extends InferPolicyName, 665 | >(policies: TPolicies): PoliciesSnapshot { 666 | return policies.reduce( 667 | (acc, policyOrTuple) => { 668 | let policyName: string; 669 | let result: boolean; 670 | 671 | if (policyOrTuple instanceof Policy) { 672 | // Policy without argument 673 | policyName = policyOrTuple.name; 674 | result = policyOrTuple.check(); 675 | } else { 676 | // Policy with argument 677 | const [policyOrName, conditionOrArg, implicitPolicyArg] = policyOrTuple; 678 | policyName = typeof policyOrName === "string" ? policyOrName : policyOrName.name; 679 | result = 680 | typeof policyOrName === "string" 681 | ? typeof conditionOrArg === "function" 682 | ? conditionOrArg(implicitPolicyArg) 683 | : conditionOrArg 684 | : policyOrName.check(conditionOrArg); 685 | } 686 | 687 | acc[policyName as TPolicyName] = result; 688 | 689 | return acc; 690 | }, 691 | {} as PoliciesSnapshot 692 | ); 693 | } 694 | 695 | /* -------------------------------------------------------------------------- */ 696 | /* Helpers; */ 697 | /* -------------------------------------------------------------------------- */ 698 | 699 | /** 700 | * Match a value against a schema 701 | * 702 | * @param schema - The schema to match against (type guard) 703 | * 704 | * @example 705 | * ```ts 706 | * if (check("params are valid", matchSchema(z.object({ id: z.string() })), params)) { 707 | * // params is { id: string } 708 | * } 709 | * ``` 710 | */ 711 | export function matchSchema(schema: Schema) { 712 | return (value: unknown): value is z.infer => schema.safeParse(value).success; 713 | } 714 | 715 | /** 716 | * Check if a value is not null (type guard) 717 | * 718 | * @param v - The value to check 719 | * 720 | * @example 721 | * ```ts 722 | * const value: string | null = "hello"; 723 | * 724 | * if (check("value is not null", notNull, value)) { 725 | * // value is not null 726 | * } 727 | * ``` 728 | */ 729 | export function notNull(v: unknown | null | undefined): v is NonNullable { 730 | return v != null; 731 | } 732 | -------------------------------------------------------------------------------- /test-apps/remix-vite-cjs/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This is intended to be a basic starting point for linting in your app. 3 | * It relies on recommended configs out of the box for simplicity, but you can 4 | * and should modify this configuration to best suit your team's needs. 5 | */ 6 | 7 | /** @type {import('eslint').Linter.Config} */ 8 | module.exports = { 9 | root: true, 10 | parserOptions: { 11 | ecmaVersion: "latest", 12 | sourceType: "module", 13 | ecmaFeatures: { 14 | jsx: true, 15 | }, 16 | }, 17 | env: { 18 | browser: true, 19 | commonjs: true, 20 | es6: true, 21 | }, 22 | ignorePatterns: ["!**/.server", "!**/.client"], 23 | 24 | // Base config 25 | extends: ["eslint:recommended"], 26 | 27 | overrides: [ 28 | // React 29 | { 30 | files: ["**/*.{js,jsx,ts,tsx}"], 31 | plugins: ["react", "jsx-a11y"], 32 | extends: [ 33 | "plugin:react/recommended", 34 | "plugin:react/jsx-runtime", 35 | "plugin:react-hooks/recommended", 36 | "plugin:jsx-a11y/recommended", 37 | ], 38 | settings: { 39 | react: { 40 | version: "detect", 41 | }, 42 | formComponents: ["Form"], 43 | linkComponents: [ 44 | { name: "Link", linkAttribute: "to" }, 45 | { name: "NavLink", linkAttribute: "to" }, 46 | ], 47 | "import/resolver": { 48 | typescript: {}, 49 | }, 50 | }, 51 | }, 52 | 53 | // Typescript 54 | { 55 | files: ["**/*.{ts,tsx}"], 56 | plugins: ["@typescript-eslint", "import"], 57 | parser: "@typescript-eslint/parser", 58 | settings: { 59 | "import/internal-regex": "^~/", 60 | "import/resolver": { 61 | node: { 62 | extensions: [".ts", ".tsx"], 63 | }, 64 | typescript: { 65 | alwaysTryTypes: true, 66 | }, 67 | }, 68 | }, 69 | extends: ["plugin:@typescript-eslint/recommended", "plugin:import/recommended", "plugin:import/typescript"], 70 | }, 71 | 72 | // Node 73 | { 74 | files: [".eslintrc.cjs"], 75 | env: { 76 | node: true, 77 | }, 78 | }, 79 | ], 80 | }; 81 | -------------------------------------------------------------------------------- /test-apps/remix-vite-cjs/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | .env 6 | -------------------------------------------------------------------------------- /test-apps/remix-vite-cjs/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Remix + Vite! 2 | 3 | 📖 See the [Remix docs](https://remix.run/docs) and the [Remix Vite docs](https://remix.run/docs/en/main/guides/vite) for details on supported features. 4 | 5 | ## Development 6 | 7 | Run the Vite dev server: 8 | 9 | ```shellscript 10 | npm run dev 11 | ``` 12 | 13 | ## Deployment 14 | 15 | First, build your app for production: 16 | 17 | ```sh 18 | npm run build 19 | ``` 20 | 21 | Then run the app in production mode: 22 | 23 | ```sh 24 | npm start 25 | ``` 26 | 27 | Now you'll need to pick a host to deploy it to. 28 | 29 | ### DIY 30 | 31 | If you're familiar with deploying Node applications, the built-in Remix app server is production-ready. 32 | 33 | Make sure to deploy the output of `npm run build` 34 | 35 | - `build/server` 36 | - `build/client` 37 | -------------------------------------------------------------------------------- /test-apps/remix-vite-cjs/app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.client 5 | */ 6 | 7 | import { RemixBrowser } from "@remix-run/react"; 8 | import { StrictMode, startTransition } from "react"; 9 | import { hydrateRoot } from "react-dom/client"; 10 | 11 | startTransition(() => { 12 | hydrateRoot( 13 | document, 14 | 15 | 16 | 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /test-apps/remix-vite-cjs/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle generating the HTTP Response for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.server 5 | */ 6 | 7 | import { PassThrough } from "node:stream"; 8 | 9 | import type { AppLoadContext, EntryContext } from "@remix-run/node"; 10 | import { createReadableStreamFromReadable } from "@remix-run/node"; 11 | import { RemixServer } from "@remix-run/react"; 12 | import { isbot } from "isbot"; 13 | import { renderToPipeableStream } from "react-dom/server"; 14 | 15 | const ABORT_DELAY = 5_000; 16 | 17 | export default function handleRequest( 18 | request: Request, 19 | responseStatusCode: number, 20 | responseHeaders: Headers, 21 | remixContext: EntryContext, 22 | // This is ignored so we can keep it in the template for visibility. Feel 23 | // free to delete this parameter in your app if you're not using it! 24 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 25 | loadContext: AppLoadContext 26 | ) { 27 | return isbot(request.headers.get("user-agent") || "") 28 | ? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext) 29 | : handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext); 30 | } 31 | 32 | function handleBotRequest( 33 | request: Request, 34 | responseStatusCode: number, 35 | responseHeaders: Headers, 36 | remixContext: EntryContext 37 | ) { 38 | return new Promise((resolve, reject) => { 39 | let shellRendered = false; 40 | const { pipe, abort } = renderToPipeableStream( 41 | , 42 | { 43 | onAllReady() { 44 | shellRendered = true; 45 | const body = new PassThrough(); 46 | const stream = createReadableStreamFromReadable(body); 47 | 48 | responseHeaders.set("Content-Type", "text/html"); 49 | 50 | resolve( 51 | new Response(stream, { 52 | headers: responseHeaders, 53 | status: responseStatusCode, 54 | }) 55 | ); 56 | 57 | pipe(body); 58 | }, 59 | onShellError(error: unknown) { 60 | reject(error); 61 | }, 62 | onError(error: unknown) { 63 | responseStatusCode = 500; 64 | // Log streaming rendering errors from inside the shell. Don't log 65 | // errors encountered during initial shell rendering since they'll 66 | // reject and get logged in handleDocumentRequest. 67 | if (shellRendered) { 68 | console.error(error); 69 | } 70 | }, 71 | } 72 | ); 73 | 74 | setTimeout(abort, ABORT_DELAY); 75 | }); 76 | } 77 | 78 | function handleBrowserRequest( 79 | request: Request, 80 | responseStatusCode: number, 81 | responseHeaders: Headers, 82 | remixContext: EntryContext 83 | ) { 84 | return new Promise((resolve, reject) => { 85 | let shellRendered = false; 86 | const { pipe, abort } = renderToPipeableStream( 87 | , 88 | { 89 | onShellReady() { 90 | shellRendered = true; 91 | const body = new PassThrough(); 92 | const stream = createReadableStreamFromReadable(body); 93 | 94 | responseHeaders.set("Content-Type", "text/html"); 95 | 96 | resolve( 97 | new Response(stream, { 98 | headers: responseHeaders, 99 | status: responseStatusCode, 100 | }) 101 | ); 102 | 103 | pipe(body); 104 | }, 105 | onShellError(error: unknown) { 106 | reject(error); 107 | }, 108 | onError(error: unknown) { 109 | responseStatusCode = 500; 110 | // Log streaming rendering errors from inside the shell. Don't log 111 | // errors encountered during initial shell rendering since they'll 112 | // reject and get logged in handleDocumentRequest. 113 | if (shellRendered) { 114 | console.error(error); 115 | } 116 | }, 117 | } 118 | ); 119 | 120 | setTimeout(abort, ABORT_DELAY); 121 | }); 122 | } 123 | -------------------------------------------------------------------------------- /test-apps/remix-vite-cjs/app/policies.ts: -------------------------------------------------------------------------------- 1 | import { definePolicy } from "comply"; 2 | 3 | const policy = definePolicy( 4 | "has items", 5 | (arr: unknown[]) => arr.length > 0, 6 | () => new Error("Array is empty") 7 | ); 8 | -------------------------------------------------------------------------------- /test-apps/remix-vite-cjs/app/root.tsx: -------------------------------------------------------------------------------- 1 | import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react"; 2 | 3 | export function Layout({ children }: { children: React.ReactNode }) { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {children} 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | export default function App() { 22 | return ; 23 | } 24 | -------------------------------------------------------------------------------- /test-apps/remix-vite-cjs/app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from "@remix-run/node"; 2 | 3 | export const meta: MetaFunction = () => { 4 | return [{ title: "New Remix App" }, { name: "description", content: "Welcome to Remix!" }]; 5 | }; 6 | 7 | export default function Index() { 8 | return ( 9 |

10 |

Welcome to Remix

11 | 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /test-apps/remix-vite-cjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-vite-cjs", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "commonjs", 6 | "scripts": { 7 | "build": "remix vite:build", 8 | "dev": "remix vite:dev", 9 | "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", 10 | "start": "remix-serve ./build/server/index.js", 11 | "typecheck": "tsc" 12 | }, 13 | "dependencies": { 14 | "@remix-run/node": "^2.9.1", 15 | "@remix-run/react": "^2.9.1", 16 | "@remix-run/serve": "^2.9.1", 17 | "isbot": "^4.1.0", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0", 20 | "comply": "*" 21 | }, 22 | "devDependencies": { 23 | "@remix-run/dev": "^2.9.1", 24 | "@types/react": "^18.2.20", 25 | "@types/react-dom": "^18.2.7", 26 | "@typescript-eslint/eslint-plugin": "^6.7.4", 27 | "@typescript-eslint/parser": "^6.7.4", 28 | "eslint": "^8.38.0", 29 | "eslint-import-resolver-typescript": "^3.6.1", 30 | "eslint-plugin-import": "^2.28.1", 31 | "eslint-plugin-jsx-a11y": "^6.7.1", 32 | "eslint-plugin-react": "^7.33.2", 33 | "eslint-plugin-react-hooks": "^4.6.0", 34 | "typescript": "^5.1.6", 35 | "vite": "^5.1.0", 36 | "vite-tsconfig-paths": "^4.2.1" 37 | }, 38 | "engines": { 39 | "node": ">=18.0.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test-apps/remix-vite-cjs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rphlmr/comply/bce02e3dbdf99b2503fc2c104cb22e2c32ae85eb/test-apps/remix-vite-cjs/public/favicon.ico -------------------------------------------------------------------------------- /test-apps/remix-vite-cjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "**/*.ts", 4 | "**/*.tsx", 5 | "**/.server/**/*.ts", 6 | "**/.server/**/*.tsx", 7 | "**/.client/**/*.ts", 8 | "**/.client/**/*.tsx" 9 | ], 10 | "compilerOptions": { 11 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 12 | "types": ["@remix-run/node", "vite/client"], 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "jsx": "react-jsx", 16 | "moduleResolution": "Node", 17 | "resolveJsonModule": true, 18 | "strict": true, 19 | "allowJs": true, 20 | "skipLibCheck": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "baseUrl": ".", 23 | "paths": { 24 | "~/*": ["./app/*"] 25 | }, 26 | 27 | // Vite takes care of building everything, not tsc. 28 | "noEmit": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test-apps/remix-vite-cjs/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { vitePlugin as remix } from "@remix-run/dev"; 2 | import { installGlobals } from "@remix-run/node"; 3 | import { defineConfig } from "vite"; 4 | import tsconfigPaths from "vite-tsconfig-paths"; 5 | 6 | installGlobals(); 7 | 8 | export default defineConfig({ 9 | plugins: [remix(), tsconfigPaths()], 10 | }); 11 | -------------------------------------------------------------------------------- /test-apps/remix-vite/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This is intended to be a basic starting point for linting in your app. 3 | * It relies on recommended configs out of the box for simplicity, but you can 4 | * and should modify this configuration to best suit your team's needs. 5 | */ 6 | 7 | /** @type {import('eslint').Linter.Config} */ 8 | module.exports = { 9 | root: true, 10 | parserOptions: { 11 | ecmaVersion: "latest", 12 | sourceType: "module", 13 | ecmaFeatures: { 14 | jsx: true, 15 | }, 16 | }, 17 | env: { 18 | browser: true, 19 | commonjs: true, 20 | es6: true, 21 | }, 22 | ignorePatterns: ["!**/.server", "!**/.client"], 23 | 24 | // Base config 25 | extends: ["eslint:recommended"], 26 | 27 | overrides: [ 28 | // React 29 | { 30 | files: ["**/*.{js,jsx,ts,tsx}"], 31 | plugins: ["react", "jsx-a11y"], 32 | extends: [ 33 | "plugin:react/recommended", 34 | "plugin:react/jsx-runtime", 35 | "plugin:react-hooks/recommended", 36 | "plugin:jsx-a11y/recommended", 37 | ], 38 | settings: { 39 | react: { 40 | version: "detect", 41 | }, 42 | formComponents: ["Form"], 43 | linkComponents: [ 44 | { name: "Link", linkAttribute: "to" }, 45 | { name: "NavLink", linkAttribute: "to" }, 46 | ], 47 | "import/resolver": { 48 | typescript: {}, 49 | }, 50 | }, 51 | }, 52 | 53 | // Typescript 54 | { 55 | files: ["**/*.{ts,tsx}"], 56 | plugins: ["@typescript-eslint", "import"], 57 | parser: "@typescript-eslint/parser", 58 | settings: { 59 | "import/internal-regex": "^~/", 60 | "import/resolver": { 61 | node: { 62 | extensions: [".ts", ".tsx"], 63 | }, 64 | typescript: { 65 | alwaysTryTypes: true, 66 | }, 67 | }, 68 | }, 69 | extends: ["plugin:@typescript-eslint/recommended", "plugin:import/recommended", "plugin:import/typescript"], 70 | }, 71 | 72 | // Node 73 | { 74 | files: [".eslintrc.cjs"], 75 | env: { 76 | node: true, 77 | }, 78 | }, 79 | ], 80 | }; 81 | -------------------------------------------------------------------------------- /test-apps/remix-vite/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | .env 6 | -------------------------------------------------------------------------------- /test-apps/remix-vite/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Remix + Vite! 2 | 3 | 📖 See the [Remix docs](https://remix.run/docs) and the [Remix Vite docs](https://remix.run/docs/en/main/guides/vite) for details on supported features. 4 | 5 | ## Development 6 | 7 | Run the Vite dev server: 8 | 9 | ```shellscript 10 | npm run dev 11 | ``` 12 | 13 | ## Deployment 14 | 15 | First, build your app for production: 16 | 17 | ```sh 18 | npm run build 19 | ``` 20 | 21 | Then run the app in production mode: 22 | 23 | ```sh 24 | npm start 25 | ``` 26 | 27 | Now you'll need to pick a host to deploy it to. 28 | 29 | ### DIY 30 | 31 | If you're familiar with deploying Node applications, the built-in Remix app server is production-ready. 32 | 33 | Make sure to deploy the output of `npm run build` 34 | 35 | - `build/server` 36 | - `build/client` 37 | -------------------------------------------------------------------------------- /test-apps/remix-vite/app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.client 5 | */ 6 | 7 | import { RemixBrowser } from "@remix-run/react"; 8 | import { StrictMode, startTransition } from "react"; 9 | import { hydrateRoot } from "react-dom/client"; 10 | 11 | startTransition(() => { 12 | hydrateRoot( 13 | document, 14 | 15 | 16 | 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /test-apps/remix-vite/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle generating the HTTP Response for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.server 5 | */ 6 | 7 | import { PassThrough } from "node:stream"; 8 | 9 | import type { AppLoadContext, EntryContext } from "@remix-run/node"; 10 | import { createReadableStreamFromReadable } from "@remix-run/node"; 11 | import { RemixServer } from "@remix-run/react"; 12 | import { isbot } from "isbot"; 13 | import { renderToPipeableStream } from "react-dom/server"; 14 | 15 | const ABORT_DELAY = 5_000; 16 | 17 | export default function handleRequest( 18 | request: Request, 19 | responseStatusCode: number, 20 | responseHeaders: Headers, 21 | remixContext: EntryContext, 22 | // This is ignored so we can keep it in the template for visibility. Feel 23 | // free to delete this parameter in your app if you're not using it! 24 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 25 | loadContext: AppLoadContext 26 | ) { 27 | return isbot(request.headers.get("user-agent") || "") 28 | ? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext) 29 | : handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext); 30 | } 31 | 32 | function handleBotRequest( 33 | request: Request, 34 | responseStatusCode: number, 35 | responseHeaders: Headers, 36 | remixContext: EntryContext 37 | ) { 38 | return new Promise((resolve, reject) => { 39 | let shellRendered = false; 40 | const { pipe, abort } = renderToPipeableStream( 41 | , 42 | { 43 | onAllReady() { 44 | shellRendered = true; 45 | const body = new PassThrough(); 46 | const stream = createReadableStreamFromReadable(body); 47 | 48 | responseHeaders.set("Content-Type", "text/html"); 49 | 50 | resolve( 51 | new Response(stream, { 52 | headers: responseHeaders, 53 | status: responseStatusCode, 54 | }) 55 | ); 56 | 57 | pipe(body); 58 | }, 59 | onShellError(error: unknown) { 60 | reject(error); 61 | }, 62 | onError(error: unknown) { 63 | responseStatusCode = 500; 64 | // Log streaming rendering errors from inside the shell. Don't log 65 | // errors encountered during initial shell rendering since they'll 66 | // reject and get logged in handleDocumentRequest. 67 | if (shellRendered) { 68 | console.error(error); 69 | } 70 | }, 71 | } 72 | ); 73 | 74 | setTimeout(abort, ABORT_DELAY); 75 | }); 76 | } 77 | 78 | function handleBrowserRequest( 79 | request: Request, 80 | responseStatusCode: number, 81 | responseHeaders: Headers, 82 | remixContext: EntryContext 83 | ) { 84 | return new Promise((resolve, reject) => { 85 | let shellRendered = false; 86 | const { pipe, abort } = renderToPipeableStream( 87 | , 88 | { 89 | onShellReady() { 90 | shellRendered = true; 91 | const body = new PassThrough(); 92 | const stream = createReadableStreamFromReadable(body); 93 | 94 | responseHeaders.set("Content-Type", "text/html"); 95 | 96 | resolve( 97 | new Response(stream, { 98 | headers: responseHeaders, 99 | status: responseStatusCode, 100 | }) 101 | ); 102 | 103 | pipe(body); 104 | }, 105 | onShellError(error: unknown) { 106 | reject(error); 107 | }, 108 | onError(error: unknown) { 109 | responseStatusCode = 500; 110 | // Log streaming rendering errors from inside the shell. Don't log 111 | // errors encountered during initial shell rendering since they'll 112 | // reject and get logged in handleDocumentRequest. 113 | if (shellRendered) { 114 | console.error(error); 115 | } 116 | }, 117 | } 118 | ); 119 | 120 | setTimeout(abort, ABORT_DELAY); 121 | }); 122 | } 123 | -------------------------------------------------------------------------------- /test-apps/remix-vite/app/policies.ts: -------------------------------------------------------------------------------- 1 | import { definePolicy } from "comply"; 2 | 3 | const policy = definePolicy( 4 | "has items", 5 | (arr: unknown[]) => arr.length > 0, 6 | () => new Error("Array is empty") 7 | ); 8 | -------------------------------------------------------------------------------- /test-apps/remix-vite/app/root.tsx: -------------------------------------------------------------------------------- 1 | import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react"; 2 | 3 | export function Layout({ children }: { children: React.ReactNode }) { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {children} 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | export default function App() { 22 | return ; 23 | } 24 | -------------------------------------------------------------------------------- /test-apps/remix-vite/app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from "@remix-run/node"; 2 | 3 | export const meta: MetaFunction = () => { 4 | return [{ title: "New Remix App" }, { name: "description", content: "Welcome to Remix!" }]; 5 | }; 6 | 7 | export default function Index() { 8 | return ( 9 |
10 |

Welcome to Remix

11 | 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /test-apps/remix-vite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-vite", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "scripts": { 7 | "build": "remix vite:build", 8 | "dev": "remix vite:dev", 9 | "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", 10 | "start": "remix-serve ./build/server/index.js", 11 | "typecheck": "tsc" 12 | }, 13 | "dependencies": { 14 | "@remix-run/node": "^2.9.1", 15 | "@remix-run/react": "^2.9.1", 16 | "@remix-run/serve": "^2.9.1", 17 | "isbot": "^4.1.0", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0", 20 | "comply": "*" 21 | }, 22 | "devDependencies": { 23 | "@remix-run/dev": "^2.9.1", 24 | "@types/react": "^18.2.20", 25 | "@types/react-dom": "^18.2.7", 26 | "@typescript-eslint/eslint-plugin": "^6.7.4", 27 | "@typescript-eslint/parser": "^6.7.4", 28 | "eslint": "^8.38.0", 29 | "eslint-import-resolver-typescript": "^3.6.1", 30 | "eslint-plugin-import": "^2.28.1", 31 | "eslint-plugin-jsx-a11y": "^6.7.1", 32 | "eslint-plugin-react": "^7.33.2", 33 | "eslint-plugin-react-hooks": "^4.6.0", 34 | "typescript": "^5.1.6", 35 | "vite": "^5.1.0", 36 | "vite-tsconfig-paths": "^4.2.1" 37 | }, 38 | "engines": { 39 | "node": ">=18.0.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test-apps/remix-vite/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rphlmr/comply/bce02e3dbdf99b2503fc2c104cb22e2c32ae85eb/test-apps/remix-vite/public/favicon.ico -------------------------------------------------------------------------------- /test-apps/remix-vite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "**/*.ts", 4 | "**/*.tsx", 5 | "**/.server/**/*.ts", 6 | "**/.server/**/*.tsx", 7 | "**/.client/**/*.ts", 8 | "**/.client/**/*.tsx" 9 | ], 10 | "compilerOptions": { 11 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 12 | "types": ["@remix-run/node", "vite/client"], 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "jsx": "react-jsx", 16 | "module": "ESNext", 17 | "moduleResolution": "Bundler", 18 | "resolveJsonModule": true, 19 | "target": "ES2022", 20 | "strict": true, 21 | "allowJs": true, 22 | "skipLibCheck": true, 23 | "forceConsistentCasingInFileNames": true, 24 | "baseUrl": ".", 25 | "paths": { 26 | "~/*": ["./app/*"] 27 | }, 28 | 29 | // Vite takes care of building everything, not tsc. 30 | "noEmit": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test-apps/remix-vite/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { vitePlugin as remix } from "@remix-run/dev"; 2 | import { installGlobals } from "@remix-run/node"; 3 | import { defineConfig } from "vite"; 4 | import tsconfigPaths from "vite-tsconfig-paths"; 5 | 6 | installGlobals(); 7 | 8 | export default defineConfig({ 9 | plugins: [remix(), tsconfigPaths()], 10 | }); 11 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | // Setup your test environment here 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 4 | "moduleResolution": "Bundler", 5 | "module": "ESNext" /* Specify what module code is generated. */, 6 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 7 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 8 | "strict": true /* Enable all strict type-checking options. */, 9 | "skipLibCheck": true /* Skip type checking all .d.ts files. */, 10 | // "types": ["vitest/globals"], 11 | "rootDir": ".", 12 | "outDir": "./dist", 13 | "noEmit": true 14 | }, 15 | "include": ["src/**/*", "tests/**/*"], 16 | "exclude": ["node_modules", "dist"] 17 | } 18 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: ["src/index.ts"], 5 | sourcemap: true, 6 | clean: true, 7 | dts: true, 8 | minify: false, 9 | format: ["esm", "cjs"], 10 | outDir: "dist", 11 | }); 12 | -------------------------------------------------------------------------------- /tsup.dev.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: ["src/index.ts"], 5 | sourcemap: true, 6 | dts: true, 7 | minify: false, 8 | format: ["esm", "cjs"], 9 | outDir: "dist", 10 | }); 11 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | setupFiles: ["./tests/setup.ts"], 7 | environment: "node", 8 | globals: true, 9 | 10 | coverage: { 11 | all: false, 12 | provider: "v8", 13 | reporter: ["json-summary", "html"], 14 | thresholds: { 15 | statements: 80, 16 | branches: 80, 17 | functions: 80, 18 | lines: 80, 19 | }, 20 | }, 21 | }, 22 | }); 23 | --------------------------------------------------------------------------------