Metadata-Version: 2.4
Name: fastapi-m8
Version: 1.6.0
Summary: FastAPI application framework for m8 consumer microservices.
Author-email: Eli Serra <e.serra173@gmail.com>
License:                                  Apache License
                                   Version 2.0, January 2004
                                http://www.apache.org/licenses/
        
           TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
        
           1. Definitions.
        
              "License" shall mean the terms and conditions for use, reproduction,
              and distribution as defined by Sections 1 through 9 of this document.
        
              "Licensor" shall mean the copyright owner or entity authorized by
              the copyright owner that is granting the License.
        
              "Legal Entity" shall mean the union of the acting entity and all
              other entities that control, are controlled by, or are under common
              control with that entity. For the purposes of this definition,
              "control" means (i) the power, direct or indirect, to cause the
              direction or management of such entity, whether by contract or
              otherwise, or (ii) ownership of fifty percent (50%) or more of the
              outstanding shares, or (iii) beneficial ownership of such entity.
        
              "You" (or "Your") shall mean an individual or Legal Entity
              exercising permissions granted by this License.
        
              "Source" form shall mean the preferred form for making modifications,
              including but not limited to software source code, documentation
              source, and configuration files.
        
              "Object" form shall mean any form resulting from mechanical
              transformation or translation of a Source form, including but
              not limited to compiled object code, generated documentation,
              and conversions to other media types.
        
              "Work" shall mean the work of authorship, whether in Source or
              Object form, made available under the License, as indicated by a
              copyright notice that is included in or attached to the work
              (an example is provided in the Appendix below).
        
              "Derivative Works" shall mean any work, whether in Source or Object
              form, that is based on (or derived from) the Work and for which the
              editorial revisions, annotations, elaborations, or other modifications
              represent, as a whole, an original work of authorship. For the purposes
              of this License, Derivative Works shall not include works that remain
              separable from, or merely link (or bind by name) to the interfaces of,
              the Work and Derivative Works thereof.
        
              "Contribution" shall mean any work of authorship, including
              the original version of the Work and any modifications or additions
              to that Work or Derivative Works thereof, that is intentionally
              submitted to Licensor for inclusion in the Work by the copyright owner
              or by an individual or Legal Entity authorized to submit on behalf of
              the copyright owner. For the purposes of this definition, "submitted"
              means any form of electronic, verbal, or written communication sent
              to the Licensor or its representatives, including but not limited to
              communication on electronic mailing lists, source code control systems,
              and issue tracking systems that are managed by, or on behalf of, the
              Licensor for the purpose of discussing and improving the Work, but
              excluding communication that is conspicuously marked or otherwise
              designated in writing by the copyright owner as "Not a Contribution."
        
              "Contributor" shall mean Licensor and any individual or Legal Entity
              on behalf of whom a Contribution has been received by Licensor and
              subsequently incorporated within the Work.
        
           2. Grant of Copyright License. Subject to the terms and conditions of
              this License, each Contributor hereby grants to You a perpetual,
              worldwide, non-exclusive, no-charge, royalty-free, irrevocable
              copyright license to reproduce, prepare Derivative Works of,
              publicly display, publicly perform, sublicense, and distribute the
              Work and such Derivative Works in Source or Object form.
        
           3. Grant of Patent License. Subject to the terms and conditions of
              this License, each Contributor hereby grants to You a perpetual,
              worldwide, non-exclusive, no-charge, royalty-free, irrevocable
              (except as stated in this section) patent license to make, have made,
              use, offer to sell, sell, import, and otherwise transfer the Work,
              where such license applies only to those patent claims licensable
              by such Contributor that are necessarily infringed by their
              Contribution(s) alone or by combination of their Contribution(s)
              with the Work to which such Contribution(s) was submitted. If You
              institute patent litigation against any entity (including a
              cross-claim or counterclaim in a lawsuit) alleging that the Work
              or a Contribution incorporated within the Work constitutes direct
              or contributory patent infringement, then any patent licenses
              granted to You under this License for that Work shall terminate
              as of the date such litigation is filed.
        
           4. Redistribution. You may reproduce and distribute copies of the
              Work or Derivative Works thereof in any medium, with or without
              modifications, and in Source or Object form, provided that You
              meet the following conditions:
        
              (a) You must give any other recipients of the Work or
                  Derivative Works a copy of this License; and
        
              (b) You must cause any modified files to carry prominent notices
                  stating that You changed the files; and
        
              (c) You must retain, in the Source form of any Derivative Works
                  that You distribute, all copyright, patent, trademark, and
                  attribution notices from the Source form of the Work,
                  excluding those notices that do not pertain to any part of
                  the Derivative Works; and
        
              (d) If the Work includes a "NOTICE" text file as part of its
                  distribution, then any Derivative Works that You distribute must
                  include a readable copy of the attribution notices contained
                  within such NOTICE file, excluding those notices that do not
                  pertain to any part of the Derivative Works, in at least one
                  of the following places: within a NOTICE text file distributed
                  as part of the Derivative Works; within the Source form or
                  documentation, if provided along with the Derivative Works; or,
                  within a display generated by the Derivative Works, if and
                  wherever such third-party notices normally appear. The contents
                  of the NOTICE file are for informational purposes only and
                  do not modify the License. You may add Your own attribution
                  notices within Derivative Works that You distribute, alongside
                  or as an addendum to the NOTICE text from the Work, provided
                  that such additional attribution notices cannot be construed
                  as modifying the License.
        
              You may add Your own copyright statement to Your modifications and
              may provide additional or different license terms and conditions
              for use, reproduction, or distribution of Your modifications, or
              for any such Derivative Works as a whole, provided Your use,
              reproduction, and distribution of the Work otherwise complies with
              the conditions stated in this License.
        
           5. Submission of Contributions. Unless You explicitly state otherwise,
              any Contribution intentionally submitted for inclusion in the Work
              by You to the Licensor shall be under the terms and conditions of
              this License, without any additional terms or conditions.
              Notwithstanding the above, nothing herein shall supersede or modify
              the terms of any separate license agreement you may have executed
              with Licensor regarding such Contributions.
        
           6. Trademarks. This License does not grant permission to use the trade
              names, trademarks, service marks, or product names of the Licensor,
              except as required for reasonable and customary use in describing the
              origin of the Work and reproducing the content of the NOTICE file.
        
           7. Disclaimer of Warranty. Unless required by applicable law or
              agreed to in writing, Licensor provides the Work (and each
              Contributor provides its Contributions) on an "AS IS" BASIS,
              WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
              implied, including, without limitation, any warranties or conditions
              of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
              PARTICULAR PURPOSE. You are solely responsible for determining the
              appropriateness of using or redistributing the Work and assume any
              risks associated with Your exercise of permissions under this License.
        
           8. Limitation of Liability. In no event and under no legal theory,
              whether in tort (including negligence), contract, or otherwise,
              unless required by applicable law (such as deliberate and grossly
              negligent acts) or agreed to in writing, shall any Contributor be
              liable to You for damages, including any direct, indirect, special,
              incidental, or consequential damages of any character arising as a
              result of this License or out of the use or inability to use the
              Work (including but not limited to damages for loss of goodwill,
              work stoppage, computer failure or malfunction, or any and all
              other commercial damages or losses), even if such Contributor
              has been advised of the possibility of such damages.
        
           9. Accepting Warranty or Additional Liability. While redistributing
              the Work or Derivative Works thereof, You may choose to offer,
              and charge a fee for, acceptance of support, warranty, indemnity,
              or other liability obligations and/or rights consistent with this
              License. However, in accepting such obligations, You may act only
              on Your own behalf and on Your sole responsibility, not on behalf
              of any other Contributor, and only if You agree to indemnify,
              defend, and hold each Contributor harmless for any liability
              incurred by, or claims asserted against, such Contributor by reason
              of your accepting any such warranty or additional liability.
        
           END OF TERMS AND CONDITIONS
        
           APPENDIX: How to apply the Apache License to your work.
        
              To apply the Apache License to your work, attach the following
              boilerplate notice, with the fields enclosed by brackets "[]"
              replaced with your own identifying information. (Don't include
              the brackets!)  The text should be enclosed in the appropriate
              comment syntax for the file format. We also recommend that a
              file or class name and description of purpose be included on the
              same "printed page" as the copyright notice for easier
              identification within third-party archives.
        
           Copyright [yyyy] [name of copyright owner]
        
           Licensed under the Apache License, Version 2.0 (the "License");
           you may not use this file except in compliance with the License.
           You may obtain a copy of the License at
        
               http://www.apache.org/licenses/LICENSE-2.0
        
           Unless required by applicable law or agreed to in writing, software
           distributed under the License is distributed on an "AS IS" BASIS,
           WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
           See the License for the specific language governing permissions and
           limitations under the License.
License-File: LICENSE
Keywords: auth,fastapi,health,jwt,microservices
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.11
Requires-Dist: anyio>=4.0
Requires-Dist: auth-sdk-m8[config,events,fastapi,observability,security]<2.0.0,>=1.3.0
Requires-Dist: fastapi>=0.136.3
Requires-Dist: httpx>=0.27.0
Requires-Dist: packaging>=24.0
Provides-Extra: all
Requires-Dist: alembic; extra == 'all'
Requires-Dist: psycopg2-binary>=2.9.0; extra == 'all'
Requires-Dist: pymysql>=1.2.0; extra == 'all'
Requires-Dist: sqlalchemy>=2.0; extra == 'all'
Requires-Dist: sqlmodel>=0.0.21; extra == 'all'
Provides-Extra: db
Requires-Dist: alembic; extra == 'db'
Requires-Dist: sqlalchemy>=2.0; extra == 'db'
Requires-Dist: sqlmodel>=0.0.21; extra == 'db'
Provides-Extra: dev
Requires-Dist: alembic; extra == 'dev'
Requires-Dist: anyio[trio]>=4.0; extra == 'dev'
Requires-Dist: asgi-lifespan>=2.0; extra == 'dev'
Requires-Dist: bandit>=1.8.0; extra == 'dev'
Requires-Dist: coverage>=7.0; extra == 'dev'
Requires-Dist: httpx; extra == 'dev'
Requires-Dist: mypy>=1.14.0; extra == 'dev'
Requires-Dist: pip-audit>=2.7.3; extra == 'dev'
Requires-Dist: psycopg2-binary>=2.9.0; extra == 'dev'
Requires-Dist: pymysql>=1.2.0; extra == 'dev'
Requires-Dist: pytest-anyio>=0.0.0; extra == 'dev'
Requires-Dist: pytest-cov>=6.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.9.0; extra == 'dev'
Requires-Dist: sqlalchemy>=2.0; extra == 'dev'
Requires-Dist: sqlmodel>=0.0.21; extra == 'dev'
Requires-Dist: tenacity>=9.0; extra == 'dev'
Provides-Extra: mysql
Requires-Dist: pymysql>=1.2.0; extra == 'mysql'
Provides-Extra: postgres
Requires-Dist: psycopg2-binary>=2.9.0; extra == 'postgres'
Description-Content-Type: text/markdown

# fastapi-m8

![CI/CD](https://github.com/mano8/fastapi-m8/actions/workflows/CI.yaml/badge.svg?branch=main)
[![PyPI version](https://img.shields.io/pypi/v/fastapi-m8)](https://pypi.org/project/fastapi-m8/)
[![Python](https://img.shields.io/pypi/pyversions/fastapi-m8)](https://pypi.org/project/fastapi-m8/)
[![PyPI Downloads](https://static.pepy.tech/personalized-badge/fastapi-m8?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/fastapi-m8)
[![codecov](https://codecov.io/gh/mano8/fastapi-m8/graph/badge.svg?token=TF6OGIHOGF)](https://codecov.io/gh/mano8/fastapi-m8)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/8b8e9726b0f8441ea480902ea8910812)](https://app.codacy.com/gh/mano8/fastapi-m8/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)

FastAPI application framework for building consumer microservices that integrate with
[fa-auth-m8](../fa-auth-m8). It wires authentication, CORS, health checks, observability,
and database lifecycle into a single `create_app()` call, removing ~90 % of the setup
boilerplate from every consumer service.

---

## Table of Contents

1. [Summary](#summary)
2. [Architecture & Package Roles](#architecture--package-roles)
3. [Installation](#installation)
4. [Quick Start](#quick-start)
5. [Configuration Reference](#configuration-reference)
6. [API Reference](#api-reference)
   - [create_app()](#create_app)
   - [ConsumerServiceSettings](#consumerservicesettings)
   - [build_auth_deps()](#build_auth_deps)
   - [create_db_engine()](#create_db_engine)
   - [Health Checks](#health-checks)
7. [Authentication](#authentication)
   - [Token Modes](#token-modes)
   - [Role System](#role-system)
   - [Protecting Routes](#protecting-routes)
8. [Health Endpoint](#health-endpoint)
9. [Database Integration](#database-integration)
10. [Pre-Start Script](#pre-start-script)
11. [Complete Example](#complete-example)
12. [Testing](#testing)
13. [Compatibility](#compatibility)

---

## Summary

`fastapi-m8` is a thin application factory layer that sits on top of FastAPI and
[auth-sdk-m8](../auth-sdk-m8). You bring a settings object, a router, and optional
health checks; the framework wires the rest.

**What it provides:**

| Capability | How |
|---|---|
| JWT validation | `build_auth_deps()` + `auth-sdk-m8` validator |
| Role-based access control | `AuthDeps.get_current_active_admin / _superuser` |
| Token revocation (stateful mode) | `RemoteRevocationClient` → `fa-auth-m8` private API |
| CORS | Auto-wired from `settings.ALLOWED_ORIGINS` |
| Metrics middleware | Optional; toggled via `METRICS_ENABLED` |
| Health endpoint | `GET {API_PREFIX}/health/` with optional detail gating |
| Database lifecycle | `create_db_engine()` wrapping SQLAlchemy |
| Startup validation | `startup_validators` list runs before app signals ready |
| Lifespan management | Auth teardown + DB pool dispose on shutdown |

**What it is NOT:**

- Not an auth issuer — that role belongs to `fa-auth-m8`.
- Not a business logic framework — it only provides plumbing and dependency injection.

---

## Architecture & Package Roles

```
┌───────────────────────────────────────────────────────────────┐
│  Your consumer service  (uses fastapi-m8)                     │
│                                                               │
│  create_app(settings, router,                                 │
│      health=HealthConfig(checks=[...]),                       │
│      lifecycle=AppLifecycle(auth_deps=auth, ...))            │
│  ├─ ConsumerServiceSettings ← auth-sdk-m8 CommonSettings     │
│  ├─ build_auth_deps(settings)                                 │
│  │   ├─ TokenValidator (local JWT check, auth-sdk-m8)        │
│  │   └─ RemoteRevocationClient (stateful only, HTTP)          │
│  └─ auto-wired: CORS · metrics · health · lifespan           │
└────────────────────────┬──────────────────────────────────────┘
                         │ Authorization: Bearer <JWT>
                         │ (stateful) POST /private/v1/jti-status
                         ▼
┌───────────────────────────────────────────────────────────────┐
│  fa-auth-m8  (auth_user_service)                              │
│                                                               │
│  POST /user/login/access-token   → issues JWT pair           │
│  POST /user/login/refresh-token/ → rotates tokens            │
│  POST /private/v1/jti-status     → revocation check          │
│                                                               │
│  Backing stores: MySQL / PostgreSQL · Redis                   │
└───────────────────────────────────────────────────────────────┘
```

**Three packages, three responsibilities:**

| Package | Role |
|---|---|
| `fa-auth-m8` | Issues and revokes JWT tokens, manages users and sessions |
| `auth-sdk-m8` | Shared schemas, JWT validation, settings base classes (read-only) |
| `fastapi-m8` | Wires `auth-sdk-m8` into a FastAPI consumer service |

---

## Installation

```bash
# Minimal (no database)
pip install fastapi-m8

# With PostgreSQL
pip install "fastapi-m8[postgres]"

# With MySQL
pip install "fastapi-m8[mysql]"

# With database (driver-agnostic, you choose the driver)
pip install "fastapi-m8[db]"

# Everything
pip install "fastapi-m8[all]"
```

**Runtime requirements:** Python 3.11+

---

## Quick Start

### 1 — Settings

```python
# app/core/config.py
from pathlib import Path
from pydantic_settings import SettingsConfigDict
from fastapi_m8 import ConsumerServiceSettings

class Settings(ConsumerServiceSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
    )

settings = Settings()
```

### 2 — Auth & DB dependencies

```python
# app/core/deps.py
from fastapi_m8 import build_auth_deps, create_db_engine
from app.core.config import settings

auth = build_auth_deps(settings)
engine = create_db_engine(settings)
```

### 3 — Routes

```python
# app/api/items.py
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlmodel import Session
from app.core.deps import auth, engine

router = APIRouter(prefix="/items", tags=["items"])
SessionDep = Annotated[Session, Depends(engine.session_dep)]

@router.get("/")
async def list_items(user: auth.CurrentUser, session: SessionDep):
    return {"owner": user.email}
```

### 4 — App factory

```python
# app/main.py
from fastapi import APIRouter
from fastapi_m8 import (
    AppLifecycle, HealthConfig, create_app, HealthCheckResult, HealthStatus,
)
from sqlmodel import select
from app.core.config import settings
from app.core.deps import auth, engine
from app.api.items import router as items_router

async def check_db() -> HealthCheckResult:
    try:
        with engine.session() as s:
            s.exec(select(1))
        return HealthCheckResult.from_bool("database", True)
    except Exception as exc:
        return HealthCheckResult(name="database", status=HealthStatus.FAIL, error=str(exc))

api_router = APIRouter()
api_router.include_router(items_router)

app = create_app(
    settings,
    api_router,
    service_name="Item Service",
    service_version="1.0.0",
    health=HealthConfig(checks=[check_db]),
    lifecycle=AppLifecycle(auth_deps=auth, db_engine=engine),
)
```

### 5 — `.env`

```ini
DOMAIN=localhost
ENVIRONMENT=local
PROJECT_NAME=Item Service
STACK_NAME=local
API_PREFIX=/api
AUTH_PREFIX=/auth
BACKEND_HOST=http://localhost:8000
FRONTEND_HOST=http://localhost:3000
BACKEND_CORS_ORIGINS=http://localhost:3000

# Token signing — must match fa-auth-m8 (secure-by-default: RS256 + JWKS)
# ACCESS_TOKEN_ALGORITHM defaults to RS256; supply the issuer's public key.
ACCESS_PUBLIC_KEY_FILE=/opt/keys/access_public.pem
# JWKS_URI=https://auth.example.com/.well-known/jwks.json   # zero-downtime rotation
REFRESH_SECRET_KEY=change-me-refresh-32-chars-min

# Strict iss/aud binding is ON by default — both are required at boot.
TOKEN_ISSUER=https://auth.example.com
TOKEN_AUDIENCE=item-service
# Opt out for single-service/dev deployments (then HS256 + ACCESS_SECRET_KEY is enough):
# TOKEN_STRICT_VALIDATION=false
# ACCESS_TOKEN_ALGORITHM=HS256
# ACCESS_SECRET_KEY=change-me-32-chars-minimum

# Event signing is ON by default — SSE payloads from fa-auth are HMAC-signed.
# DEV-ONLY placeholder — replace with the same value set on fa-auth in staging/production.
EVENT_SIGNING_KEY=DEV-ONLY-do-not-use-event-signing-key-Aa1!
# EVENT_SIGNING_ENABLED=false   # opt out only if signing is also disabled on fa-auth

TOKEN_MODE=stateless
AUTH_SERVICE_ROLE=consumer

# Host validation — set in production to prevent host-header injection
# ALLOWED_HOSTS=api.example.com

# Docs gating — docs are auto-disabled in production (ENVIRONMENT=production)
# unless SERVE_DOCS_IN_PRODUCTION=true (opt-in for public APIs)
# SERVE_DOCS_IN_PRODUCTION=false

# Database
DB_HOST=localhost
DB_PORT=5432
DB_DATABASE=items_db
DB_USER=app_user
DB_PASSWORD=secret
```

Run with:

```bash
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```

---

## Configuration Reference

All settings inherit from `auth-sdk-m8`'s `CommonSettings`. Every field maps 1:1 to an
environment variable.

### Core / Network

| Variable | Required | Default | Description |
|---|---|---|---|
| `DOMAIN` | Yes | — | Public domain, e.g. `localhost` |
| `ENVIRONMENT` | Yes | — | `local` \| `development` \| `staging` \| `production` |
| `PROJECT_NAME` | Yes | — | Human-readable service name (shown in docs) |
| `STACK_NAME` | Yes | — | Docker Compose stack slug |
| `API_PREFIX` | Yes | — | URL prefix for this service's routes, e.g. `/api` |
| `AUTH_PREFIX` | No | `/auth` | Auth endpoint prefix (consumer services) |
| `BACKEND_HOST` | Yes | — | Full backend URL, e.g. `http://127.0.0.1:8000` |
| `FRONTEND_HOST` | Yes | — | Full frontend URL |
| `BACKEND_CORS_ORIGINS` | Yes | — | Comma-separated allowed origins |

### Tokens & Cryptography

| Variable | Required | Default | Description |
|---|---|---|---|
| `TOKEN_MODE` | No | `stateful` | `stateless` \| `hybrid` \| `stateful` (see [Token Modes](#token-modes)) |
| `AUTH_SERVICE_ROLE` | No | `issuer` | Set to `consumer` in all consumer services |
| `ACCESS_TOKEN_ALGORITHM` | No | `RS256` | `RS256` (default, asymmetric/JWKS) \| `ES256` \| `HS256` (opt-in shared secret) |
| `ACCESS_PUBLIC_KEY_FILE` | RS256/ES256 | — | Path to PEM public key file (consumer validation) |
| `JWKS_URI` | RS256/ES256 alt | — | JWKS endpoint URL (auto-fetches and caches public keys; zero-downtime rotation) |
| `JWKS_CACHE_TTL_SECONDS` | No | `300` | JWKS key cache TTL in seconds |
| `ACCESS_SECRET_KEY` | HS256 only | — | Shared symmetric signing key (≥ 32 chars) — required only when opting into HS256 |
| `REFRESH_SECRET_KEY` | Yes | — | Refresh token signing key (always HS256, internal) |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | No | `30` | Access token lifetime |
| `REFRESH_TOKEN_EXPIRE_MINUTES` | No | `120` | Refresh token lifetime |
| `TOKEN_STRICT_VALIDATION` | No | `true` | Secure-by-default: enforce `iss`/`aud` binding; **requires `TOKEN_ISSUER` + `TOKEN_AUDIENCE` at boot**. Set `false` for single-service/dev. |
| `TOKEN_ISSUER` | Yes¹ | — | Expected `iss` claim. Required at boot under strict validation. |
| `TOKEN_AUDIENCE` | Yes¹ | — | Expected `aud` claim (this service). Required at boot under strict validation. |
| `EVENT_SIGNING_ENABLED` | No | `true` | Secure-by-default: HMAC-sign SSE event payloads. Set `false` to disable. |
| `EVENT_SIGNING_KEY` | Yes² | — | Shared HMAC secret for SSE payload verification. Must match fa-auth. Required at boot unless `EVENT_SIGNING_ENABLED=false`. |

¹ Required unless `TOKEN_STRICT_VALIDATION=false`. ² Required unless `EVENT_SIGNING_ENABLED=false`.

> **Secure-by-default (auth-sdk-m8 ≥ 1.0.0):** access tokens default to **RS256** and
> validation enforces **`iss`/`aud` binding**, so a factory-built app rejects
> wrong-audience / wrong-issuer tokens out of the box. Operators who need shared-secret
> signing opt back in with `ACCESS_TOKEN_ALGORITHM=HS256` + `ACCESS_SECRET_KEY`; those
> without cross-service boundaries relax binding with `TOKEN_STRICT_VALIDATION=false`.

### Stateful Mode (consumer → auth service)

Required only when `TOKEN_MODE=stateful` and `AUTH_SERVICE_ROLE=consumer`.

| Variable | Required | Default | Description |
|---|---|---|---|
| `INTROSPECTION_URL` | Yes | — | `POST` endpoint on auth service for JTI revocation checks, e.g. `http://auth_user_service:8000/user/private/v1/jti-status` |
| `PRIVATE_API_SECRET` | Yes | — | Shared secret for `X-Internal-Token` header (must match auth service) |

### Auth Event Stream (fa-auth SSE bridge)

An **optional, best-effort accelerator** for cache eviction. `fa-auth-m8` bridges its
own auth-state events (`session-revoked`, `user-deleted`) to consumers over an
authenticated Server-Sent Events stream on the **existing private API** — the same
trust channel (`INTROSPECTION_URL` + `PRIVATE_API_SECRET`) already used for JTI checks.
No second Redis, no broker: consumers speak HTTPS to fa-auth, which they already do.

> The stream is **not** the revocation authority — the JTI blacklist behind
> `INTROSPECTION_URL` is. A consumer that misses every event is still correct, just
> slower to evict caches. Stream loss is non-fatal; the service keeps running on the
> HTTP authority path alone.

Wire it in your lifespan with `build_event_stream_client`, which constructs the SDK's
`AuthEventStreamClient` from your settings (no SDK internals needed):

```python
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi_m8 import build_event_stream_client, AuthStreamEvent


async def on_auth_event(event: AuthStreamEvent) -> None:
    # session-revoked / user-deleted → evict the affected entry from local caches.
    ...


async def on_gap() -> None:
    # Unresumable stream (fa-auth restarted / buffer evicted) → flush ALL caches.
    ...


@asynccontextmanager
async def lifespan(app: FastAPI):
    client = build_event_stream_client(
        settings,
        on_event=on_auth_event,
        on_gap=on_gap,
    )
    client.start()
    try:
        yield
    finally:
        await client.stop()
```

The client verifies every payload's HMAC signature with `EVENT_SIGNING_KEY` (must match
fa-auth), auto-reconnects with jittered backoff, resumes via `Last-Event-ID`, and **never
raises into the host app**. Requires `TOKEN_MODE=stateful` so `INTROSPECTION_URL` and
`PRIVATE_API_SECRET` are present. Behind a reverse proxy, disable response buffering on
the stream endpoint so events and heartbeats pass through promptly.

| Variable | Required | Default | Description |
|---|---|---|---|
| `EVENT_STREAM_CONNECT_TIMEOUT` | No | `5` | Seconds to wait for the initial SSE connection (factory arg). |
| `EVENT_STREAM_READ_TIMEOUT` | No | `60` | Seconds to wait between SSE frames; keep above fa-auth's heartbeat interval (default 15 s). |

### Database

| Variable | Required | Default | Description |
|---|---|---|---|
| `SELECTED_DB` | No | `Mysql` | `Mysql` \| `Postgres` |
| `DB_HOST` | Yes | — | Database host |
| `DB_PORT` | Yes | — | Database port |
| `DB_DATABASE` | Yes | — | Database name |
| `DB_USER` | Yes | — | Database user |
| `DB_PASSWORD` | Yes | — | Database password |
| `TABLES_PREFIX` | No | `app` | Table name prefix |

### Redis

Required when `TOKEN_MODE=stateful` or `hybrid` on the **issuer** side. Consumer services
do not connect to Redis directly.

| Variable | Description |
|---|---|
| `REDIS_HOST` | Redis host |
| `REDIS_PORT` | Redis port |
| `REDIS_USER` | Redis username |
| `REDIS_PASSWORD` | Redis password |
| `REDIS_SSL` | Enable TLS (`true`/`false`, default `false`) |

### Observability

| Variable | Default | Description |
|---|---|---|
| `METRICS_ENABLED` | `false` | Enable Prometheus metrics middleware |
| `METRICS_GROUPS` | — | Comma-separated groups: `traffic`, `performance`, `reliability`, `health`, `auth`, or `all` |

### OpenAPI / Docs

| Variable | Default | Description |
|---|---|---|
| `SET_OPEN_API` | `true` | Expose `/openapi.json` (gated off in production unless `SERVE_DOCS_IN_PRODUCTION=true`) |
| `SET_DOCS` | `true` | Expose Swagger UI (gated off in production unless `SERVE_DOCS_IN_PRODUCTION=true`) |
| `SET_REDOC` | `true` | Expose ReDoc (gated off in production unless `SERVE_DOCS_IN_PRODUCTION=true`) |
| `SERVE_DOCS_IN_PRODUCTION` | `false` | Set `true` to explicitly re-enable docs in production (e.g. public/open-source APIs). Requires `auth-sdk-m8>=0.7.3`. |

> **Production docs gating (secure-by-default):** when `ENVIRONMENT=production` (or
> `STRICT_PRODUCTION_MODE=true`), all three doc endpoints are disabled regardless of the
> `SET_*` flags, unless `SERVE_DOCS_IN_PRODUCTION=true` is set. Non-production environments
> are unaffected.

### Security / Host Validation

| Variable | Default | Description |
|---|---|---|
| `ALLOWED_HOSTS` | `` (empty) | Comma-separated list of allowed `Host` headers, e.g. `api.example.com,localhost`. Empty = no restriction (permissive, suitable for dev). Set in production to prevent host-header injection. |

> **TrustedHostMiddleware:** when `ALLOWED_HOSTS` is non-empty, `fastapi-m8` registers
> Starlette's `TrustedHostMiddleware`. Requests with a `Host` header not in the list
> are rejected with HTTP 400. `testserver` is automatically added in non-production so
> pytest's `TestClient` works without extra configuration.

### Response Security Headers

`create_app` wires the response-hardening layer from
`auth-sdk-m8` (`auth_sdk_m8.security.headers.add_security_headers_middleware`, tiered
since `auth-sdk-m8 ≥ 1.2.1`). Headers are applied in three tiers — the browser-persisted
HSTS and CSP are now **express opt-in** rather than inferred from the production gate:

| Tier | Headers | When applied |
|---|---|---|
| Always-on | `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` | Every environment (when `SECURITY_HEADERS_ENABLED`) — safe for Swagger/ReDoc/HMR |
| Production-gated | `Referrer-Policy`, `Permissions-Policy` | `ENVIRONMENT=production` or `STRICT_PRODUCTION_MODE=true` |
| Express opt-in | `Strict-Transport-Security`, `Content-Security-Policy` | `HSTS_ENABLED` / `CONTENT_SECURITY_POLICY_ENABLED` — **never on `local`**, even when opted in |

The opt-in tier is decoupled from the production gate, so a TLS-terminated `staging` stack
can enable HSTS/CSP without masquerading as production. They are hard-blocked on
`ENVIRONMENT=local` because HSTS is browser-persisted and would poison the localhost HTTPS
cache for `HSTS_MAX_AGE` seconds (a real risk when a production-configured build is run
locally before deploy).

| Variable | Default | Description |
|---|---|---|
| `SECURITY_HEADERS_ENABLED` | `true` | Master switch; set `false` to suppress every tier |
| `HSTS_ENABLED` | `false` | Opt in to `Strict-Transport-Security` (never emitted on `local`) |
| `HSTS_MAX_AGE` | `31536000` | HSTS max-age in seconds; `0` drops the HSTS header even when enabled |
| `HSTS_INCLUDE_SUBDOMAINS` | `true` | Append `includeSubDomains` to HSTS |
| `CONTENT_SECURITY_POLICY_ENABLED` | `false` | Opt in to `Content-Security-Policy` (never emitted on `local`) |
| `CONTENT_SECURITY_POLICY` | *(hardened default)* | Override the emitted `Content-Security-Policy` |
| `REFERRER_POLICY` | `strict-origin-when-cross-origin` | `Referrer-Policy` value (production-gated) |
| `PERMISSIONS_POLICY` | *(restrictive default)* | `Permissions-Policy` value (production-gated) |

> **Behaviour change (since 1.5.0 / auth-sdk-m8 1.2.1):** HSTS and CSP were emitted
> automatically in production before; they are now **off until explicitly enabled**. To
> restore the previous behaviour set `HSTS_ENABLED=true` and `CONTENT_SECURITY_POLICY_ENABLED=true`.

> These knobs are inherited from `CommonSettings`; consumer services do not redeclare
> them. The same layer is shared by `fa-auth-m8` and every consumer.

---

## API Reference

### `create_app()`

```python
from fastapi_m8 import create_app, HealthConfig, AppLifecycle

app = create_app(
    settings: ConsumerServiceSettings,
    router: APIRouter,
    *,
    service_name: str | None = None,
    service_version: str | None = None,
    health: HealthConfig | None = None,
    lifecycle: AppLifecycle | None = None,
) -> FastAPI
```

**Parameters:**

| Parameter | Description |
|---|---|
| `settings` | Service settings object (subclass of `ConsumerServiceSettings`) |
| `router` | Your domain `APIRouter` (all routes are mounted under this) |
| `service_name` | Overrides `settings.PROJECT_NAME` in health detail response |
| `service_version` | Reported in health detail response |
| `health` | `HealthConfig` dataclass (checks, timeout, policy, detail options, cache TTL) |
| `lifecycle` | `AppLifecycle` dataclass (auth_deps, db_engine, startup_validators, configure, lifespan_extras) |

**`HealthConfig` fields:**

| Field | Default | Description |
|---|---|---|
| `checks` | `None` | List of async callables returning `HealthCheckResult` |
| `timeout` | `0.5` | Per-check timeout in seconds |
| `policy` | `LENIENT` | `LENIENT` or `STRICT` — controls when 503 is returned |
| `detail_public` | `False` | Expose per-check detail without authentication |
| `detail_authorizer` | `None` | Custom async callable; receives `Request`, returns `bool` |
| `cache_ttl` | `2.0` | Seconds to cache health results |

**`AppLifecycle` fields:**

| Field | Default | Description |
|---|---|---|
| `auth_deps` | `None` | Output of `build_auth_deps()`. Closed on shutdown |
| `db_engine` | `None` | Output of `create_db_engine()`. Disposed on shutdown |
| `startup_validators` | `None` | Async callables run before app signals ready; raise to abort |
| `configure` | `None` | Callback receiving the raw `FastAPI` instance for custom middleware |
| `lifespan_extras` | `None` | Async context manager run inside the managed lifespan |

**Lifespan sequence:**

1. Run `lifecycle.startup_validators` — raise any exception to prevent ready signal.
2. Enter `lifecycle.lifespan_extras` context (if provided).
3. Set `app.state.service_ready = True`.
4. *(app serves traffic)*
5. Exit `lifecycle.lifespan_extras`.
6. Call `lifecycle.auth_deps.close()` (closes revocation HTTP client).
7. Call `lifecycle.db_engine.dispose()` (closes connection pool).

---

### `ConsumerServiceSettings`

```python
from fastapi_m8 import ConsumerServiceSettings
```

Base settings class. Subclass it and configure `model_config` for your `.env` file.

```python
from pydantic_settings import SettingsConfigDict
from fastapi_m8 import ConsumerServiceSettings

class Settings(ConsumerServiceSettings):
    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

settings = Settings()

# Useful computed properties (inherited from auth-sdk-m8)
settings.is_stateless        # bool
settings.is_stateful         # bool
settings.ALLOWED_ORIGINS     # list[str] — derived from BACKEND_CORS_ORIGINS
settings.SQLALCHEMY_DATABASE_URI  # str — assembled from DB_* fields
```

---

### `build_auth_deps()`

```python
from fastapi_m8 import build_auth_deps, AuthDeps

auth: AuthDeps = build_auth_deps(settings)
```

Returns a frozen dataclass with everything needed for route protection.

| Field | Type | Description |
|---|---|---|
| `auth.CurrentUser` | `Annotated[UserModel, Depends(...)]` | Inject authenticated user into routes |
| `auth.get_current_user` | `async Callable` | FastAPI dependency; validates JWT, checks revocation |
| `auth.get_current_active_admin` | `Callable` | Raises 403 unless user has ADMIN or SUPERADMIN role |
| `auth.get_current_active_superuser` | `Callable` | Raises 403 unless user has SUPERADMIN role and `is_superuser=True` |
| `auth.revocation_client` | `RemoteRevocationClient \| None` | Present only in stateful mode |

**`UserModel` fields available in routes:**

| Field | Type | Description |
|---|---|---|
| `id` | `uuid.UUID` | User primary key |
| `email` | `str` | User email |
| `full_name` | `str \| None` | Display name |
| `role` | `RoleType` | `USER` \| `READER` \| `WRITER` \| `ADMIN` \| `SUPERADMIN` |
| `is_active` | `bool` | Account active flag |
| `is_superuser` | `bool` | Superuser flag |
| `email_verified` | `bool` | Email verification status |

---

### `create_db_engine()`

```python
from fastapi_m8 import create_db_engine, DbEngine

engine: DbEngine = create_db_engine(settings)
```

Wraps SQLAlchemy engine assembled from `settings.SQLALCHEMY_DATABASE_URI`.

| Method | Description |
|---|---|
| `engine.session()` | Context manager yielding a `Session` |
| `engine.session_dep()` | FastAPI dependency (use with `Depends`) |
| `engine.dispose()` | Closes connection pool (called automatically on shutdown) |

```python
from typing import Annotated
from fastapi import Depends
from sqlmodel import Session

SessionDep = Annotated[Session, Depends(engine.session_dep)]

@router.post("/items")
async def create_item(session: SessionDep, item: ItemCreate):
    session.add(Item.model_validate(item))
    session.commit()
```

---

### Health Checks

Implement the `HealthCheck` protocol — any async callable returning `HealthCheckResult`.

```python
from fastapi_m8 import HealthCheck, HealthCheckResult, HealthStatus

# Function-based
async def check_database() -> HealthCheckResult:
    try:
        with engine.session() as s:
            s.exec(select(1))
        return HealthCheckResult.from_bool("database", True)
    except Exception as exc:
        return HealthCheckResult(name="database", status=HealthStatus.FAIL, error=str(exc))

# Class-based (useful when state is needed)
class RedisCheck:
    def __init__(self, client):
        self._client = client

    async def __call__(self) -> HealthCheckResult:
        try:
            await self._client.ping()
            return HealthCheckResult.from_bool("redis", True)
        except Exception as exc:
            return HealthCheckResult(name="redis", status=HealthStatus.FAIL, error=str(exc))
```

`HealthCheckResult` fields:

| Field | Type | Description |
|---|---|---|
| `name` | `str` | Check identifier |
| `status` | `HealthStatus` | `ok` \| `degraded` \| `fail` \| `unknown` |
| `latency_ms` | `float \| None` | Auto-populated by the health subsystem |
| `error` | `str \| None` | Error message (credentials automatically scrubbed) |
| `meta` | `dict \| None` | Arbitrary metadata (sensitive keys auto-redacted) |
| `ok` | `bool` | Computed: `True` when status is `ok` |

`HealthAggregatePolicy`:

| Value | HTTP 503 when |
|---|---|
| `LENIENT` (default) | Any check is `fail` |
| `STRICT` | Any check is `fail` or `unknown` |

---

## Authentication

### Token Modes

Configured via `TOKEN_MODE` on **both** the auth service and all consumer services.
The value must match across the stack.

| Mode | Access token revocation | Requires Redis (issuer) | Google OAuth |
|---|---|---|---|
| `stateless` | None (waits for expiry) | No | No |
| `hybrid` | None for access; refresh is allowlisted | Yes | Yes |
| `stateful` | Immediate, via JTI introspection | Yes | Yes |

**Stateless** — maximum scalability, simplest setup. Logout does not invalidate
in-flight access tokens; they expire naturally.

**Stateful** — highest security. On each request a consumer performs an HTTP call to
`fa-auth-m8` to verify the JWT's JTI has not been revoked. Requires `INTROSPECTION_URL`
and `PRIVATE_API_SECRET` in consumer settings.

**Algorithm options:**

| Algorithm | Key config | Use case |
|---|---|---|
| `RS256` *(default)* | `ACCESS_PUBLIC_KEY_FILE` or `JWKS_URI` | Multi-service; consumers need only the public key |
| `ES256` | `ACCESS_PUBLIC_KEY_FILE` or `JWKS_URI` | Same as RS256, smaller keys |
| `HS256` *(opt-in)* | `ACCESS_SECRET_KEY` (symmetric, shared) | Simple single-service or trusted internal network |

Since `auth-sdk-m8 ≥ 1.0.0`, **RS256 is the default**; choose `HS256` explicitly via
`ACCESS_TOKEN_ALGORITHM=HS256`. With `JWKS_URI` set, the consumer fetches and caches the
public key automatically, refreshing on unknown `kid` headers.

---

### Role System

Roles are hierarchical. Higher roles include all permissions of lower roles.

```
SUPERADMIN > ADMIN > WRITER > READER > USER
```

| Role | Typical use |
|---|---|
| `SUPERADMIN` | Full platform access, user management |
| `ADMIN` | Administrative operations within a service |
| `WRITER` | Create and update resources |
| `READER` | Read-only access |
| `USER` | Base authenticated user |

---

### Protecting Routes

```python
from fastapi import APIRouter, Depends
from typing import Annotated
from app.core.deps import auth

router = APIRouter()

# Any authenticated user
@router.get("/profile")
async def get_profile(user: auth.CurrentUser):
    return {"id": user.id, "email": user.email, "role": user.role}

# ADMIN or SUPERADMIN
@router.delete("/users/{user_id}")
async def delete_user(
    user_id: int,
    admin: Annotated[UserModel, Depends(auth.get_current_active_admin)],
):
    ...

# SUPERADMIN only
@router.post("/admin/bootstrap")
async def bootstrap(
    su: Annotated[UserModel, Depends(auth.get_current_active_superuser)],
):
    ...
```

Unauthorized requests receive:

- `401 Unauthorized` — missing or invalid token
- `403 Forbidden` — valid token but insufficient role

---

## Health Endpoint

Mounted automatically at `GET {API_PREFIX}/health/` (e.g. `/api/health/`).

**Before app is ready** (during startup validators):
```json
HTTP 503
{"status": "initializing", "ready": false}
```

**After ready — public response:**
```json
HTTP 200   (or 503 if any check is "fail")
{"status": "ok"}
```

**After ready — authorized response** (with `X-Internal-Token` header or custom authorizer):
```json
HTTP 200
{
  "status": "ok",
  "checks": [
    {"name": "database", "status": "ok", "latency_ms": 3.2, "error": null, "ok": true}
  ],
  "service": "Item Service",
  "version": "1.0.0",
  "fastapi_m8": "1.3.0",
  "auth_sdk_m8": "1.1.x"
}
```

**Authorization options:**

```python
from fastapi_m8 import create_app, HealthConfig

# Option A — built-in X-Internal-Token (requires PRIVATE_API_SECRET in settings)
app = create_app(settings, router, health=HealthConfig(checks=[check_db]))
# Pass header: X-Internal-Token: <PRIVATE_API_SECRET>

# Option B — always public
app = create_app(settings, router, health=HealthConfig(checks=[check_db], detail_public=True))

# Option C — custom authorizer
async def is_internal(request: Request) -> bool:
    return request.client.host == "10.0.0.1"

app = create_app(
    settings,
    router,
    health=HealthConfig(checks=[check_db], detail_authorizer=is_internal),
)
```

---

## Database Integration

Install the appropriate extra:

```bash
pip install "fastapi-m8[postgres]"   # psycopg2-binary
pip install "fastapi-m8[mysql]"      # pymysql
```

Configure in `.env`:

```ini
SELECTED_DB=Postgres
DB_HOST=db
DB_PORT=5432
DB_DATABASE=my_app
DB_USER=app
DB_PASSWORD=secret
TABLES_PREFIX=app
```

`SQLALCHEMY_DATABASE_URI` is assembled automatically. You can also set it directly
to override the assembly.

Define models with `TimestampMixin` from `auth-sdk-m8` (adds `created_at` /
`updated_at` UTC columns):

```python
import uuid
from sqlmodel import SQLModel, Field
from auth_sdk_m8.models.shared import TimestampMixin

class Item(TimestampMixin, SQLModel, table=True):
    __tablename__ = "app_items"

    id: int | None = Field(default=None, primary_key=True)
    name: str
    owner_id: uuid.UUID   # references the authenticated user's UUID id
```

---

## Pre-Start Script

A CLI script that blocks until the database is reachable. Use it as a container
init step to prevent your app from starting before the database is ready.

```bash
# Installed entry point
fastapi-m8-prestart

# Or directly
python -m fastapi_m8.scripts.pre_start
```

The script expects `app.core.deps.engine` to be a `DbEngine` instance. It retries
`SELECT 1` up to 300 times with 5-second intervals, then exits. If the module is not
found or `engine` is not a `DbEngine`, it exits gracefully.

**Dockerfile:**

```dockerfile
RUN pip install "fastapi-m8[postgres]"
CMD fastapi-m8-prestart && uvicorn app.main:app --host 0.0.0.0 --port 8000
```

---

## Complete Example

```
my_service/
├── app/
│   ├── core/
│   │   ├── config.py      # Settings subclass
│   │   └── deps.py        # auth + engine singletons
│   ├── api/
│   │   └── items.py       # Domain router
│   └── main.py            # create_app() entry point
├── .env
└── pyproject.toml
```

**`app/core/config.py`**
```python
from pydantic_settings import SettingsConfigDict
from fastapi_m8 import ConsumerServiceSettings

class Settings(ConsumerServiceSettings):
    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

settings = Settings()
```

**`app/core/deps.py`**
```python
from fastapi_m8 import build_auth_deps, create_db_engine
from app.core.config import settings

auth = build_auth_deps(settings)
engine = create_db_engine(settings)
```

**`app/api/items.py`**
```python
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from app.core.deps import auth, engine

router = APIRouter(prefix="/items", tags=["items"])
SessionDep = Annotated[Session, Depends(engine.session_dep)]

@router.get("/")
async def list_items(user: auth.CurrentUser, session: SessionDep):
    return {"owner": user.email}

@router.delete("/{item_id}/admin")
async def delete_item(
    item_id: int,
    admin: Annotated[object, Depends(auth.get_current_active_admin)],
    session: SessionDep,
):
    return {"deleted": item_id}
```

**`app/main.py`**
```python
from fastapi import APIRouter
from sqlmodel import select
from fastapi_m8 import (
    AppLifecycle, HealthConfig, create_app, HealthCheckResult, HealthStatus,
)
from app.core.config import settings
from app.core.deps import auth, engine
from app.api.items import router as items_router

async def check_db() -> HealthCheckResult:
    try:
        with engine.session() as s:
            s.exec(select(1))
        return HealthCheckResult.from_bool("database", True)
    except Exception as exc:
        return HealthCheckResult(name="database", status=HealthStatus.FAIL, error=str(exc))

api_router = APIRouter()
api_router.include_router(items_router)

app = create_app(
    settings,
    api_router,
    service_name="Item Service",
    service_version="1.0.0",
    health=HealthConfig(checks=[check_db]),
    lifecycle=AppLifecycle(auth_deps=auth, db_engine=engine),
)
```

---

## Testing

Override settings to avoid reading `.env` files in tests:

```python
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from pydantic_settings import SettingsConfigDict
from fastapi_m8 import ConsumerServiceSettings, create_app

class TestSettings(ConsumerServiceSettings):
    model_config = SettingsConfigDict(env_file=None)  # no file — all from kwargs

@pytest.fixture()
def settings():
    return TestSettings(
        DOMAIN="localhost",
        ENVIRONMENT="local",
        PROJECT_NAME="test",
        STACK_NAME="test",
        API_PREFIX="/api",
        BACKEND_HOST="http://localhost:8000",
        FRONTEND_HOST="http://localhost:3000",
        BACKEND_CORS_ORIGINS="http://localhost:3000",
        ACCESS_SECRET_KEY="x" * 32,
        REFRESH_SECRET_KEY="y" * 32,
        TOKEN_MODE="stateless",
        AUTH_SERVICE_ROLE="consumer",
        DB_HOST="localhost",
        DB_PORT=5432,
        DB_DATABASE="test",
        DB_USER="test",
        DB_PASSWORD="test",
    )

@pytest.fixture()
def client(settings):
    from fastapi import APIRouter
    router = APIRouter()
    app = create_app(settings, router)
    return TestClient(app)
```

Use `anyio` for async tests (required by CLAUDE.md):

```python
import pytest
import anyio

@pytest.mark.anyio
async def test_health(client):
    response = client.get("/api/health/")
    assert response.status_code == 200
```

---

## Compatibility

| `fastapi-m8` | `auth-sdk-m8` | Python |
|---|---|---|
| `1.3.0` | `>=1.1.0, <2.0.0` | 3.11, 3.12, 3.13 |
| `1.2.0` | `>=1.0.0, <2.0.0` | 3.11, 3.12, 3.13 |
| `1.1.4` | `>=0.7.3, <0.8.0` | 3.11, 3.12, 3.13 |
| `1.1.0–1.1.3` | `>=0.7.1, <0.8.0` | 3.11, 3.12, 3.13 |
| `1.0.x` | `>=0.7.0, <0.8.0` | 3.11, 3.12, 3.13 |

The compatibility matrix is enforced at startup via `COMPAT_MATRIX`. A
`RuntimeError` is raised immediately if the installed `auth-sdk-m8` version is
outside the supported range.

Check at runtime:

```python
from fastapi_m8 import CAPABILITIES, __version__

print(__version__)          # "1.3.0"
print(CAPABILITIES)         # {"async": False, "db_optional": True, ...}
```

`create_async_app()` is a planned API stub for v2.0. Calling it raises
`NotImplementedError`.
