From 48dd91edd02094e6ffb3ab7917482630dceed3c2 Mon Sep 17 00:00:00 2001 From: Martijn van Dijk Date: Fri, 28 Aug 2026 15:04:38 +0200 Subject: [PATCH 1/2] feat: add Astro adapter with locals-based middleware Store a per-request Permix instance on Astro locals so middleware, endpoints, and pages can share rules without tying UI islands to a new client adapter. Co-authored-by: Cursor --- _artifacts/domain_map.yaml | 7 +- _artifacts/skill_tree.yaml | 5 +- docs/content/docs/comparison.mdx | 3 +- docs/content/docs/integrations/astro.mdx | 233 +++++++++++++ docs/content/docs/meta.json | 1 + docs/content/docs/migration-v3-to-v4.mdx | 1 + docs/content/docs/quick-start.mdx | 4 + examples/astro/main.ts | 54 +++ examples/astro/package.json | 16 + examples/astro/tsconfig.json | 11 + permix/package.json | 5 + permix/skills/permix/SKILL.md | 5 +- permix/skills/permix/references/server.md | 1 + permix/src/astro/index.ts | 1 + permix/src/astro/permix.test.ts | 399 ++++++++++++++++++++++ permix/src/astro/permix.ts | 212 ++++++++++++ permix/tsdown.config.ts | 1 + pnpm-lock.yaml | 76 ++--- 18 files changed, 984 insertions(+), 51 deletions(-) create mode 100644 docs/content/docs/integrations/astro.mdx create mode 100644 examples/astro/main.ts create mode 100644 examples/astro/package.json create mode 100644 examples/astro/tsconfig.json create mode 100644 permix/src/astro/index.ts create mode 100644 permix/src/astro/permix.test.ts create mode 100644 permix/src/astro/permix.ts 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/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: {} From 882fc0e0b65f60fd629f34ef46f4ad564c00b386 Mon Sep 17 00:00:00 2001 From: Martijn van Dijk Date: Fri, 28 Aug 2026 20:59:34 +0200 Subject: [PATCH 2/2] 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/**',