## Install

```typescript
import {
  withAccess,
  checkAccess,
  getPermissions,
  createNextMiddleware,
} from '@gentleduck/iam/server/next'
```

Covers four protection layers in Next.js App Router: route handler wrappers, Server Component / Server Action checks, permission map generation for client hydration, and edge middleware.

***

## Route handler wrapper

Wrap App Router route handlers (GET, POST, DELETE, etc.).

```ts
import { withAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { auth } from '@/lib/auth'

async function handler(req: Request, ctx: { params: Promise<{ id: string }> }) {
  const { id } = await ctx.params
  await deletePost(id)
  return Response.json({ deleted: true })
}

export const DELETE = withAccess(engine, 'delete', 'post', handler, {
  // SEC-101: `getUserId` is REQUIRED. The constructor throws when omitted.
  // Never derive identity from request headers - it's spoofable.
  getUserId: async (req) => {
    const session = await auth()
    return session?.user?.id ?? null
  },
})
```

The wrapper automatically reads `params.id` as the resource instance ID. `params` may be a Promise (Next.js 15+) or an object.

`withAccess` throws at construction when `opts.getUserId` is omitted. The
previous default trusted the `x-user-id` request header, which any
unauthenticated client can spoof with `curl -H 'X-User-Id: admin'`. Wire it
from your auth library (NextAuth `auth()`, Clerk `auth()`, cookie session, JWT).

`withAccess()` only auto-binds `params.id`. If your route uses `params.postId` or another
name, keep the wrapper for coarse route gating and run a deeper `engine.can()` /
`engine.check()` inside the handler once you have the real instance identifier.

***

## Server component checks

Check a permission inside a React Server Component or Server Action:

```typescript
import { checkAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'

export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const canDelete = await checkAccess(engine, userId, 'delete', 'post', id)
  const canEdit = await checkAccess(engine, userId, 'update', 'post', id)

  return (
    <article>
      <h1>Post {id}</h1>
      {canEdit && <EditButton />}
      {canDelete && <DeleteButton />}
    </article>
  )
}
```

`checkAccess` is a thin wrapper around `engine.can()` with positional args matching common Next.js patterns (subjectId, action, resourceType, resourceId?, scope?).

***

## Permission map generation

Generate a `PermissionMap` on the server and pass it to the client for hydration:

```typescript
import { getPermissions } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { AccessProvider } from '@/lib/access-client'

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const session = await auth()
  const userId = session?.user?.id

  const permissions = userId
    ? await getPermissions(engine, userId, [
        { action: 'create', resource: 'post' },
        { action: 'delete', resource: 'post' },
        { action: 'manage', resource: 'team' },
        { action: 'read', resource: 'analytics' },
      ])
    : {}

  return (
    <html>
      <body>
        <AccessProvider permissions={permissions}>{children}</AccessProvider>
      </body>
    </html>
  )
}
```

See [client/react](/duck-iam/integrations/client/react) for the consuming side.

***

## Edge middleware

Protect routes at the edge before they reach application code:

```ts
// middleware.ts
import { createNextMiddleware } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { NextResponse } from 'next/server'

const checkAccess = createNextMiddleware(engine, {
  // SEC-101: derive identity from a verified source - never trust a raw
  // header. Cookie session, JWT, or your auth library's session helper.
  getUserId: async (req) => {
    const session = await getServerSession(req)
    return session?.user?.id ?? null
  },
  rules: [
    { pattern: '/api/admin', resource: 'admin', action: 'manage' },
    { pattern: '/api/posts', resource: 'post' }, // action inferred from HTTP method
    { pattern: /^\/api\/billing/, resource: 'billing', scope: 'admin' },
  ],
})

export async function middleware(req: Request) {
  const denied = await checkAccess(req)
  if (denied) return denied // 401 or 403 Response
  return NextResponse.next()
}

export const config = {
  matcher: ['/api/:path*'],
}
```

When no rule matches a request path, the middleware returns `null` and the request passes through.

### Rule patterns

* **String pattern** - simple prefix match (`/api/posts` matches `/api/posts`, `/api/posts/42`, `/api/posts/foo/bar`)
* **RegExp pattern** - full regex test against `pathname` (use for stricter matching, optional segments, glob-like behavior)

***

## Comparing the four entry points

| Helper | Where it runs | Granularity |
| --- | --- | --- |
| `createNextMiddleware` | Edge (before handler) | Route-shape gating |
| `withAccess` | Server (route handler) | Route-shape + resource ID |
| `checkAccess` | Server (RSC, Server Action) | Single check, full control |
| `getPermissions` | Server (Layout, RSC) | Batch generation for hydration |

`createNextMiddleware()` is the cheapest - it runs at the edge and never touches the database adapter unless you wire one in. `withAccess()` runs in the function context with full subject resolution. `checkAccess()` and `getPermissions()` are explicit calls inside server logic.

***

## Options reference

### `withAccess` options

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `getUserId` | `(req) -> string or null or Promise` | **required** (SEC-101) | Extract the subject ID; constructor throws when omitted |
| `getEnvironment` | `(req) -> Environment` | IP + user agent + timestamp | Extract environment context |
| `scope` | `string` | `undefined` | Fixed scope for this handler |
| `onError` | `(err, req) -> Response` | 500 JSON | Custom error handler |

### `createNextMiddleware` options

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `rules` | `Array<Rule>` | -- | Route rules. Required. |
| `getUserId` | `(req) -> string or null or Promise` | -- | Extract the subject ID. Required. |
| `onError` | `(err, req) -> Response` | 500 JSON | Custom error handler |

### Rule format

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `pattern` | `string or RegExp` | yes | String prefix match or regex pattern |
| `resource` | `string` | yes | Resource type for matched routes |
| `action` | `string` | no | Fixed action (defaults to HTTP method map) |
| `scope` | `string` | no | Required scope for matched routes |

***

## Admin route handlers

`createAdminHandlers` returns pre-bound Route Handlers for admin CRUD. The `authorize` callback is **required** - the factory throws if it's missing.

```ts
// app/api/admin/policies/route.ts
import { createAdminHandlers } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'

const h = createAdminHandlers(engine, {
  authorize: async (req) => {
    const session = await getSession(req)
    return session?.user?.role === 'platform-admin'
  },
})

export const GET = h.listPolicies
export const PUT = h.savePolicy
```

```ts
// app/api/admin/subjects/[id]/roles/route.ts
export const POST = h.assignRole

// app/api/admin/subjects/[id]/roles/[roleId]/route.ts
export const DELETE = h.revokeRole
```

Every handler runs `authorize(req)` first; falsy -> `401`. Errors route through optional `onError(err, req)`.

Available handlers: `listPolicies`, `listRoles`, `savePolicy`, `saveRole`, `assignRole`, `revokeRole`.

### CSRF protection (default-on)

Mutation handlers (`savePolicy`, `saveRole`, `assignRole`, `revokeRole`) run
a `Sec-Fetch-Site` check by default (SEC-103 / CAVEAT-2). Browsers populate
the header automatically; cross-site form posts are rejected with 403,
same-site / same-origin requests pass. Non-browser callers (no header) pass -
they must be gated by bearer / mTLS.

```ts
// Default - cookie-auth admin UIs get CSRF protection with no opt-in.
createAdminHandlers(engine, { authorize })

// Server-to-server bearer/mTLS API - disable the check entirely.
createAdminHandlers(engine, { authorize, csrfCheck: false })

// Stricter - Origin allowlist.
const ADMIN_ORIGINS = new Set(['https://admin.example.com'])
createAdminHandlers(engine, {
  authorize,
  csrfCheck: (req) => ADMIN_ORIGINS.has(req.headers.get('origin') ?? ''),
})
```

`withAccess` options conform to `IamNext.IWithAccessOptions`, `createNextMiddleware` options conform to `IamNext.IMiddlewareOptions`, and `createAdminHandlers` options conform to `IamNext.IAdminOptions` with `authorize` of shape `IamNext.IAdminAuthorize`.

***

## Types

All types live under the `Next` namespace at `@gentleduck/iam/server/next`. Type-only - zero bundle cost.

* `IamNext.IWithAccessOptions` - options for `withAccess` (`getUserId`, `getEnvironment`, `scope`, `onError`).
* `IamNext.IMiddlewareOptions` - options for `createNextMiddleware` (`rules`, `getUserId`, `onError`).
* `IamNext.IAdminAuthorize` - signature of the required `authorize` callback for `createAdminHandlers`.
* `IamNext.IAdminOptions` - options for `createAdminHandlers`.

```typescript
import type { IamNext } from '@gentleduck/iam/server/next'

const opts: IamNext.IWithAccessOptions = {
  getUserId: async (req) => (await auth())?.user?.id ?? null,
}

const adminAuth: IamNext.IAdminAuthorize = async (req) => {
  const session = await getSession(req)
  return session?.user?.role === 'platform-admin'
}
```

Deprecated bare aliases (`IWithAccessOptions`, `IMiddlewareOptions`, `IAdminAuthorize`, `IAdminOptions`) remain for back-compat and will be removed in 3.0.