From b49e7de7bae5d652bab44253b24be18d4206b603 Mon Sep 17 00:00:00 2001 From: Martijn van Dijk Date: Fri, 28 Aug 2026 15:11:58 +0200 Subject: [PATCH 1/3] feat: add React Router adapter with middleware context isolation React Router 7 (including Remix) needs a per-request instance on middleware context so loaders can dehydrate into permix/react. Co-authored-by: Cursor --- _artifacts/domain_map.yaml | 9 +- _artifacts/skill_tree.yaml | 5 +- docs/content/docs/comparison.mdx | 3 +- docs/content/docs/integrations/next.mdx | 4 +- .../docs/integrations/react-router.mdx | 255 ++++++++++++++ docs/content/docs/integrations/react.mdx | 2 +- docs/content/docs/meta.json | 1 + docs/content/docs/migration-v3-to-v4.mdx | 3 +- docs/content/docs/quick-start.mdx | 4 + examples/react-router/main.ts | 59 ++++ examples/react-router/package.json | 16 + examples/react-router/tsconfig.json | 11 + permix/package.json | 5 + permix/skills/permix/SKILL.md | 5 +- permix/skills/permix/references/frontend.md | 6 +- permix/src/react-router/index.ts | 1 + permix/src/react-router/permix.test.ts | 331 ++++++++++++++++++ permix/src/react-router/permix.ts | 216 ++++++++++++ permix/tsdown.config.ts | 1 + pnpm-lock.yaml | 13 + 20 files changed, 937 insertions(+), 13 deletions(-) create mode 100644 docs/content/docs/integrations/react-router.mdx create mode 100644 examples/react-router/main.ts create mode 100644 examples/react-router/package.json create mode 100644 examples/react-router/tsconfig.json create mode 100644 permix/src/react-router/index.ts create mode 100644 permix/src/react-router/permix.test.ts create mode 100644 permix/src/react-router/permix.ts diff --git a/_artifacts/domain_map.yaml b/_artifacts/domain_map.yaml index 318aa7c9..c1823ab9 100644 --- a/_artifacts/domain_map.yaml +++ b/_artifacts/domain_map.yaml @@ -39,7 +39,7 @@ domains: - 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, and React Router wiring. skills: - name: 'Getting started' @@ -77,7 +77,7 @@ skills: 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 + React, Vue, Solid, Svelte, Next.js, TanStack Start, React Router; setupMiddleware and checkMiddleware for Express, Hono, Fastify, tRPC, oRPC, Node, Elysia. Single skill with a thin SKILL.md router and three reference files loaded on demand. @@ -91,6 +91,7 @@ skills: - svelte - next - tanstack-start + - react-router - express - hono - fastify @@ -119,7 +120,7 @@ skills: - '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, or permix/react-router' - 'Protect HTTP or RPC routes on the server' - 'Derive rules from authenticated request context' references: @@ -137,6 +138,7 @@ skills: - '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/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' @@ -153,6 +155,7 @@ coverage: - examples/* - next - tanstack-start + - react-router failure_modes: - mistake: 'Using v3 { action, dataType } schema shape' diff --git a/_artifacts/skill_tree.yaml b/_artifacts/skill_tree.yaml index 70b42866..2d1d6a0d 100644 --- a/_artifacts/skill_tree.yaml +++ b/_artifacts/skill_tree.yaml @@ -39,7 +39,7 @@ skills: 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, + Start, React Router; setupMiddleware/checkMiddleware for Express, Hono, Fastify, tRPC, oRPC, Node, Elysia. Thin router with references loaded on demand. requires: - permix-getting-started @@ -50,6 +50,7 @@ skills: - svelte - next - tanstack-start + - react-router - express - hono - fastify @@ -72,6 +73,7 @@ skills: - '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/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' @@ -88,3 +90,4 @@ coverage: - examples/* - next - tanstack-start + - react-router diff --git a/docs/content/docs/comparison.mdx b/docs/content/docs/comparison.mdx index d0d9ea67..760b2971 100644 --- a/docs/content/docs/comparison.mdx +++ b/docs/content/docs/comparison.mdx @@ -19,7 +19,7 @@ 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 | **2.64 kB** gzip (core) [react 0.86 kB, vue 0.87 kB, solid 0.82 kB, next 1.00 kB, react-router 1.10 kB, tanstack-start 2.05 kB, svelte ~2.17 kB, …] | **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. @@ -40,6 +40,7 @@ Built with `pnpm run build` in the `permix` package (`tsdown` for all entries ex | `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/react-router` | 1.10 kB | adapter | | `permix/node` | 0.95 kB | adapter | | `permix/server` | 1.10 kB | adapter | | `permix/express` | 0.91 kB | adapter | diff --git a/docs/content/docs/integrations/next.mdx b/docs/content/docs/integrations/next.mdx index 5f1f1204..298ad0dd 100644 --- a/docs/content/docs/integrations/next.mdx +++ b/docs/content/docs/integrations/next.mdx @@ -322,8 +322,8 @@ Returns an object with the following methods: ## 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 - 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/react-router.mdx b/docs/content/docs/integrations/react-router.mdx new file mode 100644 index 00000000..0a4f61d6 --- /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 singleton you gave to `PermixProvider`. See the [React integration](/docs/integrations/react) for `createComponents`. + + + + + +## 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..681922c2 100644 --- a/docs/content/docs/integrations/react.mdx +++ b/docs/content/docs/integrations/react.mdx @@ -156,7 +156,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). diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index dbd79bfc..bd98edf3 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -18,6 +18,7 @@ "integrations/react", "integrations/next", "integrations/tanstack-start", + "integrations/react-router", "integrations/vue", "integrations/solid", "integrations/svelte", diff --git a/docs/content/docs/migration-v3-to-v4.mdx b/docs/content/docs/migration-v3-to-v4.mdx index 20be84ca..b9a12166 100644 --- a/docs/content/docs/migration-v3-to-v4.mdx +++ b/docs/content/docs/migration-v3-to-v4.mdx @@ -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). --- @@ -340,6 +340,7 @@ These are additive — migrate the core API first, then adopt what you need: | ----------------------- | ------------------------------------------- | | `permix/next` | Next.js App Router, request-scoped instance | | `permix/tanstack-start` | TanStack Start middleware and SSR | +| `permix/react-router` | React Router 7 middleware and SSR | | `permix/server` | Framework-agnostic fetch middleware | | `permix/svelte` | Svelte 5 runes | | `permix/drizzle` | Rules from Drizzle v1 schema | diff --git a/docs/content/docs/quick-start.mdx b/docs/content/docs/quick-start.mdx index c6bc2196..ba3ba407 100644 --- a/docs/content/docs/quick-start.mdx +++ b/docs/content/docs/quick-start.mdx @@ -170,6 +170,10 @@ Continuing from the quick start, you can now explore how Permix integrates with Integration with TanStack Start. + + Integration with React Router 7 middleware and hydration. + + Integration with Solid via provider and hook. diff --git a/examples/react-router/main.ts b/examples/react-router/main.ts new file mode 100644 index 00000000..e22135d0 --- /dev/null +++ b/examples/react-router/main.ts @@ -0,0 +1,59 @@ +import { createServer } from 'node:http' + +import type { ValidateDefinition } from 'permix' +import { createPermix } from 'permix/react-router' +import type { ReactRouterContext } from 'permix/react-router' + +type PermissionsDefinition = ValidateDefinition<{ + user: ['read', 'write'] +}> + +const permix = createPermix({ + onForbidden: () => + Response.json( + { error: 'You do not have permission to access this resource' }, + { status: 403 } + ), +}) + +function createContext(): ReactRouterContext { + const store = new Map() + return { + get: (key) => store.get(key), + set: (key, value) => { + store.set(key, value) + }, + } +} + +async function handle(request: Request): Promise { + const context = createContext() + + return permix.setupMiddleware({ + user: { + read: true, + write: false, + }, + })({ request, context }, async () => { + const url = new URL(request.url) + + if (url.pathname === '/write') { + return permix.checkMiddleware('user.write')({ request, context }, () => + Response.json({ ok: true }) + ) + } + + 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/react-router/package.json b/examples/react-router/package.json new file mode 100644 index 00000000..5dea0633 --- /dev/null +++ b/examples/react-router/package.json @@ -0,0 +1,16 @@ +{ + "name": "react-router", + "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/react-router/tsconfig.json b/examples/react-router/tsconfig.json new file mode 100644 index 00000000..48ce3c61 --- /dev/null +++ b/examples/react-router/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "types": ["node"], + "esModuleInterop": true, + "skipLibCheck": true + } +} diff --git a/permix/package.json b/permix/package.json index dfbefd33..f2e5af2d 100644 --- a/permix/package.json +++ b/permix/package.json @@ -15,6 +15,7 @@ "permissions-management", "rbac", "react", + "react-router", "security", "solid", "svelte", @@ -116,6 +117,10 @@ "./tanstack-start": { "types": "./dist/tanstack-start/index.d.mts", "import": "./dist/tanstack-start/index.mjs" + }, + "./react-router": { + "types": "./dist/react-router/index.d.mts", + "import": "./dist/react-router/index.mjs" } }, "scripts": { diff --git a/permix/skills/permix/SKILL.md b/permix/skills/permix/SKILL.md index b3ebc1b5..dd831f20 100644 --- a/permix/skills/permix/SKILL.md +++ b/permix/skills/permix/SKILL.md @@ -1,7 +1,7 @@ --- name: permix description: >- - Applies Permix authorization once a schema exists: permix.check() paths and ReBAC callbacks, frontend bindings (permix/react, permix/vue, permix/solid, permix/svelte) with SSR dehydrate/hydrate for Next.js and TanStack Start, and server middleware (permix/express, hono, fastify, trpc, orpc, node, elysia). Use for anything past initial setup — checking permissions, gating UI, or protecting routes. For creating the schema and first `permix.setup()`, use permix-getting-started first. + Applies Permix authorization once a schema exists: permix.check() paths and ReBAC callbacks, frontend bindings (permix/react, permix/vue, permix/solid, permix/svelte) with SSR dehydrate/hydrate for Next.js, TanStack Start, and React Router, and server middleware (permix/express, hono, fastify, trpc, orpc, node, elysia). Use for anything past initial setup — checking permissions, gating UI, or protecting routes. For creating the schema and first `permix.setup()`, use permix-getting-started first. metadata: type: core library: permix @@ -19,6 +19,7 @@ sources: - '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/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' @@ -37,7 +38,7 @@ Assumes a `permix` instance already exists (see **permix-getting-started**). Loa | Task | Reference | | --- | --- | | `permix.check()` paths, callbacks, `~all`/`~any`, ReBAC/ABAC with entity data, `isReady` | [references/check.md](references/check.md) | -| React, Vue, Solid, or Svelte UI — `PermixProvider`, `usePermix`, `createComponents`, SSR `dehydrate`/`hydrate` for Next.js / TanStack Start | [references/frontend.md](references/frontend.md) | +| React, Vue, Solid, or Svelte UI — `PermixProvider`, `usePermix`, `createComponents`, SSR `dehydrate`/`hydrate` for Next.js / TanStack Start / React Router | [references/frontend.md](references/frontend.md) | | Protecting Express, Hono, Fastify, tRPC, oRPC, Node, or Elysia routes — `setupMiddleware`, `checkMiddleware` | [references/server.md](references/server.md) | ## Rules that apply everywhere diff --git a/permix/skills/permix/references/frontend.md b/permix/skills/permix/references/frontend.md index 510f619c..14b4f62b 100644 --- a/permix/skills/permix/references/frontend.md +++ b/permix/skills/permix/references/frontend.md @@ -188,9 +188,9 @@ function App({ Run client `permix.setup(...)` where you restore the session (e.g. after `PermixHydrate` mounts or in the same auth effect). -### Next.js / TanStack Start +### Next.js / TanStack Start / React Router -Use framework helpers from `permix/next` or `permix/tanstack-start` when available — they wire dehydrate/hydrate into the framework data flow. +Use framework helpers from `permix/next`, `permix/tanstack-start`, or `permix/react-router` when available — they wire dehydrate/hydrate into the framework data flow. React Router 7 covers Remix; there is no `permix/remix` export. In TanStack Start, `permix.get(context)` only works in server functions and server routes. To check inside `beforeLoad`/`loader`, put a core instance on the **router context** in `getRouter()` (`context: { permix }`), type it with `createRootRouteWithContext`, hydrate it in the root route's `beforeLoad`, then call `context.permix.check(...)` in any child route. Passing only the context type without the runtime value leaves `context.permix` undefined. @@ -200,6 +200,7 @@ Docs: - https://permix.letstri.dev/docs/integrations/next - https://permix.letstri.dev/docs/integrations/tanstack-start +- https://permix.letstri.dev/docs/integrations/react-router ### Flow diagram @@ -225,5 +226,6 @@ For static-only permissions (all booleans), dehydrate + hydrate + `setup` with t - Solid: https://github.com/letstri/permix/tree/main/examples/solid - Svelte: https://github.com/letstri/permix/tree/main/examples/svelte - Next.js (SSR): https://github.com/letstri/permix/tree/main/examples/next +- React Router 7 (SSR): https://github.com/letstri/permix/tree/main/examples/react-router - Role templates: https://github.com/letstri/permix/tree/main/examples/role-based - ReBAC: https://github.com/letstri/permix/tree/main/examples/rebac diff --git a/permix/src/react-router/index.ts b/permix/src/react-router/index.ts new file mode 100644 index 00000000..60dafabb --- /dev/null +++ b/permix/src/react-router/index.ts @@ -0,0 +1 @@ +export * from './permix' diff --git a/permix/src/react-router/permix.test.ts b/permix/src/react-router/permix.test.ts new file mode 100644 index 00000000..03c4eaed --- /dev/null +++ b/permix/src/react-router/permix.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { ValidateDefinition } from '../core' +import { PermixNotFoundError } from '../core' +import { createPermix } from './permix' +import type { ReactRouterContext } from './permix' + +interface Post { + id: string + authorId: string +} + +type PermissionsDefinition = ValidateDefinition<{ + post: ['create', 'read', 'update'] + user: ['delete'] +}> + +type PostWithData = ValidateDefinition<{ + post: [{ name: 'create'; type: Post }] +}> + +function createMockContext(): ReactRouterContext { + const store = new Map() + return { + get: (key) => store.get(key), + set: (key, value) => { + store.set(key, value) + }, + } +} + +function createMockNext(response = new Response('ok')) { + return vi.fn(async () => response) +} + +describe(createPermix, () => { + const permix = createPermix() + + it('should throw ts error', () => { + // @ts-expect-error path does not exist + permix.checkMiddleware('post.delete') + }) + + it('should allow access when permission is granted', async () => { + const context = createMockContext() + const next = createMockNext() + const request = new Request('https://example.com') + + await permix.setupMiddleware({ + post: { create: true, read: false, update: false }, + user: { delete: false }, + })({ request, context }, next) + + const result = await permix.checkMiddleware('post.create')( + { request, context }, + next + ) + + expect(result?.status).toBe(200) + expect(next).toHaveBeenCalledTimes(2) + }) + + it('should deny access when permission is not granted', async () => { + const context = createMockContext() + const next = createMockNext() + const request = new Request('https://example.com') + + await permix.setupMiddleware({ + post: { create: false, read: false, update: false }, + user: { delete: false }, + })({ request, context }, next) + + const result = await permix.checkMiddleware('post.create')( + { request, context }, + next + ) + + expect(result?.status).toBe(403) + await expect(result?.text()).resolves.toBe( + JSON.stringify({ error: 'Forbidden' }) + ) + expect(next).toHaveBeenCalledOnce() + }) + + it('should work with custom error handler', async () => { + const permix = createPermix({ + onForbidden: () => + new Response(JSON.stringify({ error: 'Custom error' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }), + }) + + const context = createMockContext() + const next = createMockNext() + const request = new Request('https://example.com') + + await permix.setupMiddleware({ + post: { create: false, read: false, update: false }, + user: { delete: false }, + })({ request, context }, next) + + const result = await permix.checkMiddleware('post.create')( + { request, context }, + next + ) + + expect(result?.status).toBe(403) + await expect(result?.text()).resolves.toBe( + JSON.stringify({ error: 'Custom error' }) + ) + }) + + it('should pass data through to a rule callback', async () => { + const permix = createPermix() + const context = createMockContext() + const next = createMockNext() + const request = new Request('https://example.com') + + await permix.setupMiddleware({ + post: { + create: (post) => post?.authorId === '1', + }, + })({ request, context }, next) + + const result = await permix.checkMiddleware('post.create', { + id: 'a', + authorId: '1', + })({ request, context }, next) + + expect(result?.status).toBe(200) + }) + + it('should work with checker callback form', async () => { + const permix = createPermix() + const context = createMockContext() + const next = createMockNext() + const request = new Request('https://example.com') + + await permix.setupMiddleware({ + post: { create: true, read: true, update: false }, + user: { delete: true }, + })({ request, context }, next) + + const result = await permix.checkMiddleware( + (c) => c('post.create') && c('user.delete') + )({ request, context }, next) + + expect(result?.status).toBe(200) + }) + + it('should work with an async setup callback that receives the request', async () => { + const permix = createPermix() + const context = createMockContext() + const next = createMockNext() + const request = new Request('https://example.com/?admin=1') + + await permix.setupMiddleware(async ({ request: req }) => ({ + post: { + create: new URL(req.url).searchParams.get('admin') === '1', + read: true, + update: false, + }, + user: { delete: false }, + }))({ request, context }, next) + + expect(permix.getOrThrow(context).check('post.create')).toBe(true) + }) + + it('should dehydrate permissions', async () => { + const permix = createPermix() + const context = createMockContext() + const next = createMockNext() + const request = new Request('https://example.com') + + await permix.setupMiddleware({ + post: { create: true, read: false, update: true }, + user: { delete: false }, + })({ request, context }, next) + + expect(permix.dehydrate(context)).toStrictEqual({ + post: { create: true, read: false, update: true }, + user: { delete: false }, + }) + }) + + it('should isolate instances between requests', async () => { + const permix = createPermix() + const next = createMockNext() + + const admin = createMockContext() + await permix.setupMiddleware({ + post: { create: true, read: true, update: true }, + user: { delete: true }, + })( + { request: new Request('https://example.com/admin'), context: admin }, + next + ) + + const guest = createMockContext() + await permix.setupMiddleware({ + post: { create: false, read: true, update: false }, + user: { delete: false }, + })({ request: new Request('https://example.com'), context: guest }, next) + + expect(permix.getOrThrow(admin).check('post.create')).toBe(true) + expect(permix.getOrThrow(guest).check('post.create')).toBe(false) + }) + + it('should isolate independent factories on the same request context', async () => { + const first = createPermix() + const second = createPermix() + const context = createMockContext() + const next = createMockNext() + const request = new Request('https://example.com') + + await first.setupMiddleware({ + post: { create: true, read: true, update: true }, + user: { delete: true }, + })({ request, context }, next) + await second.setupMiddleware({ + post: { create: false, read: false, update: false }, + user: { delete: false }, + })({ request, context }, next) + + expect(first.getOrThrow(context).check('post.create')).toBe(true) + expect(second.getOrThrow(context).check('post.create')).toBe(false) + }) + + it('should work with template', async () => { + const permix = createPermix() + const template = permix.template({ + post: { create: true, read: true, update: true }, + user: { delete: true }, + }) + + const context = createMockContext() + const next = createMockNext() + const request = new Request('https://example.com') + + await permix.setupMiddleware(() => template())({ request, context }, next) + + const result = await permix.checkMiddleware('post.create')( + { request, context }, + next + ) + + expect(result?.status).toBe(200) + }) +}) + +describe('get / getOrThrow', () => { + const permix = createPermix() + + it('should return null when setupMiddleware has not run', () => { + expect(permix.get(createMockContext())).toBeNull() + }) + + it('should return the instance when setupMiddleware has run', async () => { + const context = createMockContext() + const next = createMockNext() + + await permix.setupMiddleware({ + post: { create: true, read: true, update: true }, + user: { delete: true }, + })({ request: new Request('https://example.com'), context }, next) + + expect(permix.getOrThrow(context).check).toBeTypeOf('function') + }) + + it('getOrThrow should throw PermixNotFoundError when missing', () => { + expect(() => permix.getOrThrow(createMockContext())).toThrow( + PermixNotFoundError + ) + }) +}) + +describe('checkMiddleware without setupMiddleware', () => { + it('should throw PermixNotFoundError', async () => { + const permix = createPermix() + const next = createMockNext() + + await expect( + permix.checkMiddleware('post.create')( + { + request: new Request('https://example.com'), + context: createMockContext(), + }, + next + ) + ).rejects.toBeInstanceOf(PermixNotFoundError) + expect(next).not.toHaveBeenCalled() + }) +}) + +describe('React Router-style middleware composition', () => { + it('should compose setup and check middleware', async () => { + const permix = createPermix() + const context = createMockContext() + const request = new Request('https://example.com') + + const middleware = [ + permix.setupMiddleware({ + post: { create: true, read: false, update: false }, + user: { delete: false }, + }), + permix.checkMiddleware('post.create'), + ] + + let index = 0 + const dispatch = async (): Promise => { + const handler = middleware[index++] + if (handler) { + return await handler({ request, context }, dispatch) + } + return Response.json({ ok: true }) + } + + const res = await dispatch() + expect(res.status).toBe(200) + await expect(res.json()).resolves.toStrictEqual({ ok: true }) + }) +}) + +describe('context key', () => { + it('should expose a unique context key per factory', () => { + const first = createPermix() + const second = createPermix() + expect(first.context).not.toBe(second.context) + }) +}) diff --git a/permix/src/react-router/permix.ts b/permix/src/react-router/permix.ts new file mode 100644 index 00000000..02a30740 --- /dev/null +++ b/permix/src/react-router/permix.ts @@ -0,0 +1,216 @@ +import type { Permix as PermixCore } from '../core' +import { + createCheckContext, + createHooks, + createPermix as createPermixCore, + createTemplate, + PermixNotFoundError, +} from '../core' +import type { CheckArgs, CheckContext } from '../core/check' +import type { Definition } from '../core/definitions' +import type { PermixHooks, Rules, RulesPaths } from '../core/permix' +import type { DehydratedState } from '../core/rules' +import type { MaybePromise } from '../utils' + +/** + * Opaque key used with React Router's `context.set` / `context.get`. + * Compatible with `RouterContext` from `react-router`. + */ +export interface ReactRouterContextKey { + readonly __permix?: T +} + +/** + * Structural React Router middleware context. Compatible with + * `RouterContextProvider` from `react-router`. + */ +export interface ReactRouterContext { + get: (key: ReactRouterContextKey) => unknown + set: (key: ReactRouterContextKey, value: unknown) => void +} + +export interface MiddlewareArgs { + request: Request + context: ReactRouterContext + params?: Record +} + +export type MiddlewareNext = () => MaybePromise + +/** + * React Router middleware: `(args, next) => Response`. Compatible with + * `MiddlewareFunction` from `react-router` 7.9+. + */ +export type ReactRouterMiddleware = ( + args: MiddlewareArgs, + next: MiddlewareNext +) => MaybePromise + +export interface SetupContext { + request: Request + params?: Record +} + +export interface MiddlewareContext { + request: Request + context: ReactRouterContext + next: MiddlewareNext +} + +export interface PermixOptions { + /** + * Called when a `checkMiddleware` denies the request. Defaults to a 403 JSON + * response of `{ error: 'Forbidden' }`. + */ + onForbidden?: ( + params: CheckContext & MiddlewareContext + ) => MaybePromise +} + +function buildPermix( + resolveKey: () => ReactRouterContextKey>, + options: PermixOptions = {} +) { + const onForbidden = + options.onForbidden ?? + (() => + new Response(JSON.stringify({ error: 'Forbidden' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + })) + + const hooks = createHooks>() + + function get( + context: ReactRouterContext | null | undefined + ): PermixCore | null { + const instance = context?.get(resolveKey()) as PermixCore | undefined + return instance ?? null + } + + function getOrThrow( + context: ReactRouterContext | null | undefined + ): PermixCore { + const instance = get(context) + if (!instance) { + throw new PermixNotFoundError() + } + return instance + } + + function setupMiddleware( + callbackOrRules: + | ((setup: SetupContext) => MaybePromise>) + | Rules + ): ReactRouterMiddleware { + return async ({ request, context, params }, next) => { + const rules = + typeof callbackOrRules === 'function' + ? await callbackOrRules({ request, params }) + : callbackOrRules + const instance = createPermixCore(rules) + instance.hook('check', (checkContext) => { + hooks.callHook('check', checkContext) + }) + context.set(resolveKey(), instance) + return await next() + } + } + + const checkMiddleware: (...args: CheckArgs) => ReactRouterMiddleware = + (...args) => + async ({ request, context }, next) => { + const permix = get(context) + + if (!permix) { + throw new PermixNotFoundError() + } + + const allowed = permix.check(...args) + + if (!allowed) { + return await onForbidden({ + request, + context, + next, + ...createCheckContext(...args), + }) + } + + return await next() + } + + function dehydrate( + context: ReactRouterContext | null | undefined + ): DehydratedState { + return getOrThrow(context).dehydrate() + } + + function getRules( + context: ReactRouterContext | null | undefined + ): Rules | null { + return get(context)?.getRules() ?? null + } + + function template(rules: Rules | ((param: T) => Rules)) { + return createTemplate(rules) + } + + return { + setupMiddleware, + checkMiddleware, + get, + getOrThrow, + dehydrate, + getRules, + template, + hook: hooks.hook, + hookOnce: hooks.hookOnce, + get context() { + return resolveKey() + }, + $inferDefinition: undefined as unknown as D, + $inferPath: undefined as unknown as RulesPaths, + } +} + +/** + * Create a per-request Permix helper for React Router 7 (including Remix). + * + * Uses React Router middleware context (`context.set` / `context.get`) so + * loaders, actions, and middleware share one instance per request. Hydrate + * the client with `permix/react`. + * + * @example + * ```ts + * // app/lib/permix.ts + * import { createPermix } from 'permix/react-router' + * + * export const permix = createPermix<{ + * post: ['create', 'read', 'update', 'delete'] + * }>() + * ``` + * + * ```ts + * // app/root.tsx + * import { permix } from './lib/permix' + * + * export const middleware = [ + * permix.setupMiddleware(({ request }) => ({ + * post: { create: true, read: true, update: false, delete: false }, + * })), + * ] + * ``` + * + * @link https://permix.letstri.dev/docs/integrations/react-router + */ +export function createPermix( + options: PermixOptions = {} +) { + const key: ReactRouterContextKey> = {} + return buildPermix(() => key, options) +} + +export type ReactRouterPermix = ReturnType< + typeof createPermix +> diff --git a/permix/tsdown.config.ts b/permix/tsdown.config.ts index 00e9a0a3..3463d426 100644 --- a/permix/tsdown.config.ts +++ b/permix/tsdown.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ './src/drizzle/legacy/index.ts', './src/next/index.ts', './src/tanstack-start/index.ts', + './src/react-router/index.ts', ], dts: { build: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 761d130a..c80189cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -325,6 +325,19 @@ importers: specifier: ^8.0.16 version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + examples/react-router: + dependencies: + permix: + specifier: workspace:* + version: link:../../permix + devDependencies: + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + tsx: + specifier: ^4.22.4 + version: 4.22.4 + examples/rebac: dependencies: permix: From 9afa3543594f72ac890f31128c496dee50cb74ee Mon Sep 17 00:00:00 2001 From: Martijn van Dijk Date: Fri, 28 Aug 2026 20:59:34 +0200 Subject: [PATCH 2/3] fix: skip vendored agent skills in Oxfmt and Oxlint Toolchain skills from skills.sh fail format check and are not project source. --- ignores.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ignores.ts b/ignores.ts index 9e77439e..65349182 100644 --- a/ignores.ts +++ b/ignores.ts @@ -3,6 +3,8 @@ export const ignorePatterns = [ '**/.next/**', '**/.turbo/**', '**/.vercel/**', + '**/.agents/**', + '**/.claude/**', '**/dist/**', '**/build/**', '**/coverage/**', From 7a7dc34b01528b581b0c74a1ee403f948ae790ec Mon Sep 17 00:00:00 2001 From: Valerii Strilets Date: Sun, 6 Sep 2026 10:03:31 +0300 Subject: [PATCH 3/3] fix: pass middleware context to the React Router setup callback Rules usually depend on values earlier middleware stored on the router context (session, user). The setup callback now receives `context` next to `request` and `params`. Co-Authored-By: Claude Fable 5.1 --- docs/content/docs/integrations/react-router.mdx | 2 +- permix/src/react-router/permix.test.ts | 9 ++++++--- permix/src/react-router/permix.ts | 4 +++- permix/tsconfig.base.tsbuildinfo | 2 +- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/content/docs/integrations/react-router.mdx b/docs/content/docs/integrations/react-router.mdx index 0a4f61d6..b9192900 100644 --- a/docs/content/docs/integrations/react-router.mdx +++ b/docs/content/docs/integrations/react-router.mdx @@ -74,7 +74,7 @@ export const middleware = [ ] ``` -You can also attach it to a specific route's `middleware` array instead of the root. +You can also attach it to a specific route's `middleware` array instead of the root. The callback also receives `context`, so rules can read values set by earlier middleware (for example `context.get(userContext)`). diff --git a/permix/src/react-router/permix.test.ts b/permix/src/react-router/permix.test.ts index 03c4eaed..9858dba7 100644 --- a/permix/src/react-router/permix.test.ts +++ b/permix/src/react-router/permix.test.ts @@ -149,22 +149,25 @@ describe(createPermix, () => { expect(result?.status).toBe(200) }) - it('should work with an async setup callback that receives the request', async () => { + it('should work with an async setup callback that receives the request and context', async () => { const permix = createPermix() const context = createMockContext() const next = createMockNext() const request = new Request('https://example.com/?admin=1') + const userContext = {} + context.set(userContext, { role: 'admin' }) - await permix.setupMiddleware(async ({ request: req }) => ({ + await permix.setupMiddleware(async ({ request: req, context: ctx }) => ({ post: { create: new URL(req.url).searchParams.get('admin') === '1', read: true, - update: false, + update: (ctx.get(userContext) as { role: string }).role === 'admin', }, user: { delete: false }, }))({ request, context }, next) expect(permix.getOrThrow(context).check('post.create')).toBe(true) + expect(permix.getOrThrow(context).check('post.update')).toBe(true) }) it('should dehydrate permissions', async () => { diff --git a/permix/src/react-router/permix.ts b/permix/src/react-router/permix.ts index 02a30740..d164ae14 100644 --- a/permix/src/react-router/permix.ts +++ b/permix/src/react-router/permix.ts @@ -48,6 +48,8 @@ export type ReactRouterMiddleware = ( export interface SetupContext { request: Request + /** Middleware context, so rules can read values set by earlier middleware. */ + context: ReactRouterContext params?: Record } @@ -106,7 +108,7 @@ function buildPermix( return async ({ request, context, params }, next) => { const rules = typeof callbackOrRules === 'function' - ? await callbackOrRules({ request, params }) + ? await callbackOrRules({ request, context, params }) : callbackOrRules const instance = createPermixCore(rules) instance.hook('check', (checkContext) => { diff --git a/permix/tsconfig.base.tsbuildinfo b/permix/tsconfig.base.tsbuildinfo index 2c6c0e5c..40d52077 100644 --- a/permix/tsconfig.base.tsbuildinfo +++ b/permix/tsconfig.base.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/utils.test.ts","./src/utils.ts","./src/core/check.ts","./src/core/definitions.ts","./src/core/errors.ts","./src/core/hooks.test.ts","./src/core/hooks.ts","./src/core/index.ts","./src/core/merge.test.ts","./src/core/merge.ts","./src/core/permix.test.ts","./src/core/permix.ts","./src/core/rules.ts","./src/core/template.ts","./src/drizzle/errors.ts","./src/drizzle/index.ts","./src/drizzle/permix.test.ts","./src/drizzle/permix.ts","./src/drizzle/legacy/index.ts","./src/drizzle/legacy/permix.test.ts","./src/drizzle/legacy/permix.ts","./src/effect/index.ts","./src/effect/permix.test.ts","./src/effect/permix.ts","./src/elysia/index.ts","./src/elysia/permix.test.ts","./src/elysia/permix.ts","./src/express/index.ts","./src/express/permix.test.ts","./src/express/permix.ts","./src/fastify/index.ts","./src/fastify/permix.test.ts","./src/fastify/permix.ts","./src/hono/index.ts","./src/hono/permix.test.ts","./src/hono/permix.ts","./src/node/index.ts","./src/node/permix.test.ts","./src/node/permix.ts","./src/orpc/index.ts","./src/orpc/permix.test.ts","./src/orpc/permix.ts","./src/server/index.ts","./src/server/permix.test.ts","./src/server/permix.ts","./src/trpc/index.ts","./src/trpc/permix.test.ts","./src/trpc/permix.ts","./src/vue/components.test.ts","./src/vue/components.ts","./src/vue/composables.test.ts","./src/vue/composables.ts","./src/vue/context.ts","./src/vue/index.ts","./src/vue/provider.test.ts","./src/vue/test-utils.ts","./tsdown.config.ts","./vitest.config.ts"],"version":"6.0.3"} \ No newline at end of file +{"root":["./src/utils.test.ts","./src/utils.ts","./src/core/check.ts","./src/core/definitions.ts","./src/core/errors.ts","./src/core/hooks.test.ts","./src/core/hooks.ts","./src/core/index.ts","./src/core/merge.test.ts","./src/core/merge.ts","./src/core/permix.test.ts","./src/core/permix.ts","./src/core/rules.ts","./src/core/template.ts","./src/drizzle/errors.ts","./src/drizzle/index.ts","./src/drizzle/permix.test.ts","./src/drizzle/permix.ts","./src/drizzle/legacy/index.ts","./src/drizzle/legacy/permix.test.ts","./src/drizzle/legacy/permix.ts","./src/effect/index.ts","./src/effect/permix.test.ts","./src/effect/permix.ts","./src/elysia/index.ts","./src/elysia/permix.test.ts","./src/elysia/permix.ts","./src/express/index.ts","./src/express/permix.test.ts","./src/express/permix.ts","./src/fastify/index.ts","./src/fastify/permix.test.ts","./src/fastify/permix.ts","./src/hono/index.ts","./src/hono/permix.test.ts","./src/hono/permix.ts","./src/node/index.ts","./src/node/permix.test.ts","./src/node/permix.ts","./src/orpc/index.ts","./src/orpc/permix.test.ts","./src/orpc/permix.ts","./src/react-router/index.ts","./src/react-router/permix.test.ts","./src/react-router/permix.ts","./src/server/index.ts","./src/server/permix.test.ts","./src/server/permix.ts","./src/trpc/index.ts","./src/trpc/permix.test.ts","./src/trpc/permix.ts","./src/vue/components.test.ts","./src/vue/components.ts","./src/vue/composables.test.ts","./src/vue/composables.ts","./src/vue/context.ts","./src/vue/index.ts","./src/vue/provider.test.ts","./src/vue/test-utils.ts","./tsdown.config.ts","./vitest.config.ts"],"version":"6.0.3"} \ No newline at end of file