## Install

```typescript
import { accessMiddleware, guard, adminRouter } from '@gentleduck/iam/server/express'
```

No runtime dependency on Express - the integration is typed against minimal req/res/next interfaces and works with Express 4, Express 5, and any compatible router.

***

## Global middleware

Apply access control to every route under a path prefix.

```typescript
import { accessMiddleware } from '@gentleduck/iam/server/express'
import { IAM_METHOD_ACTION_MAP } from '@gentleduck/iam/server/generic'

const middleware = accessMiddleware(engine, {
  // Extract user ID from your auth layer (passport, jwt, etc.)
  getUserId: (req) => req.user?.id,

  // Map HTTP method to action (defaults to IAM_METHOD_ACTION_MAP)
  getAction: (req) => IAM_METHOD_ACTION_MAP[req.method],

  // Infer resource from the URL path
  getResource: (req) => {
    const parts = req.path.split('/').filter(Boolean)
    return { type: parts[0] ?? 'root', id: parts[1], attributes: {} }
  },

  // Optional: extract scope from headers or query
  getScope: (req) => req.headers['x-org-id'] as string | undefined,

  // Optional: extract environment context (IP, user agent, timestamp)
  getEnvironment: (req) => ({
    ip: req.ip,
    userAgent: req.headers['user-agent'],
    timestamp: Date.now(),
  }),

  // Custom denial response
  onDenied: (req, res) => res.status(403).json({ error: 'Forbidden' }),

  // Custom error handler
  onError: (err, req, res) => res.status(500).json({ error: 'Internal server error' }),
})

app.use('/api', middleware)
```

***

## Per-route guard

Use `guard` on individual routes when the action and resource are known at definition time.

```typescript
import { guard } from '@gentleduck/iam/server/express'

// Basic guard - action and resource are fixed
app.delete('/posts/:id', guard(engine, 'delete', 'post'), (req, res) => {
  // Only reached if engine.can() returns true
  res.json({ deleted: true })
})

// Scoped guard - restrict to a specific scope
app.post('/admin/users', guard(engine, 'manage', 'user', { scope: 'admin' }), (req, res) => {
  res.json({ created: true })
})

// With custom environment
app.patch(
  '/posts/:id',
  guard(engine, 'update', 'post', {
    getEnvironment: (req) => ({ ip: req.ip, timestamp: Date.now() }),
  }),
  handler,
)

// Custom user ID extraction
app.get(
  '/reports',
  guard(engine, 'read', 'report', {
    getUserId: (req) => req.headers['x-api-key'] as string,
  }),
  handler,
)
```

Error handling differs between the two:

* `guard` catches errors and passes them to `next(err)` - they reach Express's error-handling middleware.
* `accessMiddleware` catches errors and calls the `onError` option - defaults to a 500 JSON response.

The Express `guard()` helper reads the resource instance ID from `req.params.id`. If your
route uses a different param name such as `:postId`, either use `accessMiddleware()` with a
custom `getResource`, or run `engine.can()` / `engine.check()` yourself inside the handler
once you have the real resource ID and attributes.

***

## Admin router

Mount a pre-built admin API for managing policies, roles, and assignments. The `authorize` callback is **required** - the factory throws at boot if it's missing.

```ts
import express from 'express'
import { adminRouter } from '@gentleduck/iam/server/express'

const createRouter = adminRouter(engine, {
  authorize: (req) => req.user?.role === 'platform-admin',
})
app.use('/api/access-admin', createRouter(() => express.Router()))
```

Every endpoint (read + write) runs `authorize(req)` before dispatching. Falsy returns produce `401 Unauthorized`; thrown errors route through `onError` (default `500`).

### CSRF protection (default-on)

Mutation handlers (PUT/POST/DELETE) 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.
adminRouter(engine, { authorize })

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

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

The built-in delete route revokes by `subjectId` + `roleId` only. If you store both global and
scoped assignments for the same role and you need to revoke only one scoped assignment, expose a
custom route that calls `engine.admin.revokeRole(subjectId, roleId, scope)` explicitly.

This exposes:

| Method | Path | Description |
| --- | --- | --- |
| GET | `/policies` | List all policies |
| GET | `/roles` | List all roles |
| PUT | `/policies` | Create or update a policy |
| PUT | `/roles` | Create or update a role |
| POST | `/subjects/:id/roles` | Assign a role to a subject |
| DELETE | `/subjects/:id/roles/:roleId` | Revoke a role from a subject |

`adminRouter` options:

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `authorize` | `(req) -> boolean \| Promise<boolean>` | **required** | Per-request guard. Mounting unauthenticated is impossible. |
| `csrfCheck` | `((req) -> boolean) \| false` | built-in `Sec-Fetch-Site` (SEC-103) | CSRF guard for mutations; `false` to disable |
| `onUnauthorized` | `(req, res) -> void` | `401` JSON | Custom unauthorized response |
| `onError` | `(err, req, res) -> void` | `500` JSON | Custom error handler |

***

## Options reference

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `getUserId` | `(req) -> string or null` | `req.user?.id` | Extract the subject ID from the request |
| `getAction` | `(req) -> string` | HTTP method map | Map the request to an action |
| `getResource` | `(req) -> Resource` | Infer from URL path | Map the request to a resource |
| `getEnvironment` | `(req) -> Environment` | IP + user agent + timestamp | Extract environment context |
| `getScope` | `(req) -> string or undefined` | `undefined` | Extract scope (e.g. org ID, team ID) |
| `onDenied` | `(req, res) -> void` | 403 JSON | Custom denial response |
| `onError` | `(err, req, res, next) -> void` | 500 JSON | Custom error handler |

The full options object conforms to `Express.IOptions`. The admin router options conform to `Express.IAdminRouterOptions`, and the required `authorize` callback signature is `Express.IAdminAuthorize`.

***

## Types

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

* `Express.IOptions` - options for `accessMiddleware` and `guard` (identity, action, resource, scope, environment, and failure handlers).
* `Express.IAdminAuthorize` - signature of the required `authorize` callback for `adminRouter`.
* `Express.IAdminRouterOptions` - options for `adminRouter` (`authorize`, `onUnauthorized`, `onError`).

```typescript
import type { Express } from '@gentleduck/iam/server/express'

const opts: Express.IOptions = {
  getUserId: (req) => req.user?.id,
  getScope: (req) => req.headers['x-org-id'] as string | undefined,
}

const adminAuth: Express.IAdminAuthorize = (req) => req.user?.role === 'platform-admin'
```

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