diff --git a/_artifacts/domain_map.yaml b/_artifacts/domain_map.yaml
index 318aa7c9..d03694c1 100644
--- a/_artifacts/domain_map.yaml
+++ b/_artifacts/domain_map.yaml
@@ -34,7 +34,7 @@ domains:
slug: server
description: >
Per-request setupMiddleware and checkMiddleware for Express, Hono, Fastify,
- tRPC, oRPC, Node, and Elysia.
+ tRPC, oRPC, Node, Elysia, and Astro.
- name: 'SSR and hydration'
slug: ssr
@@ -78,7 +78,7 @@ skills:
~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.
+ checkMiddleware for Express, Hono, Fastify, tRPC, oRPC, Node, Elysia, Astro.
Single skill with a thin SKILL.md router and three reference files
loaded on demand.
type: core
@@ -98,6 +98,7 @@ skills:
- orpc
- node
- elysia
+ - astro
covers:
- check
- isReady
@@ -144,6 +145,7 @@ skills:
- '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:permix/src/core/check.ts'
@@ -153,6 +155,7 @@ coverage:
- examples/*
- next
- tanstack-start
+ - astro
failure_modes:
- mistake: 'Using v3 { action, dataType } schema shape'
diff --git a/_artifacts/skill_tree.yaml b/_artifacts/skill_tree.yaml
index 70b42866..e9b87b2d 100644
--- a/_artifacts/skill_tree.yaml
+++ b/_artifacts/skill_tree.yaml
@@ -40,7 +40,7 @@ skills:
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.
+ tRPC, oRPC, Node, Elysia, Astro. Thin router with references loaded on demand.
requires:
- permix-getting-started
subsystems:
@@ -57,6 +57,7 @@ skills:
- orpc
- node
- elysia
+ - astro
references:
- 'references/check.md'
- 'references/frontend.md'
@@ -79,6 +80,7 @@ skills:
- '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:permix/src/core/check.ts'
@@ -88,3 +90,4 @@ coverage:
- examples/*
- next
- tanstack-start
+ - astro
diff --git a/docs/content/docs/comparison.mdx b/docs/content/docs/comparison.mdx
index d0d9ea67..b7a69875 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, astro 1.19 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.
@@ -42,6 +42,7 @@ Built with `pnpm run build` in the `permix` package (`tsdown` for all entries ex
| `permix/tanstack-start` | 2.05 kB | adapter |
| `permix/node` | 0.95 kB | adapter |
| `permix/server` | 1.10 kB | adapter |
+| `permix/astro` | 1.19 kB | adapter |
| `permix/express` | 0.91 kB | adapter |
| `permix/hono` | 0.88 kB | adapter |
| `permix/fastify` | 1.04 kB | adapter |
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/meta.json b/docs/content/docs/meta.json
index dbd79bfc..b05644f9 100644
--- a/docs/content/docs/meta.json
+++ b/docs/content/docs/meta.json
@@ -23,6 +23,7 @@
"integrations/svelte",
"integrations/node",
"integrations/server",
+ "integrations/astro",
"integrations/trpc",
"integrations/orpc",
"integrations/express",
diff --git a/docs/content/docs/migration-v3-to-v4.mdx b/docs/content/docs/migration-v3-to-v4.mdx
index 20be84ca..de876cf4 100644
--- a/docs/content/docs/migration-v3-to-v4.mdx
+++ b/docs/content/docs/migration-v3-to-v4.mdx
@@ -341,6 +341,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/server` | Framework-agnostic fetch middleware |
+| `permix/astro` | Astro middleware and `locals` |
| `permix/svelte` | Svelte 5 runes |
| `permix/drizzle` | Rules from Drizzle v1 schema |
| `permix/drizzle/legacy` | Drizzle v0 (`>=0.30 <1`) |
diff --git a/docs/content/docs/quick-start.mdx b/docs/content/docs/quick-start.mdx
index c6bc2196..58be00cd 100644
--- a/docs/content/docs/quick-start.mdx
+++ b/docs/content/docs/quick-start.mdx
@@ -150,6 +150,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.
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/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/**',
diff --git a/permix/package.json b/permix/package.json
index dfbefd33..ef771f4f 100644
--- a/permix/package.json
+++ b/permix/package.json
@@ -7,6 +7,7 @@
"keywords": [
"access-control",
"acl",
+ "astro",
"authorization",
"frontend",
"javascript",
@@ -79,6 +80,10 @@
"types": "./dist/server/index.d.mts",
"import": "./dist/server/index.mjs"
},
+ "./astro": {
+ "types": "./dist/astro/index.d.mts",
+ "import": "./dist/astro/index.mjs"
+ },
"./elysia": {
"types": "./dist/elysia/index.d.mts",
"import": "./dist/elysia/index.mjs"
diff --git a/permix/skills/permix/SKILL.md b/permix/skills/permix/SKILL.md
index b3ebc1b5..b4b7acee 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 and TanStack Start, and server middleware (permix/express, hono, fastify, trpc, orpc, node, elysia, astro). 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
@@ -26,6 +26,7 @@ sources:
- '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:permix/src/core/check.ts'
---
@@ -38,7 +39,7 @@ Assumes a `permix` instance already exists (see **permix-getting-started**). Loa
| --- | --- |
| `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) |
-| Protecting Express, Hono, Fastify, tRPC, oRPC, Node, or Elysia routes — `setupMiddleware`, `checkMiddleware` | [references/server.md](references/server.md) |
+| Protecting Express, Hono, Fastify, tRPC, oRPC, Node, Elysia, or Astro routes — `setupMiddleware`, `checkMiddleware` | [references/server.md](references/server.md) |
## Rules that apply everywhere
diff --git a/permix/skills/permix/references/server.md b/permix/skills/permix/references/server.md
index 8b8c79d5..74590619 100644
--- a/permix/skills/permix/references/server.md
+++ b/permix/skills/permix/references/server.md
@@ -80,6 +80,7 @@ app.get('/posts/:id', (req, res) => {
| tRPC | `permix/trpc` |
| oRPC | `permix/orpc` |
| Generic HTTP | `permix/node` or `permix/server` |
+| Astro | `permix/astro` |
| Elysia | `permix/elysia` |
| Effect | `permix/effect` — see integration docs |
| Drizzle ORM | `permix/drizzle` (and `permix/drizzle/legacy`) — see integration docs |
diff --git a/permix/src/astro/index.ts b/permix/src/astro/index.ts
new file mode 100644
index 00000000..60dafabb
--- /dev/null
+++ b/permix/src/astro/index.ts
@@ -0,0 +1 @@
+export * from './permix'
diff --git a/permix/src/astro/permix.test.ts b/permix/src/astro/permix.test.ts
new file mode 100644
index 00000000..8ae85cd6
--- /dev/null
+++ b/permix/src/astro/permix.test.ts
@@ -0,0 +1,399 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import type { ValidateDefinition } from '../core'
+import { PermixNotFoundError } from '../core'
+import { createPermix } from './permix'
+import type { AstroContext } 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(): AstroContext {
+ return {
+ request: new Request('https://example.com'),
+ locals: {},
+ }
+}
+
+function createMockNext(response = new Response('ok')) {
+ return vi.fn(() => 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()
+
+ await permix.setupMiddleware({
+ post: { create: true, read: false, update: false },
+ user: { delete: false },
+ })(context, next)
+
+ const result = await permix.checkMiddleware('post.create')(context, next)
+
+ expect(result?.status).toBe(200)
+ expect(next).toHaveBeenCalledTimes(2)
+ expect(next).toHaveBeenLastCalledWith()
+ })
+
+ it('should deny access when permission is not granted', async () => {
+ const context = createMockContext()
+ const next = createMockNext()
+
+ await permix.setupMiddleware({
+ post: { create: false, read: false, update: false },
+ user: { delete: false },
+ })(context, next)
+
+ const result = await permix.checkMiddleware('post.create')(context, next)
+
+ expect(result?.status).toBe(403)
+ await expect(result?.text()).resolves.toBe(
+ JSON.stringify({ error: 'Forbidden' })
+ )
+ })
+
+ 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()
+
+ await permix.setupMiddleware({
+ post: { create: false, read: false, update: false },
+ user: { delete: false },
+ })(context, next)
+
+ const result = await permix.checkMiddleware('post.create')(context, next)
+
+ expect(result?.status).toBe(403)
+ await expect(result?.text()).resolves.toBe(
+ JSON.stringify({ error: 'Custom error' })
+ )
+ })
+
+ it('should work with custom error and params', async () => {
+ const permix = createPermix({
+ onForbidden: ({ path }) =>
+ new Response(
+ JSON.stringify({ error: `You do not have permission for ${path}` }),
+ {
+ status: 403,
+ headers: { 'Content-Type': 'application/json' },
+ }
+ ),
+ })
+
+ const context = createMockContext()
+ const next = createMockNext()
+
+ await permix.setupMiddleware({
+ post: { create: false, read: false, update: false },
+ user: { delete: false },
+ })(context, next)
+
+ const result = await permix.checkMiddleware('post.create')(context, next)
+
+ expect(result?.status).toBe(403)
+ await expect(result?.text()).resolves.toBe(
+ JSON.stringify({ error: 'You do not have permission for post.create' })
+ )
+ })
+
+ it('should pass data through to a rule callback', async () => {
+ const permix = createPermix()
+
+ const context = createMockContext()
+ const next = createMockNext()
+
+ await permix.setupMiddleware({
+ post: {
+ create: (post) => post?.authorId === '1',
+ },
+ })(context, next)
+
+ const result = await permix.checkMiddleware('post.create', {
+ id: 'a',
+ authorId: '1',
+ })(context, next)
+
+ expect(result?.status).toBe(200)
+ })
+
+ it('should work with checker callback form', async () => {
+ const permix = createPermix()
+
+ const context = createMockContext()
+ const next = createMockNext()
+
+ await permix.setupMiddleware({
+ post: { create: true, read: true, update: false },
+ user: { delete: true },
+ })(context, next)
+
+ const result = await permix.checkMiddleware(
+ (c) => c('post.create') && c('user.delete')
+ )(context, next)
+
+ expect(result?.status).toBe(200)
+ })
+
+ it('should work with template', async () => {
+ const template = permix.template({
+ post: { create: true, read: true, update: true },
+ user: { delete: true },
+ })
+
+ const context = createMockContext()
+ const next = createMockNext()
+
+ await permix.setupMiddleware(() => template())(context, next)
+
+ const result = await permix.checkMiddleware('post.create')(context, next)
+
+ expect(result?.status).toBe(200)
+ })
+
+ it('should dehydrate permissions', async () => {
+ const template = permix.template({
+ post: { create: true, read: false, update: true },
+ user: { delete: false },
+ })
+
+ const context = createMockContext()
+ const next = createMockNext()
+
+ await permix.setupMiddleware(() => template())(context, next)
+
+ expect(permix.getOrThrow(context).dehydrate()).toStrictEqual({
+ post: { create: true, read: false, update: true },
+ user: { delete: false },
+ })
+ })
+
+ it('should read the instance from locals as well as context', async () => {
+ const context = createMockContext()
+ const next = createMockNext()
+
+ await permix.setupMiddleware({
+ post: { create: true, read: true, update: true },
+ user: { delete: true },
+ })(context, next)
+
+ expect(permix.get(context.locals)?.check('post.create')).toBe(true)
+ expect(permix.getOrThrow(context.locals).check('post.create')).toBe(true)
+ })
+
+ it('should let two factories with different keys coexist on the same request', async () => {
+ const admin = createPermix().contextKey('admin')
+ const guest = createPermix().contextKey('guest')
+
+ const context = createMockContext()
+ const next = createMockNext()
+
+ await admin.setupMiddleware({
+ post: { create: true, read: true, update: true },
+ user: { delete: true },
+ })(context, next)
+ await guest.setupMiddleware({
+ post: { create: false, read: true, update: false },
+ user: { delete: false },
+ })(context, next)
+
+ const adminNext = createMockNext()
+ const adminResult = await admin.checkMiddleware('post.create')(
+ context,
+ adminNext
+ )
+ expect(adminResult?.status).toBe(200)
+ expect(adminNext).toHaveBeenCalledWith()
+
+ const guestNext = createMockNext()
+ const guestResult = await guest.checkMiddleware('post.create')(
+ context,
+ guestNext
+ )
+ expect(guestResult?.status).toBe(403)
+ expect(guestNext).not.toHaveBeenCalled()
+ })
+
+ it('should default to a per-instance symbol so two factories without a key do not collide', async () => {
+ const first = createPermix()
+ const second = createPermix()
+
+ const context = createMockContext()
+ const next = createMockNext()
+
+ await first.setupMiddleware({
+ post: { create: true, read: true, update: true },
+ user: { delete: true },
+ })(context, next)
+ await second.setupMiddleware({
+ post: { create: false, read: false, update: false },
+ user: { delete: false },
+ })(context, next)
+
+ const firstNext = createMockNext()
+ const firstResult = await first.checkMiddleware('post.create')(
+ context,
+ firstNext
+ )
+ expect(firstResult?.status).toBe(200)
+
+ const secondNext = createMockNext()
+ const secondResult = await second.checkMiddleware('post.create')(
+ context,
+ secondNext
+ )
+ expect(secondResult?.status).toBe(403)
+ })
+
+ it('should accept an explicit symbol key', async () => {
+ const key = Symbol('my-permix')
+ const permix = createPermix().contextKey(key)
+
+ const context = createMockContext()
+ const next = createMockNext()
+
+ await permix.setupMiddleware({
+ post: { create: true, read: true, update: true },
+ user: { delete: true },
+ })(context, next)
+
+ expect(Boolean((context.locals as Record)[key])).toBe(
+ true
+ )
+ })
+})
+
+describe('get / getOrThrow', () => {
+ const permix = createPermix()
+
+ it('should return null when setupMiddleware has not run', () => {
+ const context = createMockContext()
+ expect(permix.get(context)).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 },
+ })(context, next)
+
+ const p = permix.getOrThrow(context)
+ expect(p.check).toBeTypeOf('function')
+ })
+
+ it('getOrThrow should throw PermixNotFoundError when missing', () => {
+ const context = createMockContext()
+ expect(() => permix.getOrThrow(context)).toThrow(PermixNotFoundError)
+ })
+})
+
+describe('checkMiddleware without setupMiddleware', () => {
+ it('should throw PermixNotFoundError', async () => {
+ const permix = createPermix()
+
+ const context = createMockContext()
+ const next = createMockNext()
+
+ await expect(
+ permix.checkMiddleware('post.create')(context, next)
+ ).rejects.toBeInstanceOf(PermixNotFoundError)
+ expect(next).not.toHaveBeenCalled()
+ })
+})
+
+describe('onForbidden receives next', () => {
+ it('should allow onForbidden to throw custom errors', async () => {
+ const permix = createPermix({
+ onForbidden: ({ path }) => {
+ throw new Error(`Forbidden: ${path}`)
+ },
+ })
+
+ const context = createMockContext()
+ const next = createMockNext()
+
+ await permix.setupMiddleware({
+ post: { create: false, read: false, update: false },
+ user: { delete: false },
+ })(context, next)
+
+ await expect(
+ permix.checkMiddleware('post.create')(context, next)
+ ).rejects.toMatchObject({
+ message: 'Forbidden: post.create',
+ })
+ })
+})
+
+describe('Astro-style middleware composition', () => {
+ it('should compose setup and check middleware like sequence()', async () => {
+ const permix = createPermix()
+
+ const middleware = [
+ permix.setupMiddleware({
+ post: { create: true, read: false, update: false },
+ user: { delete: false },
+ }),
+ permix.checkMiddleware('post.create'),
+ ]
+
+ const run = async (context: AstroContext) => {
+ let index = 0
+ const dispatch = async (): Promise => {
+ const handler = middleware[index++]
+ if (handler) {
+ return await handler(context, dispatch)
+ }
+ return Response.json({ ok: true })
+ }
+ return await dispatch()
+ }
+
+ const res = await run(createMockContext())
+ expect(res.status).toBe(200)
+ await expect(res.json()).resolves.toStrictEqual({ ok: true })
+ })
+})
+
+describe('key exposure', () => {
+ it('should expose the key on the factory return', () => {
+ const permix =
+ createPermix().contextKey('custom-key')
+ expect(permix.key).toBe('custom-key')
+ })
+
+ it('should expose a symbol key when using default', () => {
+ const permix = createPermix()
+ expect(permix.key).toBeTypeOf('symbol')
+ })
+})
diff --git a/permix/src/astro/permix.ts b/permix/src/astro/permix.ts
new file mode 100644
index 00000000..9f2731d7
--- /dev/null
+++ b/permix/src/astro/permix.ts
@@ -0,0 +1,212 @@
+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 { MaybePromise } from '../utils'
+
+/**
+ * Minimal Astro `locals` bag. Compatible with `App.Locals`.
+ */
+export type AstroLocals = object
+
+/**
+ * Minimal Astro middleware / endpoint context. Compatible with `APIContext`
+ * from `astro`.
+ */
+export interface AstroContext {
+ request: Request
+ locals: AstroLocals
+}
+
+export type MiddlewareNext = () => MaybePromise
+
+/**
+ * Astro middleware: `(context, next) => Response`. Compatible with
+ * `defineMiddleware` and `sequence` from `astro:middleware`.
+ */
+export type AstroMiddleware = (
+ context: AstroContext,
+ next: MiddlewareNext
+) => MaybePromise
+
+export interface MiddlewareContext {
+ context: AstroContext
+ request: Request
+ locals: AstroLocals
+ 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 isContext(source: AstroContext | AstroLocals): source is AstroContext {
+ return (
+ typeof source === 'object' &&
+ source !== null &&
+ 'locals' in source &&
+ 'request' in source
+ )
+}
+
+function readLocals(source: AstroContext | AstroLocals): AstroLocals {
+ return isContext(source) ? source.locals : source
+}
+
+function buildPermix(
+ resolveKey: () => string | symbol,
+ options: PermixOptions = {}
+) {
+ const onForbidden =
+ options.onForbidden ??
+ (() =>
+ new Response(JSON.stringify({ error: 'Forbidden' }), {
+ status: 403,
+ headers: { 'Content-Type': 'application/json' },
+ }))
+
+ const hooks = createHooks>()
+
+ function get(source: AstroContext | AstroLocals): PermixCore | null {
+ const locals = readLocals(source) as Record
+ const instance = locals[resolveKey()] as PermixCore | undefined
+ return instance ?? null
+ }
+
+ function getOrThrow(source: AstroContext | AstroLocals): PermixCore {
+ const instance = get(source)
+ if (!instance) {
+ throw new PermixNotFoundError(resolveKey())
+ }
+ return instance
+ }
+
+ function setupMiddleware(
+ callbackOrRules:
+ | ((context: MiddlewareContext) => MaybePromise>)
+ | Rules
+ ): AstroMiddleware {
+ return async (context, next) => {
+ const rules =
+ typeof callbackOrRules === 'function'
+ ? await callbackOrRules({
+ context,
+ request: context.request,
+ locals: context.locals,
+ next,
+ })
+ : callbackOrRules
+ const instance = createPermixCore(rules)
+ instance.hook('check', (checkContext) => {
+ hooks.callHook('check', checkContext)
+ })
+ const locals = context.locals as Record
+ locals[resolveKey()] = instance
+ return await next()
+ }
+ }
+
+ const checkMiddleware: (...args: CheckArgs) => AstroMiddleware =
+ (...args) =>
+ async (context, next) => {
+ const permix = get(context)
+
+ if (!permix) {
+ throw new PermixNotFoundError(resolveKey())
+ }
+
+ const allowed = permix.check(...args)
+
+ if (!allowed) {
+ return await onForbidden({
+ context,
+ request: context.request,
+ locals: context.locals,
+ next,
+ ...createCheckContext(...args),
+ })
+ }
+
+ return await next()
+ }
+
+ function getRules(source: AstroContext | AstroLocals): Rules | null {
+ return get(source)?.getRules() ?? null
+ }
+
+ function template(rules: Rules | ((param: T) => Rules)) {
+ return createTemplate(rules)
+ }
+
+ return {
+ setupMiddleware,
+ checkMiddleware,
+ template,
+ get,
+ getOrThrow,
+ getRules,
+ hook: hooks.hook,
+ hookOnce: hooks.hookOnce,
+ get key() {
+ return resolveKey()
+ },
+ $inferDefinition: undefined as unknown as D,
+ $inferPath: undefined as unknown as RulesPaths,
+ }
+}
+
+/**
+ * Create a middleware factory that wires Permix into Astro.
+ *
+ * The instance is stored on `context.locals`, so middleware, endpoints, and
+ * server-rendered pages in the same request share one instance.
+ *
+ * @example
+ * ```ts
+ * // src/middleware.ts
+ * import { defineMiddleware } from 'astro:middleware'
+ * import { createPermix } from 'permix/astro'
+ *
+ * export const permix = createPermix<{
+ * post: ['create', 'read', 'update', 'delete']
+ * }>()
+ *
+ * export const onRequest = defineMiddleware(
+ * permix.setupMiddleware(({ request }) => ({
+ * post: { create: true, read: true, update: false, delete: false },
+ * })),
+ * )
+ * ```
+ *
+ * @link https://permix.letstri.dev/docs/integrations/astro
+ */
+export function createPermix(
+ options: PermixOptions = {}
+) {
+ let key: string | symbol = Symbol('permix')
+ const permix = buildPermix(() => key, options)
+
+ return Object.assign(permix, {
+ contextKey(newKey: string | symbol) {
+ key = newKey
+ return permix
+ },
+ })
+}
+
+export type AstroPermix = ReturnType<
+ typeof createPermix
+>
diff --git a/permix/tsdown.config.ts b/permix/tsdown.config.ts
index 00e9a0a3..4822174e 100644
--- a/permix/tsdown.config.ts
+++ b/permix/tsdown.config.ts
@@ -14,6 +14,7 @@ export default defineConfig({
'./src/hono/index.ts',
'./src/node/index.ts',
'./src/server/index.ts',
+ './src/astro/index.ts',
'./src/elysia/index.ts',
'./src/fastify/index.ts',
'./src/solid/index.ts',
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 761d130a..1fa750af 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -136,6 +136,19 @@ importers:
specifier: ^6.0.3
version: 6.0.3
+ examples/astro:
+ 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/enum-based:
dependencies:
permix:
@@ -435,7 +448,7 @@ importers:
version: 1.167.1(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.171.27)(csstype@3.2.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@tanstack/react-router-ssr-query':
specifier: latest
- version: 1.167.1(@tanstack/query-core@5.101.0)(@tanstack/react-query@5.101.0(react@19.2.6))(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.171.27)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ version: 1.167.2(@tanstack/query-core@5.101.0)(@tanstack/react-query@5.101.0(react@19.2.6))(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.171.27)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@tanstack/react-start':
specifier: latest
version: 1.168.49(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
@@ -548,7 +561,7 @@ importers:
version: 4.12.23
next:
specifier: '>=14'
- version: 16.2.6(@babel/core@7.29.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ version: 16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react:
specifier: '>=18'
version: 19.2.6
@@ -2634,12 +2647,12 @@ packages:
'@tanstack/router-core':
optional: true
- '@tanstack/react-router-ssr-query@1.167.1':
- resolution: {integrity: sha512-W9j5JPnBikyafvuUfykFfHIWod58OAbAAa5leNkXBcoDoocghMmu6w9uZOmUZvAWT7CSvgj5tBUtF7CM2OoHXQ==}
+ '@tanstack/react-router-ssr-query@1.167.2':
+ resolution: {integrity: sha512-yRvy0VJ00R8huPuquk+qyYQGMpYRc/G33mc6/yZ4f7LaBZz9h/VbACjmLzhm/+6u5WSmUt6ouJJZnI1/5Npfag==}
engines: {node: '>=20.19'}
peerDependencies:
- '@tanstack/query-core': '>=5.90.0'
- '@tanstack/react-query': '>=5.90.0'
+ '@tanstack/query-core': '>=5.102.0'
+ '@tanstack/react-query': '>=5.102.0'
'@tanstack/react-router': '>=1.127.0'
react: '>=18.0.0 || >=19.0.0'
react-dom: '>=18.0.0 || >=19.0.0'
@@ -2927,11 +2940,11 @@ packages:
webpack:
optional: true
- '@tanstack/router-ssr-query-core@1.169.1':
- resolution: {integrity: sha512-rngux8s/3mPQzcjLYDLkNU31coYVyCgrVTfpdwqUdY5jIEHqGTXrO73DTkPR1PppwYUeVhmNCgl8TctRcnupjg==}
+ '@tanstack/router-ssr-query-core@1.169.2':
+ resolution: {integrity: sha512-7pO65Aiq/1+aS3Mb6vSGtIjzQ/YGv9JTfBbn2EYlHcZnJ4s4ZGCeTPI847geB1RfJqVhfwxM8bZgGM51mYkolg==}
engines: {node: '>=20.19'}
peerDependencies:
- '@tanstack/query-core': '>=5.90.0'
+ '@tanstack/query-core': '>=5.102.0'
'@tanstack/router-core': '>=1.127.0'
'@tanstack/router-utils@1.162.1':
@@ -8665,12 +8678,12 @@ snapshots:
transitivePeerDependencies:
- csstype
- '@tanstack/react-router-ssr-query@1.167.1(@tanstack/query-core@5.101.0)(@tanstack/react-query@5.101.0(react@19.2.6))(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.171.27)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ '@tanstack/react-router-ssr-query@1.167.2(@tanstack/query-core@5.101.0)(@tanstack/react-query@5.101.0(react@19.2.6))(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.171.27)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@tanstack/query-core': 5.101.0
'@tanstack/react-query': 5.101.0(react@19.2.6)
'@tanstack/react-router': 1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
- '@tanstack/router-ssr-query-core': 1.169.1(@tanstack/query-core@5.101.0)(@tanstack/router-core@1.171.27)
+ '@tanstack/router-ssr-query-core': 1.169.2(@tanstack/query-core@5.101.0)(@tanstack/router-core@1.171.27)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
transitivePeerDependencies:
@@ -9062,7 +9075,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@tanstack/router-ssr-query-core@1.169.1(@tanstack/query-core@5.101.0)(@tanstack/router-core@1.171.27)':
+ '@tanstack/router-ssr-query-core@1.169.2(@tanstack/query-core@5.101.0)(@tanstack/router-core@1.171.27)':
dependencies:
'@tanstack/query-core': 5.101.0
'@tanstack/router-core': 1.171.27
@@ -9673,7 +9686,7 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
- vitest: 4.1.8(@types/node@25.9.1)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
+ vitest: 4.1.8(@types/node@22.19.20)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
'@vitest/expect@4.1.8':
dependencies:
@@ -11982,30 +11995,6 @@ snapshots:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
- next@16.2.6(@babel/core@7.29.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
- dependencies:
- '@next/env': 16.2.6
- '@swc/helpers': 0.5.15
- baseline-browser-mapping: 2.10.33
- caniuse-lite: 1.0.30001793
- postcss: 8.4.31
- react: 19.2.6
- react-dom: 19.2.6(react@19.2.6)
- styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.6)
- optionalDependencies:
- '@next/swc-darwin-arm64': 16.2.6
- '@next/swc-darwin-x64': 16.2.6
- '@next/swc-linux-arm64-gnu': 16.2.6
- '@next/swc-linux-arm64-musl': 16.2.6
- '@next/swc-linux-x64-gnu': 16.2.6
- '@next/swc-linux-x64-musl': 16.2.6
- '@next/swc-win32-arm64-msvc': 16.2.6
- '@next/swc-win32-x64-msvc': 16.2.6
- sharp: 0.34.5
- transitivePeerDependencies:
- - '@babel/core'
- - babel-plugin-macros
-
next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.2.6
@@ -12039,7 +12028,7 @@ snapshots:
postcss: 8.4.31
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
- styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.6)
+ styled-jsx: 5.1.6(react@19.2.6)
optionalDependencies:
'@next/swc-darwin-arm64': 16.2.6
'@next/swc-darwin-x64': 16.2.6
@@ -12053,7 +12042,6 @@ snapshots:
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
- optional: true
nf3@0.3.17: {}
@@ -12946,17 +12934,15 @@ snapshots:
dependencies:
inline-style-parser: 0.2.7
- styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.6):
+ styled-jsx@5.1.6(react@19.2.4):
dependencies:
client-only: 0.0.1
- react: 19.2.6
- optionalDependencies:
- '@babel/core': 7.29.7
+ react: 19.2.4
- styled-jsx@5.1.6(react@19.2.4):
+ styled-jsx@5.1.6(react@19.2.6):
dependencies:
client-only: 0.0.1
- react: 19.2.4
+ react: 19.2.6
stylis@4.4.0: {}