diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 00000000..81dbe331
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,94 @@
+name: Bug report
+description: Something isn't working as expected
+title: '[Bug]: '
+labels: ['bug']
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Before opening a bug report, please check that the issue hasn't already been reported.
+
+ - type: textarea
+ id: description
+ attributes:
+ label: Describe the bug
+ description: A clear and concise description of what the bug is.
+ validations:
+ required: true
+
+ - type: textarea
+ id: reproduce
+ attributes:
+ label: Minimal reproduction
+ description: A minimal code snippet or repository that reproduces the issue.
+ placeholder: |
+ import { createPermix } from 'permix'
+
+ const permix = createPermix<{ post: ['read'] }>()
+ permix.setup({ post: { read: true } })
+ permix.check('post.read')
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected behavior
+ description: What did you expect to happen?
+ validations:
+ required: true
+
+ - type: textarea
+ id: actual
+ attributes:
+ label: Actual behavior
+ description: What actually happened? Include any error messages or console output.
+ validations:
+ required: true
+
+ - type: input
+ id: version
+ attributes:
+ label: permix version
+ placeholder: e.g. 4.1.2
+ validations:
+ required: true
+
+ - type: dropdown
+ id: framework
+ attributes:
+ label: Framework / adapter
+ options:
+ - Core (no adapter)
+ - React
+ - Vue
+ - Solid
+ - Svelte
+ - Next.js
+ - TanStack Start
+ - Express
+ - Hono
+ - Fastify
+ - tRPC
+ - oRPC
+ - Elysia
+ - Node
+ - Other
+ validations:
+ required: true
+
+ - type: input
+ id: framework_version
+ attributes:
+ label: Framework version
+ placeholder: e.g. React 19.2, Next.js 16
+ validations:
+ required: false
+
+ - type: checkboxes
+ id: checklist
+ attributes:
+ label: Checklist
+ options:
+ - label: I searched existing issues first
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 00000000..203644b5
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Documentation
+ url: https://permix.letstri.dev/docs
+ about: Usage questions are often answered in the docs.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 00000000..5a0fe5b6
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,41 @@
+name: Feature request
+description: Propose a DX, API, or adapter improvement
+title: '[Feature]: '
+labels: ['enhancement']
+body:
+ - type: input
+ id: summary
+ attributes:
+ label: Summary
+ placeholder: Add a typed helper for checking several permissions at once
+ validations:
+ required: true
+
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem
+ description: What workflow or maintenance pain does this solve?
+ validations:
+ required: true
+
+ - type: textarea
+ id: proposal
+ attributes:
+ label: Proposed solution
+ description: Keep this focused on implementation shape, not product marketing.
+ validations:
+ required: true
+
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives considered
+
+ - type: checkboxes
+ id: checklist
+ attributes:
+ label: Checklist
+ options:
+ - label: I checked existing issues and docs first
+ required: true
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..489b5f19
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,28 @@
+version: 2
+
+updates:
+ - package-ecosystem: npm
+ directory: /
+ schedule:
+ interval: weekly
+ day: monday
+ time: '05:00'
+ timezone: UTC
+ open-pull-requests-limit: 10
+ groups:
+ workspace-dependencies:
+ patterns:
+ - '*'
+
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
+ day: monday
+ time: '05:30'
+ timezone: UTC
+ open-pull-requests-limit: 5
+ groups:
+ github-actions:
+ patterns:
+ - '*'
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 00000000..51ac51a0
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,18 @@
+## Summary
+
+- [ ] closes #
+- [ ] follow-up issues opened and linked
+
+## Testing
+
+- [ ] `pnpm format:check`
+- [ ] `pnpm lint`
+- [ ] `pnpm check-types`
+- [ ] `pnpm test`
+- [ ] `pnpm verify`
+
+## Checklist
+
+- [ ] docs updated where needed (`docs/content/docs/`, `README.md`, `CONTRIBUTING.md`)
+- [ ] `permix/skills/` aligned when public API or integration patterns changed
+- [ ] added or updated tests where it materially reduces regression risk
diff --git a/.github/release.yml b/.github/release.yml
new file mode 100644
index 00000000..4eb61d04
--- /dev/null
+++ b/.github/release.yml
@@ -0,0 +1,22 @@
+changelog:
+ exclude:
+ labels:
+ - ignore-for-release
+ categories:
+ - title: Breaking Changes
+ labels:
+ - breaking
+ - title: New Features
+ labels:
+ - enhancement
+ - feature
+ - title: Bug Fixes
+ labels:
+ - bug
+ - fix
+ - title: Documentation
+ labels:
+ - documentation
+ - title: Other Changes
+ labels:
+ - '*'
diff --git a/.github/scripts/select-react-catalog.mjs b/.github/scripts/select-react-catalog.mjs
new file mode 100644
index 00000000..de5edb04
--- /dev/null
+++ b/.github/scripts/select-react-catalog.mjs
@@ -0,0 +1,17 @@
+import { readFileSync, writeFileSync } from 'node:fs'
+
+const react = process.env.REACT
+const reactTypes = process.env.REACT_TYPES
+const reactDomTypes = process.env.REACT_DOM_TYPES
+
+if (!react || !reactTypes || !reactDomTypes) {
+ throw new Error('REACT, REACT_TYPES, and REACT_DOM_TYPES must be set')
+}
+
+const yaml = readFileSync('pnpm-workspace.yaml', 'utf-8')
+ .replace(/^( {2}react: ).+$/m, `$1${react}`)
+ .replace(/^( {2}react-dom: ).+$/m, `$1${react}`)
+ .replace(/^( {2}'@types\/react': ).+$/m, `$1${reactTypes}`)
+ .replace(/^( {2}'@types\/react-dom': ).+$/m, `$1${reactDomTypes}`)
+
+writeFileSync('pnpm-workspace.yaml', yaml)
diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml
new file mode 100644
index 00000000..c83af8b6
--- /dev/null
+++ b/.github/workflows/bundle-size.yml
@@ -0,0 +1,34 @@
+name: Bundle Size
+
+on:
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ bundle-size:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Build package
+ run: pnpm --filter permix build
+
+ - name: Enforce bundle-size budgets
+ run: pnpm --filter permix size:compare
diff --git a/.github/workflows/lint-check.yml b/.github/workflows/lint-check.yml
index 7e646a89..39fecf19 100644
--- a/.github/workflows/lint-check.yml
+++ b/.github/workflows/lint-check.yml
@@ -16,15 +16,14 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
-
- - name: Setup pnpm
- uses: pnpm/action-setup@v4
- with:
- version: 11.5.0
+ cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
diff --git a/.github/workflows/next-integration.yml b/.github/workflows/next-integration.yml
new file mode 100644
index 00000000..809c956d
--- /dev/null
+++ b/.github/workflows/next-integration.yml
@@ -0,0 +1,36 @@
+name: Next Integration
+
+on:
+ pull_request:
+ types: [opened, synchronize]
+ branches:
+ - main
+
+jobs:
+ next-matrix:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Install Playwright Chromium
+ run: pnpm --filter @permix/next-integration exec playwright install chromium --with-deps
+
+ - name: Build Permix and run Next fixtures
+ run: pnpm test:next
diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml
index 47318b17..822b573b 100644
--- a/.github/workflows/npm-publish.yml
+++ b/.github/workflows/npm-publish.yml
@@ -1,6 +1,8 @@
name: Build and publish
on:
+ release:
+ types: [published]
workflow_dispatch:
permissions:
@@ -15,7 +17,8 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
- node-version: latest
+ node-version: 24
+ cache: pnpm
registry-url: 'https://registry.npmjs.org'
- run: pnpm i --frozen-lockfile
- run: pnpm run lint
diff --git a/.github/workflows/react-compatibility.yml b/.github/workflows/react-compatibility.yml
new file mode 100644
index 00000000..e5bb4a70
--- /dev/null
+++ b/.github/workflows/react-compatibility.yml
@@ -0,0 +1,62 @@
+name: React Compatibility
+
+on:
+ pull_request:
+ types: [opened, synchronize]
+ branches:
+ - main
+
+jobs:
+ react-compatibility:
+ name: React ${{ matrix.react }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - react: '18.3.1'
+ react-types: '18.3.23'
+ react-dom-types: '18.3.7'
+ pin: true
+ - react: '19.2.6'
+ react-types: '19.2.15'
+ react-dom-types: '19.2.3'
+ pin: false
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: pnpm
+
+ - name: Pin React catalog versions
+ if: matrix.pin
+ env:
+ REACT: ${{ matrix.react }}
+ REACT_TYPES: ${{ matrix.react-types }}
+ REACT_DOM_TYPES: ${{ matrix.react-dom-types }}
+ run: node .github/scripts/select-react-catalog.mjs
+
+ - name: Install pinned Permix dependencies
+ if: matrix.pin
+ # Catalog pin is workspace-wide; docs/fumadocs still declare React 19 peers.
+ run: pnpm install --filter permix --no-frozen-lockfile --config.strictPeerDependencies=false
+
+ - name: Install dependencies
+ if: ${{ !matrix.pin }}
+ run: pnpm install --frozen-lockfile
+
+ - name: Test React adapter
+ run: pnpm --filter permix exec vitest run src/react
+
+ - name: Build
+ run: pnpm --filter permix build
diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml
new file mode 100644
index 00000000..e6251c62
--- /dev/null
+++ b/.github/workflows/release-please.yml
@@ -0,0 +1,27 @@
+name: Release Please
+
+on:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: write
+ issues: write
+ pull-requests: write
+
+concurrency:
+ group: release-please-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ release-please:
+ name: Release Please
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Run Release Please
+ uses: googleapis/release-please-action@v5
+ with:
+ config-file: release-please-config.json
+ manifest-file: .release-please-manifest.json
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 00000000..e37732df
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,32 @@
+name: Test
+
+on:
+ pull_request:
+ types: [opened, synchronize]
+ branches:
+ - main
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Test
+ run: pnpm test
diff --git a/.github/workflows/types-check.yml b/.github/workflows/types-check.yml
index 0f555beb..c7a6c09f 100644
--- a/.github/workflows/types-check.yml
+++ b/.github/workflows/types-check.yml
@@ -16,21 +16,90 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Check types
+ run: pnpm run check-types
+
+ build-dist:
+ name: Build dist
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
with:
- version: 11.5.0
+ node-version: 24
+ cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
- run: pnpm run build
+ run: pnpm --filter permix build
- - name: Check types
- run: pnpm run check-types
+ - name: Upload dist
+ uses: actions/upload-artifact@v4
+ with:
+ name: permix-dist
+ path: permix/dist
+
+ typescript-compatibility:
+ name: TypeScript ${{ matrix.version }}
+ needs: build-dist
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - version: '5.9.3'
+ script: type-check:compat:5.9
+ - version: '6.0.2'
+ script: type-check:compat:6
+ - version: '7.0.2'
+ script: type-check:compat:7
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Download dist
+ uses: actions/download-artifact@v4
+ with:
+ name: permix-dist
+ path: permix/dist
+
+ - name: Check source and published package
+ run: pnpm --filter permix ${{ matrix.script }}
diff --git a/.gitignore b/.gitignore
index e812c3e6..ef182563 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,10 @@
node_modules
.DS_Store
.pnpm-store
+.turbo
+permix/test/next/.scratch
+permix/test/next/playwright-report
+permix/test/next/test-results
+permix/benchmarks/.bundle-size
+.agents
+.claude
diff --git a/.husky/commit-msg b/.husky/commit-msg
new file mode 100644
index 00000000..9ef41ae4
--- /dev/null
+++ b/.husky/commit-msg
@@ -0,0 +1 @@
+pnpm commitlint --edit "$1"
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
new file mode 100644
index 00000000..be491988
--- /dev/null
+++ b/.release-please-manifest.json
@@ -0,0 +1,3 @@
+{
+ "permix": "4.1.2"
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index fa404b73..0e37544a 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -14,5 +14,8 @@
},
"[jsonc]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
- }
+ },
+ "js/ts.experimental.useTsgo": true,
+ "js/ts.tsdk.path": "./node_modules/typescript",
+ "typescript.preferences.preferTypeOnlyAutoImports": true
}
diff --git a/AGENTS.md b/AGENTS.md
index bde723ed..e24bc51d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,7 +8,7 @@ Skills for teams **using** Permix ship in the published npm package at [`permix/
Repo-root [`_artifacts/`](_artifacts/skill_tree.yaml) (`domain_map.yaml`, `skill_spec.md`, `skill_tree.yaml`) tracks skill coverage and source-doc references for CI staleness checks.
-When you change public API behavior, docs examples, or integration patterns, keep `permix/skills/` aligned with `docs/content/docs/` and `examples/`, bump `library_version` in SKILL frontmatter on release, and run `cd permix && pnpm run skills:stale`.
+When you change public API behavior, docs examples, or integration patterns, keep `permix/skills/` aligned with `docs/content/docs/` and `examples/`, then run `cd permix && pnpm run skills:stale`. Release Please bumps `library_version` in SKILL frontmatter on release.
## Repository layout
@@ -33,13 +33,19 @@ From repo root (pnpm workspace: `permix`, `docs`, `examples/*`):
```bash
pnpm install
+pnpm verify # format, lint, test, types, build
pnpm test && pnpm run check-types
pnpm run lint
pnpm run format
pnpm run format:check
cd permix && pnpm run build
+cd permix && pnpm run size:compare # run after build
cd docs && pnpm dev # http://localhost:3000
cd docs && pnpm types:check # fumadocs-mdx + tsc for docs only
```
+Bundle-size fixtures, budgets, and the committed baseline live in [`permix/benchmarks/`](permix/benchmarks/README.md). Every public export and the extractor CLI must remain covered. Do not raise budgets or refresh the baseline solely to make CI pass; inspect the generated bundle, document an intentional increase, and keep browser fixtures free of extractor dependencies.
+
+Use [Conventional Commits](https://www.conventionalcommits.org/). Husky runs commitlint on `commit-msg`. Release Please on `main` opens a release PR that bumps `permix`, updates [CHANGELOG.md](CHANGELOG.md), and tags `vMAJOR.MINOR.PATCH`. Merging that PR publishes a GitHub Release; npm publish runs from `.github/workflows/npm-publish.yml`. The docs changelog page (`/docs/changelog`) inlines repo-root `CHANGELOG.md` at compile time.
+
Do not commit unless the user asks.
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..a2916b1d
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,984 @@
+# Changelog
+
+All notable changes to `permix` are documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Entries through 4.1.2 were reconstructed from npm publish dates and git history; later versions are maintained by Release Please.
+
+## [4.1.2](https://github.com/letstri/permix/compare/v4.1.1...v4.1.2) (2026-07-02)
+
+### Bug Fixes
+
+- fix: tanstack start docs
+
+### Documentation
+
+- docs: improve for agents
+- docs: fix llms link
+
+### Miscellaneous
+
+- refactor: update skills
+- chore: add claude launch
+- updates
+- add tanstack/intent
+
+## [4.1.1](https://github.com/letstri/permix/compare/v4.1.0...v4.1.1) (2026-06-11)
+
+### Miscellaneous
+
+- update skills
+- fixes
+- updates
+
+## [4.1.0](https://github.com/letstri/permix/compare/v4.0.1...v4.1.0) (2026-06-08)
+
+### Bug Fixes
+
+- fix: change jsx in tsconfig to react-jsx
+
+### Miscellaneous
+
+- fix trpc types
+- add tanstack start example
+- refactor: change symbol to plain string
+- update docs
+- add new 'check' hook and add hooks to each integration
+- updates
+
+## [4.0.1](https://github.com/letstri/permix/compare/v4.0.0...v4.0.1) (2026-06-03)
+
+### Miscellaneous
+
+- updates
+- updates
+- updates
+- updates
+- updates
+- add workflows
+- update docs
+- minor
+- updates
+- remove better auth
+- update comments
+
+## [4.0.0](https://github.com/letstri/permix/compare/v3.8.1...v4.0.0) (2026-06-03)
+
+### Miscellaneous
+
+- update packages
+- updates
+- updates
+- fixes
+- fixes
+- updates
+- add svelte
+- rename typeRequired to required
+- fix doc
+- updates
+- add better auth
+- remove commitlint
+- chore: add drizzle
+- v4
+- chore: add zed config
+- chore: add imports
+
+## [3.8.1](https://github.com/letstri/permix/compare/v3.8.0...v3.8.1) (2026-04-29)
+
+### Bug Fixes
+
+- fix(elysia): relax checkHandler context type to support schema validation
+
+## [3.8.0](https://github.com/letstri/permix/compare/v3.7.0...v3.8.0) (2026-03-23)
+
+### Features
+
+- feat(better-auth): export PermixSession interface
+
+### Miscellaneous
+
+- feat(better-auth)!: redesign API — split permixPlugin, createPermix, permixClient
+- refactor: update publish workflow
+
+## [3.7.0](https://github.com/letstri/permix/compare/v3.6.0...v3.7.0) (2026-03-19)
+
+### Features
+
+- feat(better-auth): add server and client plugins for Better Auth integration
+- feat(orpc): allow customizing the context key for the permix instance
+
+### Documentation
+
+- docs(better-auth): add integration docs page
+- docs: fix permix check function
+
+### Miscellaneous
+
+- refactor: improve better auth types
+- chore(better-auth): add build entry, subpath export, and peer dependency
+- test(better-auth): add tests for Better Auth plugin
+- chore: update packages
+- chore: change fumadocs theme
+- chore: update packages
+- refactor: move all packages to workspace
+- chore: remove context7
+- chore: add context7
+
+## [3.6.0](https://github.com/letstri/permix/compare/v3.5.2...v3.6.0) (2025-11-06)
+
+### Features
+
+- feat: add "any" keyword for permissions checking
+
+## [3.5.2](https://github.com/letstri/permix/compare/v3.5.1...v3.5.2) (2025-11-04)
+
+### Bug Fixes
+
+- fix: add react-dom ad optional peer
+- fix: markdown url 404 by proxying to github raw files
+
+### Documentation
+
+- docs: fix build
+- docs: update packages
+- docs: update fumadocs
+- docs: restore twoslash
+- docs: fix llms
+- docs(refactor): added new toc style in docs
+
+### Miscellaneous
+
+- chore: update packages
+
+## [3.5.1](https://github.com/letstri/permix/compare/v3.5.0...v3.5.1) (2025-11-02)
+
+### Miscellaneous
+
+- chore: update packages
+- chore: update packages
+- refactor: convert script from js to ts
+- chore: bump node version
+- refactor: add template to trpc
+- refactor: add more tests
+- chore: update packages
+
+## [3.5.0](https://github.com/letstri/permix/compare/v3.4.1...v3.5.0) (2025-08-02)
+
+### Features
+
+- feat: add dehydrate and hydrate to permix instance
+
+### Documentation
+
+- docs: update hydration to new api
+
+## [3.4.1](https://github.com/letstri/permix/compare/v3.4.0...v3.4.1) (2025-07-23)
+
+### Bug Fixes
+
+- fix: add typescript export to avoid missing types
+
+### Documentation
+
+- docs: improve llms
+- docs: add llms-full
+- docs: fix example
+- docs: rename page
+
+### Miscellaneous
+
+- chore: update packages
+- refactor: simplify setup
+- chore: update lock
+- chore: add more test for hydration
+
+## [3.4.0](https://github.com/letstri/permix/compare/v3.3.0...v3.4.0) (2025-07-09)
+
+### Features
+
+- feat: add `template` function to backend integrations
+
+### Documentation
+
+- docs: update intro
+- docs: fix solid example
+
+### Miscellaneous
+
+- chore: update packages
+- refactor: remove `checkAsync` method from the backend integrations
+- chore: update lock
+- chore: clean dependencies
+
+## [3.3.0](https://github.com/letstri/permix/compare/v3.2.1...v3.3.0) (2025-06-24)
+
+### Features
+
+- feat: add Solid.js integration
+
+### Documentation
+
+- docs: add Solid.js example
+- docs: add Solid.js integration
+- docs: add `dataRequired` and fix styles
+
+### Miscellaneous
+
+- chore: update packages
+- build: use Babel insted of esbuild
+- chore: update packages
+
+## [3.2.1](https://github.com/letstri/permix/compare/v3.2.0...v3.2.1) (2025-06-23)
+
+### Documentation
+
+- docs: improve docs to new api
+
+### Miscellaneous
+
+- refactor: update exported types
+
+## [3.2.0](https://github.com/letstri/permix/compare/v3.1.0...v3.2.0) (2025-06-22)
+
+### Features
+
+- feat: add dataRequired prop and update datType type
+
+### Miscellaneous
+
+- chore: remove useless code, add console error
+- refactor: improved javascript error handling
+
+## [3.1.0](https://github.com/letstri/permix/compare/v3.0.0...v3.1.0) (2025-06-22)
+
+### Features
+
+- feat: add Fastify integration
+
+### Documentation
+
+- docs: improve some parts
+- docs: add Fastify integration
+- docs: update orpc
+- docs: add initial section
+- docs: update orpc section
+
+### Miscellaneous
+
+- chore: fix lock
+- refactor: update examples
+- refactor: rename setState method
+- build: add Fastify integration
+- chore: update lock
+- chore: update permix version
+- chore: add external package
+
+## [3.0.0](https://github.com/letstri/permix/compare/v2.1.5...v3.0.0) (2025-06-21)
+
+### Features
+
+- feat: add elysia integration and packages updates
+
+### Documentation
+
+- docs: update setup due to refactor
+- docs: add elysia
+- docs: fix build
+- docs: fix build
+
+### Miscellaneous
+
+- refactor: update trpc, orpc, vue integrations
+- chore: add funding
+- chore: update packages
+- chore: add funding
+- chore: add more tests
+
+## [2.1.5](https://github.com/letstri/permix/compare/v2.1.4...v2.1.5) (2025-04-09)
+
+### Bug Fixes
+
+- fix: add exports to utils
+
+### Documentation
+
+- docs: add orpc
+
+## [2.1.4](https://github.com/letstri/permix/compare/v2.1.3...v2.1.4) (2025-04-08)
+
+### Bug Fixes
+
+- fix: add orpc export
+
+## [2.1.3](https://github.com/letstri/permix/compare/v2.1.2...v2.1.3) (2025-04-08)
+
+### Bug Fixes
+
+- fix: add orpc to export
+
+## [2.1.2](https://github.com/letstri/permix/compare/v2.1.1...v2.1.2) (2025-04-08)
+
+### Bug Fixes
+
+- fix: orpc context
+
+## [2.1.1](https://github.com/letstri/permix/compare/v2.1.0...v2.1.1) (2025-04-08)
+
+### Bug Fixes
+
+- fix: trpc context
+
+## [2.1.0](https://github.com/letstri/permix/compare/v2.0.0...v2.1.0) (2025-04-08)
+
+### Features
+
+- feat: add orpc integration
+
+### Bug Fixes
+
+- fix: instance docs
+
+### Documentation
+
+- docs: update node and express
+- docs: minor changes
+- docs: minor
+- docs: improve landing
+- docs: fix type
+- docs: update instance
+- docs: add initial
+
+### Miscellaneous
+
+- refactor: trpc plugin to use initTRPC
+- chore: update packages
+
+## [2.0.0](https://github.com/letstri/permix/compare/v2.0.0-rc.13...v2.0.0) (2025-03-06)
+
+### Features
+
+- feat: add test for initial state
+- feat: add initial state for permix
+
+### Bug Fixes
+
+- fix: test for initial state
+
+### Miscellaneous
+
+- chore: update packages
+- refactor: remove server
+- refactor: remove server
+
+## [2.0.0-rc.13](https://github.com/letstri/permix/compare/v2.0.0-rc.12...v2.0.0-rc.13) (2025-03-03)
+
+### Bug Fixes
+
+- fix: export types
+
+## [2.0.0-rc.12](https://github.com/letstri/permix/compare/v2.0.0-rc.11...v2.0.0-rc.12) (2025-03-03)
+
+### Bug Fixes
+
+- fix: types
+
+### Documentation
+
+- docs: add get rules
+
+### Miscellaneous
+
+- chore: update versions
+
+## [2.0.0-rc.11](https://github.com/letstri/permix/compare/v2.0.0-rc.10...v2.0.0-rc.11) (2025-03-03)
+
+### Miscellaneous
+
+- refactor: rename method
+
+## [2.0.0-rc.10](https://github.com/letstri/permix/compare/v2.0.0-rc.9...v2.0.0-rc.10) (2025-03-03)
+
+### Documentation
+
+- docs: fix templates
+
+### Miscellaneous
+
+- refactor: improve state
+
+## [2.0.0-rc.9](https://github.com/letstri/permix/compare/v2.0.0-rc.8...v2.0.0-rc.9) (2025-03-03)
+
+### Documentation
+
+- docs: minor
+- docs: update description
+- docs: add link
+
+### Miscellaneous
+
+- refactor: templates
+- refactor: internals
+- chore: update packages
+
+## [2.0.0-rc.8](https://github.com/letstri/permix/compare/v2.0.0-rc.7...v2.0.0-rc.8) (2025-02-28)
+
+### Documentation
+
+- docs: update
+
+### Miscellaneous
+
+- refactor: backend internals
+
+## [2.0.0-rc.7](https://github.com/letstri/permix/compare/v2.0.0-rc.6...v2.0.0-rc.7) (2025-02-28)
+
+### Miscellaneous
+
+- refactor: backend adapters
+- chore: improve tests, remove coverage
+- refactor: improve server handlers
+- refactor: improve server handlers
+- Revert "refactor: internal server"
+- Revert "refactor: minor"
+- Revert "refactor: minor"
+- refactor: minor
+- refactor: minor
+- refactor: internal server
+
+## [2.0.0-rc.6](https://github.com/letstri/permix/compare/v2.0.0-rc.5...v2.0.0-rc.6) (2025-02-27)
+
+### Bug Fixes
+
+- fix: docs
+
+### Documentation
+
+- docs: update version
+
+### Miscellaneous
+
+- refactor: rename functions
+
+## [2.0.0-rc.5](https://github.com/letstri/permix/compare/v2.0.0-rc.4...v2.0.0-rc.5) (2025-02-27)
+
+### Documentation
+
+- docs: update version
+
+### Miscellaneous
+
+- refactor: remove res
+
+## [2.0.0-rc.4](https://github.com/letstri/permix/compare/v2.0.0-rc.3...v2.0.0-rc.4) (2025-02-27)
+
+### Features
+
+- feat: add node and server
+
+### Documentation
+
+- docs: minor
+- docs: update package
+
+### Miscellaneous
+
+- refactor: backend integrations
+
+## [2.0.0-rc.3](https://github.com/letstri/permix/compare/v2.0.0-rc.2...v2.0.0-rc.3) (2025-02-27)
+
+### Documentation
+
+- docs: update version
+
+### Miscellaneous
+
+- refactor: templates
+- refactor: docs
+
+## [2.0.0-rc.2](https://github.com/letstri/permix/compare/v2.0.0-rc.1...v2.0.0-rc.2) (2025-02-27)
+
+### Miscellaneous
+
+- refactor: packages
+- refactor: templates
+
+## [2.0.0-rc.1](https://github.com/letstri/permix/compare/v2.0.0-beta.1...v2.0.0-rc.1) (2025-02-26)
+
+### Bug Fixes
+
+- fix: workflow
+- fix: workflow
+- fix: workflow
+
+### Documentation
+
+- docs: update package
+
+### Miscellaneous
+
+- refactor: templates
+- chore: update package
+- chore: update packages
+
+## [2.0.0-beta.1](https://github.com/letstri/permix/compare/v1.0.4...v2.0.0-beta.1) (2025-02-26)
+
+### Bug Fixes
+
+- fix: trpc middleware types
+- fix: express example
+
+### Documentation
+
+- docs: update express, hono, trpc based on new features
+
+### Miscellaneous
+
+- refactor: hono middleware
+- chore: fix lock
+- refactor: internal instances
+- refactor: remove next and nuxt
+- refactor: remove nuxt
+- refactor: remove next and nuxt
+- refactor: trpc middleware
+- refactor: rename express methods
+- refactor: express minor
+- refactor: rename express instance
+- refactor: finish express middlewares
+- refactor: minor updates
+- refactor: minor updates
+- refactor: update template, refactor express middleware
+- refactor: update types
+- refactor: update types
+- chore: improve examples
+
+## [1.0.4](https://github.com/letstri/permix/compare/v1.0.3...v1.0.4) (2025-02-04)
+
+### Bug Fixes
+
+- fix: vue prop type
+
+### Documentation
+
+- docs: fix nuxt
+- docs: fix nuxt
+
+## [1.0.3](https://github.com/letstri/permix/compare/v1.0.2...v1.0.3) (2025-02-03)
+
+### Documentation
+
+- docs: add known issues
+
+### Miscellaneous
+
+- refactor: internal interfaces
+- chore: update coverage
+- chore: add build
+
+## [1.0.2](https://github.com/letstri/permix/compare/v1.0.1...v1.0.2) (2025-02-02)
+
+### Bug Fixes
+
+- fix: remove useless prop
+
+## [1.0.1](https://github.com/letstri/permix/compare/v1.0.0...v1.0.1) (2025-02-02)
+
+### Features
+
+- feat: add new interface to use as components definition
+
+### Bug Fixes
+
+- fix: lock
+
+### Documentation
+
+- docs: fix vue
+- docs: update instance
+- docs: update instance
+- docs: fix names
+- docs: update introduction
+- docs: update introduction
+- docs: update introduction
+- docs: update introduction
+- docs: improve introduction
+- docs: change permix version
+- docs: change permix to workspace
+- docs: fix link
+- docs: fix typo
+
+### Miscellaneous
+
+- chore: ignore test for nuxt
+- refactor: rename commitlint config
+- refactor: update spaces
+
+## [1.0.0](https://github.com/letstri/permix/compare/v1.0.0-rc.2...v1.0.0) (2025-01-22)
+
+### Features
+
+- feat: add to react and vue check component `otherwise`
+
+### Bug Fixes
+
+- fix: react context updates
+- fix: trpc middleware types
+- fix: sandbox
+- fix: sandbox
+- fix: react component generic
+
+### Documentation
+
+- docs: update examples
+- docs: minor improvements
+- docs: remove useless
+- docs: improvements
+- docs: minor improvements
+- docs: add compare, feature flags, some imrovements
+- docs: update integrations
+
+### Miscellaneous
+
+- chore: update lock
+- refactor: remove infers
+- refactor: use method to check isready
+- chore: improve vue tests
+- refactor: improve build
+- chore: add more examples
+- refactor: remove useless type
+- refactor: tests
+- chore: update examples
+- chore: add more examples
+- chore: minor improvements
+- refactor: internal serialization
+- chore: update example
+- refactor: slots
+- chore: update examples
+- refactor: vue component
+
+## [1.0.0-rc.2](https://github.com/letstri/permix/compare/v1.0.0-rc.1...v1.0.0-rc.2) (2025-01-22)
+
+### Features
+
+- feat: add vue check component
+- feat: add react component
+
+### Miscellaneous
+
+- chore: update coverage
+- chore: add more examples
+- chore: update packages
+
+## [1.0.0-rc.1](https://github.com/letstri/permix/compare/v0.8.0...v1.0.0-rc.1) (2025-01-22)
+
+### Features
+
+- feat: add isReadyAsync
+- feat: add nuxt
+
+### Documentation
+
+- docs: improvements
+- docs: add nuxt integration
+- docs: improvements
+- docs: add files to react
+
+## [0.8.0](https://github.com/letstri/permix/compare/v0.7.3...v0.8.0) (2025-01-21)
+
+### Features
+
+- feat: improve hydration
+
+### Bug Fixes
+
+- fix: lock
+
+### Documentation
+
+- docs: add nextjs
+- docs: add hydration
+- docs: update enums
+- docs: improve docs
+- docs: add analytics
+- docs: add analytics
+- docs: add examples
+- docs: update version
+- docs: update version
+- docs: add integrations
+
+### Miscellaneous
+
+- refactor: internals
+- refactor: vue composable
+- refactor: coverage
+- refactor: remove async from setup, add custom hooks, update tests
+- refactor: internals
+- refactor: internals
+- chore: update next example
+- refactor: update internals
+- chore: update lock
+- refactor: react internal
+- chore: updates
+- chore: add next example
+- refactor: internal checks
+- refactor: add links
+
+## [0.7.3](https://github.com/letstri/permix/compare/v0.7.2...v0.7.3) (2025-01-18)
+
+### Documentation
+
+- docs: finish main section
+
+### Miscellaneous
+
+- refactor: add links, update hooks
+
+## [0.7.2](https://github.com/letstri/permix/compare/v0.7.2-beta.2...v0.7.2) (2025-01-18)
+
+### Bug Fixes
+
+- fix: publish command
+- fix: publish command
+- fix: publish command
+
+## [0.7.2-beta.2](https://github.com/letstri/permix/compare/v0.7.2-beta.1...v0.7.2-beta.2) (2025-01-18)
+
+- Published to npm.
+
+## [0.7.2-beta.1](https://github.com/letstri/permix/compare/v0.7.1...v0.7.2-beta.1) (2025-01-18)
+
+### Bug Fixes
+
+- fix: publish command
+
+### Miscellaneous
+
+- refactor: remove readme
+
+## [0.7.1](https://github.com/letstri/permix/compare/v0.7.0...v0.7.1) (2025-01-18)
+
+### Features
+
+- feat: add auto-publish
+
+### Bug Fixes
+
+- fix: publish command
+- fix: publish command
+- fix: publish command
+- fix: publish command
+
+### Documentation
+
+- docs: remove useless file
+- docs: add quick start
+
+### Miscellaneous
+
+- refactor: add scripts and coverage
+- chore: change docs link
+- chore: change docs link
+
+## [0.7.0](https://github.com/letstri/permix/compare/v0.6.0...v0.7.0) (2025-01-17)
+
+### Features
+
+- feat: add async check
+
+### Bug Fixes
+
+- fix: temp remove setup from options
+
+### Miscellaneous
+
+- refactor: rename type
+
+## [0.6.0](https://github.com/letstri/permix/compare/v0.5.0...v0.6.0) (2025-01-17)
+
+### Features
+
+- feat: improve error handling
+
+### Miscellaneous
+
+- chore: add link to docs
+
+## [0.5.0](https://github.com/letstri/permix/compare/v0.4.2...v0.5.0) (2025-01-17)
+
+### Features
+
+- feat: add to react and vue `isReady`
+
+### Miscellaneous
+
+- chore: add examples
+
+## [0.4.2](https://github.com/letstri/permix/compare/v0.4.1...v0.4.2) (2025-01-17)
+
+### Bug Fixes
+
+- fix: trpc types context
+
+### Miscellaneous
+
+- chore: update package
+
+## [0.4.1](https://github.com/letstri/permix/compare/v0.4.0...v0.4.1) (2025-01-17)
+
+### Documentation
+
+- docs: add introduction
+
+### Miscellaneous
+
+- refactor: remove initialPermissions
+
+## [0.4.0](https://github.com/letstri/permix/compare/v0.3.5...v0.4.0) (2025-01-16)
+
+### Features
+
+- feat: add param to template
+
+### Bug Fixes
+
+- fix: script
+
+### Miscellaneous
+
+- chore: update lock
+- chore: update packages
+
+## [0.3.5](https://github.com/letstri/permix/compare/v0.3.4...v0.3.5) (2025-01-16)
+
+### Bug Fixes
+
+- fix: backend types
+
+## [0.3.4](https://github.com/letstri/permix/compare/v0.3.3...v0.3.4) (2025-01-16)
+
+### Features
+
+- feat: add permissions definition
+
+## [0.3.3](https://github.com/letstri/permix/compare/v0.3.2...v0.3.3) (2025-01-16)
+
+### Bug Fixes
+
+- fix: react and vue `check` methods
+
+### Miscellaneous
+
+- refactor: improve internal methods
+
+## [0.3.2](https://github.com/letstri/permix/compare/v0.3.1...v0.3.2) (2025-01-16)
+
+### Bug Fixes
+
+- fix: reexport in react and vue
+
+### Miscellaneous
+
+- chore: update descriptions
+- refactor: remove useless directive
+
+## [0.3.1](https://github.com/letstri/permix/compare/v0.3.0...v0.3.1) (2025-01-15)
+
+### Bug Fixes
+
+- fix: datatype
+
+## [0.3.0](https://github.com/letstri/permix/compare/v0.2.1...v0.3.0) (2025-01-15)
+
+### Miscellaneous
+
+- refactor: make all permissions required, fix vue and react adapters
+- refactor: temp remove examples
+
+## [0.2.1](https://github.com/letstri/permix/compare/v0.2.0...v0.2.1) (2025-01-15)
+
+### Bug Fixes
+
+- fix: type in frontend
+- fix: readme
+
+### Documentation
+
+- docs: update frontend
+- docs: update backend
+- docs: update backend
+- docs: update backend
+
+### Miscellaneous
+
+- chore: remove useless command
+
+## [0.2.0](https://github.com/letstri/permix/compare/v0.1.2...v0.2.0) (2025-01-15)
+
+### Features
+
+- feat: add `all` permission
+
+### Documentation
+
+- docs: remove static
+- docs: temp comment links
+- docs: update landing
+
+### Miscellaneous
+
+- refactor: improve prepublish command
+
+## [0.1.2](https://github.com/letstri/permix/compare/v0.1.1...v0.1.2) (2025-01-15)
+
+### Bug Fixes
+
+- fix: text examples
+
+### Miscellaneous
+
+- chore: add some text
+- refactor: add private
+
+## [0.1.1](https://github.com/letstri/permix/compare/v0.1.0...v0.1.1) (2025-01-15)
+
+### Miscellaneous
+
+- refactor: update descriptions
+- refactor: remove turbo
+
+## [0.1.0](https://github.com/letstri/permix/compare/v0.0.1...v0.1.0) (2025-01-15)
+
+### Features
+
+- feat: add nuxt adapter
+- feat: add next and nuxt examples
+- feat: add hono middleware
+- feat: add express middleware
+
+### Bug Fixes
+
+- fix: build
+- fix: react and vue reactivity
+
+### Documentation
+
+- docs: init
+
+### Miscellaneous
+
+- refactor: update tests
+- chore: remove useless variable
+- chore: improve tests
+- refactor: add jsdoc
+
+## [0.0.1](https://github.com/letstri/permix/compare/v0.0.1-alpha.1...v0.0.1) (2025-01-14)
+
+### Miscellaneous
+
+- refactor: add workspaces, commitlint, turbo, some permix stuff
+- update readme
+- Create README.md
+- init first version
+- Initial commit
+
+## [0.0.1-alpha.1](https://github.com/letstri/permix/releases/tag/v0.0.1-alpha.1) (2025-01-13)
+
+- Published to npm.
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 00000000..269566fb
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,79 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our community include:
+
+- Demonstrating empathy and kindness toward other people
+- Being respectful of differing opinions, viewpoints, and experiences
+- Giving and gracefully accepting constructive feedback
+- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
+- Focusing on what is best not just for us as individuals, but for the overall community
+
+Examples of unacceptable behavior include:
+
+- The use of sexualized language or imagery, and sexual attention or advances of any kind
+- Trolling, insulting or derogatory comments, and personal or political attacks
+- Public or private harassment
+- Publishing others' private information, such as a physical or email address, without their explicit permission
+- Other conduct which could reasonably be considered inappropriate in a professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at valerii.strilets@gmail.com. All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/inclusion).
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..ca0a9cb8
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,66 @@
+# Contributing
+
+Thanks for helping improve Permix.
+
+## Prerequisites
+
+- Node `>=22` and pnpm `>=11` (`packageManager` in root `package.json`)
+- Run `pnpm install` from the repository root
+
+## Development workflow
+
+```bash
+pnpm format
+pnpm lint
+pnpm test
+pnpm check-types
+pnpm verify
+```
+
+`pnpm verify` is the closest local equivalent of the main CI quality gate (format, lint, tests, types, and the library build). CI also runs `pnpm test` and `pnpm test:next` as separate jobs. Use `pnpm verify:full` to run `verify` plus the Next Playwright suite locally.
+
+Use focused branches. Commit messages must follow [Conventional Commits](https://www.conventionalcommits.org/):
+
+```
+feat: add createSetupHandler for TanStack Start
+fix(react): re-run client setup after re-hydration
+docs: document Permix checks in beforeLoad
+chore: bump oxlint
+```
+
+Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`. Breaking changes use `feat!:` / `fix!:` or a `BREAKING CHANGE:` footer. Husky runs commitlint on `commit-msg`.
+
+## Change expectations
+
+- Add or adjust tests when behavior changes in a meaningful way.
+- Keep `permix/skills/` aligned with `docs/content/docs/` and `examples/` when public API, docs examples, or integration patterns change.
+- Keep `permix/benchmarks/entries/` and bundle-size budgets aligned with every public export. After building, run `pnpm --filter permix size:compare`.
+- Treat bundle-size baseline and budget updates as reviewed product changes: inspect the generated bundle and explain intentional growth instead of raising limits only to pass CI.
+- Do not bump `permix` version or edit released changelog sections by hand. Release Please opens a release PR from conventional commits.
+
+## Documentation
+
+API documentation belongs in `docs/content/docs`. The [changelog page](https://permix.letstri.dev/docs/changelog) reads repo-root `CHANGELOG.md`; do not duplicate release notes in MDX.
+
+```bash
+cd docs && pnpm dev # http://localhost:3000
+```
+
+## Pull requests
+
+- Fill out the PR template.
+- Link related issues.
+- Include screenshots or recordings for docs-site UI changes.
+
+## Releases
+
+Releases are driven by Release Please on `main`. Merging the release PR creates a `vMAJOR.MINOR.PATCH` GitHub Release; the provenance-enabled publish workflow then publishes `permix` to npm. After a release, CI may open a skills review PR.
+
+Historical npm versions that predate this process can be tagged locally with:
+
+```bash
+node scripts/tag-historical-releases.mjs # dry run
+node scripts/tag-historical-releases.mjs --apply # create annotated tags
+```
+
+Do not bulk-create GitHub Releases for those tags.
diff --git a/README.md b/README.md
index 7b38be33..4cbbf028 100644
--- a/README.md
+++ b/README.md
@@ -13,21 +13,33 @@ You can find the documentation [here](https://permix.letstri.dev).
To quick start you only need to write the following code:
```ts
-import { createPermix } from 'permix'
+import { permission } from 'permix'
+
+export const canReadPost = permission({
+ key: 'post.read',
+ title: 'Read posts',
+})
+```
-const permix = createPermix<{
- post: ['read']
-}>()
+```bash
+pnpm permix extract
+```
-permix.setup({
- post: {
- read: true,
- },
+```ts
+import { createPermix } from 'permix'
+import { type Definition, permissions } from './.permix/permissions'
+
+const permix = createPermix().setup({
+ post: { read: true },
})
-permix.check('post.read') // true
+permix.check(permissions.post.read) // true
```
+Mark paths with `permission()`, generate a typed catalog with `permix extract`, then pass `Definition` to `createPermix`. Manual generics (`createPermix<{ post: ['read'] }>()`) remain the escape hatch for dynamic keys.
+
+`setup()` returns a frozen instance and does not mutate the factory. On the server, stash that returned instance per request (`setupMiddleware`, or `createPermix().setup(rules)` inside the request). Do not share one instance across concurrent requests.
+
Permix has other powerful features, so here's check out the [docs](https://permix.letstri.dev/docs) or the [examples](https://github.com/letstri/permix/tree/main/examples) directory.
## Agent skills (TanStack Intent)
@@ -40,6 +52,14 @@ pnpm dlx @tanstack/intent@latest install
Skills are indexed on the [Agent Skills Registry](https://tanstack.com/intent/registry) and update when you update the package.
+## Changelog
+
+Release notes are in [CHANGELOG.md](CHANGELOG.md) and on the docs site at [permix.letstri.dev/docs/changelog](https://permix.letstri.dev/docs/changelog).
+
+## Contributing
+
+See [CONTRIBUTING.md](CONTRIBUTING.md). Please follow the [Code of Conduct](CODE_OF_CONDUCT.md). To report a vulnerability, see [SECURITY.md](SECURITY.md).
+
## License
MIT License - see the [LICENSE](https://github.com/letstri/permix/blob/main/LICENSE) file for details
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..c9416973
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,13 @@
+# Security Policy
+
+## Supported versions
+
+Security fixes are applied to the latest `permix` release on npm. Please upgrade before reporting issues that are already fixed.
+
+## Reporting a vulnerability
+
+Do not open a public GitHub issue for security reports.
+
+Report vulnerabilities through [GitHub private security advisories](https://github.com/letstri/permix/security/advisories/new). We will acknowledge the report, investigate, and coordinate a fix and disclosure.
+
+If GitHub advisories are unavailable, email [valerii.strilets@gmail.com](mailto:valerii.strilets@gmail.com).
diff --git a/_artifacts/domain_map.yaml b/_artifacts/domain_map.yaml
index 318aa7c9..db69c1a5 100644
--- a/_artifacts/domain_map.yaml
+++ b/_artifacts/domain_map.yaml
@@ -34,12 +34,14 @@ domains:
slug: server
description: >
Per-request setupMiddleware and checkMiddleware for Express, Hono, Fastify,
- tRPC, oRPC, Node, and Elysia.
+ NestJS, tRPC, oRPC, Node, Elysia, and Astro; provider identity adapters
+ and fetch-standard policy decision points.
- name: 'SSR and hydration'
slug: ssr
description: >
- dehydrate/hydrate snapshots, PermixHydrate, Next.js and TanStack Start wiring.
+ dehydrate/hydrate snapshots, PermixHydrate, Next.js, TanStack Start, Nuxt,
+ and React Router wiring.
skills:
- name: 'Getting started'
@@ -68,6 +70,8 @@ skills:
- 'letstri/permix:docs/content/docs/guide/instance.mdx'
- 'letstri/permix:docs/content/docs/guide/events.mdx'
- 'letstri/permix:docs/content/docs/migration-v3-to-v4.mdx'
+ - 'letstri/permix:docs/content/docs/migration-v4-to-v5.mdx'
+ - 'letstri/permix:docs/content/docs/integrations/standard-schema.mdx'
- 'letstri/permix:permix/src/core/index.ts'
- name: 'Permix (check, frontend, server)'
@@ -76,9 +80,10 @@ skills:
description: >
Everything past initial setup: dot-path check, callback combinators,
~all/~any, entity-aware ReBAC rules, isReady/isReadyAsync;
- PermixProvider/usePermix/createComponents and SSR dehydrate/hydrate for
- React, Vue, Solid, Svelte, Next.js, TanStack Start; setupMiddleware and
- checkMiddleware for Express, Hono, Fastify, tRPC, oRPC, Node, Elysia.
+ PermixProvider/usePermix/Check from createPermix() in permix/react (or vue/solid/svelte) and SSR dehydrate/hydrate/install for
+ React, Vue, Solid, Svelte, Next.js, TanStack Start, Nuxt, React Router;
+ setupMiddleware and checkMiddleware for Express, Hono, Fastify, NestJS,
+ tRPC, oRPC, Node, Elysia, Astro.
Single skill with a thin SKILL.md router and three reference files
loaded on demand.
type: core
@@ -91,13 +96,23 @@ skills:
- svelte
- next
- tanstack-start
+ - nuxt
+ - react-router
- express
- hono
- fastify
+ - nest
- trpc
- orpc
- node
- elysia
+ - astro
+ - adapter
+ - pdp
+ - supabase
+ - better-auth
+ - clerk
+ - convex
covers:
- check
- isReady
@@ -105,46 +120,64 @@ skills:
- "'~all' / '~any'"
- PermixProvider
- usePermix
- - createComponents
- Check component
- dehydrate
- hydrate
+ - install
- PermixHydrate
- setupMiddleware
- checkMiddleware
- getOrThrow
+ - permission extraction
+ - provider identity adapters
+ - HTTP PDP and client
tasks:
- 'Gate UI or API logic with permix.check'
- 'Implement resource-level authorization with entity data'
- 'Wrap the app tree and hide actions based on permissions'
- 'Integrate permix/react, permix/vue, permix/solid, or permix/svelte'
- 'Pass permission booleans from server render to client'
- - 'Wire permix/next or permix/tanstack-start'
+ - 'Wire permix/next, permix/tanstack-start, permix/nuxt, or permix/react-router'
- 'Protect HTTP or RPC routes on the server'
- 'Derive rules from authenticated request context'
+ - 'Generate typed permission catalogs from source markers'
+ - 'Resolve per-request rules from Supabase, Better Auth, Clerk, or Convex'
+ - 'Expose typed authorization checks through an HTTP PDP'
references:
- 'references/check.md'
- 'references/frontend.md'
- 'references/server.md'
+ - 'references/extraction.md'
+ - 'references/providers.md'
sources:
- 'letstri/permix:docs/content/docs/guide/check.mdx'
- 'letstri/permix:docs/content/docs/guide/rebac.mdx'
- 'letstri/permix:docs/content/docs/guide/ready.mdx'
- 'letstri/permix:docs/content/docs/guide/hydration.mdx'
+ - 'letstri/permix:docs/content/docs/guide/extraction.mdx'
- 'letstri/permix:docs/content/docs/integrations/react.mdx'
- 'letstri/permix:docs/content/docs/integrations/vue.mdx'
- 'letstri/permix:docs/content/docs/integrations/solid.mdx'
- 'letstri/permix:docs/content/docs/integrations/svelte.mdx'
- 'letstri/permix:docs/content/docs/integrations/next.mdx'
- 'letstri/permix:docs/content/docs/integrations/tanstack-start.mdx'
+ - 'letstri/permix:docs/content/docs/integrations/nuxt.mdx'
+ - 'letstri/permix:docs/content/docs/integrations/react-router.mdx'
- 'letstri/permix:docs/content/docs/integrations/express.mdx'
- 'letstri/permix:docs/content/docs/integrations/hono.mdx'
- 'letstri/permix:docs/content/docs/integrations/fastify.mdx'
+ - 'letstri/permix:docs/content/docs/integrations/nest.mdx'
- 'letstri/permix:docs/content/docs/integrations/trpc.mdx'
- 'letstri/permix:docs/content/docs/integrations/orpc.mdx'
- 'letstri/permix:docs/content/docs/integrations/node.mdx'
- 'letstri/permix:docs/content/docs/integrations/server.mdx'
+ - 'letstri/permix:docs/content/docs/integrations/astro.mdx'
- 'letstri/permix:docs/content/docs/integrations/elysia.mdx'
+ - 'letstri/permix:docs/content/docs/integrations/pdp.mdx'
+ - 'letstri/permix:docs/content/docs/integrations/supabase.mdx'
+ - 'letstri/permix:docs/content/docs/integrations/better-auth.mdx'
+ - 'letstri/permix:docs/content/docs/integrations/clerk.mdx'
+ - 'letstri/permix:docs/content/docs/integrations/convex.mdx'
- 'letstri/permix:permix/src/core/check.ts'
coverage:
@@ -153,6 +186,9 @@ coverage:
- examples/*
- next
- tanstack-start
+ - nuxt
+ - astro
+ - react-router
failure_modes:
- mistake: 'Using v3 { action, dataType } schema shape'
@@ -178,8 +214,7 @@ failure_modes:
- mistake: 'Relying on dehydrate alone for dynamic rules'
impact: 'Function-based ReBAC checks wrong after SSR hydrate'
correct_pattern: |
- permix.hydrate(state)
- permix.setup(clientRulesForUser)
+ permix.install({ dehydrated: state, rules: clientRulesForUser })
source: 'letstri/permix:docs/content/docs/guide/hydration.mdx'
priority: CRITICAL
status: active
diff --git a/_artifacts/skill_spec.md b/_artifacts/skill_spec.md
index 143b1571..2cf532c7 100644
--- a/_artifacts/skill_spec.md
+++ b/_artifacts/skill_spec.md
@@ -6,18 +6,19 @@ Docs: https://permix.letstri.dev/docs
## Purpose
-These skills teach coding agents how to integrate Permix v4 in consumer applications: schema design, `setup`, `check`, UI adapters, server middleware, and SSR hydration. They are derived from `docs/content/docs/` and `permix/src/` — not from model training cutoffs.
+These skills teach coding agents how to integrate Permix v4 in consumer applications: schema design, `setup`, `check`, UI adapters, server middleware, SSR hydration, HTTP PDP, and provider identity adapters. They are derived from `docs/content/docs/` and `permix/src/` — not from model training cutoffs.
## Skill inventory
| Slug | Type | Domain | Load when |
|------|------|--------|-----------|
| `permix-getting-started` | core | core-setup | New Permix install, schema, roles, templates |
-| `permix` | core | authorization + frontend + server | Everything past initial setup: `check`/ReBAC, React/Vue/Solid/Svelte + SSR, Express/Hono/Fastify/tRPC/oRPC middleware |
+| `permix` | core | authorization + frontend + server | Everything past initial setup: `check`/ReBAC, UI + SSR, server middleware, HTTP PDP, Supabase, Better Auth, Clerk, and Convex |
-`permix` is a single skill with a thin `SKILL.md` router and three reference
+`permix` is a single skill with a thin `SKILL.md` router and five reference
files loaded on demand: `references/check.md`, `references/frontend.md`,
-`references/server.md`.
+`references/server.md`, `references/extraction.md`, and
+`references/providers.md`.
## Dependency graph
@@ -26,7 +27,9 @@ permix-getting-started
└── permix
├── references/check.md
├── references/frontend.md
- └── references/server.md
+ ├── references/server.md
+ ├── references/extraction.md
+ └── references/providers.md
```
## Critical failure modes
@@ -36,6 +39,8 @@ See `_artifacts/domain_map.yaml` → `failure_modes`. Highest priority:
1. **v3 schema shape in v4 projects** — use action tuples, not `{ action, dataType }`.
2. **hydrate without client `setup`** — dynamic rules are lost in JSON; always `setup` after hydrate.
3. **Client-only checks** — mirror paths on server with `setupMiddleware` + `checkMiddleware`.
+4. **Treating app checks as database enforcement** — Supabase browser access
+ still requires RLS; JWT claims may remain stale until refresh.
## Source-of-truth policy
@@ -47,7 +52,7 @@ When `docs/content/docs/` or public API in `permix/src/` changes:
## Out of scope (docs-only for now)
-- `permix/effect`, `permix/drizzle` — documented at https://permix.letstri.dev/docs/integrations/effect and `/drizzle`; no dedicated skill yet.
+- `permix/effect`, `permix/drizzle`, `permix/standard-schema` — documented at https://permix.letstri.dev/docs/integrations/effect, `/drizzle`, and `/standard-schema`; no dedicated skill yet.
## Registry
diff --git a/_artifacts/skill_tree.yaml b/_artifacts/skill_tree.yaml
index 70b42866..30f23f5b 100644
--- a/_artifacts/skill_tree.yaml
+++ b/_artifacts/skill_tree.yaml
@@ -27,6 +27,8 @@ skills:
- 'letstri/permix:docs/content/docs/guide/instance.mdx'
- 'letstri/permix:docs/content/docs/guide/events.mdx'
- 'letstri/permix:docs/content/docs/migration-v3-to-v4.mdx'
+ - 'letstri/permix:docs/content/docs/migration-v4-to-v5.mdx'
+ - 'letstri/permix:docs/content/docs/integrations/standard-schema.mdx'
- 'letstri/permix:permix/src/core/index.ts'
- name: 'Permix (check, frontend, server)'
@@ -37,10 +39,12 @@ skills:
package: permix
description: >
check() dot paths, callbacks, ~all/~any, entity-aware ReBAC rules,
- isReady/isReadyAsync; PermixProvider/usePermix/createComponents and SSR
- dehydrate/hydrate for React, Vue, Solid, Svelte, Next.js, TanStack
- Start; setupMiddleware/checkMiddleware for Express, Hono, Fastify,
- tRPC, oRPC, Node, Elysia. Thin router with references loaded on demand.
+ isReady/isReadyAsync; factory createPermix from permix/react (vue/solid/svelte)
+ with bound Provider/usePermix/Check and SSR dehydrate/hydrate/install for React, Vue, Solid, Svelte, Next.js, TanStack
+ Start, Nuxt, React Router; setupMiddleware/checkMiddleware for Express,
+ Hono, Fastify, NestJS, tRPC, oRPC, Node, Elysia, Astro. Thin router with
+ HTTP PDP and provider adapters for Supabase, Better Auth, Clerk, and
+ Convex. Thin router with references loaded on demand.
requires:
- permix-getting-started
subsystems:
@@ -50,36 +54,59 @@ skills:
- svelte
- next
- tanstack-start
+ - nuxt
+ - react-router
- express
- hono
- fastify
+ - nest
- trpc
- orpc
- node
- elysia
+ - astro
+ - extractor
+ - adapter
+ - pdp
+ - supabase
+ - better-auth
+ - clerk
+ - convex
references:
- 'references/check.md'
- 'references/frontend.md'
- 'references/server.md'
+ - 'references/extraction.md'
+ - 'references/providers.md'
sources:
- 'letstri/permix:docs/content/docs/guide/check.mdx'
- 'letstri/permix:docs/content/docs/guide/rebac.mdx'
- 'letstri/permix:docs/content/docs/guide/ready.mdx'
- 'letstri/permix:docs/content/docs/guide/hydration.mdx'
+ - 'letstri/permix:docs/content/docs/guide/extraction.mdx'
- 'letstri/permix:docs/content/docs/integrations/react.mdx'
- 'letstri/permix:docs/content/docs/integrations/vue.mdx'
- 'letstri/permix:docs/content/docs/integrations/solid.mdx'
- 'letstri/permix:docs/content/docs/integrations/svelte.mdx'
- 'letstri/permix:docs/content/docs/integrations/next.mdx'
- 'letstri/permix:docs/content/docs/integrations/tanstack-start.mdx'
+ - 'letstri/permix:docs/content/docs/integrations/nuxt.mdx'
+ - 'letstri/permix:docs/content/docs/integrations/react-router.mdx'
- 'letstri/permix:docs/content/docs/integrations/express.mdx'
- 'letstri/permix:docs/content/docs/integrations/hono.mdx'
- 'letstri/permix:docs/content/docs/integrations/fastify.mdx'
+ - 'letstri/permix:docs/content/docs/integrations/nest.mdx'
- 'letstri/permix:docs/content/docs/integrations/trpc.mdx'
- 'letstri/permix:docs/content/docs/integrations/orpc.mdx'
- 'letstri/permix:docs/content/docs/integrations/node.mdx'
- 'letstri/permix:docs/content/docs/integrations/server.mdx'
+ - 'letstri/permix:docs/content/docs/integrations/astro.mdx'
- 'letstri/permix:docs/content/docs/integrations/elysia.mdx'
+ - 'letstri/permix:docs/content/docs/integrations/pdp.mdx'
+ - 'letstri/permix:docs/content/docs/integrations/supabase.mdx'
+ - 'letstri/permix:docs/content/docs/integrations/better-auth.mdx'
+ - 'letstri/permix:docs/content/docs/integrations/clerk.mdx'
+ - 'letstri/permix:docs/content/docs/integrations/convex.mdx'
- 'letstri/permix:permix/src/core/check.ts'
coverage:
@@ -88,3 +115,6 @@ coverage:
- examples/*
- next
- tanstack-start
+ - nuxt
+ - astro
+ - react-router
diff --git a/commitlint.config.mts b/commitlint.config.mts
new file mode 100644
index 00000000..4e03c10d
--- /dev/null
+++ b/commitlint.config.mts
@@ -0,0 +1,5 @@
+const config = {
+ extends: ['@commitlint/config-conventional'],
+}
+
+export default config
diff --git a/docs/content/docs/changelog.mdx b/docs/content/docs/changelog.mdx
new file mode 100644
index 00000000..571634ff
--- /dev/null
+++ b/docs/content/docs/changelog.mdx
@@ -0,0 +1,5 @@
+---
+title: Changelog
+description: Release notes for the permix npm package
+icon: RiHistoryLine
+---
diff --git a/docs/content/docs/comparison.mdx b/docs/content/docs/comparison.mdx
index d0d9ea67..62076f60 100644
--- a/docs/content/docs/comparison.mdx
+++ b/docs/content/docs/comparison.mdx
@@ -19,40 +19,24 @@ Permix is a library that provides a way to manage permissions in your applicatio
| Events | ✅ | ❌ |
| Simple DX | ✅ Create instance, use built-in integrations | ❌ In CASL you need to manage a lot of stuff manually (type-safe, hydration, etc.) |
| Modernity | ✅ Uses modern updates and features of each lib and framework | ❌ CASL was created a long time ago and hasn't updated the core |
-| Size | **2.64 kB** gzip (core) [react 0.86 kB, vue 0.87 kB, solid 0.82 kB, next 1.00 kB, tanstack-start 2.05 kB, svelte ~2.17 kB, …] | **6.17 kB** min+gzip (core) [@casl/react 0.62 kB] |
+| Size | **1.27 kB** min+gzip for a realistic core fixture; **1.71 kB** with the classic React provider and hook | **6.17 kB** min+gzip (core) [@casl/react 0.62 kB] |
-Sizes are hard numbers from published builds — see [Bundle size](#bundle-size). Bracketed values are integration adapters imported on top of core.
+Permix figures come from reproducible consumer-style bundles — see [Bundle size](#bundle-size).
## Bundle size
-All figures are **gzip**. Measured on **2026-06-02** from production builds.
+Permix figures below were measured on **2026-08-28** from the committed bundle-size baseline. Both columns are bytes emitted by a production consumer bundle, shown as decimal-free source bytes converted to kB for readability.
### Permix
-Built with `pnpm run build` in the `permix` package (`tsdown` for all entries except Svelte).
+The benchmark first builds the package, then uses esbuild to bundle tiny, realistic fixtures from `permix/benchmarks/entries`. Each fixture keeps the used Permix APIs alive, emits minified ESM with `NODE_ENV=production`, and is compressed with gzip level 9. Framework peer dependencies such as React are external, matching how applications normally deduplicate peers.
-| Entry | gzip | Notes |
-| --- | --: | --- |
-| **`permix` (core)** | **2.64 kB** | `dist/core/index.mjs` (7.96 kB raw) |
-| `permix/react` | 0.86 kB | adapter |
-| `permix/vue` | 0.87 kB | adapter |
-| `permix/solid` | 0.82 kB | adapter |
-| `permix/svelte` | ~2.17 kB | adapter (`dist/svelte/`, `svelte-package` build) |
-| `permix/next` | 1.00 kB | adapter |
-| `permix/tanstack-start` | 2.05 kB | adapter |
-| `permix/node` | 0.95 kB | adapter |
-| `permix/server` | 1.10 kB | adapter |
-| `permix/express` | 0.91 kB | adapter |
-| `permix/hono` | 0.88 kB | adapter |
-| `permix/fastify` | 1.04 kB | adapter |
-| `permix/elysia` | 0.88 kB | adapter |
-| `permix/trpc` | 0.96 kB | adapter |
-| `permix/orpc` | 0.92 kB | adapter |
-| `permix/effect` | 1.28 kB | adapter |
-| `permix/drizzle` | 0.89 kB | adapter |
-| `permix/drizzle/legacy` | 0.83 kB | adapter |
+| Fixture | Minified | Minified + gzip | What it exercises |
+| --- | --: | --: | --- |
+| **Core** | **2.89 kB** | **1.27 kB** | Creates a typed instance, supplies rules, and checks a permission |
+| **React classic** | **3.97 kB** | **1.71 kB** | Core instance plus `PermixProvider` and `usePermix` |
-Integration entries import `../core/index.mjs`, so a typical app ships **core + adapter** (for example React ≈ **2.64 + 0.86 ≈ 3.50 kB** gzip of published chunks before your bundler minifies further).
+These are complete fixture bundles, not sizes of unminified files in `dist/`, and the React number already includes the Permix core used by that fixture. The CI harness also measures every other public export and rejects unexpected extractor dependencies in browser bundles.
### CASL (`@casl/ability@7.0.0`, `@casl/react@7.0.0`)
diff --git a/docs/content/docs/guide/check.mdx b/docs/content/docs/guide/check.mdx
index 6932636e..90192b47 100644
--- a/docs/content/docs/guide/check.mdx
+++ b/docs/content/docs/guide/check.mdx
@@ -113,6 +113,30 @@ permix.check('invalid-entity.create')
Invalid paths cause a TypeScript error at compile time. At runtime, checking an undefined path throws `PermixRuleNotDefinedError`.
+## Explain
+
+`check()` always returns a boolean. Use `explain()` with the same arguments when you need to show **why** a path was denied:
+
+```ts
+permix.setup({
+ post: {
+ update: (post) =>
+ post.authorId === currentUserId
+ ? true
+ : { allow: false, reason: 'Only the author can edit this post' },
+ },
+})
+
+permix.check('post.update', post) // false
+
+const { allowed, path, reasons } = permix.explain('post.update', post)
+// { allowed: false, path: 'post.update', reasons: ['Only the author can edit this post'] }
+```
+
+Boolean rules and `{ allow: true }` produce an empty `reasons` array. `'~all'` / `'~any'` collect reasons from every denied leaf. `dehydrate()` still collapses function rules to `true`/`false` — reasons are not serialized.
+
+`onForbidden` handlers and `PermixForbiddenError` carry the same `{ path, reasons }` payload. UI adapters expose `explain` next to `check` on `usePermix()` so tooltips can use the rule reason instead of a guessed string.
+
## Errors
Permix throws typed errors from `permix` when checks cannot run safely:
diff --git a/docs/content/docs/guide/events.mdx b/docs/content/docs/guide/events.mdx
index c9c59ec1..09ebe3b7 100644
--- a/docs/content/docs/guide/events.mdx
+++ b/docs/content/docs/guide/events.mdx
@@ -40,21 +40,21 @@ Available events:
- `setup` - Triggered when permissions are updated through the `setup` method or `hydrate()`.
- `ready` - Triggered **once** when the instance first becomes ready (first `setup()`, or initial rules passed to `createPermix`). Later `setup()` calls do not fire `ready` again. `hydrate()` alone does not trigger `ready`.
-- `check` - Triggered every time `check()` is called. The handler receives a context object with `path` and `data`.
+- `check` - Triggered after every `check()` evaluation. The handler receives `{ path, data?, allowed, reasons, error? }`. `explain()` does not fire this hook.
Use the `setup` event when UI or caches should refresh after every permission change. Use `ready` only for one-time bootstrap (for example, hiding a global loading shell). Use `check` for logging, analytics, or debugging permission evaluations.
## Check Event
-The `check` event fires before every permission evaluation and provides the path and data being checked:
+The `check` event fires **after** every permission evaluation and includes the result:
```ts
const permix = createPermix<{
post: ['create', { name: 'edit'; type: { authorId: string }; required: true }]
}>()
-permix.hook('check', ({ path, data }) => {
- console.log(`Checking permission: ${path}`, data)
+permix.hook('check', ({ path, data, allowed, reasons }) => {
+ console.log(`Checked ${path}: ${allowed}`, data, reasons)
})
permix.setup({
@@ -65,15 +65,15 @@ permix.setup({
})
permix.check('post.create')
-// logs: Checking permission: post.create undefined
+// logs: Checked post.create: true undefined []
permix.check('post.edit', { authorId: '1' })
-// logs: Checking permission: post.edit { authorId: '1' }
+// logs: Checked post.edit: true { authorId: '1' } []
```
When using the callback form of `check()`, `path` will be `null`:
```ts
permix.check((c) => c('post.create') && c('post.edit', { authorId: '1' }))
-// logs: Checking permission: null undefined
+// logs: Checked null: false undefined []
```
diff --git a/docs/content/docs/guide/extraction.mdx b/docs/content/docs/guide/extraction.mdx
new file mode 100644
index 00000000..51f98944
--- /dev/null
+++ b/docs/content/docs/guide/extraction.mdx
@@ -0,0 +1,141 @@
+---
+title: Permission extraction
+description: Generate typed permission constants, definitions, and metadata from application code
+---
+
+Permission extraction is opt-in. It turns explicit permission markers in your application into:
+
+- `.permix/permissions.ts`, with a key union, nested constants, metadata, and a `Definition` for every Permix adapter
+- `.permix/permissions.json`, with versioned metadata and every source reference
+
+The generated catalog describes your permission vocabulary. It does not grant access, synchronize a database, or replace `setup()` rules.
+
+## Mark permissions
+
+Import `permission` directly from `permix`. The function returns the key unchanged, so it can be used wherever a string permission path is accepted.
+
+```ts
+import { permission } from 'permix'
+
+export const canComment = permission({
+ key: 'tasks.comment',
+ title: 'Comment on tasks',
+ description: 'Add a comment to an existing task.',
+ tags: ['tasks', 'collaboration'],
+ annotations: {
+ area: 'work-management',
+ risk: 'standard',
+ surfaces: ['web', 'api', 'ai-tool'],
+ },
+})
+```
+
+The extractor follows direct named imports, aliases, and namespace imports in JS, JSX, TS, and TSX files. Keys and metadata must be static. Dynamic values, spreads, malformed metadata, and conflicting metadata for the same key fail the scan with a file and line diagnostic.
+
+## Generate the catalog
+
+```bash
+pnpm permix extract
+pnpm permix extract --watch
+pnpm permix extract --check
+```
+
+The CLI and `withPermix` depend on `chokidar`, `oxc-parser`, and `tinyglobby`. Those packages are optional peers of `permix`, so a UI-only install does not download the native parser. Install them (or reinstall `permix` with optional dependencies enabled) before running extract.
+
+Use repeatable `--include` and `--exclude` options for monorepos:
+
+```bash
+pnpm permix extract \
+ --cwd ../.. \
+ --include "apps/dashboard/src/**/*.{ts,tsx}" \
+ --include "packages/features/src/**/*.{ts,tsx}" \
+ --module-output apps/dashboard/src/permissions.generated.ts \
+ --catalog-output apps/dashboard/permissions.json
+```
+
+`--check` does not write files. It exits non-zero when either artifact is missing or stale, which makes it suitable for CI.
+
+## Use generated types and constants
+
+```ts
+import { createPermix } from 'permix'
+import { type Definition, permissions } from './.permix/permissions'
+
+export const permix = createPermix()
+
+permix.setup({
+ tasks: {
+ comment: true,
+ },
+})
+
+permix.check(permissions.tasks.comment)
+```
+
+Manual definitions remain supported. You can migrate one application at a time.
+
+## Add typed payload data
+
+Extraction never serializes validators or erased TypeScript types. Add payload types with the generated overlay helper and the existing `action()` API:
+
+```ts
+import { action, createPermix } from 'permix'
+import { z } from 'zod'
+import { type Definition, definePermissionOverlay } from './.permix/permissions'
+
+const taskSchema = z.object({ taskId: z.string() })
+
+const overlay = definePermissionOverlay({
+ tasks: [action('comment', taskSchema, { required: true })],
+})
+
+type AppDefinition = Definition
+
+export const permix = createPermix()
+```
+
+Overlay paths must already exist in extracted source. The schema remains in application code and is not copied into either generated artifact. Extraction also does not enable runtime validation; use the explicit `permix/standard-schema` factory options when runtime validation is required. That factory validates by the first path segment, not by an individual deep permission path.
+
+## Enrich metadata centrally
+
+Inline metadata supplies defaults. A typed central config can replace or fill presentation fields. Run extraction once to create the generated helper before adding this config:
+
+```ts
+import { definePermissionConfig } from './.permix/permissions'
+
+export const permissionMetadata = definePermissionConfig({
+ 'tasks.comment': {
+ title: 'Comment on a task',
+ description: 'Available from the task page, API, and AI tools.',
+ },
+})
+```
+
+Pass that object as `metadata` to `generatePermissions`, `watchPermissions`, or `createPermixPlugin`. Unknown config keys fail extraction instead of leaving stale documentation behind.
+
+## Next.js
+
+Wrap `next.config.ts` with `withPermix` from `permix/next/config`. Development watches by default; production performs one scan before Next compiles.
+
+```ts title="next.config.ts"
+import { withPermix } from 'permix/next/config'
+import { permissionMetadata } from './src/permission-metadata'
+
+export default withPermix(
+ {
+ reactStrictMode: true,
+ },
+ {
+ metadata: permissionMetadata,
+ moduleOutput: 'src/permissions.generated.ts',
+ }
+)
+```
+
+For plugin composition, `createPermixPlugin(options)` returns a preconfigured `withPermix` function.
+
+## Renames and removals
+
+Removing a marker removes it from the next TypeScript and JSON artifacts. Nothing else is deleted. Before renaming or removing a permission, separately review persisted roles, policy providers, SQL seeds, RLS policies, and audit integrations.
+
+Provider adapters can compare their operation keys with the catalog by calling `validatePermissionCoverage()` from `permix/extractor`. It reports both unknown provider keys and catalog permissions with no provider coverage.
diff --git a/docs/content/docs/guide/hydration.mdx b/docs/content/docs/guide/hydration.mdx
index 432ed4e7..5bd66089 100644
--- a/docs/content/docs/guide/hydration.mdx
+++ b/docs/content/docs/guide/hydration.mdx
@@ -9,17 +9,18 @@ Hydration is the process of converting server-side state into client-side state.
Note that function-based permissions will be converted to `false` during
- dehydration since functions cannot be serialized to JSON. You should call
- `setup` method on the client side after hydration to fully restore
- function-based permissions.
+ dehydration since functions cannot be serialized to JSON. Call `install(
+ {rules})` (or `setup()`) on the client after hydration to restore
+ function-based permissions. `hydrate()` does not fire the `setup` hook.
## Usage
-Permix provides two instance methods for handling hydration:
+Permix provides `dehydrate()`, `hydrate()`, and `install()`:
- `dehydrate()` - Converts the current permissions state into a JSON-serializable format
-- `hydrate(state)` - Restores permissions from a previously dehydrated state
+- `hydrate(state)` - Restores booleans from a previously dehydrated state (`isReady()` stays false; no `setup` hook)
+- `install({ dehydrated, rules })` - One-shot client entry: dehydrated booleans and/or function rules
```ts twoslash
import { createPermix } from 'permix'
diff --git a/docs/content/docs/guide/instance.mdx b/docs/content/docs/guide/instance.mdx
index 8b682504..77b9aace 100644
--- a/docs/content/docs/guide/instance.mdx
+++ b/docs/content/docs/guide/instance.mdx
@@ -73,11 +73,68 @@ const canEdit = permix.check('post.edit') // false
const canEditWithPost = permix.check('post.edit', somePost) // true
```
+#### `schema`
+
+
+ Not required. Alternative to `type` when you already have a Zod (or Valibot,
+ ArkType, …) schema.
+
+
+Pass a [Standard Schema](https://standardschema.dev/) on the action spec. Entity data is inferred from the schema's output type, so you do not need a separate `interface Post`. If both `type` and `schema` are set, `type` wins.
+
+```ts twoslash title="/lib/permix-schema.ts"
+import { action, createPermix } from 'permix'
+import { z } from 'zod'
+
+const postSchema = z.object({
+ id: z.string(),
+ author: z.string(),
+})
+
+const permix = createPermix<{
+ post: [{ name: 'edit'; schema: typeof postSchema }]
+}>()
+
+permix.setup({
+ post: {
+ edit: (post) => post?.author === 'John Doe',
+ // ^?
+ },
+})
+```
+
+`action(name, schema, { required }?)` builds the same spec from a value. Use `as const` on the definition so action names stay literal:
+
+```ts twoslash title="/lib/permix-action.ts"
+import { action, createPermix } from 'permix'
+import { z } from 'zod'
+
+const postSchema = z.object({
+ id: z.string(),
+ authorId: z.string(),
+})
+
+const definition = {
+ post: ['create', action('edit', postSchema, { required: true })],
+} as const
+
+const permix = createPermix()
+
+permix.setup({
+ post: {
+ create: true,
+ edit: (post) => post.authorId === 'user-1',
+ },
+})
+```
+
+To generate a whole CRUD tree from a map of schemas, see the [Standard Schema](/docs/integrations/standard-schema) integration. The factory can also parse `check()` data at runtime with `{ validate: 'deny' | 'throw' }`.
+
#### `required`
Not required, defaults to `false`.
-By default, when an action declares a `type`, the data argument in `check` is optional. Set `required: true` to require data for that action.
+By default, when an action declares a `type` (or `schema`), the data argument in `check` is optional. Set `required: true` to require data for that action.
```ts twoslash title="/lib/permix-type-required.ts"
import { createPermix } from 'permix'
diff --git a/docs/content/docs/guide/ready.mdx b/docs/content/docs/guide/ready.mdx
index 2edf128b..58b04514 100644
--- a/docs/content/docs/guide/ready.mdx
+++ b/docs/content/docs/guide/ready.mdx
@@ -8,9 +8,9 @@ description: Learn how to use the `isReady()` method to check if permissions are
Sometimes you need to know when permissions are ready to use. For example, you might want to wait for permissions to be ready before rendering a component. That's where the `isReady()` and `isReadyAsync()` methods come in.
- `hydrate()` alone does **not** make the instance ready. You must call
- `setup()` after hydration to restore function-based rules and mark the
- instance as ready.
+ `hydrate()` alone does **not** make the instance ready. Call `install({rules}
+ )` or `setup()` after hydration to restore function-based rules and mark the
+ instance as ready. Boolean `check()` still works on a hydrated instance.
## Usage
@@ -91,18 +91,18 @@ const clientPermix = createPermix<{
clientPermix.hydrate(state)
console.log(clientPermix.isReady()) // false
-// Boolean rules from hydration work immediately:
console.log(clientPermix.check('post.read')) // false
-// Restore function-based rules and mark the instance ready
-clientPermix.setup({
- post: {
- read: (post) => !!post?.isPublic,
+const ready = clientPermix.install({
+ rules: {
+ post: {
+ read: (post) => !!post?.isPublic,
+ },
},
})
-console.log(clientPermix.isReady()) // true
-console.log(clientPermix.check('post.read', { isPublic: true })) // true
+console.log(ready.isReady()) // true
+console.log(ready.check('post.read', { isPublic: true })) // true
```
diff --git a/docs/content/docs/guide/setup.mdx b/docs/content/docs/guide/setup.mdx
index 4b95750d..a71251a9 100644
--- a/docs/content/docs/guide/setup.mdx
+++ b/docs/content/docs/guide/setup.mdx
@@ -5,7 +5,22 @@ description: Learn how to setup permissions in your project
## Overview
-After creating Permix instance, you need to define permissions with `setup` method. You can call `setup` in any time with any permissions and Permix will replace the previous permissions.
+After creating a Permix factory, call `setup()` to get a frozen instance. `setup()` does not mutate the factory — overlapping calls return isolated instances.
+
+```ts
+const permix = createPermix<{
+ post: ['create']
+ comment: ['create', 'update']
+}>().setup({
+ post: {
+ create: true,
+ },
+ comment: {
+ create: true,
+ update: true,
+ },
+})
+```
You always should describe all permissions in the `setup` method that was
@@ -23,9 +38,7 @@ After creating Permix instance, you need to define permissions with `setup` meth
const permix = createPermix<{
post: ['create']
comment: ['create', 'update']
-}>()
-
-permix.setup({
+}>().setup({
post: {
create: true,
},
@@ -77,7 +90,7 @@ permix.isReady() // true
## Type-Based
-When creating a Permix instance, you can attach a `type` to actions that need entity data in rule callbacks. This allows you to check permissions for specific data entities. So instead of `boolean` you can use functions to check permissions.
+When creating a Permix instance, you can attach a `type` to actions that need entity data in rule callbacks. This allows you to check permissions for specific data entities. So instead of `boolean` you can use functions to check permissions. Functions may return `true` / `false` or `{ allow, reason }` — `check()` still collapses the result to a boolean; `explain()` keeps the reason. See [Check](/docs/guide/check#explain).
```ts twoslash
import { createPermix } from 'permix'
@@ -105,6 +118,30 @@ permix.setup({
})
```
+### Standard Schema
+
+If the entity already has a Zod (or Valibot, ArkType, …) schema, pass `schema` instead of `type`. See [Standard Schema](/docs/integrations/standard-schema) and [`action()`](/docs/guide/instance#schema).
+
+```ts twoslash
+import { createPermix } from 'permix'
+import { z } from 'zod'
+
+const postSchema = z.object({
+ id: z.string(),
+ authorId: z.string(),
+})
+
+const permix = createPermix<{
+ post: [{ name: 'update'; schema: typeof postSchema }]
+}>()
+
+permix.setup({
+ post: {
+ update: (post) => post?.authorId === 'user-1',
+ },
+})
+```
+
### Required
By default, a `type` on an action makes the data argument optional in `check`. Set `required: true` to require it.
diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx
index 50db3dba..b3a03e86 100644
--- a/docs/content/docs/index.mdx
+++ b/docs/content/docs/index.mdx
@@ -36,14 +36,40 @@ When creating Permix, the goal was to simplify DX as much as possible without lo
That is why you only need to write the following code to get started:
+```ts
+import { permission } from 'permix'
+
+export const canReadPost = permission({
+ key: 'post.read',
+ title: 'Read posts',
+})
+```
+
+```bash
+pnpm permix extract
+```
+
+```ts
+import { createPermix } from 'permix'
+import { type Definition, permissions } from './.permix/permissions'
+
+const permix = createPermix().setup({
+ post: {
+ read: true,
+ },
+})
+
+const canRead = permix.check(permissions.post.read) // true
+```
+
+Mark paths with `permission()`, generate a typed catalog with `permix extract`, then pass `Definition` to `createPermix`. Manual generics stay as the escape hatch when keys are dynamic:
+
```ts twoslash
import { createPermix } from 'permix'
const permix = createPermix<{
post: ['read']
-}>()
-
-permix.setup({
+}>().setup({
post: {
read: true,
},
@@ -52,6 +78,8 @@ permix.setup({
const canReadPost = permix.check('post.read') // true
```
+Core `setup()` returns a frozen instance and does not mutate the factory. It is fine to keep a factory at module scope in a SPA; on the server, stash the **returned** instance per request. Use an adapter `setupMiddleware` (or `createPermix().setup(rules)` inside the request).
+
It looks too simple, so here's a more interesting example:
```ts twoslash
@@ -138,14 +166,14 @@ async function setupPermix() {
user: () => userPermissions(user),
}
- permix.setup(permissionsMap[user.role]())
+ return permix.setup(permissionsMap[user.role]())
}
// Call setupPermix where you need to setup permissions
-setupPermix()
+const instance = await setupPermix()
// Check if a user has permission to do something
-const canCreatePost = permix.check('post.create')
+const canCreatePost = instance.check('post.create')
async function getComment() {
// Imagine that this function is fetching comment from database
@@ -158,7 +186,7 @@ async function getComment() {
const comment = await getComment()
-const canUpdateComment = permix.check('comment.update', comment)
+const canUpdateComment = instance.check('comment.update', comment)
```
## Benefits
@@ -166,6 +194,7 @@ const canUpdateComment = permix.check('comment.update', comment)
What are the benefits of using Permix?
- 100% type-safe without writing TypeScript (except for initialization)
+- Requires TypeScript 5.9 or newer (5.9, 6, and 7 are supported)
- Single source of truth for your entire app
- Perfect match for TypeScript monorepos
- Zero dependencies
diff --git a/docs/content/docs/integrations/astro.mdx b/docs/content/docs/integrations/astro.mdx
new file mode 100644
index 00000000..a23c7703
--- /dev/null
+++ b/docs/content/docs/integrations/astro.mdx
@@ -0,0 +1,233 @@
+---
+title: Astro
+description: Learn how to use Permix with Astro
+---
+
+## Overview
+
+Permix provides middleware for [Astro](https://astro.build/) through `permix/astro`. It stores a per-request instance on `context.locals`, so Astro middleware, endpoints, and server-rendered pages share the same rules.
+
+UI checks in islands reuse the existing [`permix/react`](/docs/integrations/react), [`permix/vue`](/docs/integrations/vue), [`permix/solid`](/docs/integrations/solid), or [`permix/svelte`](/docs/integrations/svelte) integrations. Dehydrate on the server and hydrate in the island with `PermixHydrate`.
+
+
+ Before getting started with the Astro integration, make sure you've completed
+ the initial setup steps in the [Quick Start](/docs/quick-start) guide.
+
+
+
+
+
+
+## Setup
+
+Create a Permix instance and run `setupMiddleware` from `src/middleware.ts`:
+
+```ts title="src/lib/permix.ts"
+import { createPermix } from 'permix/astro'
+
+interface Post {
+ id: string
+ authorId: string
+}
+
+export const permix = createPermix<{
+ post: [
+ { name: 'create'; type: Post },
+ { name: 'read'; type: Post },
+ { name: 'update'; type: Post },
+ { name: 'delete'; type: Post },
+ ]
+}>()
+```
+
+```ts title="src/middleware.ts"
+import { defineMiddleware } from 'astro:middleware'
+import { permix } from './lib/permix'
+
+export const onRequest = defineMiddleware(
+ permix.setupMiddleware(({ request }) => {
+ const isAdmin = request.headers.get('x-user-role') === 'admin'
+
+ return {
+ post: {
+ create: true,
+ read: true,
+ update: isAdmin,
+ delete: isAdmin,
+ },
+ }
+ })
+)
+```
+
+`setupMiddleware` is compatible with [`sequence`](https://docs.astro.build/en/guides/middleware/#chaining-middleware) if you already have other middleware.
+
+The instance lives on `locals`, not on the `Request` object.
+
+
+
+
+
+## Checking permissions
+
+Use `checkMiddleware` to guard an endpoint, or `getOrThrow` inside the handler for entity-aware checks:
+
+```ts title="src/pages/api/posts.ts"
+import type { APIRoute } from 'astro'
+import { permix } from '../../lib/permix'
+
+export const POST: APIRoute = (context) =>
+ permix.checkMiddleware('post.create')(context, async () => {
+ return Response.json({ ok: true })
+ })
+```
+
+```ts title="src/pages/api/posts/[id].ts"
+import type { APIRoute } from 'astro'
+import { permix } from '../../../lib/permix'
+
+export const PATCH: APIRoute = async (context) => {
+ const post = await getPost(context.params.id)
+
+ if (!permix.getOrThrow(context).check('post.update', post)) {
+ return Response.json({ error: 'Forbidden' }, { status: 403 })
+ }
+
+ return Response.json({ ok: true })
+}
+```
+
+`checkMiddleware` accepts the same arguments as the core `check`: a path, a path plus entity data, or a callback.
+
+Denied requests default to `403` with `{ error: 'Forbidden' }`. Customize with `onForbidden` in `createPermix` options.
+
+
+
+
+
+## Pages and islands
+
+In a `.astro` page, read the instance from `Astro.locals` (or pass `Astro` as the context — `get` accepts either):
+
+```astro title="src/pages/posts/[id].astro"
+---
+import { permix } from '../../lib/permix'
+
+const post = await getPost(Astro.params.id)
+
+if (!permix.getOrThrow(Astro).check('post.read', post)) {
+ return Astro.redirect('/404')
+}
+
+const state = permix.getOrThrow(Astro).dehydrate()
+---
+
+
+```
+
+Hydrate the island with the matching UI adapter:
+
+```tsx title="src/components/EditButton.tsx"
+import { createPermix } from 'permix'
+import { PermixHydrate, PermixProvider, usePermix } from 'permix/react'
+
+const permix = createPermix<{
+ post: [
+ { name: 'create'; type: { id: string; authorId: string } },
+ { name: 'read'; type: { id: string; authorId: string } },
+ { name: 'update'; type: { id: string; authorId: string } },
+ { name: 'delete'; type: { id: string; authorId: string } },
+ ]
+}>()
+
+export function EditButton({
+ post,
+ state,
+}: {
+ post: { id: string; authorId: string }
+ state: ReturnType
+}) {
+ return (
+
+
+
+
+
+ )
+}
+
+function Inner({ post }: { post: { id: string; authorId: string } }) {
+ const { check } = usePermix(permix)
+ if (!check('post.update', post)) return null
+ return
+}
+```
+
+
+ `hydrate()` restores booleans but does not restore function-based rules or
+ mark the instance ready. Call `permix.setup(...)` on the client with the full
+ rule set (including closures) after hydration. See the [Hydration
+ guide](/docs/guide/hydration).
+
+
+
+
+
+
+## Templates
+
+```ts title="src/lib/permix.ts"
+import { createPermix } from 'permix/astro'
+
+export const permix = createPermix<{
+ post: ['create', 'read', 'update', 'delete']
+}>()
+
+export const adminTemplate = permix.template({
+ post: { create: true, read: true, update: true, delete: true },
+})
+
+export const guestTemplate = permix.template({
+ post: { create: false, read: true, update: false, delete: false },
+})
+```
+
+```ts title="src/middleware.ts"
+import { defineMiddleware } from 'astro:middleware'
+import { adminTemplate, guestTemplate, permix } from './lib/permix'
+
+export const onRequest = defineMiddleware(
+ permix.setupMiddleware(({ request }) => {
+ const isAdmin = request.headers.get('x-user-role') === 'admin'
+ return isAdmin ? adminTemplate() : guestTemplate()
+ })
+)
+```
+
+
+
+
+
+## Example
+
+You can find a runnable example of the Astro integration [here](https://github.com/letstri/permix/tree/main/examples/astro).
+
+
+
+
+
+## API
+
+### `createPermix(options?)`
+
+Returns an object with the following methods:
+
+| Method | Description |
+| --- | --- |
+| `setupMiddleware(rules \| callback)` | Astro middleware that creates a per-request instance on `locals`. |
+| `checkMiddleware(...args)` | Astro middleware that allows or returns the `onForbidden` response. |
+| `get(context \| locals)` | Return the instance, or `null`. |
+| `getOrThrow(context \| locals)` | Return the instance, or throw `PermixNotFoundError`. |
+| `getRules(context \| locals)` | Return the current rules, or `null`. |
+| `template(rules)` | Create a reusable rule set. Same as the core [`template`](/docs/guide/template). |
+| `contextKey(key)` | Store this factory under a custom `locals` key. |
diff --git a/docs/content/docs/integrations/better-auth.mdx b/docs/content/docs/integrations/better-auth.mdx
new file mode 100644
index 00000000..867b50d2
--- /dev/null
+++ b/docs/content/docs/integrations/better-auth.mdx
@@ -0,0 +1,78 @@
+---
+title: Better Auth
+description: Add isolated server and client permission plugins to Better Auth
+---
+
+## Overview
+
+`permix/better-auth` provides a native Better Auth server/client plugin pair. Each configured plugin captures its own rules resolver, so multiple auth instances and concurrent users cannot overwrite shared permission state.
+
+## Server plugin
+
+```ts
+import { betterAuth } from 'better-auth'
+import { createBetterAuthPermixPlugin } from 'permix/better-auth'
+
+type Definition = {
+ documents: [
+ 'read',
+ { name: 'update'; type: { ownerId: string }; required: true },
+ ]
+}
+
+export const permixPlugin = createBetterAuthPermixPlugin({
+ resolveRules: async (session) => ({
+ documents: {
+ read: true,
+ update: ({ ownerId }) => ownerId === session.user.id,
+ },
+ }),
+})
+
+export const auth = betterAuth({
+ plugins: [permixPlugin],
+})
+```
+
+The plugin uses Better Auth's current session middleware. Signed-out requests are rejected, async rules are supported, and every request receives a fresh Permix instance.
+
+## Client plugin
+
+```ts
+import { createAuthClient } from 'better-auth/client'
+import { createBetterAuthPermixClient } from 'permix/better-auth'
+import { permixPlugin } from './auth'
+
+export const authClient = createAuthClient({
+ plugins: [createBetterAuthPermixClient()],
+})
+
+const { data: permissions } = await authClient.permix.getPermissions()
+```
+
+The client method returns typed dehydrated booleans for UI gating. Protected work must still be checked on the server.
+
+## Better Auth access control
+
+If an application already uses Better Auth's access-control statements, Permix can reuse that vocabulary:
+
+```ts
+import { createAccessControl } from 'better-auth/plugins/access'
+import {
+ inferDefinitionFromAccessControl,
+ rulesFromBetterAuthRole,
+} from 'permix/better-auth'
+
+const access = createAccessControl({
+ documents: ['read', 'update'],
+} as const)
+
+const member = access.newRole({
+ documents: ['read'],
+})
+
+const definition = inferDefinitionFromAccessControl(access.statements)
+const rules = rulesFromBetterAuthRole(access.statements, member)
+```
+
+These helpers are optional. A manual or extracted Permix `Definition` remains the canonical permission vocabulary when provider-native statements do not match the application's authorization model.
diff --git a/docs/content/docs/integrations/clerk.mdx b/docs/content/docs/integrations/clerk.mdx
new file mode 100644
index 00000000..e5dd7204
--- /dev/null
+++ b/docs/content/docs/integrations/clerk.mdx
@@ -0,0 +1,96 @@
+---
+title: Clerk
+description: Resolve isolated Permix rules from Clerk sessions and organization permissions
+---
+
+## Overview
+
+`permix/clerk` provides the same authorization outcome as the Better Auth integration: authenticated server checks, async rules, dehydrated permission transport, and a typed client. Clerk has no third-party plugin registry, so this is a Permix provider integration rather than a Clerk-native plugin.
+
+## Server integration
+
+```ts
+import {
+ createClerkPermix,
+ createClerkRequestAuthenticator,
+} from 'permix/clerk'
+import { clerkClient } from '@clerk/nextjs/server'
+
+type Definition = {
+ documents: [
+ 'read',
+ { name: 'update'; type: { ownerId: string }; required: true },
+ ]
+}
+
+const permissions = createClerkPermix({
+ authenticateRequest: createClerkRequestAuthenticator(clerkClient),
+ resolveRules: async (principal) => ({
+ documents: {
+ read: principal.orgId !== undefined,
+ update: ({ ownerId }) => ownerId === principal.userId,
+ },
+ }),
+})
+```
+
+You may also pass an already authenticated Clerk `Auth` object directly to `check`, `resolve`, or `dehydrate`. Permix exposes verified identifiers, organization role/permissions, session claims, and Clerk's `has()` function to the resolver without fetching full Clerk resources.
+
+## Mapping Clerk authorization
+
+```ts
+import { createClerkAuthorizationMapping } from 'permix/clerk'
+
+const mapping = createClerkAuthorizationMapping({
+ 'documents.read': { permission: 'org:documents:read' },
+ 'documents.update': { role: 'org:editor' },
+})
+
+const allowed = mapping.check(principal, 'documents.read')
+```
+
+The mapping is explicit: Permix paths do not need to use Clerk's `org::` naming. Supply a permission catalog to validate unknown and uncovered mapped paths.
+
+## Permissions endpoint and client
+
+```ts
+import {
+ createClerkPermissionsHandler,
+ createClerkPermixClient,
+} from 'permix/clerk'
+
+export const GET = createClerkPermissionsHandler(permissions)
+
+const client = createClerkPermixClient({
+ endpoint: '/api/permissions',
+ organizationId: 'org_123',
+ getToken: async ({ organizationId }) =>
+ getTokenForOrganization(organizationId),
+})
+
+const permix = await client.getPermix()
+```
+
+Use an explicit bearer token for the intended active organization. Relying only on a singleton browser session cookie can select the wrong tenant in multi-organization clients.
+
+## Next.js convenience
+
+```ts
+import { createNextClerkPermix } from 'permix/clerk/next'
+
+export const permissions = createNextClerkPermix({
+ resolveRules: (principal) => ({
+ documents: {
+ read: principal.orgId !== undefined,
+ update: ({ ownerId }) => ownerId === principal.userId,
+ },
+ }),
+})
+```
+
+Clerk boundaries to keep explicit:
+
+- Organization permission checks require an active organization.
+- `has({ permission })` covers custom organization permissions, not system permissions.
+- Role and permission claims can be stale until Clerk refreshes the token.
+- Browser checks are UX only; enforce permissions again on the server.
diff --git a/docs/content/docs/integrations/convex.mdx b/docs/content/docs/integrations/convex.mdx
new file mode 100644
index 00000000..3438dd88
--- /dev/null
+++ b/docs/content/docs/integrations/convex.mdx
@@ -0,0 +1,87 @@
+---
+title: Convex
+description: Resolve permissions before Convex query, mutation, action, and HTTP handlers
+---
+
+## Overview
+
+`permix/convex` wraps generated Convex function builders. Every invocation calls `ctx.auth.getUserIdentity()`, resolves async rules, creates an isolated Permix instance, and only then starts application handler work.
+
+## Configure wrappers
+
+```ts
+import { createConvexPermix } from 'permix/convex'
+import type { DataModel } from './_generated/dataModel'
+
+type Definition = {
+ documents: [
+ 'read',
+ { name: 'update'; type: { ownerId: string }; required: true },
+ ]
+}
+
+export const permissions = createConvexPermix({
+ resolveRules: async ({ identity }) => ({
+ documents: {
+ read: true,
+ update: ({ ownerId }) => ownerId === identity.subject,
+ },
+ }),
+})
+```
+
+The resolver receives a discriminated `kind` (`query`, `mutation`, `action`, or `httpAction`), the original Convex context, and function arguments/request.
+
+## Wrap functions
+
+```ts
+import { v } from 'convex/values'
+import { query, mutation, action, httpAction } from './_generated/server'
+import { permissions } from './permissions'
+
+export const getDocument = permissions.query(query)({
+ args: { ownerId: v.string() },
+ returns: v.boolean(),
+ handler: ({ permix }, args) => permix.check('documents.update', args),
+})
+
+export const updateDocument = permissions.mutation(mutation)({
+ args: { ownerId: v.string() },
+ returns: v.null(),
+ handler: async ({ permix }, args) => {
+ if (!permix.check('documents.update', args)) {
+ throw new Error('Forbidden')
+ }
+ return null
+ },
+})
+
+export const runImport = permissions.action(action)(async ({ permix }) => {
+ if (!permix.check('documents.read')) throw new Error('Forbidden')
+ return null
+})
+
+export const health = permissions.httpAction(httpAction)(async ({ identity }) =>
+ Response.json({ subject: identity.subject })
+)
+```
+
+The wrappers preserve Convex argument/return validators and public/internal builder visibility. Unauthenticated invocations are rejected before the handler.
+
+## Data-model inference
+
+```ts
+import {
+ defineConvexTableSelection,
+ type ConvexDefinition,
+} from 'permix/convex'
+import type { DataModel } from './_generated/dataModel'
+
+const tables = defineConvexTableSelection()(['documents'] as const)
+
+type Definition = ConvexDefinition
+```
+
+The inferred actions mirror unambiguous generated document operations: `get`, `insert`, `patch`, `replace`, and `delete`, including their generated ID and document payload types. Business permissions such as `publish` or `moderate` should remain explicit in your canonical Permix definition.
+
+Internal or scheduled functions without an end-user identity should use a separate trusted authorization path instead of pretending to be a user.
diff --git a/docs/content/docs/integrations/drizzle.mdx b/docs/content/docs/integrations/drizzle.mdx
index 36e915f9..4becefc0 100644
--- a/docs/content/docs/integrations/drizzle.mdx
+++ b/docs/content/docs/integrations/drizzle.mdx
@@ -7,14 +7,7 @@ description: Auto-generate a Permix definition and CRUD rules from your Drizzle
Permix ships with a Drizzle integration that takes your existing Drizzle schema and produces a fully type-safe Permix instance with one permission entity per table. By default each table receives the four CRUD actions (`create`, `read`, `update`, `delete`), but the action list can be customised.
-Two entry points are exposed so you can choose the one that matches your installed Drizzle version:
-
-| Import path | Drizzle version | Notes |
-| --- | --- | --- |
-| `permix/drizzle` | **v1** (`>=1.0.0-rc`) | Uses Drizzle's official `extractTablesFromSchema` helper. Detects tables and views. |
-| `permix/drizzle/legacy` | **v0** (`>=0.30 <1`) | Detects tables only via `is(value, Table)`. |
-
-Both entry points expose the same API surface, so migrating between them is a one-line import change once you upgrade Drizzle.
+Import from `permix/drizzle`. It requires Drizzle ORM **v1** (`extractTablesFromSchema`) and detects tables and views.
Before getting started with the Drizzle integration, make sure you've
@@ -30,9 +23,7 @@ Both entry points expose the same API surface, so migrating between them is a on
Define your Drizzle schema as usual, then create a Permix instance from it.
-### Drizzle v1
-
-```ts twoslash title="/lib/drizzle-v1.ts"
+```ts twoslash title="/lib/drizzle.ts"
import { defineRelations } from 'drizzle-orm'
import { integer, pgTable, serial, text } from 'drizzle-orm/pg-core'
import { createPermix } from 'permix/drizzle'
@@ -67,44 +58,7 @@ permix.check('users.read') // true
permix.check('posts.delete') // false
```
-### Drizzle v0
-
-```ts twoslash title="/lib/drizzle-legacy.ts"
-import { relations } from 'drizzle-orm/_relations'
-import { integer, pgTable, serial, text } from 'drizzle-orm/pg-core'
-import { createPermix } from 'permix/drizzle/legacy'
-
-const users = pgTable('users', {
- id: serial('id').primaryKey(),
- name: text('name').notNull(),
-})
-
-const posts = pgTable('posts', {
- id: serial('id').primaryKey(),
- title: text('title').notNull(),
- authorId: integer('author_id')
- .notNull()
- .references(() => users.id),
-})
-
-const usersRelations = relations(users, ({ many }) => ({
- posts: many(posts),
-}))
-
-const schema = { users, posts, usersRelations }
-
-const permix = createPermix(schema)
-
-permix.setup({
- users: { create: true, read: true, update: false, delete: false },
- posts: { create: true, read: true, update: true, delete: false },
-})
-
-permix.check('users.read') // true
-permix.check('posts.delete') // false
-```
-
-The returned instance is a regular Permix object, so every API you already know — `check`, `setup`, `template`, `dehydrate`, `hydrate`, `hook`, `isReady`, etc. — is available unchanged.
+The returned instance is a regular Permix object, so every API you already know — `check`, `setup`, `template`, `dehydrate`, `hydrate`, `install`, `hook`, `isReady`, etc. — is available unchanged.
@@ -149,8 +103,8 @@ permix.actions // readonly ['create', 'read', 'update', 'delete']
## Notes
-- **v1** detects both tables **and views** (via Drizzle's `extractTablesFromSchema`). **v0** detects tables only.
-- Both versions skip non-entity exports automatically, so `import * as schema` works as-is. In v1 a `defineRelations(...)` object is filtered out; in v0 the same is true of `relations(...)` calls.
+- Detects both tables **and views** via Drizzle's `extractTablesFromSchema`.
+- Non-entity exports are skipped automatically, so `import * as schema` works as-is (`defineRelations(...)` objects are filtered out).
- Tables from any Drizzle dialect (`drizzle-orm/pg-core`, `drizzle-orm/mysql-core`, `drizzle-orm/sqlite-core`) are detected, so a single schema can mix dialects if needed.
-- Drizzle is declared as an optional peer dependency. You only need to install it if you plan to use a `permix/drizzle*` entry point.
+- Drizzle is declared as an optional peer dependency. You only need to install it if you plan to use `permix/drizzle`.
- The permission keys default to the **schema export names**, not the SQL table names. If you exported `pgTable('app_users', { ... })` as `users`, you check `users.read`, not `app_users.read`.
diff --git a/docs/content/docs/integrations/elysia.mdx b/docs/content/docs/integrations/elysia.mdx
index 44705098..0ddc0f33 100644
--- a/docs/content/docs/integrations/elysia.mdx
+++ b/docs/content/docs/integrations/elysia.mdx
@@ -232,6 +232,7 @@ The `onForbidden` handler receives:
- `context`: Elysia Context object
- `path`: The permission path that was checked (or `null` for callback checks)
- `data`: Optional entity data passed to the check
+- `reasons`: Denial strings from `{ allow: false, reason }` rules (empty for boolean denials)
## Advanced Usage
diff --git a/docs/content/docs/integrations/express.mdx b/docs/content/docs/integrations/express.mdx
index 927a9ff1..421d3ff1 100644
--- a/docs/content/docs/integrations/express.mdx
+++ b/docs/content/docs/integrations/express.mdx
@@ -204,6 +204,7 @@ The `onForbidden` handler receives:
- `res`: Express Response object
- `path`: The permission path that was checked (or `null` for callback checks)
- `data`: Optional entity data passed to the check
+- `reasons`: Denial strings from `{ allow: false, reason }` rules (empty for boolean denials)
## Advanced Usage
diff --git a/docs/content/docs/integrations/fastify.mdx b/docs/content/docs/integrations/fastify.mdx
index a262e357..6df2cce4 100644
--- a/docs/content/docs/integrations/fastify.mdx
+++ b/docs/content/docs/integrations/fastify.mdx
@@ -228,6 +228,7 @@ The `onForbidden` handler receives:
- `reply`: Fastify Reply object
- `path`: The permission path that was checked (or `null` for callback checks)
- `data`: Optional entity data passed to the check
+- `reasons`: Denial strings from `{ allow: false, reason }` rules (empty for boolean denials)
## Advanced Usage
diff --git a/docs/content/docs/integrations/hono.mdx b/docs/content/docs/integrations/hono.mdx
index 502d807c..2b711faf 100644
--- a/docs/content/docs/integrations/hono.mdx
+++ b/docs/content/docs/integrations/hono.mdx
@@ -221,6 +221,7 @@ The `onForbidden` handler receives:
- `c`: Hono Context object
- `path`: The permission path that was checked (or `null` for callback checks)
- `data`: Optional entity data passed to the check
+- `reasons`: Denial strings from `{ allow: false, reason }` rules (empty for boolean denials)
## Advanced Usage
diff --git a/docs/content/docs/integrations/nest.mdx b/docs/content/docs/integrations/nest.mdx
new file mode 100644
index 00000000..5d00e5ea
--- /dev/null
+++ b/docs/content/docs/integrations/nest.mdx
@@ -0,0 +1,262 @@
+---
+title: NestJS
+description: Learn how to use Permix with NestJS
+---
+
+## Overview
+
+Permix provides a NestJS integration that sets up permissions per request and enforces them with a guard plus a `@Check` decorator. The factory is created using `createPermix` from `permix/nest`.
+
+
+ Before getting started with the NestJS integration, make sure you've completed
+ the initial setup steps in the [Quick Start](/docs/quick-start) guide.
+
+
+
+
+
+
+## Setup
+
+Create a Permix factory and register its guard as a global `APP_GUARD`. The guard always attaches a per-request instance; it only enforces a permission when `@Check` is present.
+
+```ts
+import { Module } from '@nestjs/common'
+import { APP_GUARD } from '@nestjs/core'
+import { createPermix } from 'permix/nest'
+
+interface Post {
+ id: string
+ authorId: string
+ title: string
+ content: string
+}
+
+export const permix = createPermix<{
+ post: [
+ { name: 'create'; type: Post },
+ { name: 'read'; type: Post },
+ { name: 'update'; type: Post },
+ ]
+}>()
+
+@Module({
+ providers: [
+ {
+ provide: APP_GUARD,
+ useValue: permix.guard(({ req }) => {
+ // You can access req.user or other properties to determine permissions
+ return {
+ post: {
+ create: true,
+ read: true,
+ update: false,
+ },
+ }
+ }),
+ },
+ ],
+})
+export class AppModule {}
+```
+
+
+ The guard works with both the Express and Fastify Nest HTTP adapters. It
+ preserves full type safety from your Permix definition.
+
+
+
+
+
+
+## Checking Permissions
+
+Use the `Check` decorator on a handler or controller:
+
+```ts
+import { Controller, Delete, Get, Post, Put } from '@nestjs/common'
+import { permix } from './permix'
+
+@Controller('posts')
+export class PostsController {
+ @Post()
+ @permix.Check('post.create')
+ create() {
+ return { success: true }
+ }
+
+ @Put(':id')
+ @permix.Check((c) => c('post.read') && c('post.update'))
+ update() {
+ return { success: true }
+ }
+
+ @Delete(':id')
+ @permix.Check('post.~all')
+ remove() {
+ return { success: true }
+ }
+
+ @Get()
+ @permix.Check('post.~any')
+ findAll() {
+ return { posts: getAllPosts() }
+ }
+}
+```
+
+
+
+
+
+## Accessing Permix Directly
+
+You can access the Permix instance directly in your route handlers using the `get` function:
+
+```ts
+@Get()
+findAll(@Req() req: Request) {
+ const { check } = permix.getOrThrow(req)
+
+ if (check('post.read')) {
+ return { posts: getAllPosts() }
+ }
+
+ throw new ForbiddenException({
+ error: 'You do not have permission to read posts',
+ })
+}
+```
+
+Entity-based (ReBAC) checks usually run in the handler after the resource is loaded:
+
+```ts
+@Put(':id')
+async update(@Param('id') id: string, @Req() req: Request) {
+ const post = await getPostById(id)
+ const { check } = permix.getOrThrow(req)
+
+ if (!check('post.update', post)) {
+ throw new ForbiddenException({ error: 'You cannot update this post' })
+ }
+
+ return { success: true }
+}
+```
+
+
+
+
+
+## Using Templates
+
+Permix provides a template helper to create reusable permission rule sets:
+
+```ts
+const adminTemplate = permix.template({
+ post: {
+ create: true,
+ read: true,
+ update: true,
+ },
+})
+
+{
+ provide: APP_GUARD,
+ useValue: permix.guard(({ req }) => {
+ if (req.user?.role === 'admin') {
+ return adminTemplate()
+ }
+
+ return {
+ post: {
+ create: false,
+ read: true,
+ update: false,
+ },
+ }
+ }),
+}
+```
+
+
+
+
+
+## Custom Error Handling
+
+By default, a denied `@Check` throws a Nest `ForbiddenException` with `{ error: 'Forbidden' }`. You can customize this by providing an `onForbidden` handler:
+
+### Basic Error Handler
+
+```ts
+const permix = createPermix({
+ onForbidden: () => {
+ throw new ForbiddenException({
+ error: 'Custom forbidden message',
+ })
+ },
+})
+```
+
+### Dynamic Error Handler
+
+You can also throw different responses based on the checked path:
+
+```ts
+const permix = createPermix({
+ onForbidden: ({ path }) => {
+ if (path === 'post.create') {
+ throw new ForbiddenException({
+ error: `You don't have permission for ${path}`,
+ })
+ }
+
+ throw new ForbiddenException({
+ error: 'You do not have permission to perform this action',
+ })
+ },
+})
+```
+
+The `onForbidden` handler receives:
+
+- `req`: The HTTP request object (Express or Fastify)
+- `context`: Nest `ExecutionContext`
+- `path`: The permission path that was checked (or `null` for callback checks)
+- `data`: Optional entity data passed to the check
+- `reasons`: Denial strings from `{ allow: false, reason }` rules (empty for boolean denials)
+
+## Advanced Usage
+
+### Async Permission Rules
+
+You can use async functions in your guard setup:
+
+```ts
+permix.guard(async ({ req }) => {
+ const userPermissions = await getUserPermissions(req.user.id)
+
+ return {
+ post: {
+ create: userPermissions.canCreatePosts,
+ read: userPermissions.canReadPosts,
+ update: userPermissions.canUpdatePosts,
+ },
+ }
+})
+```
+
+### Hooks
+
+You can register hooks at the factory level to listen for events across all requests:
+
+```ts
+permix.hook('check', ({ path, data }) => {
+ console.log(`Permission checked: ${path}`, data)
+})
+```
+
+### Example
+
+You can find the example of the NestJS integration [here](https://github.com/letstri/permix/tree/main/examples/nest).
diff --git a/docs/content/docs/integrations/next.mdx b/docs/content/docs/integrations/next.mdx
index 5f1f1204..6f547b88 100644
--- a/docs/content/docs/integrations/next.mdx
+++ b/docs/content/docs/integrations/next.mdx
@@ -5,244 +5,248 @@ description: Learn how to use Permix with Next.js App Router
## Overview
-Permix provides a dedicated integration for the Next.js **App Router** through `permix/next`. It exposes a `createPermix` factory that returns a **per-request** Permix instance backed by React's [`cache()`](https://react.dev/reference/react/cache), so you can `setup()` the rules once and `check()` them anywhere on the server — layouts, pages, route handlers, and server actions — without threading the instance through props.
+`permix/next` is a request-safe helper for the Next.js **App Router**. You pass a rules resolver into `createPermix`. React's [`cache()`](https://react.dev/reference/react/cache) memoizes **one Promise** of a fully initialized core instance per request, so concurrent Server Components share the same setup instead of racing layout mutation.
-The client side reuses the existing React integration (`permix/react`): the server `dehydrate()`s its state and the client hydrates it into its own singleton via `PermixProvider` + `PermixHydrate`.
+The facade exposes **dual access** to that Promise:
+
+- Async `getPermix` / `check` / `getRules` / `dehydrate` for async Server Components
+- `usePermix()` for non-async Server Components — it unwraps the same Promise with React [`use()`](https://react.dev/reference/react/use). `check()` stays synchronous at the call site; React may suspend only while rules resolve
+
+Sync vs async is an **API** choice, not the PPR boundary. A permission UI can prerender after either path when its policy inputs are static or cached. User, session, or cookie-dependent outcomes must stay behind Suspense and must not enter a shared build-time shell.
+
+The client still uses `permix/react`. Hydrate only the permission-dependent islands — do not block the static App Shell on dehydrated state.
- Before getting started with the Next.js integration, make sure you've
- completed the initial setup steps in the [Quick Start](/docs/quick-start)
- guide. Familiarity with the [Hydration guide](/docs/guide/hydration) helps
- too.
+ Complete the [Quick Start](/docs/quick-start) and read the [Hydration
+ guide](/docs/guide/hydration) before this page.
+
+
+
+ To generate typed permission constants before Next compiles, use `withPermix`
+ from `permix/next/config`. See [Permission
+ extraction](/docs/guide/extraction).
- This integration is designed for the **App Router**. It relies on `react`'s
- request-scoped `cache()`, which is available in server components, route
- handlers, and server actions within a single request.
+ Requires **Next.js 15+**. Route Handlers and Server Actions do **not** share
+ the RSC `cache()` identity. Create and `setup()` an explicit core
+ `createPermix()` instance inside each invocation.
-## Define your permissions
-
-Create a Permix instance once in a shared module so it can be imported anywhere on the server:
+## Define the resolver
```ts title="lib/permix.ts"
import { createPermix } from 'permix/next'
-
-interface Post {
- id: string
- authorId: string
-}
+import { getSession } from '@/lib/auth'
export const permix = createPermix<{
post: [
- { name: 'create'; type: Post },
- { name: 'read'; type: Post },
- { name: 'update'; type: Post },
- { name: 'delete'; type: Post },
+ { name: 'create'; type: { id: string; authorId: string } },
+ { name: 'read'; type: { id: string; authorId: string } },
+ { name: 'update'; type: { id: string; authorId: string } },
+ { name: 'delete'; type: { id: string; authorId: string } },
]
-}>()
-```
-
-The returned helper does **not** hold any permission state at module scope — every request gets its own isolated instance.
-
-
-
-
-
-## Setup per request
-
-Call `setup()` early in the request lifecycle. A common place is the root layout (or any server component that runs before the ones doing `check`s). Resolve any async data (session, headers, cookies, DB lookups) first, then pass plain rules to `setup`:
-
-```tsx title="app/layout.tsx"
-import { permix } from '@/lib/permix'
-import { getSession } from '@/lib/auth'
-
-export default async function RootLayout({
- children,
-}: {
- children: React.ReactNode
-}) {
+}>(async () => {
const session = await getSession()
-
- permix.setup({
+ return {
post: {
create: !!session,
read: true,
update: (post) => post?.authorId === session?.userId,
delete: session?.role === 'admin',
},
- })
-
- return (
-
- {children}
-
- )
-}
+ }
+})
```
-
- Because `setup()` is scoped to the current request, calling it again from a
- nested server component or route handler in the **same** request simply
- replaces the rules for that request. Other requests are unaffected.
-
+The helper holds no permission state at module scope. The first caller in a request starts initialization; every other caller awaits the same Promise.
+
+Keep layouts and pages **synchronous**. Move async permission and data work into feature-owned Server Components, and put shaped skeletons behind **page-owned** Suspense so static chrome remains in the App Shell.
-## Check on the server
-
-Anywhere a server component, route handler, or server action runs in that request, you can use `check()`:
+## Async Server Components
```tsx title="app/posts/[id]/page.tsx"
import { notFound } from 'next/navigation'
+import { Suspense } from 'react'
import { permix } from '@/lib/permix'
import { getPost } from '@/lib/posts'
-export default async function PostPage({
+export default function PostPage({
params,
}: {
params: Promise<{ id: string }>
}) {
- const { id } = await params
- const post = await getPost(id)
+ return (
+
+
Post
+ }>
+ {params.then(({ id }) => (
+
+ ))}
+
+
+ )
+}
- if (!permix.check('post.read', post)) {
+async function PostArticle({ id }: { id: string }) {
+ const post = await getPost(id)
+ if (!post || !(await permix.check('post.read', post))) {
notFound()
}
-
- return {/* ... */}
+ return {post.id}
}
```
-```ts title="app/api/posts/route.ts"
+`await permix.getPermix()` returns the initialized core instance when you need `isReady()` or multiple synchronous `check()` calls after the await.
+
+
+
+
+
+## Non-async Server Components
+
+```tsx title="app/features/sync-read-badge.tsx"
import { permix } from '@/lib/permix'
-export async function POST(req: Request) {
- if (!permix.check('post.create')) {
- return Response.json({ error: 'Forbidden' }, { status: 403 })
+export function SyncReadBadge() {
+ const instance = permix.usePermix()
+ return {instance.check('post.read') ? 'allowed' : 'denied'}
+}
+```
+
+Place this behind Suspense. `usePermix()` from `permix/next` is for Server Components. Client components keep using `usePermix` from `permix/react`.
+
+
+
+
+
+## Prerender-safe vs request-specific rules
+
+| Outcome | When | Where it lives |
+| --- | --- | --- |
+| Globally cacheable check | Policy inputs are static or `"use cache"` | Shared App Shell |
+| Root-param-keyed check | URL is known (`next/root-params` in **your** resolver); optional `prefetch={true}` | Per-link prefetch |
+| Warm `"use cache: private"` payload | Same session already cached/prefetched | Authorized content can commit immediately |
+| Cold or deliberately fresh session check | Cookie/session/user data, uncached | Dynamic hole behind Suspense |
+
+Permix does not import generated root-param getters. For tenant or locale policies, read `next/root-params` in the **app-owned** resolver (the same pattern as next-intl). Cache serializable policy inputs with `"use cache"`, then build any function rules outside that cache boundary.
+
+Keep ordinary tenant/resource slugs as page values passed through `params.then(...)`. Do not promote a nested slug to a root param solely to avoid prop passing.
+
+
+
+
+
+## Permission-first private payloads
+
+Check permission **before** loading feature data. Return serializable data or `null` when denied. An uncached caller performs `notFound()` / `redirect()` — denial control flow stays outside the cached payload. Cache tags remain app-owned; Permix cannot infer a feature's data or membership lifecycle.
+
+```tsx title="app/features/private-edit.tsx"
+import { createPermix } from 'permix'
+import { getSession } from '@/lib/auth'
+import { rulesForSession, type PermissionsDefinition } from '@/lib/permissions'
+import { getPost } from '@/lib/posts'
+
+async function readPostUpdatePayload(postId: string) {
+ 'use cache: private'
+ const session = await getSession()
+ const permix = createPermix()
+ permix.setup(rulesForSession(session))
+ const post = await getPost(postId)
+ if (!post || !permix.check('post.update', post)) {
+ return null
}
+ return { id: post.id, authorId: post.authorId }
+}
- // create the post...
- return Response.json({ ok: true })
+export async function PrivateEditIsland({ postId }: { postId: string }) {
+ const payload = await readPostUpdatePayload(postId)
+ if (!payload) {
+ return null
+ }
+ return
}
```
-You can also reach the underlying core instance through `permix.get()` if you need methods like `isReady()` or `getRules()`.
+A client `check()` after hydrate is a **UI hint**, not enforcement. Authoritative decisions stay on the server (this payload, Route Handlers, Server Actions).
-## Send permissions to the client
+## Hydrate permission-dependent UI only
-Use `dehydrate()` to serialize the request's permissions and hand them to a client provider. The server cannot send the Permix instance itself across the boundary — only the JSON state.
+Create the client bindings with `createPermix` from `permix/react` — same factory name as the server adapter, different import path:
+
+```ts title="lib/client-permix.ts"
+import { createPermix } from 'permix/react'
+import type { PermissionsDefinition } from './permissions'
+
+export const {
+ permix: clientPermix,
+ PermixProvider,
+ PermixHydrate,
+ usePermix,
+ Check,
+} = createPermix()
+```
```tsx title="app/providers.tsx"
'use client'
-import { createPermix } from 'permix'
-import { PermixHydrate, PermixProvider } from 'permix/react'
import type { DehydratedState } from 'permix'
-
-// One singleton per browser tab. The same type definition as on the server.
-const permix = createPermix<{
- post: [
- { name: 'create'; type: { id: string; authorId: string } },
- { name: 'read'; type: { id: string; authorId: string } },
- { name: 'update'; type: { id: string; authorId: string } },
- { name: 'delete'; type: { id: string; authorId: string } },
- ]
-}>()
+import { PermixHydrate, PermixProvider } from '@/lib/client-permix'
+import type { PermissionsDefinition } from '@/lib/permissions'
export function Providers({
state,
children,
}: {
- state: DehydratedState
+ state: DehydratedState
children: React.ReactNode
}) {
return (
-
+ {children}
)
}
-
-export { permix }
-```
-
-Then wire it up in your root layout right after `setup()`:
-
-```tsx title="app/layout.tsx"
-import { permix } from '@/lib/permix'
-import { getSession } from '@/lib/auth'
-import { Providers } from './providers'
-
-export default async function RootLayout({
- children,
-}: {
- children: React.ReactNode
-}) {
- const session = await getSession()
-
- permix.setup({/* ...rules derived from session... */})
-
- return (
-
-
- {children}
-
-
- )
-}
```
-
- `hydrate()` restores the boolean state but does not flip `isReady` on its own
- — function-based rules are lost during serialization. If you need `isReady` on
- the client (e.g. to gate UI on `usePermix(...).isReady`), call
- `permix.setup(...)` on the client too with the same shape (using booleans and
- any function rules you want active client-side). See the [Hydration
- guide](/docs/guide/hydration) for details.
-
+Await `permix.dehydrate()` in the feature that owns the island, not in the root layout. `hydrate()` restores booleans and does not set `isReady`. Call `clientPermix.setup(...)` on the client for function rules — see [Hydration](/docs/guide/hydration).
-## Use on the client
-
-From any client component, import the singleton from `app/providers.tsx` and the hooks/components from `permix/react`:
-
-```tsx title="app/posts/[id]/edit-button.tsx"
-'use client'
+## Route Handlers and Server Actions
-import { usePermix } from 'permix/react'
-import { permix } from '@/app/providers'
+```ts title="app/api/posts/route.ts"
+import { createPermix } from 'permix'
+import { getSession } from '@/lib/auth'
+import { rulesForSession, type PermissionsDefinition } from '@/lib/permissions'
-export function EditButton({
- post,
-}: {
- post: { id: string; authorId: string }
-}) {
- const { check } = usePermix(permix)
+export async function POST() {
+ const permix = createPermix()
+ permix.setup(rulesForSession(await getSession()))
- if (!check('post.update', post)) {
- return null
+ if (!permix.check('post.create')) {
+ return Response.json({ error: 'Forbidden' }, { status: 403 })
}
- return
+ return Response.json({ ok: true })
}
```
-If you prefer the component API, create checkers with `createComponents` from `permix/react` and use them in your client components — see the [React integration](/docs/integrations/react#components) for details.
+Share `rulesForSession` (or templates) with the RSC resolver so the policy stays in one place.
@@ -250,32 +254,15 @@ If you prefer the component API, create checkers with `createComponents` from `p
## Templates
-`createPermix` exposes the same `template()` helper as the core API for reusing rule sets:
+`template()` does not wait on initialization:
```ts title="lib/permix.ts"
-import { createPermix } from 'permix/next'
-
-export const permix = createPermix<{
- post: ['create', 'read', 'update', 'delete']
-}>()
-
export const adminTemplate = permix.template({
post: { create: true, read: true, update: true, delete: true },
})
-
-export const guestTemplate = permix.template({
- post: { create: false, read: true, update: false, delete: false },
-})
```
-```tsx title="app/layout.tsx"
-import { permix, adminTemplate, guestTemplate } from '@/lib/permix'
-import { getSession } from '@/lib/auth'
-
-const session = await getSession()
-
-permix.setup(session?.role === 'admin' ? adminTemplate() : guestTemplate())
-```
+You can also use `createTemplate` from `permix` in a shared module imported by the resolver, handlers, and the client.
@@ -283,47 +270,34 @@ permix.setup(session?.role === 'admin' ? adminTemplate() : guestTemplate())
## Example
-You can find a runnable example of the Next.js integration [here](https://github.com/letstri/permix/tree/main/examples/next).
+Runnable App Router example with Cache Components and Partial Prefetching: [examples/next](https://github.com/letstri/permix/tree/main/examples/next).
-## How per-request isolation works
-
-`createPermix` from `permix/next` wraps a single core instance per request using React's `cache()`. Inside one Next.js request:
-
-- The first call to `setup`/`check`/`get`/`dehydrate` creates (or reuses) **one** instance.
-- All subsequent calls in the same request — across server components, route handlers, and server actions — share that instance.
-
-Across concurrent requests, each request gets its **own** instance. State never leaks between users.
-
-
- Do **not** store the result of `permix.get()` (or any rule data) in
- module-level variables. That would defeat per-request isolation. Always go
- through `permix.check()` / `permix.get()` so the request-scoped cache is
- consulted.
-
-
## API
-### `createPermix()`
+### `createPermix(resolveRules)`
-Returns an object with the following methods:
+`resolveRules` is `() => Rules | Promise>`.
| Method | Description |
| --- | --- |
-| `setup(rules)` | Set the per-request permission rules. Resolve any async data (session, etc.) before calling. |
-| `check(...args)` | Check a permission against the current request's rules. Same signature as the core `check`. |
-| `get()` | Return the underlying [`Permix`](/docs/guide/instance) instance for the current request. |
-| `getRules()` | Return the current rules object for the request-scoped instance, or `null`. |
-| `dehydrate()` | Serialize the current request's rules to JSON (for `` on the client). |
-| `template(rules)` | Create a reusable rule set. Same as the core [`template`](/docs/guide/template). |
+| `getPermix()` | Promise of the initialized [`Permix`](/docs/guide/instance) for this request |
+| `usePermix()` | Same instance via React `use()` (Server Components) |
+| `check(...args)` | Async check against the initialized instance |
+| `getRules()` | Async current rules, or `null` |
+| `dehydrate()` | Async JSON snapshot for `` |
+| `template(rules)` | Reusable rule set — same as core [`template`](/docs/guide/template) |
+
+There is no `setup`, `hook`, or `hookOnce` on the Next facade. Initialization is the resolver. Subscribe to core hooks on the instance from `getPermix()` / `usePermix()` if you need them.
## TanStack Start and other frameworks
-The client layer (`permix/react`) is framework-agnostic. If you're using TanStack Start, Remix, or a custom React SSR setup, you can still use `permix/react` on the client. For the server side, either:
+The client layer (`permix/react`) is framework-agnostic. If you're using TanStack Start, React Router 7, or a custom React SSR setup, you can still use `permix/react` on the client. For the server side, either:
-- Use the dedicated [`permix/tanstack-start`](/docs/integrations/tanstack-start) integration, which follows the same shape as `permix/next`, or
+- Use the dedicated [`permix/tanstack-start`](/docs/integrations/tanstack-start) or [`permix/react-router`](/docs/integrations/react-router) integration, which follow the same shape as `permix/next`, or
+- For Nuxt, use [`permix/nuxt`](/docs/integrations/nuxt) on the server and `permix/vue` on the client, or
- Create a core Permix instance per request manually (see [Hydration guide](/docs/guide/hydration)), or
- Use an existing server integration like [`permix/node`](/docs/integrations/node), [`permix/express`](/docs/integrations/express), or [`permix/hono`](/docs/integrations/hono) when applicable.
diff --git a/docs/content/docs/integrations/node.mdx b/docs/content/docs/integrations/node.mdx
index afdb8812..7701af39 100644
--- a/docs/content/docs/integrations/node.mdx
+++ b/docs/content/docs/integrations/node.mdx
@@ -233,6 +233,7 @@ The `onForbidden` handler receives:
- `res`: Node.js ServerResponse object
- `path`: The permission path that was checked (or `null` for callback checks)
- `data`: Optional entity data passed to the check
+- `reasons`: Denial strings from `{ allow: false, reason }` rules (empty for boolean denials)
## Advanced Usage
diff --git a/docs/content/docs/integrations/nuxt.mdx b/docs/content/docs/integrations/nuxt.mdx
new file mode 100644
index 00000000..d03ea2ca
--- /dev/null
+++ b/docs/content/docs/integrations/nuxt.mdx
@@ -0,0 +1,280 @@
+---
+title: Nuxt
+description: Learn how to use Permix with Nuxt
+---
+
+## Overview
+
+Permix provides a dedicated integration for [Nuxt](https://nuxt.com/) through `permix/nuxt`. It exposes a `createPermix` factory that returns a **per-request** Permix instance stored on the Nitro `event.context`, so you can `setup()` the rules once and `check()` them in server middleware, API routes, and Vue server components without leaking state between concurrent requests.
+
+The client side reuses the existing Vue integration (`permix/vue`): the server `dehydrate()`s its state and the client hydrates it into its own singleton via `PermixProvider` + `PermixHydrate`.
+
+
+ Before getting started with the Nuxt integration, make sure you've completed
+ the initial setup steps in the [Quick Start](/docs/quick-start) guide.
+ Familiarity with the [Hydration guide](/docs/guide/hydration) and the [Vue
+ integration](/docs/integrations/vue) helps too.
+
+
+
+
+
+
+## Define your permissions
+
+Create a Permix instance once in a shared module so it can be imported from Nitro and from Vue:
+
+```ts title="lib/permix.ts"
+import { createPermix } from 'permix/nuxt'
+
+interface Post {
+ id: string
+ authorId: string
+}
+
+export const permix = createPermix<{
+ post: [
+ { name: 'create'; type: Post },
+ { name: 'read'; type: Post },
+ { name: 'update'; type: Post },
+ { name: 'delete'; type: Post },
+ ]
+}>()
+```
+
+The returned helper does **not** hold any permission state at module scope — every request gets its own isolated instance on `event.context`.
+
+
+
+
+
+## Setup per request
+
+Call `setup()` early in the request lifecycle. A Nitro server middleware is a good place. Resolve any async data (session, headers, DB lookups) first, then pass plain rules to `setup`. Pass the `event` so the instance is stored on this request:
+
+```ts title="server/middleware/permix.ts"
+import { permix } from '~/lib/permix'
+
+export default defineEventHandler((event) => {
+ const user = event.context.user
+
+ permix.setup(
+ {
+ post: {
+ create: !!user,
+ read: true,
+ update: (post) => post?.authorId === user?.id,
+ delete: user?.role === 'admin',
+ },
+ },
+ event
+ )
+})
+```
+
+
+ Because `setup()` is scoped to the current request's `event.context`, calling
+ it again from a nested route handler in the **same** request simply replaces
+ the rules for that request. Other requests are unaffected.
+
+
+If you omit `event`, Permix tries h3's current-request helpers when they exist (`getRequestEvent` / `useEvent`). In Nitro handlers and Vue server components, pass `event` or `useRequestEvent()` so this works on all Nuxt versions.
+
+
+
+
+
+## Check on the server
+
+Anywhere a server route or Vue server component runs in that request, you can use `check()`. Pass `event` (or `useRequestEvent()`) when you are not relying on AsyncLocalStorage:
+
+```ts title="server/api/posts/index.post.ts"
+import { permix } from '~/lib/permix'
+
+export default defineEventHandler((event) => {
+ if (!permix.get(event).check('post.create')) {
+ throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
+ }
+
+ // create the post...
+ return { ok: true }
+})
+```
+
+```vue title="pages/posts/[id].vue"
+
+```
+
+You can also reach the underlying core instance through `permix.get(event)` if you need methods like `isReady()` or `getRules()`.
+
+
+
+
+
+## Send permissions to the client
+
+Use `dehydrate()` to serialize the request's permissions and hand them to a client provider. The server cannot send the Permix instance itself across the boundary — only the JSON state.
+
+`permix/nuxt` is **server-only**. Create a client factory with `permix/vue` and wrap the app with the bound `PermixProvider` + `PermixHydrate`. Dehydrate in a server plugin so the client bundle never imports `h3`:
+
+```ts title="lib/permix-client.ts"
+import { createPermix } from 'permix/vue'
+
+export const { permix, PermixProvider, PermixHydrate, usePermix, Check } =
+ createPermix<{
+ post: [
+ { name: 'create'; type: { id: string; authorId: string } },
+ { name: 'read'; type: { id: string; authorId: string } },
+ { name: 'update'; type: { id: string; authorId: string } },
+ { name: 'delete'; type: { id: string; authorId: string } },
+ ]
+ }>()
+```
+
+```ts title="plugins/permix.server.ts"
+import { permix } from '~/lib/permix'
+
+export default defineNuxtPlugin(() => {
+ const event = useRequestEvent()
+ useState('permix-state', () => (event ? permix.dehydrate(event) : null))
+})
+```
+
+```vue title="app.vue"
+
+
+
+
+
+
+
+
+
+```
+
+
+ `hydrate()` restores the boolean state but does not flip `isReady` on its own
+ — function-based rules are lost during serialization. If you need `isReady` on
+ the client (e.g. to gate UI on `usePermix(...).isReady`), call
+ `permix.setup(...)` on the client too with the same shape (using booleans and
+ any function rules you want active client-side). See the [Hydration
+ guide](/docs/guide/hydration) for details.
+
+
+
+
+
+
+## Use on the client
+
+From any Vue component, import the factory-bound `usePermix` from your `createPermix` module:
+
+```vue title="components/EditButton.vue"
+
+
+
+
+
+```
+
+If you prefer the component API, use `Check` from `createPermix` in `permix/vue` — see the [Vue integration](/docs/integrations/vue).
+
+
+
+
+
+## Templates
+
+`createPermix` exposes the same `template()` helper as the core API for reusing rule sets:
+
+```ts title="lib/permix.ts"
+import { createPermix } from 'permix/nuxt'
+
+export const permix = createPermix<{
+ post: ['create', 'read', 'update', 'delete']
+}>()
+
+export const adminTemplate = permix.template({
+ post: { create: true, read: true, update: true, delete: true },
+})
+
+export const guestTemplate = permix.template({
+ post: { create: false, read: true, update: false, delete: false },
+})
+```
+
+```ts title="server/middleware/permix.ts"
+import { permix, adminTemplate, guestTemplate } from '~/lib/permix'
+
+export default defineEventHandler((event) => {
+ const user = event.context.user
+ permix.setup(
+ user?.role === 'admin' ? adminTemplate() : guestTemplate(),
+ event
+ )
+})
+```
+
+
+
+
+
+## Example
+
+You can find a runnable example of the Nuxt integration [here](https://github.com/letstri/permix/tree/main/examples/nuxt).
+
+
+
+
+
+## How per-request isolation works
+
+`createPermix` from `permix/nuxt` stores one core instance per factory on the current Nitro event's `context`. Inside one request:
+
+- The first call to `setup`/`check`/`get`/`dehydrate` creates (or reuses) **one** instance.
+- All subsequent calls in the same request — across middleware, API routes, and Vue server components that share that event — share that instance.
+
+Across concurrent requests, each event object gets its **own** instance. State never leaks between users.
+
+
+ Do **not** store the result of `permix.get()` (or any rule data) in
+ module-level variables. That would defeat per-request isolation. Always go
+ through `permix.check()` / `permix.get(event)` so the request-scoped store is
+ consulted.
+
+
+## API
+
+### `createPermix()`
+
+Returns an object with the following methods:
+
+| Method | Description |
+| --- | --- |
+| `setup(rules, event?)` | Set the per-request permission rules. Pass `event` in Nitro handlers (or `useRequestEvent()` in Vue). |
+| `check(...args)` | Check a permission against the current request's rules (via `getRequestEvent()`). Same signature as the core `check`. |
+| `get(event?)` | Return the underlying [`Permix`](/docs/guide/instance) instance for the request. |
+| `getRules(event?)` | Return the current rules object for the request-scoped instance, or `null`. |
+| `dehydrate(event?)` | Serialize the current request's rules to JSON (for `` on the client). |
+| `template(rules)` | Create a reusable rule set. Same as the core [`template`](/docs/guide/template). |
diff --git a/docs/content/docs/integrations/orpc.mdx b/docs/content/docs/integrations/orpc.mdx
index 05329b58..b4ad980f 100644
--- a/docs/content/docs/integrations/orpc.mdx
+++ b/docs/content/docs/integrations/orpc.mdx
@@ -211,6 +211,7 @@ The `onForbidden` handler receives:
- `path`: The permission path that was checked (e.g. `'post.create'`)
- `data`: Optional data passed to the check
+- `reasons`: Denial strings from `{ allow: false, reason }` rules (empty for boolean denials)
- `context`: Your oRPC context object
- `next`: The middleware `next` function — call it to allow the request through
diff --git a/docs/content/docs/integrations/pdp.mdx b/docs/content/docs/integrations/pdp.mdx
new file mode 100644
index 00000000..d2b2717c
--- /dev/null
+++ b/docs/content/docs/integrations/pdp.mdx
@@ -0,0 +1,68 @@
+---
+title: HTTP PDP
+description: Expose typed permission decisions through a fetch-standard HTTP API
+---
+
+## Overview
+
+`permix/pdp` exposes a stateless policy decision point (PDP) for services that cannot run your rules directly. It includes a fetch-standard handler, a browser-compatible TypeScript client, and a deterministic OpenAPI 3.1 document.
+
+The server authenticates every request, resolves fresh rules for the verified subject, and creates an isolated Permix instance. A JSON permission catalog is optional metadata; it is never required to authorize.
+
+## Server
+
+```ts
+import { createPdpHandler } from 'permix/pdp'
+
+type Definition = {
+ document: ['read', 'create']
+}
+
+const handler = createPdpHandler({
+ authenticateCaller: async (request) => verifyUserToken(request),
+ authenticateService: async (request) => verifyServiceCredential(request),
+ resolveSubject: ({ service, subject }) =>
+ service.canImpersonate ? loadCaller(subject) : null,
+ resolveRules: async ({ principal }) => ({
+ document: {
+ read: await canReadDocuments(principal.userId),
+ create: await canCreateDocuments(principal.userId),
+ },
+ }),
+})
+
+export const POST = handler
+```
+
+Caller-scoped requests derive the subject from the verified caller. Only a trusted service credential may name a different subject; never forward an untrusted request body subject directly into `resolveRules`.
+
+The handler supports single checks, batch checks, dehydrated permission retrieval, and health/version metadata. Malformed transport input, authentication failures, denials, validation failures, and internal errors use distinct structured responses.
+
+## Client
+
+```ts
+import { createPdpClient } from 'permix/pdp'
+
+const permissions = createPdpClient({
+ baseUrl: 'https://permissions.example.com',
+ getToken: async () => getAccessToken(),
+})
+
+const decision = await permissions.check('document.read')
+const decisions = await permissions.checkMany([
+ { path: 'document.read' },
+ { path: 'document.create' },
+])
+```
+
+Client checks are useful for UX, but the API that performs protected work must still enforce the same permission server-side.
+
+## OpenAPI and catalogs
+
+```ts
+import { createPdpOpenApiDocument } from 'permix/pdp'
+
+const document = createPdpOpenApiDocument(permissionCatalog)
+```
+
+Generate or persist this document at build time. When a catalog is supplied, permission enums and descriptions come from that versioned artifact. Unsupported catalog schema versions are rejected; unknown fields on a supported version are tolerated for forward-compatible metadata.
diff --git a/docs/content/docs/integrations/react-router.mdx b/docs/content/docs/integrations/react-router.mdx
new file mode 100644
index 00000000..4bc4da26
--- /dev/null
+++ b/docs/content/docs/integrations/react-router.mdx
@@ -0,0 +1,255 @@
+---
+title: React Router
+description: Learn how to use Permix with React Router 7
+---
+
+## Overview
+
+Permix provides a dedicated integration for [React Router 7](https://reactrouter.com/) through `permix/react-router`. Remix apps that have moved to React Router 7 use this same adapter — there is no separate `permix/remix` export.
+
+It stores a **per-request** Permix instance on React Router's middleware context (`context.set` / `context.get`), so loaders, actions, and middleware share one instance. The client reuses [`permix/react`](/docs/integrations/react): dehydrate on the server and hydrate with `PermixProvider` + `PermixHydrate`.
+
+
+ Before getting started, complete the [Quick Start](/docs/quick-start). This
+ adapter uses [React Router
+ middleware](https://reactrouter.com/how-to/middleware) (React Router 7.9+).
+ Enable `v8_middleware` (or the current middleware flag) in your React Router
+ config if it is not on by default.
+
+
+
+
+
+
+## Define your permissions
+
+```ts title="app/lib/permix.ts"
+import type { ValidateDefinition } from 'permix'
+import { createPermix } from 'permix/react-router'
+
+interface Post {
+ id: string
+ authorId: string
+}
+
+export type PermissionsDefinition = ValidateDefinition<{
+ post: [
+ { name: 'create'; type: Post },
+ { name: 'read'; type: Post },
+ { name: 'update'; type: Post },
+ { name: 'delete'; type: Post },
+ ]
+}>
+
+export const permix = createPermix()
+```
+
+Each `createPermix()` call uses its own context key, so two factories on the same request do not collide.
+
+
+
+
+
+## Setup per request
+
+Register `setupMiddleware` on the root route so every request gets a fresh instance:
+
+```ts title="app/root.tsx"
+import { permix } from './lib/permix'
+import { getSession } from './lib/auth'
+
+export const middleware = [
+ permix.setupMiddleware(async ({ request }) => {
+ const session = await getSession(request)
+
+ return {
+ post: {
+ create: !!session,
+ read: true,
+ update: (post) => post?.authorId === session?.userId,
+ delete: session?.role === 'admin',
+ },
+ }
+ }),
+]
+```
+
+You can also attach it to a specific route's `middleware` array instead of the root.
+
+
+
+
+
+## Check in loaders and actions
+
+```ts title="app/routes/posts.$id.tsx"
+import { data } from 'react-router'
+import { permix } from '../lib/permix'
+import { getPost } from '../lib/posts'
+import type { Route } from './+types/posts.$id'
+
+export async function loader({ params, context }: Route.LoaderArgs) {
+ const post = await getPost(params.id)
+
+ if (!permix.getOrThrow(context).check('post.read', post)) {
+ throw data('Not Found', { status: 404 })
+ }
+
+ return { post, permixState: permix.dehydrate(context) }
+}
+
+export async function action({ context }: Route.ActionArgs) {
+ if (!permix.getOrThrow(context).check('post.create')) {
+ throw data({ error: 'Forbidden' }, { status: 403 })
+ }
+
+ return { ok: true }
+}
+```
+
+To guard a whole route, compose `checkMiddleware` after setup:
+
+```ts title="app/routes/posts.new.tsx"
+import { permix } from '../lib/permix'
+
+export const middleware = [permix.checkMiddleware('post.create')]
+```
+
+Denied requests default to `403` with `{ error: 'Forbidden' }`. Customize with `onForbidden` (for example to `redirect`).
+
+
+
+
+
+## Hydrate the client
+
+Dehydrate in a loader (often the root loader) and wrap the tree with `permix/react`:
+
+```tsx title="app/root.tsx"
+import { PermixHydrate, PermixProvider } from 'permix/react'
+import { createPermix } from 'permix'
+import { permix as serverPermix } from './lib/permix'
+import type { PermissionsDefinition } from './lib/permix'
+import type { Route } from './+types/root'
+
+const clientPermix = createPermix()
+
+export async function loader({ context }: Route.LoaderArgs) {
+ return { permixState: serverPermix.dehydrate(context) }
+}
+
+export function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+export default function App({ loaderData }: Route.ComponentProps) {
+ return (
+
+
+
+
+
+ )
+}
+```
+
+
+ `hydrate()` restores booleans but does not restore function-based rules or
+ mark the instance ready. Call `clientPermix.setup(...)` on the client with the
+ full rule set after hydration. See the [Hydration
+ guide](/docs/guide/hydration).
+
+
+
+
+
+
+## Use on the client
+
+```tsx title="app/components/edit-button.tsx"
+import { usePermix } from 'permix/react'
+
+export function EditButton({
+ permix,
+ post,
+}: {
+ permix: Parameters[0]
+ post: { id: string; authorId: string }
+}) {
+ const { check } = usePermix(permix)
+
+ if (!check('post.update', post)) {
+ return null
+ }
+
+ return
+}
+```
+
+Pass the same client factory you used for `PermixProvider`. See the [React integration](/docs/integrations/react).
+
+
+
+
+
+## Templates
+
+```ts title="app/lib/permix.ts"
+import { createPermix } from 'permix/react-router'
+
+export const permix = createPermix<{
+ post: ['create', 'read', 'update', 'delete']
+}>()
+
+export const adminTemplate = permix.template({
+ post: { create: true, read: true, update: true, delete: true },
+})
+
+export const guestTemplate = permix.template({
+ post: { create: false, read: true, update: false, delete: false },
+})
+```
+
+```ts title="app/root.tsx"
+export const middleware = [
+ permix.setupMiddleware(async ({ request }) => {
+ const session = await getSession(request)
+ return session?.role === 'admin' ? adminTemplate() : guestTemplate()
+ }),
+]
+```
+
+
+
+
+
+## Example
+
+You can find a runnable example of the React Router integration [here](https://github.com/letstri/permix/tree/main/examples/react-router).
+
+
+
+
+
+## Remix
+
+Remix merged into React Router 7. Use `permix/react-router` — do not look for a `permix/remix` export.
+
+## API
+
+### `createPermix(options?)`
+
+| Method | Description |
+| --- | --- |
+| `setupMiddleware(rules \| callback)` | Middleware that creates a per-request instance on `context`. |
+| `checkMiddleware(...args)` | Middleware that allows or returns the `onForbidden` response. |
+| `get(context)` | Return the instance, or `null`. |
+| `getOrThrow(context)` | Return the instance, or throw `PermixNotFoundError`. |
+| `dehydrate(context)` | Serialize rules for ``. |
+| `getRules(context)` | Return the current rules, or `null`. |
+| `template(rules)` | Create a reusable rule set. |
+| `context` | The opaque key passed to `context.set` / `context.get`. |
diff --git a/docs/content/docs/integrations/react.mdx b/docs/content/docs/integrations/react.mdx
index 2eb3e010..f2e6ad76 100644
--- a/docs/content/docs/integrations/react.mdx
+++ b/docs/content/docs/integrations/react.mdx
@@ -5,7 +5,9 @@ description: Learn how to use Permix with React applications
## Overview
-Permix provides official React integration through the `PermixProvider` component and `usePermix` hook. This allows you to manage permissions reactively in your React app.
+Permix provides official React integration through `createPermix` from `permix/react`. Same factory name as `permix/next` and `permix/express`: it returns a Permix instance plus isolated `PermixProvider`, `usePermix`, `Check`, and `PermixHydrate` bindings.
+
+The adapter supports React 18 and React 19. On React 19.2+ it uses native `useEffectEvent`; React 18 gets a compatible fallback. Peers are `react` / `react-dom` `>=18`.
Before getting started with React integration, make sure you've completed the
@@ -18,25 +20,42 @@ Permix provides official React integration through the `PermixProvider` componen
## Setup
-First, wrap your application with the `PermixProvider`:
+Call `createPermix` once at module scope:
+
+```ts title="lib/permix.ts"
+import { createPermix } from 'permix/react'
+
+export const { permix, PermixProvider, PermixHydrate, usePermix, Check } =
+ createPermix<{
+ post: ['create', 'read', { name: 'edit'; type: Post }]
+ }>()
+```
+
+Pass an existing core instance when you already created one (for example a client copy used with server hydration):
+
+```ts
+import { createPermix } from 'permix/react'
+import { clientPermix } from './client-core'
+
+export const { PermixProvider, PermixHydrate, usePermix, Check } =
+ createPermix(clientPermix)
+```
+
+Wrap the tree with the bound provider. It does not take a `permix` prop — the factory already closed over the instance:
```tsx title="App.tsx"
-import { PermixProvider } from 'permix/react'
-import { permix } from './lib/permix'
+import { PermixProvider } from './lib/permix'
function App() {
return (
-
+
)
}
```
-
- Remember to always pass the same Permix instance to both the `PermixProvider`
- and `usePermix` hook to maintain type safety.
-
+Each `createPermix` call gets its own React context, so nested factories (for example a posts policy and a comments policy) stay independent.
@@ -44,14 +63,29 @@ function App() {
## Hook
-For checking permissions in your components, you can use the `usePermix` hook. And to avoid importing the hook and Permix instance in every component, you can create a custom hook:
+`usePermix()` from the factory does not take an instance argument:
+
+```tsx title="page.tsx"
+import { usePermix } from './lib/permix'
+
+export default function Page() {
+ const post = usePost()
+ const { check, explain, isReady } = usePermix()
-```tsx title="hooks/use-permissions.ts"
-import { usePermix } from 'permix/react'
-import { permix } from '../lib/permix'
+ if (!isReady) {
+ return
Loading permissions...
+ }
-export function usePermissions() {
- return usePermix(permix)
+ const canEdit = check('post.edit', post)
+ const denial = explain('post.edit', post)
+
+ return canEdit ? (
+
+ ) : (
+
+ )
}
```
@@ -61,19 +95,11 @@ export function usePermissions() {
## Components
-If you prefer using components, you can import the `createComponents` function from `permix/react` and create checking components:
-
-```ts title="lib/permix.ts"
-import { createComponents } from 'permix/react'
-
-// ...
-
-export const { Check } = createComponents(permix)
-```
-
-And then you can use the `Check` component in your components:
+The factory also returns a typed `Check` component:
```tsx title="page.tsx"
+import { Check } from './lib/permix'
+
export default function Page() {
return (
-## Usage
-
-Use the `usePermix` hook and checking components in your components:
-
-```tsx title="page.tsx"
-import { usePermix } from 'permix/react'
-import { permix } from './lib/permix'
-import { Check } from './lib/permix-components'
-
-export default function Page() {
- const post = usePost()
- const { check, isReady } = usePermix(permix)
-
- if (!isReady) {
- return
- )}
-
- Can I create a post inside the Check component?
-
-
- )
-}
-```
-
-
-
-
-
## Hydration
-For SSR applications, use `PermixHydrate` to restore dehydrated server state on the client. `hydrate()` does not mark the instance ready and cannot restore function-based rules — call `setup()` on the client with the full rule set (usually in the same place you restore the session):
+For SSR, use the bound `PermixHydrate` to restore dehydrated server state on the client. `hydrate()` does not mark the instance ready and cannot restore function-based rules — call `install({ rules })` (or `setup()`) on the client with the full rule set:
```tsx title="App.tsx"
import { useEffect } from 'react'
import type { DehydratedState } from 'permix'
-import { PermixHydrate, PermixProvider } from 'permix/react'
-import { permix } from './lib/permix'
+import { permix, PermixHydrate, PermixProvider } from './lib/permix'
import { getClientRules } from './lib/permissions'
-function App({ dehydratedState }: { dehydratedState: DehydratedState }) {
+function App({
+ dehydratedState,
+}: {
+ dehydratedState: DehydratedState<{ post: ['create', 'read'] }>
+}) {
useEffect(() => {
- permix.setup(getClientRules())
+ permix.install({ rules: getClientRules() })
}, [])
return (
-
+
@@ -156,7 +147,7 @@ function App({ dehydratedState }: { dehydratedState: DehydratedState }) {
}
```
-See the [Hydration guide](/docs/guide/hydration) and framework-specific pages for [Next.js](/docs/integrations/next) and [TanStack Start](/docs/integrations/tanstack-start).
+See the [Hydration guide](/docs/guide/hydration) and framework-specific pages for [Next.js](/docs/integrations/next), [TanStack Start](/docs/integrations/tanstack-start), and [React Router](/docs/integrations/react-router). `PermixHydrate` uses a dehydrated snapshot on the first render so boolean checks work before client `setup()`. `isReady` stays `false` until you call `setup()` or `install({ rules })` on the client.
diff --git a/docs/content/docs/integrations/server.mdx b/docs/content/docs/integrations/server.mdx
index 48d2f32e..91347852 100644
--- a/docs/content/docs/integrations/server.mdx
+++ b/docs/content/docs/integrations/server.mdx
@@ -219,6 +219,7 @@ The `onForbidden` handler receives:
- `next`: the downstream handler — call it to let the request through anyway
- `path`: the permission path that was checked (e.g. `'post.create'`)
- `data`: optional data passed to the check
+- `reasons`: denial strings from `{ allow: false, reason }` rules (empty for boolean denials)
## Advanced Usage
diff --git a/docs/content/docs/integrations/solid.mdx b/docs/content/docs/integrations/solid.mdx
index c02e5393..73ffd0b2 100644
--- a/docs/content/docs/integrations/solid.mdx
+++ b/docs/content/docs/integrations/solid.mdx
@@ -5,7 +5,7 @@ description: Learn how to use Permix with Solid applications
## Overview
-Permix provides official Solid integration through the `PermixProvider` component and `usePermix` hook. This allows you to manage permissions reactively in your Solid app.
+Permix provides official Solid integration through `createPermix` from `permix/solid`. Same factory name as `permix/react` and `permix/express`: it returns a Permix instance plus isolated `PermixProvider`, `usePermix`, `Check`, and `PermixHydrate` bindings.
Before getting started with Solid integration, make sure you've completed the
@@ -18,112 +18,57 @@ Permix provides official Solid integration through the `PermixProvider` componen
## Setup
-First, wrap your application with the `PermixProvider`:
+Call `createPermix` once at module scope:
-```tsx title="App.tsx"
-import { PermixProvider } from 'permix/solid'
-import { permix } from './lib/permix'
+```ts title="lib/permix.ts"
+import { createPermix } from 'permix/solid'
-function App() {
- return (
-
-
-
- )
-}
+export const { permix, PermixProvider, PermixHydrate, usePermix, Check } =
+ createPermix<{
+ post: ['create', 'read', { name: 'edit'; type: Post }]
+ }>()
```
-
- Remember to always pass the same Permix instance to both the `PermixProvider`
- and `usePermix` hook to maintain type safety.
-
+Wrap the tree with the bound provider. It does not take a `permix` prop:
-
+```tsx title="index.tsx"
+import { PermixProvider } from './lib/permix'
-
-
-## Hook
-
-For checking permissions in your components, you can use the `usePermix` hook. And to avoid importing the hook and Permix instance in every component, you can create a custom utility:
-
-```tsx title="hooks/use-permissions.ts"
-import { usePermix } from 'permix/solid'
-import { permix } from '../lib/permix'
-
-export function usePermissions() {
- return usePermix(permix)
-}
-```
-
-
-
-
-
-## Components
-
-If you prefer using components, you can import the `createComponents` function from `permix/solid` and create checking components:
-
-```ts title="lib/permix.ts"
-import { createComponents } from 'permix/solid'
-
-// ...
-
-export const { Check } = createComponents(permix)
+render(
+ () => (
+
+
+
+ ),
+ root
+)
```
-And then you can use the `Check` component in your components:
-
-```tsx title="page.tsx"
-export default function Page() {
- return (
- Will show this if a user doesn't have permission
}
- reverse
- >
- Will show this if a user has permission
-
- )
-}
-```
+Each `createPermix` call gets its own Solid context, so nested factories stay independent.
-## Usage
+## Hook
-Use the `usePermix` hook and checking components in your components:
+`usePermix()` from the factory does not take an instance argument. `isReady` is a function:
```tsx title="page.tsx"
-import { usePermix } from 'permix/solid'
-import { permix } from './lib/permix'
-import { Check } from './lib/permix-components'
+import { usePermix, Check } from './lib/permix'
export default function Page() {
const post = usePost()
- const { check, isReady } = usePermix(permix)
+ const { check, explain, isReady } = usePermix()
- const canEdit = () => check('post.edit', post)
+ if (!isReady()) {
+ return
Loading permissions...
+ }
- return (
- <>
- {!isReady() ? (
-
Loading permissions...
- ) : (
-
- {canEdit() ? (
-
- ) : (
-
You don't have permission to edit this post
- )}
-
- Can I create a post inside the Check component?
-
-
- )}
- >
+ return check('post.edit', post) ? (
+
+ ) : (
+ Can I create a post?
)
}
```
@@ -134,25 +79,20 @@ export default function Page() {
## Hydration
-For SSR, use `PermixHydrate` with the dehydrated server state, then call `permix.setup()` on the client to restore function-based rules:
+For SSR, use the bound `PermixHydrate` for first-paint booleans, then `install({ rules })` on the client:
```tsx title="App.tsx"
import { onMount } from 'solid-js'
-import type { DehydratedState } from 'permix'
-import { PermixHydrate, PermixProvider } from 'permix/solid'
-import { permix } from './lib/permix'
+import { permix, PermixHydrate, PermixProvider } from './lib/permix'
import { getClientRules } from './lib/permissions'
-export function App(props: {
- dehydratedState: DehydratedState
- children: any
-}) {
+export function App(props: { dehydratedState: unknown; children: any }) {
onMount(() => {
- permix.setup(getClientRules())
+ permix.install({ rules: getClientRules() })
})
return (
-
+
{props.children}
diff --git a/docs/content/docs/integrations/standard-schema.mdx b/docs/content/docs/integrations/standard-schema.mdx
new file mode 100644
index 00000000..7de056ed
--- /dev/null
+++ b/docs/content/docs/integrations/standard-schema.mdx
@@ -0,0 +1,250 @@
+---
+title: Standard Schema
+description: Infer entity types from Zod, Valibot, ArkType, and other Standard Schema validators
+---
+
+## Overview
+
+Permix can take entity types from any library that implements [Standard Schema](https://standardschema.dev/) — Zod, Valibot, ArkType, Effect Schema, and others. You already have those schemas for API validation; reuse them so permission callbacks and `check()` stay aligned with the same object shape.
+
+Two entry points:
+
+| Import | Use when |
+| --- | --- |
+| `action` from `permix` | You already have a `createPermix()` tree and want a few actions typed from a schema |
+| `createPermix` from `permix/standard-schema` | You want a Drizzle-style factory: one entity per schema, CRUD actions by default |
+
+Type inference is always on. Runtime parsing is **off** unless you pass `{ validate: 'deny' | 'throw' }` to the factory. Core `createPermix()` never parses `check()` data.
+
+
+ Before getting started, complete the initial setup steps in the [Quick
+ Start](/docs/quick-start) guide.
+
+
+
+
+
+
+## `schema` on an action spec
+
+Pass `schema: typeof yourSchema` instead of (or in addition to) `type`. Entity data is inferred from the schema's output type. If both `type` and `schema` are set, **`type` wins**.
+
+```ts twoslash title="/lib/permix-schema.ts"
+import { createPermix } from 'permix'
+import { z } from 'zod'
+
+const postSchema = z.object({
+ id: z.string(),
+ authorId: z.string(),
+})
+
+const permix = createPermix<{
+ post: ['create', { name: 'edit'; schema: typeof postSchema; required: true }]
+}>()
+
+permix.setup({
+ post: {
+ create: true,
+ edit: (post) => post.authorId === 'user-1',
+ // ^?
+ },
+})
+
+permix.check('post.edit', { id: '1', authorId: 'user-1' })
+```
+
+The same `schema` field works with Valibot, ArkType, or any other Standard Schema implementation — not only Zod.
+
+
+
+
+
+## `action()` helper
+
+`action(name, schema, { required }?)` builds an action spec so you can infer the definition from a value instead of repeating `typeof schema` on every leaf.
+
+```ts twoslash title="/lib/permix-action.ts"
+import { action, createPermix } from 'permix'
+import { z } from 'zod'
+
+const postSchema = z.object({
+ id: z.string(),
+ authorId: z.string(),
+})
+
+const definition = {
+ post: ['create', action('edit', postSchema, { required: true })],
+} as const
+
+const permix = createPermix()
+
+permix.setup({
+ post: {
+ create: true,
+ edit: (post) => post.authorId === 'user-1',
+ },
+})
+
+permix.check('post.edit', { id: '1', authorId: 'user-1' })
+```
+
+Use `as const` so action names stay literal. Nested trees work the same way — `action()` is only a leaf constructor.
+
+
+
+
+
+## Schema map factory
+
+`permix/standard-schema` turns a map of schemas into a Permix instance. Each key becomes an entity; by default each entity gets `create`, `read`, `update`, and `delete`, with entity data typed from that schema.
+
+```ts twoslash title="/lib/standard-schema.ts"
+import { createPermix } from 'permix/standard-schema'
+import { z } from 'zod'
+
+const postSchema = z.object({
+ id: z.string(),
+ authorId: z.string(),
+})
+
+const commentSchema = z.object({
+ id: z.string(),
+ postId: z.string(),
+})
+
+const permix = createPermix({
+ post: postSchema,
+ comment: commentSchema,
+})
+
+permix.setup({
+ post: {
+ create: true,
+ read: true,
+ update: (post) => post.authorId === 'user-1',
+ delete: false,
+ },
+ comment: { create: true, read: true, update: false, delete: false },
+})
+
+permix.check('post.update', { id: '1', authorId: 'user-1' })
+permix.check('comment.delete')
+```
+
+The returned instance is a regular Permix object — `check`, `setup`, `template`, `dehydrate`, `hydrate`, `hook`, `isReady` work unchanged.
+
+
+
+
+
+## Customising actions
+
+Override the action list for every bare schema, or per entity with `entity()`. Plain action tuples stay untyped (same as `createPermix<{ dashboard: ['view'] }>()`).
+
+```ts twoslash title="/lib/standard-schema-entity.ts"
+import { createPermix, entity } from 'permix/standard-schema'
+import { z } from 'zod'
+
+const postSchema = z.object({
+ id: z.string(),
+ authorId: z.string(),
+})
+
+const permix = createPermix({
+ post: entity(postSchema, [
+ 'create',
+ 'read',
+ { name: 'publish', required: true },
+ ]),
+ dashboard: ['view'],
+})
+
+permix.setup({
+ post: {
+ create: true,
+ read: true,
+ publish: (post) => post.authorId === 'user-1',
+ },
+ dashboard: { view: true },
+})
+
+permix.check('post.publish', { id: '1', authorId: 'user-1' })
+permix.check('dashboard.view')
+```
+
+Same action names for every schema, without `entity()`:
+
+```ts
+const permix = createPermix(
+ { post: postSchema, comment: commentSchema },
+ { actions: ['view', 'edit'] as const }
+)
+```
+
+
+
+
+
+## Discovering entities and actions at runtime
+
+```ts
+const permix = createPermix({ post: postSchema })
+
+permix.entities // ['post']
+permix.actions // readonly ['create', 'read', 'update', 'delete']
+permix.validate // false | 'deny' | 'throw'
+```
+
+`actions` is the default set applied to **bare** schemas. `entity()` entries keep their own list.
+
+
+
+
+
+## Runtime validation
+
+By default the factory still only infers types. Pass `validate` to parse `check()` data with the entity schema **before** the rule runs. The rule sees the parsed output, including transforms.
+
+| Mode | Invalid data |
+| --- | --- |
+| omitted / `false` | No parse. The rule receives the raw argument. |
+| `'deny'` | `check()` returns `false` |
+| `'throw'` | `check()` throws `PermixValidationError` (`error.path`, `error.issues`) |
+
+Checks without data, `'~any'` / `'~all'`, and untyped action tuples skip validation. Async schemas throw `PermixAsyncValidationError` — `check()` is synchronous.
+
+```ts twoslash title="/lib/standard-schema-validate.ts"
+import { createPermix } from 'permix/standard-schema'
+import { z } from 'zod'
+
+const postSchema = z.object({
+ id: z.string(),
+ authorId: z.string(),
+})
+
+const permix = createPermix({ post: postSchema }, { validate: 'deny' })
+
+permix.setup({
+ post: {
+ create: true,
+ read: true,
+ update: (post) => post?.authorId === 'user-1',
+ delete: false,
+ },
+})
+
+permix.check('post.update', { id: '1', authorId: 'user-1' })
+```
+
+Prefer `'deny'` in UI `check()` paths. Use `'throw'` on the server when invalid entity payloads should be treated as a programmer error. tRPC/oRPC input schemas still belong on the procedure — this is a backstop for `check()` data, not a replacement for request validation.
+
+
+
+
+
+## Notes
+
+- Nested permission trees (`workspace.member`) stay on core `createPermix()` plus `action()` — the factory is one entity level, like [Drizzle](/docs/integrations/drizzle).
+- Core `createPermix()` never parses. The factory parses only when `validate` is `'deny'` or `'throw'`.
+- No extra peer dependency. Install Zod (or Valibot, ArkType, …) yourself. Permix only depends on the Standard Schema type protocol.
+- Complements Drizzle: Drizzle answers “which tables and actions?”; Standard Schema answers “what does the entity look like?”
diff --git a/docs/content/docs/integrations/supabase.mdx b/docs/content/docs/integrations/supabase.mdx
new file mode 100644
index 00000000..486c694f
--- /dev/null
+++ b/docs/content/docs/integrations/supabase.mdx
@@ -0,0 +1,98 @@
+---
+title: Supabase
+description: Verify Supabase identity, infer permission types, and align app checks with Postgres RLS
+---
+
+## Overview
+
+`permix/supabase` covers three separate concerns:
+
+1. Verify a bearer token with Supabase Auth and resolve per-request Permix rules.
+2. Infer a Permix definition from selected generated `Database` tables/views.
+3. Describe how canonical permission paths map to native Postgres RLS policies.
+
+Permix checks do not replace RLS. Enable RLS on every browser-accessible table and enforce data access in Postgres.
+
+## Verified auth
+
+```ts
+import { createSupabaseClaimsAdapter } from 'permix/supabase'
+
+const permissions = createSupabaseClaimsAdapter({
+ client: supabase,
+ resolveRules: ({ principal }) => ({
+ documents: {
+ read: principal.claims.app_metadata.permissions.includes(
+ 'documents.read'
+ ),
+ update: ({ ownerId }) => ownerId === principal.claims.sub,
+ },
+ }),
+})
+
+const decision = await permissions.check(
+ request.headers.get('authorization'),
+ 'documents.update',
+ document
+)
+```
+
+Use `createSupabaseUserAdapter` when rules require a freshly fetched Auth user. The claims adapter only exposes verified authorization claims; it deliberately does not expose user-controlled metadata as trusted authorization input.
+
+## Generated database inference
+
+```ts
+import {
+ defineSupabaseSelection,
+ type SupabaseDefinition,
+} from 'permix/supabase'
+import type { Database } from './database.types'
+
+const selection = defineSupabaseSelection()({
+ public: {
+ tables: ['documents'],
+ views: ['published_documents'],
+ },
+} as const)
+
+type Definition = SupabaseDefinition
+```
+
+Selected tables infer `select`, `insert`, `update`, and `delete` payloads. Selected views infer `select`. This is type-only and never reads a generated file or live project at runtime.
+
+## Policy manifest
+
+```ts
+import { createSupabasePolicyManifest } from 'permix/supabase'
+
+const manifest = createSupabasePolicyManifest({
+ 'public.tables.documents.select': {
+ schema: 'public',
+ relation: 'documents',
+ relationType: 'table',
+ operation: 'select',
+ },
+})
+```
+
+Pass an extracted `PermissionCatalog` to validate unknown and uncovered paths. The manifest describes intent; it does not generate or apply migrations.
+
+## RLS recipes
+
+The package exports SQL recipe strings and helpers for an access-token hook, `authorize(permission)`, ownership predicates, permission predicates, and RLS policy templates. Copy the reviewed SQL into your own migration and adapt its schema, claim names, and roles.
+
+The repository also ships a transactional fixture covering anonymous access, malformed and stale claims, ownership, role permissions, `UPDATE` without `SELECT`, and service-role bypass. Run it against a local Supabase stack:
+
+```sh
+supabase db start --workdir permix/test/supabase-rls
+pnpm --filter permix test:supabase-rls
+supabase stop --workdir permix/test/supabase-rls
+```
+
+Important boundaries:
+
+- JWT authorization claims can remain stale until the token refreshes.
+- Ownership checks should compare verified `auth.uid()`/claims to row data.
+- Supabase API update workflows normally need a matching `SELECT` policy, but the Postgres `UPDATE` policy independently controls which rows may mutate.
+- Service-role clients bypass RLS and must stay on trusted servers.
+- App-layer checks improve composition and UX; Postgres remains the final enforcement point for Supabase data.
diff --git a/docs/content/docs/integrations/svelte.mdx b/docs/content/docs/integrations/svelte.mdx
index 9612c18f..1b04e860 100644
--- a/docs/content/docs/integrations/svelte.mdx
+++ b/docs/content/docs/integrations/svelte.mdx
@@ -5,12 +5,11 @@ description: Learn how to use Permix with Svelte applications
## Overview
-Permix provides official Svelte integration through the `PermixProvider` component and `usePermix` hook. This allows you to manage permissions reactively in your Svelte app using runes.
+Permix provides official Svelte integration through `createPermix` from `permix/svelte`. Same factory name as `permix/react` and `permix/express`: it returns a Permix instance plus isolated `PermixProvider`, `usePermix`, `Check`, and `PermixHydrate` bindings. Requires Svelte 5.
Before getting started with Svelte integration, make sure you've completed the
- initial setup steps in the [Quick Start](/docs/quick-start) guide. Svelte
- integration requires Svelte 5.
+ initial setup steps in the [Quick Start](/docs/quick-start) guide.
@@ -19,25 +18,32 @@ Permix provides official Svelte integration through the `PermixProvider` compone
## Setup
-First, wrap your application with the `PermixProvider`:
+Call `createPermix` once at module scope:
-```svelte title="App.svelte"
+```ts title="lib/permix.ts"
+import { createPermix } from 'permix/svelte'
+
+export const { permix, PermixProvider, PermixHydrate, usePermix, Check } =
+ createPermix<{
+ post: ['create', 'read', { name: 'edit'; type: Post }]
+ }>()
+```
+
+Wrap the tree with the bound provider. It does not take a `permix` prop:
+
+```svelte title="+layout.svelte"
-
+
{@render children()}
```
-
- Remember to always pass the same Permix instance to both the `PermixProvider`
- and `usePermix` hook to maintain type safety.
-
+Each `createPermix` call gets its own context key, so nested factories stay independent.
@@ -45,91 +51,26 @@ First, wrap your application with the `PermixProvider`:
## Hook
-For checking permissions in your components, you can use the `usePermix` hook. And to avoid importing the hook and Permix instance in every component, you can create a custom utility:
-
-```ts title="lib/use-permissions.ts"
-import { usePermix } from 'permix/svelte'
-import { permix } from './permix'
-
-export function usePermissions() {
- return usePermix(permix)
-}
-```
-
-
-
-
-
-## Components
-
-If you prefer using components, you can import the `createComponents` function from `permix/svelte` and create checking components:
-
-```ts title="lib/permix.ts"
-import { createComponents } from 'permix/svelte'
-
-// ...
-
-export const { Check } = createComponents(permix)
-```
-
-And then you can use the `Check` component in your components. The default content is rendered when a user has permission, and the `otherwise` snippet is rendered when they don't:
-
-```svelte title="Page.svelte"
-
-
-
-
- Will show this if a user has permission
- {#snippet otherwise()}
- Will show this if a user doesn't have permission
- {/snippet}
-
-```
-
-
-
-
-
-## Usage
-
-Use the `usePermix` hook and checking components in your components:
+`usePermix()` from the factory does not take an instance argument. `isReady` is a reactive getter — access it on the returned object (don't destructure):
```svelte title="Page.svelte"
-
- {#if !permissions.isReady}
-
Loading permissions...
- {:else if permissions.check('post.edit', post)}
-
- {:else}
-
You don't have permission to edit this post
- {/if}
-
-
- Can I create a post inside the Check component?
-
-
-```
+{#if !permissions.isReady}
+
Loading permissions...
+{:else if permissions.check('post.edit', post)}
+
+{/if}
-
- `usePermix` returns an object with a reactive `isReady` getter and a `check`
- method. Access them directly on the returned object (for example
- `permissions.isReady`) to keep reactivity — don't destructure `isReady`.
-
+
+ Can I create a post inside the Check component?
+
+```
@@ -137,27 +78,22 @@ Use the `usePermix` hook and checking components in your components:
## Hydration
-For SSR, wrap the app with `PermixHydrate` and pass the dehydrated server state. Call `permix.setup()` on the client afterward to restore function-based rules and set `isReady`:
+For SSR, use the bound `PermixHydrate` for first-paint booleans, then `install({ rules })` on the client:
-```svelte title="App.svelte"
+```svelte title="+layout.svelte"
-
+
{@render children()}
diff --git a/docs/content/docs/integrations/tanstack-start.mdx b/docs/content/docs/integrations/tanstack-start.mdx
index fcf485f0..73961e09 100644
--- a/docs/content/docs/integrations/tanstack-start.mdx
+++ b/docs/content/docs/integrations/tanstack-start.mdx
@@ -409,7 +409,7 @@ export function EditButton({
}
```
-If you prefer the component API, create checkers with `createComponents` from `permix/react` — see the [React integration](/docs/integrations/react#components) for details.
+If you prefer the component API, use `Check` from `createPermix` in `permix/react` — see the [React integration](/docs/integrations/react).
@@ -493,7 +493,7 @@ Returns an object with the following methods:
| Option | Description |
| --- | --- |
-| `onForbidden({ path, data })` | Called when `checkMiddleware` denies a request. Defaults to throwing a `PermixError`. |
+| `onForbidden({ path, data, reasons })` | Called when `checkMiddleware` denies a request. Defaults to throwing a `PermixForbiddenError`. |
### Hooks
diff --git a/docs/content/docs/integrations/trpc.mdx b/docs/content/docs/integrations/trpc.mdx
index 0836718c..4d290258 100644
--- a/docs/content/docs/integrations/trpc.mdx
+++ b/docs/content/docs/integrations/trpc.mdx
@@ -209,6 +209,7 @@ The `onForbidden` handler receives:
- `path`: The permission path that was checked (e.g. `'post.create'`)
- `data`: Optional data passed to the check
+- `reasons`: Denial strings from `{ allow: false, reason }` rules (empty for boolean denials)
- `ctx`: Your tRPC context object
- `next`: The middleware `next` function — call it to allow the request through
diff --git a/docs/content/docs/integrations/vue.mdx b/docs/content/docs/integrations/vue.mdx
index fb47ab9a..b09ef424 100644
--- a/docs/content/docs/integrations/vue.mdx
+++ b/docs/content/docs/integrations/vue.mdx
@@ -5,7 +5,7 @@ description: Learn how to use Permix with Vue applications
## Overview
-Permix provides official Vue integration through the `PermixProvider` component and `usePermix` composable. This allows you to manage permissions reactively in your Vue app.
+Permix provides official Vue integration through `createPermix` from `permix/vue`. Same factory name as `permix/react` and `permix/express`: it returns a Permix instance plus isolated `PermixProvider`, `usePermix`, `Check`, and `PermixHydrate` bindings.
Before getting started with Vue integration, make sure you've completed the
@@ -18,43 +18,31 @@ Permix provides official Vue integration through the `PermixProvider` component
## Setup
-First, wrap your application with the `PermixProvider`:
+Call `createPermix` once at module scope:
-```vue title="App.vue"
-
+```ts title="lib/permix.ts"
+import { createPermix } from 'permix/vue'
-
-
-
-
-
+export const { permix, PermixProvider, PermixHydrate, usePermix, Check } =
+ createPermix<{
+ post: ['create', 'read', { name: 'edit'; type: Post }]
+ }>()
```
-You can also register the provider from `main.ts`:
+Wrap the tree with the bound provider. It does not take a `permix` prop:
```ts title="main.ts"
import { createApp } from 'vue'
-import { PermixProvider } from 'permix/vue'
-import { permix } from './lib/permix'
import App from './App.vue'
+import { PermixProvider } from './lib/permix'
createApp({
components: { PermixProvider, App },
- template: '',
- setup() {
- return { permix }
- },
+ template: '',
}).mount('#app')
```
-
- Remember to always pass the same Permix instance to both the `PermixProvider`
- and `usePermix` composable to maintain type safety.
-
+Each `createPermix` call gets its own Vue inject key, so nested factories stay independent.
@@ -62,84 +50,22 @@ createApp({
## Composable
-For checking permissions in your components, you can use the `usePermix` composable. And to avoid importing the composable and Permix instance in every component, you can create a custom composable:
-
-```ts title="composables/use-permissions.ts"
-import { usePermix } from 'permix/vue'
-import { permix } from './lib/permix'
-
-export function usePermissions() {
- return usePermix(permix)
-}
-```
-
-
-
-
-
-## Components
-
-If you prefer using components, you can import the `createComponents` function from `permix/vue` and create checking components:
-
-```ts title="lib/permix.ts"
-import { createComponents } from 'permix/vue'
-
-// ...
-
-export const { Check } = createComponents(permix)
-```
-
-And then you can use the `Check` component in your templates:
-
-```vue title="page.vue"
-
-
-
- Will show this if a user has permission
-
- Will show this if a user doesn't have permission
-
-
-
-```
-
-
-
-
-
-## Usage
-
-Use the `usePermix` composable in your components to check permissions:
+`usePermix()` from the factory does not take an instance argument:
```vue title="page.vue"
-
-
Loading permissions...
-
-
-
You don't have permission to edit this post
-
-
- Can I edit this post?
-
- You don't have permission to edit this post
-
-
-
+
Loading permissions...
+
+ Can I create a post?
```
@@ -149,35 +75,31 @@ const { check, isReady } = usePermix(permix)
## Hydration
-For SSR applications, use `PermixHydrate` to restore dehydrated server state on the client. `hydrate()` does not mark the instance ready and cannot restore function-based rules — call `setup()` on the client with the full rule set (usually in the same place you restore the session):
+For SSR, use the bound `PermixHydrate` for first-paint booleans, then `install({ rules })` (or `setup()`) on the client to attach function rules:
```vue title="App.vue"
-
+
-
+
```
-See the [Hydration guide](/docs/guide/hydration).
+See the [Hydration guide](/docs/guide/hydration). `isReady` stays `false` until you call `setup()` / `install({ rules })`.
diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json
index dbd79bfc..14ab818b 100644
--- a/docs/content/docs/meta.json
+++ b/docs/content/docs/meta.json
@@ -4,10 +4,13 @@
"index",
"quick-start",
"migration-v3-to-v4",
+ "migration-v4-to-v5",
"comparison",
+ "changelog",
"---Guide---",
"guide/instance",
"guide/setup",
+ "guide/extraction",
"guide/check",
"guide/template",
"guide/rebac",
@@ -18,19 +21,29 @@
"integrations/react",
"integrations/next",
"integrations/tanstack-start",
+ "integrations/nuxt",
+ "integrations/react-router",
"integrations/vue",
"integrations/solid",
"integrations/svelte",
"integrations/node",
"integrations/server",
+ "integrations/astro",
"integrations/trpc",
"integrations/orpc",
"integrations/express",
"integrations/hono",
"integrations/elysia",
"integrations/fastify",
+ "integrations/nest",
"integrations/effect",
"integrations/drizzle",
+ "integrations/standard-schema",
+ "integrations/pdp",
+ "integrations/supabase",
+ "integrations/better-auth",
+ "integrations/clerk",
+ "integrations/convex",
"---",
"[Examples](https://github.com/letstri/permix/tree/main/examples)",
"---LLMs---",
diff --git a/docs/content/docs/migration-v3-to-v4.mdx b/docs/content/docs/migration-v3-to-v4.mdx
index 20be84ca..dd21e8ac 100644
--- a/docs/content/docs/migration-v3-to-v4.mdx
+++ b/docs/content/docs/migration-v3-to-v4.mdx
@@ -16,7 +16,7 @@ This guide summarizes the breaking changes and how to update your app. For the f
permix@^4
```
-v4 is developed with **TypeScript 6**. Your app does not need to match the monorepo's exact TypeScript or pnpm versions, but upgrade TypeScript if you hit inference issues.
+v4 is developed with **TypeScript 7** and supports **TypeScript 5.9–7**. Your app does not need to match the monorepo's exact TypeScript or pnpm versions, but upgrade TypeScript if you hit inference issues.
## Quick reference
@@ -240,7 +240,7 @@ permix.setup(getClientRules(user))
`hydrate()` fires the **`setup` hook** (not a separate `hydrate` hook). Update listeners that used `hook('hydrate', ...)` in v3.
-See [Hydration](/docs/guide/hydration) and [Ready state](/docs/guide/ready). For App Router / TanStack Start, see [Next.js](/docs/integrations/next) and [TanStack Start](/docs/integrations/tanstack-start).
+See [Hydration](/docs/guide/hydration) and [Ready state](/docs/guide/ready). For App Router / TanStack Start / React Router, see [Next.js](/docs/integrations/next), [TanStack Start](/docs/integrations/tanstack-start), and [React Router](/docs/integrations/react-router).
---
@@ -336,15 +336,18 @@ Map each Better Auth role to a rules object yourself — the same booleans you p
These are additive — migrate the core API first, then adopt what you need:
-| Import | Use case |
-| ----------------------- | ------------------------------------------- |
-| `permix/next` | Next.js App Router, request-scoped instance |
-| `permix/tanstack-start` | TanStack Start middleware and SSR |
-| `permix/server` | Framework-agnostic fetch middleware |
-| `permix/svelte` | Svelte 5 runes |
-| `permix/drizzle` | Rules from Drizzle v1 schema |
-| `permix/drizzle/legacy` | Drizzle v0 (`>=0.30 <1`) |
-| `permix/effect` | Effect `Layer` / `Context` |
+| Import | Use case |
+| ------------------------ | ---------------------------------------------- |
+| `permix/next` | Next.js App Router, resolver + dual RSC access |
+| `permix/tanstack-start` | TanStack Start middleware and SSR |
+| `permix/nuxt` | Nuxt / Nitro, request-scoped instance |
+| `permix/react-router` | React Router 7 middleware and SSR |
+| `permix/server` | Framework-agnostic fetch middleware |
+| `permix/astro` | Astro middleware and `locals` |
+| `permix/svelte` | Svelte 5 runes |
+| `permix/drizzle` | Rules from Drizzle v1 schema |
+| `permix/standard-schema` | Entity types from Zod, Valibot, ArkType, … |
+| `permix/effect` | Effect `Layer` / `Context` |
See the [examples directory](https://github.com/letstri/permix/tree/main/examples) (`next`, `tanstack-start`, `svelte`, `rebac`, and updated `react`, `vue`, …).
diff --git a/docs/content/docs/migration-v4-to-v5.mdx b/docs/content/docs/migration-v4-to-v5.mdx
new file mode 100644
index 00000000..5c4edcec
--- /dev/null
+++ b/docs/content/docs/migration-v4-to-v5.mdx
@@ -0,0 +1,64 @@
+---
+title: Migrate v4 to v5
+description: Breaking change — setup() and hydrate() return frozen instances
+icon: RiArrowUpCircleLine
+---
+
+## Overview
+
+Permix **v5** makes authorization instances immutable. `setup()` and `hydrate()` **return** a new frozen instance. They never mutate the factory.
+
+v4's in-place `setup()` is gone. There is no mutating alias.
+
+## Install
+
+```package-install
+permix@^5
+```
+
+## Quick reference
+
+| v4 | v5 |
+| --- | --- |
+| `const p = createPermix(); p.setup(rules); p.check(...)` | `const p = createPermix().setup(rules); p.check(...)` |
+| `p.setup(next)` mutates `p` | `const next = p.setup(rules)` — `p` is unchanged |
+| `p.hydrate(state)` mutates `p` | `const hydrated = p.hydrate(state)` |
+| Overlapping `setup()` last-write-wins | Each returned instance is isolated |
+| Next `cache()`: create then `setup()` then return | `return createPermix().setup(await rules)` |
+
+## Capture the returned instance
+
+```ts
+// v4
+const permix = createPermix<{ post: ['create'] }>()
+permix.setup({ post: { create: true } })
+permix.check('post.create')
+
+// v5
+const permix = createPermix<{ post: ['create'] }>().setup({
+ post: { create: true },
+})
+permix.check('post.create')
+```
+
+On the server, keep using adapter `setupMiddleware` / `setupContext`. Those already stash the instance returned from `setup()`.
+
+Do not treat a module-level `createPermix()` factory as a request-scoped singleton you `setup()` in place.
+
+## Hydration
+
+```ts
+const dehydrated = server.dehydrate()
+const client = createPermix().hydrate(dehydrated) // isReady() === false
+const ready = client.install({ rules: clientRules }) // isReady() === true
+```
+
+`hydrate()` does **not** fire the `setup` hook. UI providers subscribe to `setup` / `ready` for function rules. Prefer `install({ dehydrated, rules })` as the client entry, with `PermixHydrate` for first-paint booleans.
+
+Vue, Solid, and Svelte use the same factory as React (`createPermix` from `permix/vue` etc.). Classic `usePermix(instance)` / `createComponents` / provider-from-raw-instance are gone.
+
+`permix/drizzle/legacy` is removed; use `permix/drizzle` with Drizzle v1.
+
+## `explain()` (from v4.x)
+
+`check()` is still boolean. Use `explain()` for `{ allowed, path, reasons }` when rule functions `return { allow, reason }`.
diff --git a/docs/content/docs/quick-start.mdx b/docs/content/docs/quick-start.mdx
index c6bc2196..acfabfea 100644
--- a/docs/content/docs/quick-start.mdx
+++ b/docs/content/docs/quick-start.mdx
@@ -58,7 +58,9 @@ permix
### Create an instance
-To create a base instance, you need to provide a schema as a generic type to `createPermix` function that defines your permissions:
+Prefer a generated catalog: mark paths with `permission()`, run `pnpm permix extract`, then `createPermix()`. See [Permission extraction](/docs/guide/extraction).
+
+Manual generics remain the escape hatch for dynamic keys:
```ts title="/lib/permix.ts"
import { createPermix } from 'permix'
@@ -85,7 +87,7 @@ You can setup your permissions by calling `setup` method on your instance in any
// Call setupPermissions in your application
export function setupPermissions() {
- permix.setup({
+ return permix.setup({
post: {
create: true,
read: true,
@@ -150,6 +152,10 @@ Continuing from the quick start, you can now explore how Permix integrates with
Integration with native Request and Response handlers.
+
+ Integration with Astro middleware and locals.
+
+
Integration with Hono via middleware.
@@ -170,6 +176,14 @@ Continuing from the quick start, you can now explore how Permix integrates with
Integration with TanStack Start.
+
+ Integration with Nuxt via Nitro request isolation.
+
+
+
+ Integration with React Router 7 middleware and hydration.
+
+
Integration with Solid via provider and hook.
@@ -194,6 +208,10 @@ Continuing from the quick start, you can now explore how Permix integrates with
Schema-driven permissions from Drizzle tables.
+
+ Entity types from Zod, Valibot, ArkType, and other Standard Schema validators.
+
+
Integration with Effect services and layers.
diff --git a/docs/package.json b/docs/package.json
index 7b7c16f4..644bca86 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -9,27 +9,28 @@
"check-types": "fumadocs-mdx && tsc --noEmit",
"dev": "vite dev",
"postinstall": "fumadocs-mdx",
- "prebuild": "cd ../permix && pnpm run build",
- "predeploy": "cd ../permix && pnpm run build",
"preview": "vite preview",
"start": "node .output/server/index.mjs"
},
"dependencies": {
+ "@base-ui/react": "^1.7.0",
"@remixicon/react": "^4.9.0",
"@tanstack/react-router": "^1.170.8",
"@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/react-start": "^1.168.14",
"@vercel/analytics": "^2.0.1",
- "fumadocs-core": "^16.9.3",
- "fumadocs-mdx": "^15.0.10",
- "fumadocs-twoslash": "^3.2.0",
- "fumadocs-ui": "^16.9.3",
+ "flexsearch": "^0.8.212",
+ "fumadocs-core": "^16.15.4",
+ "fumadocs-mdx": "^15.4.0",
+ "fumadocs-twoslash": "^3.3.0",
+ "fumadocs-ui": "npm:@fumadocs/base-ui@^16.15.4",
+ "mdast-util-from-markdown": "^2.0.2",
"mermaid": "^11.15.0",
"permix": "workspace:*",
- "react": "^19.2.6",
- "react-dom": "^19.2.6",
+ "react": "catalog:",
+ "react-dom": "catalog:",
"tailwind-merge": "^3.6.0",
- "vite": "^8.0.16"
+ "vite": "catalog:"
},
"devDependencies": {
"@orpc/server": "^1.14.4",
@@ -37,10 +38,10 @@
"@trpc/server": "^11.17.0",
"@types/express": "^5.0.6",
"@types/mdx": "^2.0.13",
- "@types/node": "^24.10.0",
- "@types/react": "^19.2.15",
- "@types/react-dom": "^19.2.3",
- "@vitejs/plugin-react": "^6.0.2",
+ "@types/node": "catalog:",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "@vitejs/plugin-react": "catalog:",
"drizzle-orm": "^1.0.0-rc.3",
"elysia": "^1.4.28",
"express": "^5",
@@ -48,6 +49,7 @@
"hono": "^4.12.23",
"nitro": "^3.0.260522-beta",
"tailwindcss": "^4.3.0",
- "typescript": "^6.0.3"
+ "typescript": "catalog:",
+ "zod": "^4.4.3"
}
}
diff --git a/docs/remark-include-changelog.ts b/docs/remark-include-changelog.ts
new file mode 100644
index 00000000..2c134e8d
--- /dev/null
+++ b/docs/remark-include-changelog.ts
@@ -0,0 +1,44 @@
+import { readFileSync } from 'node:fs'
+import path from 'node:path'
+
+import { fromMarkdown } from 'mdast-util-from-markdown'
+
+interface MdastNode {
+ type: string
+ depth?: number
+ value?: string
+ children?: MdastNode[]
+}
+
+const changelogPath = path.resolve(import.meta.dirname, '..', 'CHANGELOG.md')
+
+function headingText(node: MdastNode): string {
+ return (node.children ?? [])
+ .map((child) => child.value ?? '')
+ .join('')
+ .trim()
+}
+
+export function remarkIncludeChangelog() {
+ return (tree: { children: MdastNode[] }, file: { path?: string }) => {
+ const filePath = file.path ?? ''
+ if (!filePath.endsWith('changelog.mdx')) {
+ return
+ }
+
+ const parsed = fromMarkdown(readFileSync(changelogPath, 'utf-8')) as {
+ children: MdastNode[]
+ }
+ const first = parsed.children[0]
+ if (
+ first &&
+ first.type === 'heading' &&
+ first.depth === 1 &&
+ headingText(first).toLowerCase() === 'changelog'
+ ) {
+ parsed.children.shift()
+ }
+
+ tree.children.push(...parsed.children)
+ }
+}
diff --git a/docs/source.config.ts b/docs/source.config.ts
index 71c2d569..f66fd49f 100644
--- a/docs/source.config.ts
+++ b/docs/source.config.ts
@@ -2,22 +2,29 @@ import {
rehypeCodeDefaultOptions,
remarkMdxMermaid,
} from 'fumadocs-core/mdx-plugins'
+import { metaSchema, pageSchema } from 'fumadocs-core/source/schema'
import { defineConfig, defineDocs } from 'fumadocs-mdx/config'
import { transformerTwoslash } from 'fumadocs-twoslash'
import { createFileSystemTypesCache } from 'fumadocs-twoslash/cache-fs'
+import { remarkIncludeChangelog } from './remark-include-changelog'
+
export const docs = defineDocs({
dir: 'content/docs',
docs: {
+ schema: pageSchema,
postprocess: {
includeProcessedMarkdown: true,
},
},
+ meta: {
+ schema: metaSchema,
+ },
})
export default defineConfig({
mdxOptions: {
- remarkPlugins: [remarkMdxMermaid],
+ remarkPlugins: [remarkMdxMermaid, remarkIncludeChangelog],
rehypeCodeOptions: {
themes: {
light: 'github-light',
diff --git a/docs/src/components/sidebar-scroll.tsx b/docs/src/components/sidebar-scroll.tsx
index 50e8ee06..b1eeb363 100644
--- a/docs/src/components/sidebar-scroll.tsx
+++ b/docs/src/components/sidebar-scroll.tsx
@@ -1,10 +1,18 @@
import { useRouterState } from '@tanstack/react-router'
import { useEffect } from 'react'
-function scrollActiveSidebarItem() {
- const viewport = document.querySelector(
- '#nd-sidebar [data-radix-scroll-area-viewport]'
+function getSidebarViewport() {
+ return document.querySelector(
+ [
+ '#nd-sidebar [data-slot="scroll-area-viewport"]',
+ '#nd-sidebar [data-base-ui-scroll-area-viewport]',
+ '#nd-sidebar',
+ ].join(', ')
)
+}
+
+function scrollActiveSidebarItem() {
+ const viewport = getSidebarViewport()
const active = document.querySelector('#nd-sidebar [data-active="true"]')
if (!viewport || !active) {
diff --git a/docs/src/lib/shared.ts b/docs/src/lib/shared.ts
index f8f21b35..92a881bf 100644
--- a/docs/src/lib/shared.ts
+++ b/docs/src/lib/shared.ts
@@ -7,3 +7,11 @@ export const gitConfig = {
repo: 'permix',
branch: 'main',
}
+
+export function docsGithubUrl(path: string) {
+ const base = `https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}`
+ if (path === 'changelog.mdx' || path === 'changelog.md') {
+ return `${base}/CHANGELOG.md`
+ }
+ return `${base}/docs/content/docs/${path}`
+}
diff --git a/docs/src/router.tsx b/docs/src/router.tsx
index 4c3fd8f7..a13539de 100644
--- a/docs/src/router.tsx
+++ b/docs/src/router.tsx
@@ -9,7 +9,10 @@ export function getRouter() {
routeTree,
defaultPreload: 'intent',
scrollRestoration: true,
- scrollToTopSelectors: ['#nd-sidebar [data-radix-scroll-area-viewport]'],
+ scrollToTopSelectors: [
+ '#nd-sidebar [data-slot="scroll-area-viewport"]',
+ '#nd-sidebar [data-base-ui-scroll-area-viewport]',
+ ],
defaultNotFoundComponent: NotFound,
})
}
diff --git a/docs/src/routes/docs/$.tsx b/docs/src/routes/docs/$.tsx
index d9d5649e..135f8c88 100644
--- a/docs/src/routes/docs/$.tsx
+++ b/docs/src/routes/docs/$.tsx
@@ -16,7 +16,7 @@ import { Suspense } from 'react'
import { useMDXComponents } from '@/components/mdx'
import { SidebarScrollFix } from '@/components/sidebar-scroll'
import { baseOptions } from '@/lib/layout.shared'
-import { gitConfig } from '@/lib/shared'
+import { docsGithubUrl } from '@/lib/shared'
import { slugsToMarkdownPath, source } from '@/lib/source'
const serverLoader = createServerFn({
@@ -65,7 +65,7 @@ const clientLoader = browserCollections.docs.createClientLoader({
diff --git a/docs/src/styles/app.css b/docs/src/styles/app.css
index 796799a4..f4e4ebfb 100644
--- a/docs/src/styles/app.css
+++ b/docs/src/styles/app.css
@@ -3,6 +3,9 @@
@import 'fumadocs-ui/css/preset.css';
@import 'fumadocs-twoslash/twoslash.css';
+@source "../**/*.{ts,tsx}";
+@source "../../node_modules/fumadocs-ui/dist/**/*.js";
+
html {
scrollbar-gutter: stable;
}
@@ -12,6 +15,7 @@ html > body[data-scroll-locked] {
--removed-body-scroll-bar-size: 0px !important;
}
-[data-radix-scroll-area-viewport] {
+[data-slot='scroll-area-viewport'],
+[data-base-ui-scroll-area-viewport] {
overflow-y: auto !important;
}
diff --git a/docs/vite.config.ts b/docs/vite.config.ts
index 8005021a..bd5c0a47 100644
--- a/docs/vite.config.ts
+++ b/docs/vite.config.ts
@@ -1,3 +1,5 @@
+import path from 'node:path'
+
import tailwindcss from '@tailwindcss/vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import react from '@vitejs/plugin-react'
@@ -5,11 +7,33 @@ import mdx from 'fumadocs-mdx/vite'
import { nitro } from 'nitro/vite'
import { defineConfig } from 'vite'
+const changelogFile = path.resolve(import.meta.dirname, '../CHANGELOG.md')
+
export default defineConfig({
server: {
port: 3000,
+ fs: {
+ allow: ['..'],
+ },
},
plugins: [
+ {
+ name: 'watch-changelog',
+ configureServer(server) {
+ server.watcher.add(changelogFile)
+ },
+ handleHotUpdate({ file, server }) {
+ if (file !== changelogFile) {
+ return
+ }
+ const changelogModule = [
+ ...server.moduleGraph.urlToModuleMap.values(),
+ ].find((mod) => mod.file?.endsWith('changelog.mdx'))
+ if (changelogModule) {
+ return [changelogModule]
+ }
+ },
+ },
mdx(),
tailwindcss(),
tanstackStart({
diff --git a/examples/astro/main.ts b/examples/astro/main.ts
new file mode 100644
index 00000000..f9a403a8
--- /dev/null
+++ b/examples/astro/main.ts
@@ -0,0 +1,54 @@
+import { createServer } from 'node:http'
+
+import type { ValidateDefinition } from 'permix'
+import { createPermix } from 'permix/astro'
+
+type PermissionsDefinition = ValidateDefinition<{
+ user: ['read', 'write']
+}>
+
+const permix = createPermix({
+ onForbidden: () =>
+ Response.json(
+ { error: 'You do not have permission to access this resource' },
+ { status: 403 }
+ ),
+})
+
+async function handle(request: Request): Promise {
+ const context = { request, locals: {} }
+
+ return permix.setupMiddleware({
+ user: {
+ read: true,
+ write: false,
+ },
+ })(context, async () => {
+ const url = new URL(request.url)
+
+ if (url.pathname === '/write') {
+ return permix.checkMiddleware('user.write')(context, () =>
+ Response.json({ ok: true })
+ )
+ }
+
+ if (url.pathname === '/permix') {
+ return Response.json({
+ canRead: permix.getOrThrow(context).check('user.read'),
+ })
+ }
+
+ return Response.json({
+ canRead: permix.getOrThrow(context).check('user.read'),
+ })
+ })
+}
+
+createServer(async (req, res) => {
+ const request = new Request(`http://127.0.0.1:3000${req.url ?? '/'}`)
+ const response = await handle(request)
+ res.writeHead(response.status, Object.fromEntries(response.headers))
+ res.end(Buffer.from(await response.arrayBuffer()))
+}).listen(3000, () => {
+ console.log('Server is running on port 3000')
+})
diff --git a/examples/astro/package.json b/examples/astro/package.json
new file mode 100644
index 00000000..422ef367
--- /dev/null
+++ b/examples/astro/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "astro",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "check-types": "tsc --noEmit",
+ "start": "tsx main.ts"
+ },
+ "dependencies": {
+ "permix": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/node": "^25.9.1",
+ "tsx": "^4.22.4"
+ }
+}
diff --git a/examples/astro/tsconfig.json b/examples/astro/tsconfig.json
new file mode 100644
index 00000000..48ce3c61
--- /dev/null
+++ b/examples/astro/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "types": ["node"],
+ "esModuleInterop": true,
+ "skipLibCheck": true
+ }
+}
diff --git a/examples/enum-based/package.json b/examples/enum-based/package.json
index fa5a72a9..dabe0ff2 100644
--- a/examples/enum-based/package.json
+++ b/examples/enum-based/package.json
@@ -10,14 +10,14 @@
},
"dependencies": {
"permix": "workspace:*",
- "react": "^19.2.0",
- "react-dom": "^19.2.6"
+ "react": "catalog:",
+ "react-dom": "catalog:"
},
"devDependencies": {
- "@types/react": "^19.2.15",
- "@types/react-dom": "^19.2.3",
- "@vitejs/plugin-react": "^6.0.2",
- "typescript": "^6.0.3",
- "vite": "^8.0.16"
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "@vitejs/plugin-react": "catalog:",
+ "typescript": "catalog:",
+ "vite": "catalog:"
}
}
diff --git a/examples/enum-based/src/hooks/use-permissions.ts b/examples/enum-based/src/hooks/use-permissions.ts
index 023cbb2f..fc23d858 100644
--- a/examples/enum-based/src/hooks/use-permissions.ts
+++ b/examples/enum-based/src/hooks/use-permissions.ts
@@ -1,7 +1 @@
-import { usePermix } from 'permix/react'
-
-import { permix } from '../lib/permix'
-
-export function usePermissions() {
- return usePermix(permix)
-}
+export { usePermix as usePermissions } from '../lib/permix'
diff --git a/examples/enum-based/src/lib/permix.ts b/examples/enum-based/src/lib/permix.ts
index 44454a1e..cae1dd9f 100644
--- a/examples/enum-based/src/lib/permix.ts
+++ b/examples/enum-based/src/lib/permix.ts
@@ -1,11 +1,9 @@
-import { createPermix } from 'permix'
-import { createComponents } from 'permix/react'
+import { createPermix } from 'permix/react'
import { PostPermission, UserPermission } from './permissions'
import { getUser } from './user'
-// Define permix instance
-export const permix = createPermix<{
+export const { permix, PermixProvider, usePermix, Check } = createPermix<{
post: [
PostPermission.Create,
PostPermission.Read,
@@ -20,9 +18,6 @@ export const permix = createPermix<{
]
}>()
-// Not necessary, but you can use components to check permissions
-export const { Check } = createComponents(permix)
-
// Define the permissions for each role
export const adminPermissions = permix.template({
post: {
diff --git a/examples/enum-based/src/main.tsx b/examples/enum-based/src/main.tsx
index a477ba08..dbb24289 100644
--- a/examples/enum-based/src/main.tsx
+++ b/examples/enum-based/src/main.tsx
@@ -1,13 +1,12 @@
-import { PermixProvider } from 'permix/react'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
-import { permix } from './lib/permix'
+import { PermixProvider } from './lib/permix'
createRoot(document.querySelector('#root')!).render(
-
+
diff --git a/examples/express-trpc-react/client/src/App.tsx b/examples/express-trpc-react/client/src/App.tsx
index e933b3ac..94cf7ff6 100644
--- a/examples/express-trpc-react/client/src/App.tsx
+++ b/examples/express-trpc-react/client/src/App.tsx
@@ -3,9 +3,8 @@ import { useEffect, useState } from 'react'
import { getRules } from '@/shared/permix'
import type { RouterOutput } from '@/shared/trpc'
-import { Check } from './components/permix'
import { usePermissions } from './hooks/use-permissions'
-import { permix } from './permix'
+import { Check, permix } from './permix'
import { trpc } from './trpc'
export default function App() {
diff --git a/examples/express-trpc-react/client/src/components/permix.ts b/examples/express-trpc-react/client/src/components/permix.ts
deleted file mode 100644
index 3baf309e..00000000
--- a/examples/express-trpc-react/client/src/components/permix.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { createComponents } from 'permix/react'
-
-import { permix } from '../permix'
-
-export const { Check } = createComponents(permix)
diff --git a/examples/express-trpc-react/client/src/hooks/use-permissions.ts b/examples/express-trpc-react/client/src/hooks/use-permissions.ts
index 72c3d94a..96d23664 100644
--- a/examples/express-trpc-react/client/src/hooks/use-permissions.ts
+++ b/examples/express-trpc-react/client/src/hooks/use-permissions.ts
@@ -1,7 +1 @@
-import { usePermix } from 'permix/react'
-
-import { permix } from '../permix'
-
-export function usePermissions() {
- return usePermix(permix)
-}
+export { usePermix as usePermissions } from '../permix'
diff --git a/examples/express-trpc-react/client/src/main.tsx b/examples/express-trpc-react/client/src/main.tsx
index b8797f4e..0557533b 100644
--- a/examples/express-trpc-react/client/src/main.tsx
+++ b/examples/express-trpc-react/client/src/main.tsx
@@ -1,13 +1,12 @@
-import { PermixProvider } from 'permix/react'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
-import { permix } from './permix'
+import { PermixProvider } from './permix'
createRoot(document.querySelector('#root')!).render(
-
+
diff --git a/examples/express-trpc-react/client/src/permix.ts b/examples/express-trpc-react/client/src/permix.ts
index 550b59d1..5202b632 100644
--- a/examples/express-trpc-react/client/src/permix.ts
+++ b/examples/express-trpc-react/client/src/permix.ts
@@ -1,5 +1,6 @@
-import { createPermix } from 'permix'
+import { createPermix } from 'permix/react'
import type { PermissionsDefinition } from '@/shared/permix'
-export const permix = createPermix()
+export const { permix, PermixProvider, usePermix, Check } =
+ createPermix()
diff --git a/examples/express-trpc-react/package.json b/examples/express-trpc-react/package.json
index 617a0261..3d6f96ac 100644
--- a/examples/express-trpc-react/package.json
+++ b/examples/express-trpc-react/package.json
@@ -14,19 +14,20 @@
"cors": "^2.8.6",
"express": "^5.1.0",
"permix": "workspace:*",
- "react": "^19.2.0",
- "react-dom": "^19.2.6",
+ "react": "catalog:",
+ "react-dom": "catalog:",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/pg": "^8.20.0",
- "@types/react": "^19.2.15",
- "@types/react-dom": "^19.2.3",
- "@vitejs/plugin-react": "^6.0.2",
- "tsx": "^4.22.4",
- "vite": "^8.0.16",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "@vitejs/plugin-react": "catalog:",
+ "tsx": "catalog:",
+ "typescript": "catalog:",
+ "vite": "catalog:",
"vite-tsconfig-paths": "^6.1.1"
}
}
diff --git a/examples/express-trpc-react/tsconfig.json b/examples/express-trpc-react/tsconfig.json
index 2c5b1e86..ece8100b 100644
--- a/examples/express-trpc-react/tsconfig.json
+++ b/examples/express-trpc-react/tsconfig.json
@@ -5,7 +5,6 @@
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"moduleDetection": "force",
"useDefineForClassFields": true,
- "ignoreDeprecations": "6.0",
"module": "ESNext",
"moduleResolution": "bundler",
"paths": {
diff --git a/examples/express/package.json b/examples/express/package.json
index 3270a084..7fce8c1b 100644
--- a/examples/express/package.json
+++ b/examples/express/package.json
@@ -12,6 +12,7 @@
},
"devDependencies": {
"@types/express": "^5.0.6",
- "tsx": "^4.22.4"
+ "tsx": "catalog:",
+ "typescript": "catalog:"
}
}
diff --git a/examples/extracted-catalog/package.json b/examples/extracted-catalog/package.json
new file mode 100644
index 00000000..21f2707d
--- /dev/null
+++ b/examples/extracted-catalog/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "extracted-catalog",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "catalog": "tsx src/generate.ts",
+ "catalog:check": "tsx src/generate.ts --check",
+ "check-types": "tsc --noEmit"
+ },
+ "dependencies": {
+ "permix": "workspace:*",
+ "zod": "^4.4.3"
+ },
+ "devDependencies": {
+ "@types/node": "catalog:",
+ "tsx": "catalog:",
+ "typescript": "catalog:"
+ }
+}
diff --git a/examples/extracted-catalog/permissions.generated.json b/examples/extracted-catalog/permissions.generated.json
new file mode 100644
index 00000000..9c923b33
--- /dev/null
+++ b/examples/extracted-catalog/permissions.generated.json
@@ -0,0 +1,76 @@
+{
+ "permissions": [
+ {
+ "annotations": {
+ "area": "work-management",
+ "risk": "standard",
+ "surfaces": [
+ "task-page",
+ "api",
+ "ai-tool"
+ ]
+ },
+ "description": "Add comments from the task page, public API, or an AI tool.",
+ "key": "tasks.comment",
+ "references": [
+ {
+ "column": 12,
+ "file": "src/permission-markers.ts",
+ "line": 4
+ }
+ ],
+ "tags": [
+ "tasks",
+ "collaboration"
+ ],
+ "title": "Comment on a task"
+ },
+ {
+ "annotations": {
+ "area": "work-management",
+ "risk": "elevated",
+ "surfaces": [
+ "task-page",
+ "api"
+ ]
+ },
+ "description": "Permanently delete a task.",
+ "key": "tasks.delete",
+ "references": [
+ {
+ "column": 11,
+ "file": "src/permission-markers.ts",
+ "line": 13
+ }
+ ],
+ "title": "Delete a task"
+ },
+ {
+ "key": "tasks.read",
+ "references": [
+ {
+ "column": 9,
+ "file": "src/permission-markers.ts",
+ "line": 21
+ }
+ ],
+ "title": "Read tasks"
+ },
+ {
+ "annotations": {
+ "area": "organization",
+ "risk": "elevated"
+ },
+ "key": "workspace.members.invite",
+ "references": [
+ {
+ "column": 29,
+ "file": "src/permission-markers.ts",
+ "line": 24
+ }
+ ],
+ "title": "Invite workspace members"
+ }
+ ],
+ "schemaVersion": 1
+}
diff --git a/examples/extracted-catalog/src/generate.ts b/examples/extracted-catalog/src/generate.ts
new file mode 100644
index 00000000..faaf3dfd
--- /dev/null
+++ b/examples/extracted-catalog/src/generate.ts
@@ -0,0 +1,18 @@
+import { checkPermissions, generatePermissions } from 'permix/extractor'
+
+import { permissionMetadata } from './permission-metadata'
+
+const options = {
+ catalogOutput: 'permissions.generated.json',
+ metadata: permissionMetadata,
+ moduleOutput: 'src/permissions.generated.ts',
+} as const
+
+if (process.argv.includes('--check')) {
+ const result = await checkPermissions(options)
+ if (!result.valid) {
+ throw new Error(`Stale artifacts: ${result.stale.join(', ')}`)
+ }
+} else {
+ await generatePermissions(options)
+}
diff --git a/examples/extracted-catalog/src/permission-markers.ts b/examples/extracted-catalog/src/permission-markers.ts
new file mode 100644
index 00000000..69b6a925
--- /dev/null
+++ b/examples/extracted-catalog/src/permission-markers.ts
@@ -0,0 +1,30 @@
+import { permission } from 'permix'
+
+export const taskPermissions = {
+ comment: permission({
+ key: 'tasks.comment',
+ tags: ['tasks', 'collaboration'],
+ annotations: {
+ area: 'work-management',
+ risk: 'standard',
+ surfaces: ['task-page', 'api', 'ai-tool'],
+ },
+ }),
+ delete: permission({
+ key: 'tasks.delete',
+ annotations: {
+ area: 'work-management',
+ risk: 'elevated',
+ surfaces: ['task-page', 'api'],
+ },
+ }),
+ read: permission('tasks.read'),
+} as const
+
+export const inviteMember = permission({
+ key: 'workspace.members.invite',
+ annotations: {
+ area: 'organization',
+ risk: 'elevated',
+ },
+})
diff --git a/examples/extracted-catalog/src/permission-metadata.ts b/examples/extracted-catalog/src/permission-metadata.ts
new file mode 100644
index 00000000..16ef4fe0
--- /dev/null
+++ b/examples/extracted-catalog/src/permission-metadata.ts
@@ -0,0 +1,18 @@
+import { definePermissionConfig } from './permissions.generated'
+
+export const permissionMetadata = definePermissionConfig({
+ 'tasks.comment': {
+ title: 'Comment on a task',
+ description: 'Add comments from the task page, public API, or an AI tool.',
+ },
+ 'tasks.delete': {
+ title: 'Delete a task',
+ description: 'Permanently delete a task.',
+ },
+ 'tasks.read': {
+ title: 'Read tasks',
+ },
+ 'workspace.members.invite': {
+ title: 'Invite workspace members',
+ },
+})
diff --git a/examples/extracted-catalog/src/permissions.generated.ts b/examples/extracted-catalog/src/permissions.generated.ts
new file mode 100644
index 00000000..e1c1f4ae
--- /dev/null
+++ b/examples/extracted-catalog/src/permissions.generated.ts
@@ -0,0 +1,100 @@
+/* This file is generated by Permix. Do not edit it directly. */
+import {
+ createPermissionConfig,
+ createPermissionOverlay,
+} from 'permix'
+import type {
+ ApplyPermissionOverlay,
+ Definition as PermixDefinition,
+} from 'permix'
+
+export type { PermissionReference } from 'permix/extractor'
+
+export const permissionKeys = [
+ 'tasks.comment',
+ 'tasks.delete',
+ 'tasks.read',
+ 'workspace.members.invite',
+] as const
+
+export type Permission = (typeof permissionKeys)[number]
+
+export const permissions = {
+ tasks: {
+ comment: 'tasks.comment',
+ delete: 'tasks.delete',
+ read: 'tasks.read',
+ },
+ workspace: {
+ members: {
+ invite: 'workspace.members.invite',
+ },
+ },
+} as const
+
+export const permissionMetadata = {
+ 'tasks.comment': {
+ title: 'Comment on a task',
+ description: 'Add comments from the task page, public API, or an AI tool.',
+ tags: [
+ 'tasks',
+ 'collaboration',
+ ],
+ annotations: {
+ area: 'work-management',
+ risk: 'standard',
+ surfaces: [
+ 'task-page',
+ 'api',
+ 'ai-tool',
+ ],
+ },
+ },
+ 'tasks.delete': {
+ title: 'Delete a task',
+ description: 'Permanently delete a task.',
+ annotations: {
+ area: 'work-management',
+ risk: 'elevated',
+ surfaces: [
+ 'task-page',
+ 'api',
+ ],
+ },
+ },
+ 'tasks.read': {
+ title: 'Read tasks',
+ },
+ 'workspace.members.invite': {
+ title: 'Invite workspace members',
+ annotations: {
+ area: 'organization',
+ risk: 'elevated',
+ },
+ },
+} as const
+
+export const permissionDefinition = {
+ tasks: [
+ 'comment',
+ 'delete',
+ 'read',
+ ],
+ workspace: {
+ members: [
+ 'invite',
+ ],
+ },
+} as const
+
+export type ExtractedDefinition = typeof permissionDefinition
+
+export type Definition<
+ Overlay extends PermixDefinition = ExtractedDefinition,
+> = ApplyPermissionOverlay
+
+export const definePermissionConfig =
+ createPermissionConfig()
+
+export const definePermissionOverlay =
+ createPermissionOverlay()
diff --git a/examples/extracted-catalog/src/permix.ts b/examples/extracted-catalog/src/permix.ts
new file mode 100644
index 00000000..25835ce2
--- /dev/null
+++ b/examples/extracted-catalog/src/permix.ts
@@ -0,0 +1,35 @@
+import { action, createPermix } from 'permix'
+import { z } from 'zod'
+
+import type { Definition } from './permissions.generated'
+import { definePermissionOverlay, permissions } from './permissions.generated'
+
+const taskSchema = z.object({
+ taskId: z.string(),
+})
+
+const overlay = definePermissionOverlay({
+ tasks: [
+ action('comment', taskSchema, { required: true }),
+ action('delete', taskSchema, { required: true }),
+ ],
+})
+
+type AppDefinition = Definition
+
+export const permix = createPermix().setup({
+ tasks: {
+ comment: ({ taskId }) => taskId.length > 0,
+ delete: ({ taskId }) => taskId.length > 0,
+ read: true,
+ },
+ workspace: {
+ members: {
+ invite: true,
+ },
+ },
+})
+
+export function canComment(taskId: string): boolean {
+ return permix.check(permissions.tasks.comment, { taskId })
+}
diff --git a/examples/extracted-catalog/tsconfig.json b/examples/extracted-catalog/tsconfig.json
new file mode 100644
index 00000000..093180e5
--- /dev/null
+++ b/examples/extracted-catalog/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "lib": ["ESNext"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "moduleDetection": "force",
+ "types": ["node"],
+ "strict": true,
+ "exactOptionalPropertyTypes": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "skipLibCheck": true
+ },
+ "include": ["src"]
+}
diff --git a/examples/feature-flags/package.json b/examples/feature-flags/package.json
index 1bef5b37..86ee93f6 100644
--- a/examples/feature-flags/package.json
+++ b/examples/feature-flags/package.json
@@ -10,14 +10,14 @@
},
"dependencies": {
"permix": "workspace:*",
- "react": "^19.2.0",
- "react-dom": "^19.2.6"
+ "react": "catalog:",
+ "react-dom": "catalog:"
},
"devDependencies": {
- "@types/react": "^19.2.15",
- "@types/react-dom": "^19.2.3",
- "@vitejs/plugin-react": "^6.0.2",
- "typescript": "^6.0.3",
- "vite": "^8.0.16"
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "@vitejs/plugin-react": "catalog:",
+ "typescript": "catalog:",
+ "vite": "catalog:"
}
}
diff --git a/examples/feature-flags/src/hooks/use-permissions.ts b/examples/feature-flags/src/hooks/use-permissions.ts
index 023cbb2f..fc23d858 100644
--- a/examples/feature-flags/src/hooks/use-permissions.ts
+++ b/examples/feature-flags/src/hooks/use-permissions.ts
@@ -1,7 +1 @@
-import { usePermix } from 'permix/react'
-
-import { permix } from '../lib/permix'
-
-export function usePermissions() {
- return usePermix(permix)
-}
+export { usePermix as usePermissions } from '../lib/permix'
diff --git a/examples/feature-flags/src/lib/permix.ts b/examples/feature-flags/src/lib/permix.ts
index 30f19afd..6b82d96a 100644
--- a/examples/feature-flags/src/lib/permix.ts
+++ b/examples/feature-flags/src/lib/permix.ts
@@ -1,17 +1,12 @@
-import { createPermix } from 'permix'
-import { createComponents } from 'permix/react'
+import { createPermix } from 'permix/react'
import { getUser } from './user'
-// Define permix instance with feature flags
-export const permix = createPermix<{
+export const { permix, PermixProvider, usePermix, Check } = createPermix<{
darkMode: ['enabled']
betaFeatures: ['newUI', 'experimentalAPI']
}>()
-// Not necessary, but you can use components to check permissions
-export const { Check } = createComponents(permix)
-
// Define the feature flags for each role
export const betaUserFeatures = permix.template({
darkMode: {
diff --git a/examples/feature-flags/src/main.tsx b/examples/feature-flags/src/main.tsx
index a477ba08..dbb24289 100644
--- a/examples/feature-flags/src/main.tsx
+++ b/examples/feature-flags/src/main.tsx
@@ -1,13 +1,12 @@
-import { PermixProvider } from 'permix/react'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
-import { permix } from './lib/permix'
+import { PermixProvider } from './lib/permix'
createRoot(document.querySelector('#root')!).render(
-
+
diff --git a/examples/nest/main.ts b/examples/nest/main.ts
new file mode 100644
index 00000000..291b4b7a
--- /dev/null
+++ b/examples/nest/main.ts
@@ -0,0 +1,67 @@
+import 'reflect-metadata'
+import {
+ Controller,
+ ForbiddenException,
+ Get,
+ Module,
+ Req,
+} from '@nestjs/common'
+import { APP_GUARD, NestFactory } from '@nestjs/core'
+import type { ValidateDefinition } from 'permix'
+import { createPermix } from 'permix/nest'
+
+type PermissionsDefinition = ValidateDefinition<{
+ user: ['read', 'write']
+}>
+
+const permix = createPermix({
+ onForbidden: () => {
+ throw new ForbiddenException({
+ error: 'You do not have permission to access this resource',
+ })
+ },
+})
+
+@Controller()
+class AppController {
+ @Get()
+ @permix.Check('user.read')
+ read() {
+ return 'Hello World'
+ }
+
+ @Get('write')
+ @permix.Check('user.write')
+ write() {
+ return 'Hello World'
+ }
+
+ @Get('permix')
+ inspect(@Req() req: { [key: PropertyKey]: unknown }) {
+ return { canRead: permix.getOrThrow(req).check('user.read') }
+ }
+}
+
+@Module({
+ controllers: [AppController],
+ providers: [
+ {
+ provide: APP_GUARD,
+ useValue: permix.guard(() => ({
+ user: {
+ read: true,
+ write: false,
+ },
+ })),
+ },
+ ],
+})
+class AppModule {}
+
+async function bootstrap() {
+ const app = await NestFactory.create(AppModule)
+ await app.listen(3000)
+ console.log('Server is running on port 3000')
+}
+
+bootstrap()
diff --git a/examples/nest/package.json b/examples/nest/package.json
new file mode 100644
index 00000000..425acf92
--- /dev/null
+++ b/examples/nest/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "nest",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "check-types": "tsc --noEmit",
+ "start": "tsx main.ts"
+ },
+ "dependencies": {
+ "@nestjs/common": "^11.1.6",
+ "@nestjs/core": "^11.1.6",
+ "@nestjs/platform-express": "^11.1.6",
+ "permix": "workspace:*",
+ "reflect-metadata": "^0.2.2",
+ "rxjs": "^7.8.2"
+ },
+ "devDependencies": {
+ "tsx": "^4.22.4",
+ "typescript": "^6.0.3"
+ }
+}
diff --git a/examples/nest/tsconfig.json b/examples/nest/tsconfig.json
new file mode 100644
index 00000000..c123cb28
--- /dev/null
+++ b/examples/nest/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "experimentalDecorators": true,
+ "emitDecoratorMetadata": true
+ }
+}
diff --git a/examples/next/app/actions.ts b/examples/next/app/actions.ts
index 45906646..991e3614 100644
--- a/examples/next/app/actions.ts
+++ b/examples/next/app/actions.ts
@@ -2,8 +2,12 @@
import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers'
+import { createPermix } from 'permix'
import type { DemoRole } from '@/lib/auth'
+import { getSession } from '@/lib/auth'
+import type { PermissionsDefinition } from '@/lib/permissions'
+import { rulesForSession } from '@/lib/permissions'
export async function switchRole(formData: FormData) {
const role = formData.get('role')
@@ -21,3 +25,15 @@ export async function switchRole(formData: FormData) {
cookieStore.set('demo-role', role satisfies DemoRole, { path: '/' })
revalidatePath('/', 'layout')
}
+
+export async function createPost() {
+ const permix = createPermix().setup(
+ rulesForSession(await getSession())
+ )
+
+ if (!permix.check('post.create')) {
+ return { ok: false as const, error: 'Forbidden' }
+ }
+
+ return { ok: true as const, message: 'Post created (demo)' }
+}
diff --git a/examples/next/app/api/posts/route.ts b/examples/next/app/api/posts/route.ts
index f487a04a..8e00a8fb 100644
--- a/examples/next/app/api/posts/route.ts
+++ b/examples/next/app/api/posts/route.ts
@@ -1,6 +1,14 @@
-import { permix } from '@/lib/permix'
+import { createPermix } from 'permix'
+
+import { getSession } from '@/lib/auth'
+import type { PermissionsDefinition } from '@/lib/permissions'
+import { rulesForSession } from '@/lib/permissions'
export async function POST() {
+ const permix = createPermix().setup(
+ rulesForSession(await getSession())
+ )
+
if (!permix.check('post.create')) {
return Response.json({ error: 'Forbidden' }, { status: 403 })
}
diff --git a/examples/next/app/features/post-list.tsx b/examples/next/app/features/post-list.tsx
new file mode 100644
index 00000000..cb475d99
--- /dev/null
+++ b/examples/next/app/features/post-list.tsx
@@ -0,0 +1,75 @@
+import Link from 'next/link'
+import { Suspense } from 'react'
+
+import { permix } from '@/lib/permix'
+import type { Post } from '@/lib/permix'
+import { getPosts } from '@/lib/posts'
+
+import { CreatePostForm } from '../components/create-post-form'
+import { PermissionBadge } from '../components/permission-badge'
+import { PrivateEditIsland, PrivateEditIslandSkeleton } from './private-edit'
+
+export async function PostList() {
+ const [posts, canCreate] = await Promise.all([
+ getPosts(),
+ permix.check('post.create'),
+ ])
+
+ return (
+
+
This example mirrors the{' '}
@@ -33,76 +23,26 @@ export default async function Home() {
>
Next.js integration guide
- . Rules are set once in the root layout, checked on the server in
- pages and route handlers, then dehydrated for client components.
+ . A rules resolver initializes one cached instance per request. Static
+ chrome stays in the App Shell; session-aware checks stream behind
+ Suspense.
+
+
+
-
-
-
-
Current session
-
- {session ? session.label : 'Signed out (guest)'}
-
-
-
-
-
+ }>
+
+
-
-
Server checks in this request
-
-
-
-
-
-
-
-
Posts
- {posts.map((post) => (
-
-
-
-
Post {post.id}
-
- authorId: {post.authorId}
-
-
-
-
-
-
-
-
- Open page
-
-
-
-
-
- ))}
-
+ }>
+
+
-
+ }>
+
+
)
}
diff --git a/examples/next/app/posts/[id]/edit-button.tsx b/examples/next/app/posts/[id]/edit-button.tsx
index 63b0fb0a..79da33ab 100644
--- a/examples/next/app/posts/[id]/edit-button.tsx
+++ b/examples/next/app/posts/[id]/edit-button.tsx
@@ -1,12 +1,10 @@
'use client'
-import { usePermix } from 'permix/react'
-
-import { permix } from '@/app/providers'
-import type { Post } from '@/lib/permix'
+import { usePermix } from '@/lib/client-permix'
+import type { Post } from '@/lib/permissions'
export function EditButton({ post }: { post: Post }) {
- const { check } = usePermix(permix)
+ const { check } = usePermix()
if (!check('post.update', post)) {
return null
diff --git a/examples/next/app/posts/[id]/page.tsx b/examples/next/app/posts/[id]/page.tsx
index 5929aa93..07cdf701 100644
--- a/examples/next/app/posts/[id]/page.tsx
+++ b/examples/next/app/posts/[id]/page.tsx
@@ -1,23 +1,20 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
+import { Suspense } from 'react'
import { permix } from '@/lib/permix'
import { getPost } from '@/lib/posts'
-import { EditButton } from './edit-button'
+import {
+ PrivateEditIsland,
+ PrivateEditIslandSkeleton,
+} from '../../features/private-edit'
-export default async function PostPage({
+export default function PostPage({
params,
}: {
params: Promise<{ id: string }>
}) {
- const { id } = await params
- const post = await getPost(id)
-
- if (!post || !permix.check('post.read', post)) {
- notFound()
- }
-
return (
← Back to posts
-
-
-
- Post {post.id}
-
-
- authorId: {post.authorId}
-
-
- This page calls{' '}
-
- permix.check('post.read', post)
- {' '}
- on the server before rendering.
-
+ This page calls{' '}
+
+ await permix.check('post.read', post)
+ {' '}
+ on the server before rendering. The edit control is a privately cached
+ payload — a UI hint, not enforcement.
+