npm package: @lastshotlabs/bunshot
Description: Batteries-included Bun + Hono API framework — auth, sessions, rate limiting, WebSocket, queues, and OpenAPI docs out of the box
Latest version: 0.2.0

<!-- AUTO-GENERATED — edit docs/sections/, not this file. Run: bun run readme -->

# Bunshot by Last Shot Labs

Bun + Hono API framework with a composable plugin system. Ships with a full auth plugin (`@lastshotlabs/bunshot-auth`), WebSocket rooms, BullMQ workers, Prometheus metrics, rate limiting, response caching, idempotency, and auto-generated OpenAPI docs. Add your own routes, workers, models, and plugins on top.

## Quick Start

Get a server running in under a minute. The in-memory config means no databases, no setup — just routes.

```bash
bun add @lastshotlabs/bunshot hono zod
```

```ts
// src/index.ts
import { createServer } from '@lastshotlabs/bunshot';

await createServer({
  routesDir: import.meta.dir + '/routes',
  db: { auth: 'memory', mongo: false, redis: false, sessions: 'memory', cache: 'memory' },
});
```

```ts
// src/routes/hello.ts
import { z } from 'zod';
import { createRoute, createRouter } from '@lastshotlabs/bunshot';

export const router = createRouter();

router.openapi(
  createRoute({
    method: 'get',
    path: '/hello',
    responses: {
      200: {
        content: { 'application/json': { schema: z.object({ message: z.string() }) } },
        description: 'Hello',
      },
    },
  }),
  c => c.json({ message: 'Hello world!' }, 200),
);
```

```bash
bun run src/index.ts
```

Out of the box you get:

- **Auth routes** — register, login, logout, `/auth/me`, sessions (`POST /auth/register`, etc.)
- **OpenAPI docs** — interactive Scalar UI at `/docs`, raw spec at `/openapi.json`
- **Health check** — `GET /health`
- **WebSocket** — multi-endpoint via `ws.endpoints` config, with room subscriptions, presence, and heartbeat

No databases required for the above. Swap `"memory"` for `"redis"` / `"mongo"` / `"sqlite"` when you're ready to persist data.

---

## Stack

### Packages

| Package                                                                                                | Description                                                                                                      | Backends                          |
| ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | --------------------------------- |
| [`@lastshotlabs/bunshot`](https://www.npmjs.com/package/@lastshotlabs/bunshot)                         | Main framework — `createApp`, `createServer`, route auto-discovery, OpenAPI, WebSocket, BullMQ, metrics, caching | —                                 |
| [`@lastshotlabs/bunshot-core`](https://www.npmjs.com/package/@lastshotlabs/bunshot-core)               | Plugin interface (`BunshotPlugin`), event bus (`BunshotEventBus`), auth boundary contracts                       | —                                 |
| [`@lastshotlabs/bunshot-auth`](https://www.npmjs.com/package/@lastshotlabs/bunshot-auth)               | Auth plugin — JWT, sessions, OAuth, MFA, WebAuthn, SAML                                                          | Memory, SQLite, MongoDB, Postgres |
| [`@lastshotlabs/bunshot-admin`](https://www.npmjs.com/package/@lastshotlabs/bunshot-admin)             | Admin routes, `AdminAccessProvider` and `ManagedUserProvider` seams                                              | —                                 |
| [`@lastshotlabs/bunshot-community`](https://www.npmjs.com/package/@lastshotlabs/bunshot-community)     | Forum — containers, threads, replies, reactions, moderation, notifications                                       | Memory, SQLite, MongoDB, Postgres |
| [`@lastshotlabs/bunshot-push`](https://www.npmjs.com/package/@lastshotlabs/bunshot-push)               | Web Push (VAPID) delivery — subscribe/unsubscribe, bus-driven delivery                                           | Memory, SQLite, MongoDB, Postgres |
| [`@lastshotlabs/bunshot-permissions`](https://www.npmjs.com/package/@lastshotlabs/bunshot-permissions) | Field-level grants, role-based permissions                                                                       | Memory, SQLite, MongoDB, Postgres |
| [`@lastshotlabs/bunshot-webhooks`](https://www.npmjs.com/package/@lastshotlabs/bunshot-webhooks)       | Outbound webhook delivery and management                                                                         | Memory, SQLite, MongoDB, Postgres |
| [`@lastshotlabs/bunshot-mail`](https://www.npmjs.com/package/@lastshotlabs/bunshot-mail)               | Email — provider/queue/renderer adapter seams                                                                    | —                                 |
| [`@lastshotlabs/bunshot-bullmq`](https://www.npmjs.com/package/@lastshotlabs/bunshot-bullmq)           | BullMQ queue integration                                                                                         | —                                 |
| [`@lastshotlabs/bunshot-postgres`](https://www.npmjs.com/package/@lastshotlabs/bunshot-postgres)       | Postgres adapter                                                                                                 | —                                 |

`@lastshotlabs/bunshot` depends on sub-packages and re-exports everything — importing from the main package always works.

### Runtime & build

- **Runtime**: [Bun](https://bun.sh) 1.0+ (native SQLite, native test runner, native TypeScript)
- **Framework**: [Hono](https://hono.dev) + [@hono/zod-openapi](https://github.com/honojs/middleware/tree/main/packages/zod-openapi)
- **Docs UI**: [Scalar](https://scalar.com)
- **Validation**: [Zod v4](https://zod.dev)

### Data & storage

- **Adapter parity**: All stateful plugins (auth, community, push, permissions, webhooks) support Memory, SQLite, MongoDB, and Postgres backends
- **Sessions / cache**: [ioredis](https://github.com/redis/ioredis) (Redis), MongoDB, SQLite, or in-memory — `db.sessions` / `db.cache`
- **Auth tokens**: JWT via [jose](https://github.com/panva/jose) — HttpOnly cookie + `x-user-token` header

### BunshotContext

`createApp()` returns `{ app, ctx }`. The `BunshotContext` is instance-scoped runtime state:

- Attached to the Hono app via WeakMap — available in handlers via `c.get('bunshotCtx')` or `getBunshotCtx(c)`
- Holds infrastructure (redis, mongo, sqlite, signing), resolved config, WS state, plugin state
- `ctx.clear()` resets all stores (test isolation); `ctx.destroy()` tears down connections (shutdown)

### Test utilities

Test helpers are exported from `/testing` subpath exports, not from main entry points:

```ts
import { clearCommunityMemoryStore } from '@lastshotlabs/bunshot-community/testing';
import { createCookieJar, createTestFullServer } from '@lastshotlabs/bunshot/testing';
```

Test state reset is handled by the global `tests/setup.ts` preload (via `runAllClearCallbacks()` + config unlocks). Production code must never import from `/testing`.

### Infrastructure

- **Queues**: [BullMQ](https://docs.bullmq.io) — worker auto-discovery, DLQ, cron workers (requires Redis with `noeviction` policy)
- **WebSocket**: Bun native WebSocket — rooms, heartbeat, presence, message persistence; `WsTransportAdapter` for horizontal scaling
- **Metrics**: Prometheus-compatible `/metrics` endpoint, request counters, histograms, BullMQ queue depth gauges

---

## CLI — Scaffold a New Project

```bash
bunx @lastshotlabs/bunshot "My App"
```

You can also pass a custom directory name:

```bash
bunx @lastshotlabs/bunshot "My App" my-app-dir
```

The CLI is interactive when run in a TTY. In non-TTY environments (CI, piped input) it falls back to numbered prompts.

### What gets created

```
my-app/
  src/
    index.ts            # entry point — calls createServer(appConfig)
    config/index.ts     # centralized app configuration (auth, security, db, etc.)
    lib/constants.ts    # APP_NAME, APP_VERSION, USER_ROLES
    routes/             # add your route files here
    workers/            # BullMQ workers (auto-discovered)
    queues/             # queue definitions
    ws/                 # WebSocket handlers
    services/           # business logic
    middleware/         # custom middleware
    models/             # data models
  tsconfig.json         # pre-configured with path aliases
  .env                  # environment variables template (tailored to your db choices)
  README.md             # project README
```

Path aliases set up in `tsconfig.json`: `@config/*`, `@lib/*`, `@middleware/*`, `@models/*`, `@queues/*`, `@routes/*`, `@scripts/*`, `@services/*`, `@workers/*`, `@service-facades/*`, `@constants/*`.

### Database setup

The first prompt asks how to configure database stores:

- **Full stack** — MongoDB + Redis (production-ready). Choose single connection (auth + app share one server) or separate (auth on its own cluster).
- **SQLite** — Single `.db` file, no external services.
- **Memory** — Ephemeral maps, great for prototyping and tests.
- **Custom** — Choose MongoDB mode, Redis, and individual stores (auth, sessions, cache, OAuth state) independently.

The generated `.env` is tailored to the selected stores — only the relevant variables are included.

### Auth security posture

After database setup, the CLI prompts for an **auth security posture**. Choose a preset or configure features step by step:

**Presets:**

- **Web app / SaaS** — CSRF protection, refresh tokens, bot-fingerprint rate limiting. Includes commented-out stubs for email verification, password reset, and MFA.
- **Internal / admin** — MFA required for all users, strict password policy (14+ chars + special chars), low login rate limits (5/15min), tight session cap (2 sessions), no refresh tokens.
- **Mobile / API only** — No CSRF, open CORS (`"*"`), long-lived refresh tokens with rotation grace window.
- **Dev / prototype** — Minimal password policy (1+ chars, no complexity), very high rate limits (10,000/min), bearer auth disabled.

**Step by step** — choose individual features:

- **Password policy** — Relaxed (8 chars), Strong (12+ chars + special), or Minimal (dev only)
- **Email verification** — Yes / No
- **Password reset** — Yes / No
- **Refresh tokens** — Yes / No
- **MFA** — None / Optional (users opt in) / Required (all users)
- **CSRF protection** — Yes / No
- **OAuth providers** — Google, GitHub, Apple, Microsoft (multi-select; pick "None (done)" to finish)

The selected posture is printed in the end-of-run summary and written directly into `src/config/index.ts`.

### What runs after scaffolding

1. `bun init -y` — generates `package.json`, base `tsconfig.json`, and `.gitignore`
2. The CLI patches `package.json` — sets `module`, `scripts` (`dev`, `start`), and adds `@lastshotlabs/bunshot` as a dependency
3. Writes all source files and the `.env` template
4. `git init`
5. `bun install`

After that:

```bash
cd my-app
# fill in .env with your values
bun dev
```

---

## Installation

### Full framework

```bash
bun add @lastshotlabs/bunshot hono zod
```

Includes the full framework, `@lastshotlabs/bunshot-auth`, and `@lastshotlabs/bunshot-core` as dependencies. All public APIs are re-exported from the main package.

**Requirements:** Bun 1.0+ · TypeScript 5.0+

### Auth plugin only

Use `@lastshotlabs/bunshot-auth` standalone with any Hono app — no full Bunshot framework required:

```bash
bun add @lastshotlabs/bunshot-auth @lastshotlabs/bunshot-core hono zod
```

See the [`bunshot-auth` README](./packages/bunshot-auth/README.md) for setup.

### TypeScript path aliases

The CLI scaffold pre-configures `tsconfig.json` with path aliases. If you're adding Bunshot to an existing project, add these to your `tsconfig.json`:

```json
{
  "compilerOptions": {
    "paths": {
      "@lib/*": ["./src/lib/*"],
      "@models/*": ["./src/models/*"],
      "@routes/*": ["./src/routes/*"],
      "@middleware/*": ["./src/middleware/*"],
      "@services/*": ["./src/services/*"],
      "@workers/*": ["./src/workers/*"],
      "@config/*": ["./src/config/*"]
    }
  }
}
```

---

## Full Configuration Example

For production apps, break config into its own file. Here's a real-world setup with MongoDB, Redis, OAuth, and email verification:

```ts
// src/config/index.ts
import path from 'path';
import {
  type AppMeta,
  type AuthConfig,
  type CreateServerConfig,
  type DbConfig,
  type ModelSchemasConfig,
  type SecurityConfig,
} from '@lastshotlabs/bunshot';

const app: AppMeta = {
  name: 'My App',
  version: '1.0.0',
};

const db: DbConfig = {
  mongo: 'single', // "single" | "separate" | false
  redis: true, // false to skip Redis
  sessions: 'redis', // "redis" | "mongo" | "sqlite" | "memory"
  cache: 'memory', // default store for cacheResponse
  auth: 'mongo', // "mongo" | "sqlite" | "memory"
  oauthState: 'memory', // where to store OAuth state tokens
};

const auth: AuthConfig = {
  roles: ['admin', 'user'],
  defaultRole: 'user',
  primaryField: 'email',
  rateLimit: { store: 'redis' },
  emailVerification: {
    required: true,
    tokenExpiry: 60 * 60, // 1 hour
    onSend: async (email, token) => {
      // send verification email using any provider (Resend, SES, etc.)
    },
  },
  oauth: {
    postRedirect: 'http://localhost:5175/oauth/callback',
    providers: {
      google: {
        clientId: process.env.GOOGLE_CLIENT_ID!,
        clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
        redirectUri: `http://localhost:${process.env.PORT ?? 3000}/auth/google/callback`,
      },
      apple: {
        clientId: process.env.APPLE_CLIENT_ID!,
        teamId: process.env.APPLE_TEAM_ID!,
        keyId: process.env.APPLE_KEY_ID!,
        privateKey: process.env.APPLE_PRIVATE_KEY!,
        redirectUri: `http://localhost:${process.env.PORT ?? 3000}/auth/apple/callback`,
      },
      microsoft: {
        tenantId: process.env.MICROSOFT_TENANT_ID!,
        clientId: process.env.MICROSOFT_CLIENT_ID!,
        clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
        redirectUri: `http://localhost:${process.env.PORT ?? 3000}/auth/microsoft/callback`,
      },
      github: {
        clientId: process.env.GITHUB_CLIENT_ID!,
        clientSecret: process.env.GITHUB_CLIENT_SECRET!,
        redirectUri: `http://localhost:${process.env.PORT ?? 3000}/auth/github/callback`,
      },
    },
  },
};

const security: SecurityConfig = {
  cors: ['*', 'http://localhost:5173'],
  botProtection: { fingerprintRateLimit: true },
};

const modelSchemas: ModelSchemasConfig = {
  registration: 'auto',
  paths: [path.join(import.meta.dir, '../schemas/*.ts')],
};

export const appConfig: CreateServerConfig = {
  app,
  routesDir: path.join(import.meta.dir, '../routes'),
  workersDir: path.join(import.meta.dir, '../workers'),
  port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
  db,
  auth,
  security,
  modelSchemas,
  middleware: [
    /* your global middleware here */
  ],
};
```

Every field above is optional except `routesDir`. See the [Configuration](#configuration) section for the full reference.

### Built-in endpoints

**Core auth** — mounted whenever `auth` is configured:

| Endpoint                           | Description                                                  |
| ---------------------------------- | ------------------------------------------------------------ |
| `POST /auth/register`              | Create account                                               |
| `POST /auth/login`                 | Login — returns JWT in body + sets HttpOnly cookie           |
| `POST /auth/logout`                | Revoke current session                                       |
| `GET /auth/me`                     | Current user profile (requires auth)                         |
| `PATCH /auth/me`                   | Update own profile / user metadata (requires auth)           |
| `POST /auth/set-password`          | Set or change password (requires auth)                       |
| `GET /auth/sessions`               | List active sessions with IP, UA, timestamps (requires auth) |
| `DELETE /auth/sessions/:sessionId` | Revoke a specific session (requires auth)                    |
| `POST /auth/reauth/verify`         | Verify a step-up challenge (requires auth)                   |

**Opt-in auth routes** — each mounts only when its feature is configured:

| Endpoint                                                                                     | Requires config                                            |
| -------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `POST /auth/verify-email`                                                                    | `auth.emailVerification`                                   |
| `POST /auth/resend-verification`                                                             | `auth.emailVerification`                                   |
| `POST /auth/forgot-password`                                                                 | `auth.passwordReset`                                       |
| `POST /auth/reset-password`                                                                  | `auth.passwordReset`                                       |
| `POST /auth/refresh`                                                                         | `auth.refreshTokens`                                       |
| `POST /auth/magic-link/request`                                                              | `auth.magicLink`                                           |
| `POST /auth/magic-link/verify`                                                               | `auth.magicLink`                                           |
| `DELETE /auth/me`                                                                            | `auth.accountDeletion`                                     |
| `POST /auth/cancel-deletion`                                                                 | `auth.accountDeletion` with `queued: true` + `gracePeriod` |
| `POST /auth/mfa/setup` · `verify-setup` · `verify` · `resend` · `recovery-codes` · `methods` | `auth.mfa`                                                 |
| `POST /auth/mfa/email-otp/enable` · `verify-setup` · `DELETE`                                | `auth.mfa.emailOtp`                                        |
| `POST /auth/mfa/webauthn/register-options` · `register` · `GET credentials` · `DELETE`       | `auth.mfa.webauthn`                                        |
| `GET /auth/{provider}` · `callback` · `link` · `DELETE link`                                 | `auth.oauth.providers`                                     |
| `POST /auth/oauth/exchange`                                                                  | `auth.oauth`                                               |
| `POST /auth/oauth/reauth/exchange`                                                           | `auth.oauth.reauth`                                        |

**Admin routes** — `admin.api: true`:

| Endpoint                                            | Description                    |
| --------------------------------------------------- | ------------------------------ |
| `GET /admin/users`                                  | List + search + paginate users |
| `GET /admin/users/:id` · `PATCH` · `DELETE`         | User detail, update, delete    |
| `POST /admin/users/:id/roles`                       | Set roles                      |
| `POST /admin/users/:id/suspend` · `unsuspend`       | Account suspension             |
| `DELETE /admin/users/:id/sessions` · `GET sessions` | Session management             |

**Organization routes** — `organizations.managementRoutes: true`:

| Endpoint                                                        | Description                |
| --------------------------------------------------------------- | -------------------------- |
| `GET /orgs` · `POST`                                            | List / create orgs         |
| `GET /orgs/:id` · `PATCH` · `DELETE`                            | Org detail, update, delete |
| `GET /orgs/:id/members` · `POST` · `PATCH .../roles` · `DELETE` | Membership management      |
| `GET /users/:id/orgs`                                           | User's orgs                |
| `POST /orgs/:id/invitations` · `GET` · `DELETE` · `POST accept` | Invitation flow            |

**Framework**:

| Endpoint                                          | Description                                                                                            |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `GET /health`                                     | Health check                                                                                           |
| `GET /docs`                                       | Scalar API docs UI                                                                                     |
| `GET /openapi.json`                               | OpenAPI spec                                                                                           |
| `WS ws.endpoints`                                 | WebSocket — per-endpoint via `ws: { endpoints: { ... } }` config; cookie or `?token=` query param auth |
| `GET /metrics`                                    | Prometheus scrape endpoint (`metrics.enabled: true`)                                                   |
| `GET /jobs` · `/jobs/:queue` · `/jobs/:queue/:id` | Job status (`jobs.statusEndpoint: true`)                                                               |

---

### Minimal SQLite setup

No MongoDB. No Redis. Everything on SQLite — one `.db` file. Good for small apps, internal tools, and VPS deploys where you want zero external services.

```ts
// src/config/index.ts
import path from 'path';
import type { CreateServerConfig } from '@lastshotlabs/bunshot';

export const appConfig: CreateServerConfig = {
  app: { name: 'My App', version: '1.0.0' },
  routesDir: path.join(import.meta.dir, '../routes'),
  port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
  db: {
    auth: 'sqlite',
    sqlite: path.join(import.meta.dir, '../../data.db'), // created automatically
    mongo: false,
    redis: false,
    sessions: 'sqlite',
    cache: 'sqlite',
  },
  auth: {
    roles: ['admin', 'user'],
    defaultRole: 'user',
    primaryField: 'email',
  },
};
```

Start periodic cleanup of expired rows for long-running servers:

```ts
// src/index.ts
import { createServer, startSqliteCleanup } from '@lastshotlabs/bunshot';
import { appConfig } from './config';

startSqliteCleanup(); // sweeps expired sessions/cache rows every hour
await createServer(appConfig);
```

---

### Production config with all services

MongoDB, Redis, OAuth, email verification, rate limiting, MFA, and metrics.

```ts
// src/config/index.ts
import path from 'path';
import type { CreateServerConfig } from '@lastshotlabs/bunshot';

export const appConfig: CreateServerConfig = {
  app: { name: 'My App', version: '1.0.0' },
  routesDir: path.join(import.meta.dir, '../routes'),
  workersDir: path.join(import.meta.dir, '../workers'),
  port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
  db: {
    mongo: 'single',
    redis: true,
    sessions: 'redis',
    cache: 'redis',
    auth: 'mongo',
  },
  auth: {
    roles: ['admin', 'user'],
    defaultRole: 'user',
    primaryField: 'email',
    rateLimit: { store: 'redis' },
    emailVerification: {
      required: true,
      tokenExpiry: 3600,
      onSend: async (email, token) => {
        // await mailer.send({ to: email, subject: "Verify your email", token });
      },
    },
    passwordReset: {
      tokenExpiry: 3600,
      onSend: async (email, token) => {
        // await mailer.send({ to: email, subject: "Reset your password", token });
      },
    },
    refreshTokens: {
      accessTokenExpiry: 900,
      refreshTokenExpiry: 2_592_000,
      rotationGraceSeconds: 30,
    },
    mfa: {
      emailOtp: {
        onSend: async (email, otp) => {
          // await mailer.send({ to: email, subject: "Your login code", otp });
        },
      },
    },
    oauth: {
      postRedirect: process.env.OAUTH_REDIRECT_URL!,
      providers: {
        google: {
          clientId: process.env.GOOGLE_CLIENT_ID!,
          clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
          redirectUri: `${process.env.API_BASE_URL}/auth/google/callback`,
        },
        github: {
          clientId: process.env.GITHUB_CLIENT_ID!,
          clientSecret: process.env.GITHUB_CLIENT_SECRET!,
          redirectUri: `${process.env.API_BASE_URL}/auth/github/callback`,
        },
      },
    },
  },
  security: {
    cors: [process.env.APP_URL!],
    botProtection: { fingerprintRateLimit: true },
  },
  metrics: {
    enabled: true,
    auth: 'userAuth',
    queues: ['email', 'reports'],
  },
};
```

---

### Multi-tenant SaaS

Tenant resolved from subdomain, org-based auth, role-per-org membership.

```ts
// src/config/index.ts
import path from 'path';
import type { CreateServerConfig } from '@lastshotlabs/bunshot';

export const appConfig: CreateServerConfig = {
  app: { name: 'My SaaS', version: '1.0.0' },
  routesDir: path.join(import.meta.dir, '../routes'),
  port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
  db: {
    mongo: 'single',
    redis: true,
    sessions: 'redis',
    cache: 'redis',
    auth: 'mongo',
  },
  auth: {
    roles: ['owner', 'admin', 'member'],
    defaultRole: 'member',
    primaryField: 'email',
    rateLimit: { store: 'redis' },
    emailVerification: { required: true, tokenExpiry: 3600, onSend: async () => {} },
  },
  tenancy: {
    resolution: 'subdomain',
    onResolve: async tenantId => {
      // tenantId is the resolved subdomain, e.g. "acme" from "acme.myapp.com"
      // validate/load tenant — return null to reject
      return tenantId ? {} : null;
    },
    exemptPaths: ['/auth/login', '/auth/register', '/health'],
  },
  security: {
    cors: [process.env.APP_URL!],
  },
};
```

---

### Bunshot with the community plugin

Add `@lastshotlabs/bunshot-community` for forum features: containers, threads, replies, reactions, and moderation.

```ts
// src/config/index.ts
import path from 'path';
import type { CreateServerConfig } from '@lastshotlabs/bunshot';
import { createCommunityPlugin } from '@lastshotlabs/bunshot-community';
import { createMongoAdapter } from '@lastshotlabs/bunshot-community/adapters/mongo';

const communityPlugin = createCommunityPlugin({
  adapter: createMongoAdapter(),
  containerCreation: 'admin',
  autoModerationHook: async content => {
    // return "block" | "flag" | "allow"
    return 'allow';
  },
});

export const appConfig: CreateServerConfig = {
  app: { name: 'My App', version: '1.0.0' },
  routesDir: path.join(import.meta.dir, '../routes'),
  port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
  db: {
    mongo: 'single',
    redis: true,
    sessions: 'redis',
    cache: 'redis',
    auth: 'mongo',
  },
  auth: {
    roles: ['admin', 'moderator', 'user'],
    defaultRole: 'user',
    primaryField: 'email',
  },
  plugins: [communityPlugin],
};
```

---

## Adding Routes

Drop a file in your `routes/` directory that exports a `router` — see the [Quick Start](#quick-start) example above. Routes are auto-discovered via glob — no registration needed. Subdirectories are supported, so you can organise by feature:

```
routes/
  products.ts
  ingredients/
    list.ts
    detail.ts
```

### OpenAPI Schema Registration

Import `createRoute` from `@lastshotlabs/bunshot` (not from `@hono/zod-openapi`). The wrapper automatically registers every unnamed request body and response schema as a named entry in `components/schemas`. Schemas you already named via `registerSchema` are never overwritten.

Every Zod schema that appears in your OpenAPI spec ends up as a named entry in `components/schemas` — either auto-named by the framework or explicitly named by you. There are four registration methods, each suited to a different scenario.

---

### Method 1 — Route-level auto-registration (via `createRoute`)

The most common case. When you define a route with `createRoute`, every unnamed request body and response schema is automatically registered under a name derived from the HTTP method and path.

**Naming convention**

| Route                   | Part         | Generated name                    |
| ----------------------- | ------------ | --------------------------------- |
| `POST /products`        | request body | `CreateProductsRequest`           |
| `POST /products`        | 201 response | `CreateProductsResponse`          |
| `GET /products/{id}`    | 200 response | `GetProductsByIdResponse`         |
| `DELETE /products/{id}` | 404 response | `DeleteProductsByIdNotFoundError` |
| `PATCH /products/{id}`  | request body | `UpdateProductsByIdRequest`       |

HTTP methods → verbs: `GET → Get`, `POST → Create`, `PUT → Replace`, `PATCH → Update`, `DELETE → Delete`.

Status codes → suffixes: `200/201/204 → Response`, `400 → BadRequestError`, `401 → UnauthorizedError`, `403 → ForbiddenError`, `404 → NotFoundError`, `409 → ConflictError`, `422 → ValidationError`, `429 → RateLimitError`, `500 → InternalError`, `501 → NotImplementedError`, `503 → UnavailableError`. Unknown codes fall back to the number.

**Limitation:** if the same Zod object is used in two different routes, each route names it after itself — you get two identical inline shapes instead of one shared `$ref`. Use Method 2 or 3 to fix this.

---

### Method 2 — Directory / glob auto-discovery (via `modelSchemas`)

Use this when you have schemas shared across multiple routes. Point `modelSchemas` at one or more directories and Bunshot imports every `.ts` file **before** routes are loaded. Any exported Zod schema is registered automatically — same object referenced in multiple routes → same `$ref` in the spec.

**Naming:** export name with the trailing `Schema` suffix stripped (`LedgerItemSchema` → `"LedgerItem"`). Already-registered schemas are never overwritten.

```ts
// src/schemas/ledgerItem.ts
import { z } from 'zod';

export const LedgerItemSchema = z.object({ id: z.string(), name: z.string(), amount: z.number() });
// → auto-registered as "LedgerItem"
```

```ts
// src/config/index.ts
await createServer({
  routesDir: import.meta.dir + '/routes',
  modelSchemas: import.meta.dir + '/schemas', // string shorthand — registration: "auto"
});
```

```ts
// src/routes/ledger.ts  AND  src/routes/ledgerDetail.ts
import { LedgerItemSchema } from '@schemas/ledgerItem';

// same Zod object instance
createRoute({
  responses: { 200: { content: { 'application/json': { schema: LedgerItemSchema } } } },
});
// → $ref: "#/components/schemas/LedgerItem" in both routes
```

**Multiple directories and glob patterns**

```ts
modelSchemas: [
  import.meta.dir + '/schemas', // dedicated schemas dir
  import.meta.dir + '/models', // co-located with DB models
  import.meta.dir + '/services/**/*.schema.ts', // selective glob
];
```

**Full config object** — use when you need to set `registration` or mix paths and globs:

```ts
modelSchemas: {
  paths: [import.meta.dir + "/schemas", import.meta.dir + "/models"],
  registration: "auto",   // default — auto-registers exports with suffix stripping
}
```

**`registration: "explicit"`** — files are imported but nothing is auto-registered. Registration is left entirely to `registerSchema` / `registerSchemas` calls inside each file. Use this when you want zero magic and full name control:

```ts
modelSchemas: { paths: import.meta.dir + "/schemas", registration: "explicit" }
```

---

### Method 3 — Batch explicit registration (via `registerSchemas`)

`registerSchemas` lets you name a group of schemas all at once. Object keys become the `components/schemas` names; the same object is returned so you can destructure and export normally. No suffix stripping — names are taken as-is.

```ts
// src/schemas/index.ts
import { z } from 'zod';
import { registerSchemas } from '@lastshotlabs/bunshot';

export const { LedgerItem, Product, ErrorResponse } = registerSchemas({
  LedgerItem: z.object({ id: z.string(), name: z.string(), amount: z.number() }),
  Product: z.object({ id: z.string(), price: z.number() }),
  ErrorResponse: z.object({ error: z.string() }),
});
```

Pair with `registration: "explicit"` in `modelSchemas` so the file is imported before routes, or call it inline at the top of any route file — route files are auto-discovered so the top-level call runs before the spec is served.

---

### Method 4 — Single explicit registration (via `registerSchema`)

`registerSchema("Name", schema)` registers one schema and returns it unchanged. Useful for a single shared type (e.g. a common error envelope) or to override the name auto-discovery would generate.

```ts
// src/schemas/errors.ts
import { z } from 'zod';
import { registerSchema } from '@lastshotlabs/bunshot';

export const ErrorResponse = registerSchema('ErrorResponse', z.object({ error: z.string() }));
```

Registration is idempotent — calling `registerSchema` on an already-registered schema is a no-op. This means you can safely call it in files that are also covered by `modelSchemas` auto-discovery: whichever runs first wins, and the other is silently skipped.

---

### Priority and interaction

All four methods write to the same process-global registry. The rules are simple:

1. **First write wins** — once a schema has a name, it cannot be renamed.
2. **`modelSchemas` files are imported before routes**, so explicit calls inside them always take precedence over what `createRoute` would generate for the same object.
3. **`registerSchema` / `registerSchemas` take precedence over auto-discovery** when they appear at module top level (they run at import time, before `maybeAutoRegister` inspects the export list).
4. **`createRoute` never overwrites** a schema already in the registry — it only fills gaps.

**Decision guide:**

| Situation                                                    | Use                                        |
| ------------------------------------------------------------ | ------------------------------------------ |
| Route-specific, one-off schema                               | `createRoute` auto-registration (Method 1) |
| Shared across routes, happy with suffix-stripped export name | `modelSchemas` auto-discovery (Method 2)   |
| Shared across routes, want explicit names or batch control   | `registerSchemas` (Method 3)               |
| Single shared schema or custom name override                 | `registerSchema` (Method 4)                |

**Protected routes**

Use `withSecurity` to declare security schemes on a route without breaking `c.req.valid()` type inference. (Inlining `security` directly in `createRoute({...})` causes TypeScript to collapse the handler's input types to `never`.)

```ts
import { createRoute, withSecurity } from "@lastshotlabs/bunshot";

router.openapi(
  withSecurity(
    createRoute({ method: "get", path: "/me", ... }),
    { cookieAuth: [] },
    { userToken: [] }
  ),
  async (c) => {
    const userId = c.get("authUserId"); // fully typed
  }
);
```

Pass each security scheme as a separate object argument. The security scheme names (`cookieAuth`, `userToken`, `bearerAuth`) are registered globally by `createApp`.

**Load order:** By default, routes load in filesystem order. If a route needs to be registered before another (e.g. for Hono's first-match-wins routing), export a `priority` number — lower values load first. Routes without a `priority` load last.

```ts
// routes/tenants.ts — must match before generic routes
export const priority = 1;
export const router = createRouter();
// ...
```

---

## MongoDB Connections

MongoDB and Redis connect automatically inside `createServer` / `createApp`. Control the behavior via the `db` config object:

### Single database (default)

Both auth and app data share one server. Uses `MONGO_*` env vars.

```ts
await createServer({
  // ...
  db: { mongo: 'single', redis: true }, // these are the defaults — can omit db entirely
  // app, auth, security are all optional with sensible defaults
});
```

### Separate auth database

Auth users live on a dedicated server (`MONGO_AUTH_*` env vars), app data on its own server (`MONGO_*` env vars). Useful when multiple tenant apps share one auth cluster.

```ts
await createServer({
  // ...
  db: { mongo: 'separate' },
});
```

### Manual connections

Set `mongo: false` and/or `redis: false` to skip auto-connect and manage connections yourself:

```ts
import {
  connectAppMongo,
  connectAuthMongo,
  connectRedis,
  createServer,
} from '@lastshotlabs/bunshot';

await connectAuthMongo();
await connectAppMongo();
await connectRedis();

await createServer({
  // ...
  db: { mongo: false, redis: false },
});
```

`AuthUser` and all built-in auth routes always use `authConnection`. Your app models use `appConnection` (see Adding Models below).

---

## Adding Models

Import `appConnection` and register models on it. This ensures your models use the correct connection whether you're on a single DB or a separate tenant DB.

`appConnection` is a lazy proxy — calling `.model()` at the top level works fine even before `connectMongo()` has been called. Mongoose buffers any queries until the connection is established.

```ts
// src/models/Product.ts
import { Schema } from 'mongoose';
import type { HydratedDocument } from 'mongoose';
import { appConnection } from '@lastshotlabs/bunshot';

interface IProduct {
  name: string;
  price: number;
}

export type ProductDocument = HydratedDocument<IProduct>;

const ProductSchema = new Schema<IProduct>(
  {
    name: { type: String, required: true },
    price: { type: Number, required: true },
  },
  { timestamps: true },
);

export const Product = appConnection.model<IProduct>('Product', ProductSchema);
```

> **Note:** Import types (`HydratedDocument`, `Schema`, etc.) directly from `"mongoose"` — the `appConnection` and `mongoose` exports from bunshot are runtime proxies and cannot be used as TypeScript namespaces.

### Zod as Single Source of Truth

If you use Zod schemas for your OpenAPI spec (via `createRoute` or `modelSchemas`), you can derive your Mongoose schemas and DTO mappers from those same Zod definitions — so each entity is defined **once**.

#### `zodToMongoose` — Zod → Mongoose SchemaDefinition

Converts a Zod object schema into a Mongoose field definition. Business fields are auto-converted; DB-specific concerns (ObjectId refs, type overrides, subdocuments) are declared via config. The `id` field is automatically excluded since Mongoose provides `_id`.

```ts
import { type HydratedDocument, Schema } from 'mongoose';
import { appConnection, zodToMongoose } from '@lastshotlabs/bunshot';
import { ProductSchema } from '../schemas/product';
// your Zod schema
import type { ProductDto } from '../schemas/product';

// DB interface derives from Zod DTO type
interface IProduct extends Omit<ProductDto, 'id' | 'categoryId'> {
  user: Types.ObjectId;
  category: Types.ObjectId;
}

const ProductMongoSchema = new Schema<IProduct>(
  zodToMongoose(ProductSchema, {
    dbFields: {
      user: { type: Schema.Types.ObjectId, ref: 'UserProfile', required: true },
    },
    refs: {
      categoryId: { dbField: 'category', ref: 'Category' },
    },
    typeOverrides: {
      createdAt: { type: Date, required: true },
    },
  }) as Record<string, unknown>,
  { timestamps: true },
);

export type ProductDocument = HydratedDocument<IProduct>;
export const Product = appConnection.model<IProduct>('Product', ProductMongoSchema);
```

**Config options:**

| Option          | Description                                                                                                |
| --------------- | ---------------------------------------------------------------------------------------------------------- |
| `dbFields`      | Fields that exist only in the DB, not in the API schema (e.g., `user` ObjectId ref)                        |
| `refs`          | API fields that map to ObjectId refs: `{ accountId: { dbField: "account", ref: "Account" } }`              |
| `typeOverrides` | Override the auto-converted Mongoose type for a field (e.g., Zod `z.string()` for dates → Mongoose `Date`) |
| `subdocSchemas` | Subdocument array fields: `{ items: mongooseSubSchema }`                                                   |

**Auto-conversion mapping:**

| Zod type                      | Mongoose type        |
| ----------------------------- | -------------------- |
| `z.string()`                  | `String`             |
| `z.number()`                  | `Number`             |
| `z.boolean()`                 | `Boolean`            |
| `z.date()`                    | `Date`               |
| `z.enum([...])`               | `String` with `enum` |
| `.nullable()` / `.optional()` | `required: false`    |

#### `createDtoMapper` — Zod → toDto mapper

Creates a generic `toDto` function from a Zod schema. The schema defines which fields exist in the DTO; the config declares how to transform DB-specific types.

```ts
import { createDtoMapper } from '@lastshotlabs/bunshot';
import { type ProductDto, ProductSchema } from '../schemas/product';

const toDto = createDtoMapper<ProductDto>(ProductSchema, {
  refs: { category: 'categoryId' }, // ObjectId ref → string, with rename
  dates: ['createdAt'], // Date → ISO string
});

// Use it
const product = await Product.findOne({ _id: id });
return product ? toDto(product) : null;
```

**Auto-handled transforms:**

| Transform         | Description                                                          |
| ----------------- | -------------------------------------------------------------------- |
| `_id` → `id`      | Always converted via `.toString()`                                   |
| `refs`            | ObjectId fields → string (`.toString()`), with DB→API field renaming |
| `dates`           | `Date` objects → ISO strings (`.toISOString()`)                      |
| `subdocs`         | Array fields mapped with a sub-mapper (for nested documents)         |
| nullable/optional | `undefined` → `null` coercion (based on Zod schema)                  |
| everything else   | Passthrough                                                          |

**Subdocument example:**

```ts
const itemToDto = createDtoMapper<TemplateItemDto>(TemplateItemSchema);
const toDto = createDtoMapper<TemplateDto>(TemplateSchema, {
  subdocs: { items: itemToDto },
});
```

---

## Authentication

Bunshot ships a complete auth system: credential login, OAuth social providers (Google, Apple, Microsoft, GitHub, LinkedIn, Twitter/X, GitLab, Slack, Bitbucket), multi-factor authentication (TOTP, email OTP, WebAuthn), magic link / passwordless login, session management, roles, groups, organizations, and security hardening (CSRF, rate limiting, account lockout, credential stuffing detection, bot protection). Everything is opt-in — add only what your app needs.

### How it works

Auth has two independent layers you configure separately:

| Layer             | What it stores                 | Configured via              | Default                      |
| ----------------- | ------------------------------ | --------------------------- | ---------------------------- |
| **Auth adapter**  | Users, passwords, roles        | `auth.adapter` or `db.auth` | MongoDB (`mongoAuthAdapter`) |
| **Session store** | Active sessions (JWT metadata) | `db.sessions`               | Redis                        |

When a user logs in, the auth adapter verifies their credentials and the session store creates a record for the new session. The JWT embeds a `sessionId` claim that ties the token to that record — revoking a session immediately invalidates its token even before the JWT expires.

### Minimum working setup

Enable auth by passing an `auth` block. The routes are mounted automatically.

```ts
await createServer({
  routesDir: import.meta.dir + '/routes',
  plugins: [
    createAuthPlugin({
      auth: {
        roles: ['admin', 'user'],
        defaultRole: 'user',
      },
    }),
  ],
});
```

This mounts `POST /auth/register`, `POST /auth/login`, `POST /auth/logout`, `GET /auth/me`, and the session management endpoints. Users are stored in MongoDB, sessions in Redis.

**Environment variables required:**

```bash
MONGO_URI_DEV=mongodb://localhost:27017/myapp
REDIS_URL_DEV=redis://localhost:6379
JWT_SECRET_DEV=at-least-32-characters-long-secret
```

### Choosing a store

| Setup                   | `db.auth`  | `db.sessions` | When to use                        |
| ----------------------- | ---------- | ------------- | ---------------------------------- |
| Default (Mongo + Redis) | `"mongo"`  | `"redis"`     | Production with both services      |
| Mongo only              | `"mongo"`  | `"mongo"`     | When Redis is unavailable          |
| SQLite                  | `"sqlite"` | `"sqlite"`    | Lightweight deploys, embedded DBs  |
| Memory                  | `"memory"` | `"memory"`    | Tests, local dev — lost on restart |

```ts
// MongoDB sessions instead of Redis
await createServer({
  db: { redis: false, sessions: 'mongo' },
  plugins: [
    createAuthPlugin({
      /* ... */
    }),
  ],
});

// SQLite — single file, no external services
await createServer({
  db: {
    mongo: false,
    redis: false,
    sqlite: import.meta.dir + '/data.db',
    auth: 'sqlite',
    sessions: 'sqlite',
  },
  plugins: [
    createAuthPlugin({
      /* ... */
    }),
  ],
});

// In-memory — great for tests
await createServer({
  db: { mongo: false, redis: false, auth: 'memory', sessions: 'memory' },
  plugins: [
    createAuthPlugin({
      /* ... */
    }),
  ],
});
```

### Protecting routes

```ts
import { requireRole, requireVerifiedEmail, userAuth } from '@lastshotlabs/bunshot';

router.use('/me', userAuth); // 401 if not logged in
router.use('/admin', userAuth, requireRole('admin')); // 403 if wrong role
router.use('/content', userAuth, requireRole('admin', 'editor')); // either role passes
router.use('/settings', userAuth, requireVerifiedEmail); // 403 if email unverified
```

### Feature map

Everything beyond basic credential login is opt-in:

| Feature                              | How to enable                           | Docs                                |
| ------------------------------------ | --------------------------------------- | ----------------------------------- |
| Email verification                   | `auth.emailVerification`                | [Auth Flow](#auth-flow)             |
| Password reset                       | `auth.passwordReset`                    | [Auth Flow](#auth-flow)             |
| Magic link / passwordless            | `auth.magicLink`                        | [Auth Flow](#auth-flow)             |
| Refresh tokens                       | `auth.refreshTokens`                    | [Auth Flow](#auth-flow)             |
| MFA (TOTP, email OTP, WebAuthn)      | `auth.mfa`                              | [Auth Flow](#auth-flow)             |
| Social login (OAuth)                 | `auth.oauth.providers`                  | [Social Login](#social-login-oauth) |
| Roles & groups                       | `auth.roles`, `groups`                  | [Roles](#roles)                     |
| Organizations                        | `organizations`                         | [Auth Flow](#auth-flow)             |
| CSRF protection                      | `security.csrf`                         | [Auth Flow](#auth-flow)             |
| Bot protection                       | `security.botProtection`                | [Auth Flow](#auth-flow)             |
| Account lockout                      | `auth.lockout`                          | [Auth Flow](#auth-flow)             |
| Credential stuffing detection        | `auth.credentialStuffing`               | [Auth Flow](#auth-flow)             |
| Registration enumeration concealment | `auth.concealRegistration`              | [Auth Flow](#auth-flow)             |
| Password history (prevent reuse)     | `auth.passwordPolicy.preventReuse`      | [Auth Flow](#auth-flow)             |
| Reauth / step-up challenges          | `createReauthChallenge`                 | [Auth Flow](#auth-flow)             |
| Security event emission              | `security.events`                       | [Auth Flow](#auth-flow)             |
| Data encryption at rest              | `BUNSHOT_DATA_ENCRYPTION_KEY`           | [Auth Flow](#auth-flow)             |
| Built-in email templates             | `email.templates`                       | [Auth Flow](#auth-flow)             |
| Admin API                            | `admin.api`                             | [Auth Flow](#auth-flow)             |
| Account deletion                     | `auth.accountDeletion`                  | [Auth Flow](#auth-flow)             |
| Account suspension                   | `setSuspended` / `getSuspended`         | [Auth Flow](#auth-flow)             |
| Audit logging                        | `auditLog` middleware / `logAuditEntry` | [Auth Flow](#auth-flow)             |
| Captcha enforcement                  | `requireCaptcha` / `verifyCaptcha`      | [Auth Flow](#auth-flow)             |
| SAML 2.0                             | `auth.saml`                             | [Auth Flow](#auth-flow)             |
| OIDC (OpenID Connect)                | `auth.oidc`                             | [Auth Flow](#auth-flow)             |
| SCIM 2.0 provisioning                | `auth.scim`                             | [Auth Flow](#auth-flow)             |
| M2M / service auth                   | `createM2MClient` / bearer tokens       | [Auth Flow](#auth-flow)             |
| Custom user store                    | `auth.adapter`                          | [Auth Flow](#auth-flow)             |

### Custom auth adapter

The default adapter stores users in MongoDB. Pass `auth.adapter` to use any other store — Postgres, SQLite, an external identity provider, etc. Only implement the methods your app uses:

```ts
import type { AuthAdapter } from '@lastshotlabs/bunshot';

const myAdapter: AuthAdapter = {
  async findByEmail(email) {
    const user = await db.query('SELECT id, passwordHash FROM users WHERE email = $1', [email]);
    return user ?? null;
  },
  async create(email, passwordHash) {
    const [user] = await db.query(
      'INSERT INTO users (email, passwordHash) VALUES ($1, $2) RETURNING id',
      [email, passwordHash],
    );
    return { id: user.id };
  },
  async getRoles(userId) {
    const user = await db.query('SELECT roles FROM users WHERE id = $1', [userId]);
    return user?.roles ?? [];
  },
  async setRoles(userId, roles) {
    await db.query('UPDATE users SET roles = $2 WHERE id = $1', [userId, roles]);
  },
};

await createServer({
  plugins: [
    createAuthPlugin({
      auth: { adapter: myAdapter, roles: ['admin', 'user'], defaultRole: 'user' },
    }),
  ],
});
```

The full adapter interface and all optional methods (OAuth, MFA, tenant roles, groups, etc.) are covered in [Auth Flow](#auth-flow).

### Auth Flow

Sessions are backed by Redis by default. Each login creates an independent session keyed by a UUID (`session:{appName}:{sessionId}`), so multiple devices / tabs can be logged in simultaneously. Set `db.sessions: "mongo"` to store them in MongoDB instead — useful when running without Redis. See [Running without Redis](#running-without-redis).

#### Browser clients

1. `POST /auth/login` → JWT set as HttpOnly cookie automatically
2. All subsequent requests send the cookie — no extra code needed

#### API / non-browser clients

1. `POST /auth/login` → read `token` from response body
2. Send `x-user-token: <token>` header on every request

#### Current user

```
GET /auth/me  →  { userId, email?, emailVerified?, googleLinked? }
```

Requires an active session (cookie or `x-user-token` header). Returns the authenticated user's profile — `email`, `emailVerified`, and `googleLinked` are populated when the auth adapter implements `getUser`.

#### Session management

Each login creates an independent session so multiple devices stay logged in simultaneously. The framework enforces a configurable cap (default: 6) — the oldest session is evicted when the limit is exceeded.

```
GET    /auth/sessions             → [{ sessionId, createdAt, lastActiveAt, expiresAt, ipAddress, userAgent, isActive }]
DELETE /auth/sessions/:sessionId  → revoke a specific session (other sessions unaffected)
POST   /auth/logout               → revoke only the current session
```

Session metadata (IP address, user-agent, timestamps) is persisted even after a session expires when `sessionPolicy.persistSessionMetadata: true` (default). This enables tenant apps to detect logins from novel devices or locations and prompt for MFA or send a security alert.

Set `sessionPolicy.includeInactiveSessions: true` to surface expired/deleted sessions in `GET /auth/sessions` with `isActive: false` — useful for a full device-history UI similar to Google or Meta's account security page.

##### Sliding sessions

Set `sessionPolicy.trackLastActive: true` to update `lastActiveAt` on every authenticated request. This adds one DB write per request but enables a sliding-session experience — sessions that are actively used stay fresh. Pair with refresh tokens (below) for true sliding behavior: short-lived access tokens (15 min) keep authorization tight, while a long-lived refresh token (30 days) lets the client silently renew without re-entering credentials.

#### Refresh Tokens

When configured, login and register return short-lived access tokens (default 15 min) alongside long-lived refresh tokens (default 30 days). The client uses `POST /auth/refresh` to obtain a new access token when the current one expires.

```ts
await createServer({
  plugins: [
    createAuthPlugin({
      auth: {
        refreshTokens: {
          accessTokenExpiry: 900, // seconds, default: 900 (15 min)
          refreshTokenExpiry: 2_592_000, // seconds, default: 2_592_000 (30 days)
          rotationGraceSeconds: 30, // default: 30 — old token still works briefly after rotation
        },
      },
    }),
  ],
});
```

**When not configured**, the existing 7-day JWT behavior is unchanged — fully backward compatible.

##### Endpoints

| Endpoint              | Purpose                                                     |
| --------------------- | ----------------------------------------------------------- |
| `POST /auth/login`    | Returns `token` + `refreshToken`                            |
| `POST /auth/register` | Returns `token` + `refreshToken`                            |
| `POST /auth/refresh`  | Rotates refresh token, returns new `token` + `refreshToken` |

##### Rotation with grace window

On each refresh, the server generates a new refresh token but keeps the old one valid for `rotationGraceSeconds` (default 30s). If the client's network drops mid-refresh, it can safely retry with the old token. If the old token is reused _after_ the grace window, the entire session is invalidated — this is token-family theft detection.

##### Cookie behavior

The refresh token is set as an `HttpOnly` cookie (`refresh_token`) alongside the existing session cookie. For non-browser clients, it is accepted via the `x-refresh-token` header or in the request body. **The refresh token is not returned in the JSON response body** — it is only delivered via the `HttpOnly` cookie to prevent accidental exposure in logs or client-side code.

#### MFA / TOTP

Enable multi-factor authentication with TOTP (Google Authenticator, Authy, etc.):

```ts
await createServer({
  plugins: [
    createAuthPlugin({
      auth: {
        mfa: {
          issuer: 'My App', // shown in authenticator apps (default: app name)
          algorithm: 'SHA1', // default, most compatible
          digits: 6, // default
          period: 30, // seconds, default
          recoveryCodes: 10, // number of recovery codes, default: 10
          challengeTtlSeconds: 300, // MFA challenge window, default: 5 min
        },
      },
    }),
  ],
});
```

Requires `otpauth` peer dependency:

```bash
bun add otpauth
```

##### Endpoints

| Endpoint                        | Auth                 | Purpose                                          |
| ------------------------------- | -------------------- | ------------------------------------------------ |
| `POST /auth/mfa/setup`          | userAuth             | Generate TOTP secret + otpauth URI (for QR code) |
| `POST /auth/mfa/verify-setup`   | userAuth             | Confirm with TOTP code, returns recovery codes   |
| `POST /auth/mfa/verify`         | none (uses mfaToken) | Complete login after password verified           |
| `DELETE /auth/mfa`              | userAuth             | Disable all MFA (requires TOTP code)             |
| `POST /auth/mfa/recovery-codes` | userAuth             | Regenerate codes (requires TOTP code)            |
| `GET /auth/mfa/methods`         | userAuth             | Get enabled MFA methods                          |

##### Login flow with MFA enabled

1. `POST /auth/login` with credentials → password OK + MFA enabled → `{ mfaRequired: true, mfaToken: "...", mfaMethods: ["totp"] }` (no session created)
2. `POST /auth/mfa/verify` with `{ mfaToken, code }` → verifies TOTP or recovery code → creates session → returns normal token response

The verify endpoint accepts an optional `method` field (`"totp"`, `"emailOtp"`, or `"webauthn"`) to target a specific verification method. When omitted, methods are tried automatically based on what was provided (`webauthnResponse` → webauthn, `code` → emailOtp if active else TOTP).

**OAuth logins skip MFA** — the OAuth provider is treated as the second factor.

**Recovery codes**: 10 random 8-character alphanumeric codes, stored as SHA-256 hashes. Each code can only be used once. Enabling a second MFA method regenerates recovery codes — save the new set.

##### Enforcing MFA for all users

By default MFA is opt-in — individual users choose whether to enable it. Set `required: true` to enforce MFA at the app level:

```ts
await createServer({
  plugins: [
    createAuthPlugin({
      auth: {
        mfa: {
          issuer: 'My App',
          required: true, // all authenticated users must complete MFA setup
        },
      },
    }),
  ],
});
```

When `required` is `true`:

- Authenticated users who have **not** completed MFA setup receive a `403` response on all non-auth endpoints:
  ```json
  { "error": "MFA setup required", "code": "MFA_SETUP_REQUIRED" }
  ```
- **Exempt paths** remain accessible so users can complete setup: all `/auth/*` routes (login, logout, register, MFA setup, OAuth, sessions), `/health`, `/docs`, `/openapi.json`, and the root `/`.
- **Unauthenticated requests** pass through normally — the middleware only gates users who are logged in but lack MFA.
- **OAuth users** must also set up MFA — OAuth login creates a session, but the user is still blocked from service endpoints until MFA is configured.
- **Disabling MFA** (via `DELETE /auth/mfa`) when `required: true` immediately blocks the user from service endpoints until they re-enable it.

**Client-side handling:** Check for the `MFA_SETUP_REQUIRED` code in 403 responses and redirect users to an MFA setup page that calls `POST /auth/mfa/setup`.

**Per-route usage:** The `requireMfaSetup` middleware is also exported for apps that want manual, per-route enforcement instead of global:

```ts
import { requireMfaSetup, userAuth } from '@lastshotlabs/bunshot';

router.get('/dashboard', userAuth, requireMfaSetup, c => {
  return c.json({ message: 'Welcome' });
});
```

#### Email OTP

An alternative to TOTP that sends a one-time code to the user's email. Users can enable TOTP, email OTP, or both.

```ts
await createServer({
  plugins: [
    createAuthPlugin({
      auth: {
        mfa: {
          challengeTtlSeconds: 300,
          emailOtp: {
            onSend: async (email, code) => {
              await sendEmail(email, `Your login code: ${code}`);
            },
            codeLength: 6, // default
          },
        },
      },
    }),
  ],
});
```

##### Endpoints

| Endpoint                                | Auth                 | Purpose                                     |
| --------------------------------------- | -------------------- | ------------------------------------------- |
| `POST /auth/mfa/email-otp/enable`       | userAuth             | Send verification code to email             |
| `POST /auth/mfa/email-otp/verify-setup` | userAuth             | Confirm code, enable email OTP              |
| `DELETE /auth/mfa/email-otp`            | userAuth             | Disable email OTP                           |
| `POST /auth/mfa/resend`                 | none (uses mfaToken) | Resend email OTP code (max 3 per challenge) |

##### Setup flow

1. `POST /auth/mfa/email-otp/enable` → sends code to email → returns `{ setupToken }`
2. `POST /auth/mfa/email-otp/verify-setup` with `{ setupToken, code }` → enables email OTP → returns recovery codes

This two-step flow ensures the `onSend` callback actually delivers emails before MFA is activated, preventing lockout from misconfigured email providers.

##### Login flow with email OTP

1. `POST /auth/login` → `{ mfaRequired: true, mfaToken, mfaMethods: ["emailOtp"] }` — code is auto-sent to user's email
2. `POST /auth/mfa/verify` with `{ mfaToken, code }` → creates session
3. If the code didn't arrive: `POST /auth/mfa/resend` with `{ mfaToken }` (max 3 resends, capped at 3x challenge TTL)

##### Disabling email OTP

- If TOTP is also enabled: requires a TOTP code in the `code` field
- If email OTP is the only method: requires the account password in the `password` field
- Disabling the last MFA method turns off MFA entirely

#### WebAuthn / Security Keys

Hardware security keys (YubiKey, etc.) and platform authenticators (Touch ID, Windows Hello) via the WebAuthn/FIDO2 standard. Users can register multiple keys and use them as an MFA method alongside TOTP and email OTP.

```ts
await createServer({
  plugins: [
    createAuthPlugin({
      auth: {
        mfa: {
          webauthn: {
            rpId: 'example.com', // Relying Party ID — your domain
            origin: 'https://example.com', // Expected origin(s)
            rpName: 'My App', // Display name (default: app name)
            userVerification: 'preferred', // "required" | "preferred" | "discouraged"
            timeout: 60000, // Ceremony timeout in ms (default: 60000)
            strictSignCount: false, // Reject when sign count goes backward (default: false — warn only)
          },
        },
      },
    }),
  ],
});
```

Requires `@simplewebauthn/server` peer dependency:

```bash
bun add @simplewebauthn/server
```

If `mfa.webauthn` is configured but the dependency is missing, the server fails fast at startup with a clear error message.

##### Endpoints

| Endpoint                                              | Auth     | Purpose                                                            |
| ----------------------------------------------------- | -------- | ------------------------------------------------------------------ |
| `POST /auth/mfa/webauthn/register-options`            | userAuth | Generate registration options for `navigator.credentials.create()` |
| `POST /auth/mfa/webauthn/register`                    | userAuth | Verify attestation and store credential                            |
| `GET /auth/mfa/webauthn/credentials`                  | userAuth | List registered security keys                                      |
| `DELETE /auth/mfa/webauthn/credentials/:credentialId` | userAuth | Remove a single key                                                |
| `DELETE /auth/mfa/webauthn`                           | userAuth | Disable WebAuthn entirely                                          |

##### Registration flow

1. `POST /auth/mfa/webauthn/register-options` → returns `{ options, registrationToken }`
2. Client passes `options` to `navigator.credentials.create()` — browser prompts user to tap/scan key
3. `POST /auth/mfa/webauthn/register` with `{ registrationToken, attestationResponse, name? }` → stores credential → returns recovery codes

> Credentials are registered with `residentKey: "required"` and `userVerification: "required"`, making them usable as discoverable passkeys. This enables passwordless login via `allowPasswordlessLogin` without requiring users to re-register.

##### Login flow with WebAuthn

1. `POST /auth/login` → `{ mfaRequired: true, mfaToken, mfaMethods: ["webauthn"], webauthnOptions: {...} }`
2. Client passes `webauthnOptions` to `navigator.credentials.get()` — browser prompts for key
3. `POST /auth/mfa/verify` with `{ mfaToken, webauthnResponse: {...} }` → creates session

The `webauthnOptions` object follows the WebAuthn spec — pass it directly to `navigator.credentials.get()`. The `webauthnResponse` is the full result from the browser API.

##### Credential removal

- Removing a spare key (other keys or MFA methods still active): no extra verification needed
- Removing the last credential of the last MFA method: requires TOTP code or password
- `DELETE /auth/mfa/webauthn` (disable all): always requires verification

##### Sign count validation

WebAuthn authenti