feather-framework 0.9.0
An opinionated, batteries-included web framework for building production applications with AI.
Requires Python: >=3.11
Keywords: flask, web framework, server-first, progressive enhancement, islands architecture, ai-friendly

<h1 align="center">
  <img src="https://raw.githubusercontent.com/RolandFlyBoy/Feather/main/feather/static/favicon.svg" alt="" width="64" height="64" style="vertical-align: middle;">
  Feather
</h1>

### What is Feather?

Feather is a full-stack web framework built on proven technologies: **Flask** for the backend, **Tailwind CSS** for styling, **HTMX** for dynamic interactions, and **vanilla JavaScript** for complex client-side behavior.

Built with and optimized for [Claude Code](https://claude.ai/code), though it works with any AI coding assistant. Each project includes a `CLAUDE.md` that gives AI assistants the context they need to follow framework conventions.

### What's Included

Feather provides production-ready infrastructure so you can focus on your application:

| Feature | Options |
|---------|---------|
| **Authentication** | Google OAuth with session management, approval workflow |
| **User Management** | Admin panel for approvals, roles, suspension |
| **Multi-Tenancy** | Domain-based or individual tenants (B2B+B2C) |
| **Background Jobs** | Thread pool with concurrency control, or RQ (Redis) |
| **Caching** | Memory or Redis |
| **File Storage** | Local filesystem or Google Cloud Storage |
| **Email** | Resend for transactional emails |
| **Dark Mode** | Cookie-persisted toggle on every page, including admin |
| **Security Headers** | CSP, HSTS, X-Frame-Options, Referrer-Policy (production) |
| **Rate Limiting** | In-memory (or Redis for distributed) |
| **Events** | Pub/sub with sync and async listeners |
| **Error Logging** | Database-backed, tenant-scoped |
| **Health Checks** | `/health`, `/health/live`, `/health/ready` |
| **Request Tracking** | Unique request IDs, JSON logging |

All features are optional and can be enabled during project creation or added later.

### Why Feather?

Python has a long history in web development. Flask and Django powered countless applications through the 2010s. Then the SPA revolution happened—React, Vue, Angular—and suddenly "modern" web development meant writing Python APIs that served JSON to JavaScript frontends.

That split created a gap. Python developers who wanted full-stack productivity had two choices: adopt the JavaScript ecosystem entirely, or stick with Django's monolithic approach that hadn't evolved much for the new era. Meanwhile, Ruby developers had Rails with Hotwire, PHP developers had Laravel with Livewire—both frameworks that embraced server-rendering while adding modern interactivity.

Feather fills that gap for Python. It's a full-stack framework that gives you authentication, admin panels, file storage, background jobs, and a component system out of the box. The frontend uses server-rendered HTML enhanced with HTMX and small JavaScript islands—no virtual DOM, no hydration, no "use client" confusion.

**How other frameworks approach this:**

- **Rails** and **Laravel** pioneered the batteries-included philosophy. They handle auth, database migrations, background jobs, and asset compilation in one cohesive package. Feather takes the same approach but uses Python and modern tooling (Vite 7, Tailwind CSS, HTMX).

- **Next.js** brought React to the server with excellent developer experience. But you're still managing React's complexity—state management, hydration mismatches, deciding what runs where. Feather sidesteps this by keeping JavaScript minimal and optional.

- **Django** remains powerful but feels heavyweight for many projects. Its template language is limiting, the admin is rigid, and adding modern frontend tooling requires significant configuration.

The real unlock is combining good conventions with AI assistance. Feather's predictable patterns—where files go, how services work, what components look like—mean you can describe what you want and get working code. A feature that might take a day of wiring up authentication, writing migrations, building UI, and handling edge cases can be done in a focused session.

Feather is opinionated about its defaults: Google OAuth for auth, Tailwind for styling, PostgreSQL for production data. These choices reduce decision fatigue and let you ship faster. That said, the abstractions are designed to be extensible—the storage backend interface works with local files or GCS, the job queue can run in-process or on Redis, and you can swap in other providers as your needs evolve.

### How the Frontend Works

Feather uses a three-layer approach to building UIs, each solving a different problem:

**Components** are server-rendered Jinja2 macros—similar to Rails view components, Laravel Blade components, or React Server Components. They're reusable pieces of UI (buttons, cards, modals) that render to HTML on the server. No JavaScript, no hydration, just HTML and CSS. You use them like `{{ button("Save", variant="primary") }}`.

**HTMX** handles server interactions without page reloads. If you've used Hotwire/Turbo in Rails or Livewire in Laravel, it's the same idea. Click a button, HTMX makes an HTTP request, the server returns HTML, HTMX swaps it into the page. It replaces most of what you'd use React + fetch for—forms, search, pagination, like buttons—without writing JavaScript. Think of it as server-side rendering with surgical DOM updates.

**Islands** are small JavaScript components for genuinely interactive UI that needs client-side state. The name comes from Astro's Islands Architecture—most of the page is static HTML, with small "islands" of interactivity. Use them for things like drag-and-drop, audio players, or real-time updates where round-tripping to the server would feel sluggish. They're similar to writing a small React component, but without React's runtime overhead.

The mental model: start with Components for everything static, reach for HTMX when you need server data without a page reload, and only use Islands when you genuinely need client-side state. In practice, 90% of features can be built with just Components and HTMX.

---

## Getting Started

### Prerequisites

**Core requirements (all apps):**

- **Python 3.10+** — the runtime
- **Node.js 22+** — for Vite 7 (build tooling) and Tailwind CSS
- **pipx** — for installing the Feather CLI globally

**Simple apps** (no auth, prototypes, internal tools):

- **SQLite** — works out of the box, no setup required

**Production apps** (auth, multi-tenant, background jobs):

- **PostgreSQL** — required for multi-tenant apps, recommended for anything with auth
- **Google Cloud credentials** — for OAuth (free tier works fine)
- **Redis** (optional) — for distributed caching and persistent job queues
- **Google Cloud Storage** (optional) — for file uploads in production
- **Resend** (optional) — for transactional emails

### Installation

**From PyPI (recommended):**
```bash
pip install feather-framework
```

**Or with pipx (isolated environment):**
```bash
brew install pipx && pipx ensurepath  # if you don't have pipx
pipx install feather-framework
```

**For development (contributing to Feather):**
```bash
git clone https://github.com/RolandFlyBoy/Feather.git
cd Feather
pipx install -e .
feather test --framework  # run framework tests
```

This installs the `feather` CLI. You can now run `feather new` from any directory.

### Quick Start

#### 1. Create a New Project

```bash
feather new myapp
```

You'll be prompted for app type first:

| App Type | Database | Auth | Description |
|----------|----------|------|-------------|
| `simple` (default) | Ask (default: none) | No | Static pages, minimal setup |
| `single-tenant` | Ask (default: SQLite) | Yes | One organization, user accounts |
| `multi-tenant` | PostgreSQL (required) | Yes | Multiple organizations (SaaS) |

During scaffolding, you'll be asked about optional features:

- **Background jobs** — thread pool by default, optionally Redis
- **Auto-approve users** — immediately activate new signups (authenticated apps only)
- **Caching** — memory cache for development, optionally Redis for production
- **File storage** — local filesystem for development, optionally GCS for production
- **Email** — Resend for transactional emails (authenticated apps only)
- **Display name field** — optional `display_name` field on User model (authenticated apps only)
- **Admin email** — creates your initial admin user (authenticated apps only)

#### 2. Initialize and Run

```bash
cd myapp
source venv/bin/activate

# Set up database (migrations are manual so you can review models first)
feather db migrate -m "Initial migration"
feather db upgrade
python seeds.py  # Creates admin user if auth enabled

# Start dev server
feather dev
```

Open http://localhost:5173 — Vite handles frontend assets with HMR, Flask runs on port 5000 behind the proxy. CSS and JS changes are instant; template and Python changes trigger a reload.

**Note:** If using background jobs with the thread backend, set `FLASK_DEBUG=0` in `.env`. The Flask reloader kills background threads on file changes. Use `JOB_BACKEND=sync` during development if you need debug mode.

Every Feather project includes a `CLAUDE.md` guide that helps AI assistants understand the framework's patterns and conventions. It's a starting point—add your own project-specific context, domain rules, or coding preferences as your app grows.

### Project Structure

```
myapp/
├── app.py                    # Entry point
├── config.py                 # Configuration classes
├── seeds.py                  # Initial data (if auth enabled)
├── .env                      # Environment variables
├── package.json              # Node dependencies (Vite, Tailwind)
├── vite.config.js            # Build configuration
├── models/                   # SQLAlchemy models (auto-discovered)
├── services/                 # Business logic (auto-discovered)
├── routes/
│   ├── api/                  # API routes → /api/*
│   └── pages/                # Page routes → /*
├── templates/
│   ├── base.html             # Base layout with HTMX/Vite
│   ├── components/           # Custom/override components
│   ├── partials/             # HTMX response fragments
│   └── pages/                # Full page templates
├── static/
│   ├── css/app.css           # Tailwind entry point
│   ├── js/app.js             # Shared JavaScript
│   └── islands/              # Interactive JS components
├── tests/                    # Test files
└── migrations/               # Alembic migrations
```

**Framework-provided** (served from `/feather-static/`, auto-update with Feather upgrades):
- Components: `button`, `card`, `modal`, `input`, `alert`, `icon`, `dropdown`
- JS: `api.js` (CSRF-aware fetch), `feather.js` (Islands runtime)

Override any component by creating your own version in `templates/components/`.

---

## UI Architecture

The concepts are explained in [How the Frontend Works](#how-the-frontend-works). This section is a quick reference.

### Components

```html
{% from "components/button.html" import button %}
{% from "components/icon.html" import icon %}

{{ button("Save", type="submit") }}
{{ button("Delete", variant="danger", icon=icon("delete", size="sm")) }}
```

**Available:** `button`, `card`, `modal`, `input`, `textarea`, `alert`, `icon`, `dropdown`, `confirm_modal`, `prompt_modal`, `toast`

### HTMX

```html
<button hx-post="/api/posts/123/like" hx-swap="outerHTML">Like (5)</button>
```

```python
@api.post("/posts/<post_id>/like")
def like_post(post_id):
    post = Post.query.get_or_404(post_id)
    post.toggle_like(current_user)
    return render_template("partials/like_button.html", post=post, liked=True)
```

**Cross-element updates** — use `HX-Trigger` header to fire events that other elements listen for:
```python
response = make_response(render_template('partials/todo.html', todo=todo))
response.headers['HX-Trigger'] = 'todosUpdated'
return response
```

```html
<div hx-get="/htmx/stats" hx-trigger="load, todosUpdated from:body">
```

**Built-in modals:** `hx-confirm="Delete?"` for confirmations, `window.showPrompt({...})` for input.

### Islands

```javascript
island("counter", {
  persist: true,
  state: { count: 0 },
  actions: {
    increment() { this.state.count++; },
    decrement() { this.state.count--; }
  },
  render(state) {
    return { ".count": state.count };
  }
});
```

```html
<div data-island="counter">
    <button data-action="decrement">-</button>
    <span class="count">0</span>
    <button data-action="increment">+</button>
</div>
```

**Optimistic updates:**
```javascript
await this.optimistic(
  () => { this.state.liked = true; },  // Instant UI update
  () => api.post(`/posts/${this.data.id}/like`)  // Rolls back on failure
);
```

**Drag-drop:** Built-in via `draggable` config — see [CLAUDE.md](CLAUDE.md) for full API.

### Icons

[Google Material Icons](https://fonts.google.com/icons): `{{ icon("home") }}`, `{{ icon("settings", size="lg") }}`

Sizes: `sm` (18px), `md` (24px), `lg` (36px), `xl` (48px)

### Dark Mode

Every scaffolded app includes a dark mode toggle that persists across pages via a `dm` cookie. The toggle is in the header of every page, including the admin panel.

**How it works:**
- A `dark-mode.js` script (loaded in `<head>`) reads the `dm` cookie and applies a `.dark` class to `<html>` before first render — no flash of wrong theme
- Clicking any element with `data-toggle-dark-mode` toggles the class and updates the cookie
- All CSS uses `dark:` variants via Tailwind's custom variant: `@custom-variant dark (&:where(.dark, .dark *))`

**Toggle button (scaffolded in templates):**
```html
<button data-toggle-dark-mode class="dark-mode-toggle" title="Toggle dark mode">
    <span class="material-symbols-outlined icon-light">bedtime</span>
    <span class="material-symbols-outlined icon-dark">sunny</span>
</button>
```

**CSS classes (in `app.css`):**
```css
.dark-mode-toggle .icon-light { @apply dark:hidden; }
.dark-mode-toggle .icon-dark  { @apply hidden dark:inline; }
```

When adding custom styles, include `dark:` variants for every color-related class. Feather recommends CSS classes with `@apply` rather than inline Tailwind, so dark mode support looks like:

```css
.my-card {
    @apply bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100
           border border-gray-200 dark:border-gray-700;
}
```

---

## Backend

### Routes

Routes handle HTTP requests. Feather auto-discovers routes in `routes/api/` and `routes/pages/`.

```python
# routes/api/users.py
from feather import api, auth_required, inject
from services import UserService

@api.get('/users')
@inject(UserService)
def list_users(user_service):
    return {'users': user_service.list_all()}

@api.post('/users')
@auth_required
@inject(UserService)
def create_user(user_service, email: str, username: str):
    user = user_service.create(email=email, username=username)
    return {'user': user}, 201
```

**Route prefixes:**
- `routes/api/*.py` → `/api/*`
- `routes/pages/*.py` → `/*`

### Models

Models define your database schema using SQLAlchemy with helpful mixins:

```python
# models/post.py
from feather.db import db, Model
from feather.db.mixins import UUIDMixin, TimestampMixin, SoftDeleteMixin

class Post(UUIDMixin, TimestampMixin, SoftDeleteMixin, Model):
    __tablename__ = 'posts'

    title = db.Column(db.String(255), nullable=False)
    content = db.Column(db.Text)
    author_id = db.Column(db.String(36), db.ForeignKey('users.id'))
```

**Mixins:**
| Mixin | Provides |
|-------|----------|
| `UUIDMixin` | `id` (auto-generated UUID) |
| `TimestampMixin` | `created_at`, `updated_at` |
| `SoftDeleteMixin` | `soft_delete()`, `restore()`, `query_active()` |
| `OrderingMixin` | `move_to()`, `move_above()`, `query_ordered()` |
| `TenantScopedMixin` | `tenant_id`, `for_tenant()` |

**OrderingMixin** for drag-drop:
```python
class Card(UUIDMixin, TimestampMixin, OrderingMixin, Model):
    __tablename__ = 'cards'
    __ordering_scope__ = ['column_id']  # Position is per-column

    title = db.Column(db.String(200))
    column_id = db.Column(db.String(36), db.ForeignKey('columns.id'))

# Reorder
card.move_to(0)           # Move to top
card.move_above(other)    # Move above another card
Card.query_ordered(column_id=col.id).all()
```

### Schema Design: Separating Users, Accounts, and Subscriptions

A common mistake when building SaaS apps is putting everything on the User model—subscription status, quotas, assets, preferences. This creates problems:

- **Family/team sharing impossible** — subscriptions are locked to one person
- **Profile switching breaks** — can't have separate preferences per context
- **Billing gets messy** — hard to transfer subscriptions or handle corporate accounts

**The better pattern:** separate authentication (User) from content ownership (Account) from billing (Subscription).

```
┌─────────┐     ┌─────────────┐     ┌─────────────┐
│  User   │────▶│ AccountUser │◀────│   Account   │
│ (auth)  │     │   (role)    │     │  (content)  │
└─────────┘     └─────────────┘     └──────┬──────┘
                                           │
                                    ┌──────▼──────┐
                                    │Subscription │
                                    │  (billing)  │
                                    └─────────────┘
```

**User** — authentication identity only:
```python
class User(UserMixin, Model):
    email = db.Column(db.String(255), unique=True)  # OAuth identity
    stripe_customer_id = db.Column(db.String(255))  # For billing portal
    # NO subscription_status, NO quota, NO content here
```

**Account** — where content and quotas live (like Netflix profiles):
```python
class Account(Model):
    name = db.Column(db.String(100))               # "Family", "Work", etc.
    owner_user_id = db.Column(db.ForeignKey("users.id"))
    quota = db.Column(db.Integer, default=0)       # Usage limits here
    # Projects, documents, assets belong to Account, not User
```

**AccountUser** — many-to-many with roles:
```python
class AccountUser(Model):
    user_id = db.Column(db.ForeignKey("users.id"), primary_key=True)
    account_id = db.Column(db.ForeignKey("accounts.id"), primary_key=True)
    role = db.Column(db.String(20))  # "admin", "member", "child"
```

**Subscription** — billing state attached to Account:
```python
class Subscription(Model):
    account_id = db.Column(db.ForeignKey("accounts.id"))
    stripe_subscription_id = db.Column(db.String(255))
    status = db.Column(db.String(50))  # "active", "canceled", etc.
    tier_name = db.Column(db.String(50))  # "Basic", "Pro", "Enterprise"
```

**Benefits:**
- One user can access multiple accounts (personal + work)
- Multiple users can share one account (family plan)
- Subscriptions transfer cleanly when ownership changes
- Content queries are scoped to Account, not scattered across Users
- Easy to add team features later without schema changes

**When to use this pattern:** Any app with subscriptions, quotas, shared resources, or where users might want separate "workspaces" or "profiles."

### Services

Services contain business logic. Keep routes thin, services fat.

```python
# services/user_service.py
from feather import Service, transactional
from feather.exceptions import ValidationError, ConflictError
from feather.db import paginate
from models import User

class UserService(Service):
    @transactional  # Auto-commits on success, rollbacks on exception
    def create(self, email: str, username: str) -> User:
        if not email or '@' not in email:
            raise ValidationError('Valid email required', field='email')

        if User.query.filter_by(email=email).first():
            raise ConflictError('Email already registered')

        user = User(email=email, username=username)
        self.db.add(user)
        return user

    def list_paginated(self, page: int = 1, per_page: int = 20):
        query = User.query.order_by(User.created_at.desc())
        return paginate(query, page=page, per_page=per_page)
```

**Singleton services** for expensive initialization:
```python
from feather.services import singleton, Service

@singleton
class CacheService(Service):
    def __init__(self):
        super().__init__()
        self.cache = {}  # Shared across all requests
```

### Exceptions

Exception classes that automatically convert to JSON responses:

```python
from feather.exceptions import (
    ValidationError,        # 400 - Invalid input
    AuthenticationError,    # 401 - Not logged in
    AuthorizationError,     # 403 - No permission
    AccountPendingError,    # 403 - Account awaiting approval (redirects to /account/pending)
    AccountSuspendedError,  # 403 - Account suspended (redirects to /account/suspended)
    NotFoundError,          # 404 - Resource not found
    ConflictError,          # 409 - Already exists
)

# Throws:
raise ValidationError('Email is required', field='email')

# Returns:
# {"success": false, "error": {"code": "VALIDATION_ERROR", "message": "Email is required"}}
```

**Account status exceptions:** `AccountPendingError` and `AccountSuspendedError` inherit from `AuthorizationError` but trigger redirects to dedicated status pages instead of generic 403 errors. They're raised automatically by `@auth_required` based on the user's `active` and `approved_at` fields.

---

## Features

### Authentication

Feather uses **Google OAuth** for authentication—no passwords to store, no signup forms to build. The same flow handles both login and registration: users click "Sign in with Google", authorize the app, and Feather creates their account if it doesn't exist. This eliminates the entire signup/login/forgot-password complexity that traditional auth requires.

While Google OAuth is the default, the architecture can be extended for other OAuth providers (GitHub, Microsoft, etc.) by adding additional blueprints.

**User approval workflows:**

When users first authenticate, Feather can either auto-approve them immediately or hold them for admin review:

| Workflow | CLI Option | Best For |
|----------|------------|----------|
| Auto-approve | `Auto-approve new user signups?` → Yes | Consumer apps, open registration |
| Manual approval | `Auto-approve new user signups?` → No (default) | Internal tools, B2B apps, invite-only |

**Manual approval (default)** — new users are created in suspended state and see a "pending approval" page until an admin approves them via the admin panel. This prevents drive-by signups and gives you explicit control over who uses your application.

**Auto-approve** — new users are automatically activated on first login. When you select this during scaffolding, Feather sets `AUTO_APPROVE_USERS = True` in your `config.py` and the framework handles the rest — no callback files or env vars needed.

**Converting existing apps:** To switch from manual to auto-approve, add `AUTO_APPROVE_USERS = True` to your `config.py`.

**Configuration:**
```bash
# .env
GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret

# Session settings (optional)
SESSION_LIFETIME_DAYS=7        # Default: 7
REMEMBER_COOKIE_DAYS=365       # Default: 365
SESSION_PROTECTION=basic       # Options: None, basic, strong
```

**Setup:**
1. Create credentials at [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
2. Add redirect URI: `http://localhost:5173/auth/google/callback` (dev) or your production URL
3. Add credentials to `.env`
4. Run `python seeds.py` to create your admin user

**Seeds** (`seeds.py`) populate initial data in your database. The scaffolded version creates your admin user with the email you provided during `feather new`. Extend it for your own initial data:

```python
# seeds.py
def seed():
    # Admin user (scaffolded)
    admin = User(email=ADMIN_EMAIL, role="admin", active=True)
    db.session.add(admin)

    # Add your seed data here
    default_categories = ["General", "Support", "Billing"]
    for name in default_categories:
        db.session.add(Category(name=name))

    db.session.commit()
```

Run seeds anytime with `python seeds.py` or `feather db seed`. The scaffolded seed is idempotent—it updates existing users rather than creating duplicates.

**Routes:**
| Route | Description |
|-------|-------------|
| `/auth/google/login` | Start OAuth flow |
| `/auth/google/callback` | OAuth callback (automatic) |
| `/auth/logout` | End session |

**Usage:**
```html
<a href="/auth/google/login">Sign in with Google</a>
<a href="/auth/logout">Sign out</a>
```

**Auth decorators:**
```python
from feather import auth_required, admin_required, role_required, login_only
from feather.auth import permission_required, platform_admin_required

@api.get('/me')
@auth_required  # Any authenticated + approved user
def get_profile():
    return {'user': current_user.to_dict()}

@page.get('/account/pending')
@login_only  # Authenticated but may be pending/suspended
def account_pending():
    return render_template('pages/account/pending.html')

@api.delete('/users/<id>')
@admin_required  # Tenant admin (role="admin")
def delete_user(id):
    pass

@api.post('/articles')
@role_required('editor')  # Specific role (admin inherits all)
def create_article():
    pass

@api.post('/tenants')
@platform_admin_required  # Cross-tenant operations
def create_tenant():
    pass
```

**Roles** — these defaults cover most apps, but you can add, remove, or rename them:

| Role | Purpose | Inherits |
|------|---------|----------|
| `user` | Basic access (default for new users) | — |
| `editor` | Content creation | `user` |
| `moderator` | Content moderation | `user` |
| `admin` | Tenant administration | all roles |

Roles inherit permissions: `@role_required('editor')` allows both editors and admins.

**To customize roles**, edit the hierarchy in `feather/auth/roles.py`:
```python
# Add a new role
ROLE_INHERITS = {
    "admin": {"admin", "editor", "moderator", "reviewer", "user"},
    "editor": {"editor", "user"},
    "moderator": {"moderator", "user"},
    "reviewer": {"reviewer", "user"},  # New role
    "user": {"user"},
}
```

Then use it in routes: `@role_required('reviewer')`. The User model's `role` field is a simple string—no migration needed when adding roles.

**Permissions** — CRUD-based access control that maps to roles:

| Permission | Who Has It | Use Case |
|------------|------------|----------|
| `resources.read` | all roles | View data |
| `resources.create` | editor, admin | Create content |
| `resources.update` | editor, admin | Edit content |
| `resources.manage` | moderator, admin | Moderation actions |
| `resources.delete` | admin only | Delete content |
| `*` | admin only | All permissions |

```python
from feather.auth import permission_required

@api.get('/articles')
@permission_required('resources.read')  # All authenticated users
def list_articles():
    pass

@api.post('/articles')
@permission_required('resources.create')  # Editors and admins
def create_article():
    pass

@api.delete('/articles/<id>')
@permission_required('resources.delete')  # Admins only
def delete_article(id):
    pass
```

**When to use which:**
- `@auth_required` — any logged-in, approved user
- `@login_only` — authenticated but may be pending/suspended (for status pages, account setup)
- `@role_required('editor')` — check by role name (with inheritance)
- `@permission_required('resources.create')` — check by action (more semantic)
- `@admin_required` — shorthand for `@role_required('admin')`

Permissions are defined in `feather/auth/permissions.py` and can be extended like roles.

#### Approval Workflow Pages

When users are pending approval or suspended, they're automatically redirected to dedicated pages instead of seeing generic error messages:

| State | Redirect | Description |
|-------|----------|-------------|
| Pending | `/account/pending` | New user awaiting admin approval |
| Suspended | `/account/suspended` | Previously approved, now deactivated |

These pages are scaffolded with friendly messages and logout buttons. They use `@login_only` so users remain authenticated while seeing their account status.

**Customizing the flow:** Edit the templates in `templates/pages/account/` to match your branding and add contact information.

#### Post-Login Callback

For B2B+B2C apps that need custom account setup logic after OAuth:

```bash
# .env
FEATHER_POST_LOGIN_CALLBACK=myapp.auth:handle_login
```

```python
# myapp/auth.py
def handle_login(user, token):
    """Called after OAuth login with user and token.

    Args:
        user: The User model instance
        token: OAuth token dict (access_token, refresh_token, etc.)

    Returns:
        Redirect URL string, or None for default behavior
    """
    if not user.account_id:
        # New user needs account setup
        return '/onboarding/select-plan'
    return None  # Default redirect to home
```

Use this for creating Account/Membership records, assigning tenants to public email users, or custom onboarding flows.

#### Pre-Register Callback

Block new user registrations before the account is created. This runs during OAuth signup, **only for new users** — existing users logging in are unaffected.

```bash
# .env
FEATHER_PRE_REGISTER_CALLBACK=myapp.auth:check_registration
```

```python
# myapp/auth.py
from flask import request

def check_registration():
    """Called before creating a new user during OAuth signup.

    Use Flask's request object to access the current request context
    (e.g., IP address, headers).

    Returns:
        Error message string to block registration, or None to allow it.
    """
    ip = request.headers.get("X-Real-IP", request.remote_addr)
    if is_blocked(ip):
        return "Registration is not available from your location."
    return None  # Allow registration
```

If the callback returns a string, registration is blocked — the message is shown as a toast error and no user record is created. If it returns `None` (or raises an exception), registration proceeds normally. Errors in the callback are logged but do not block signups (graceful degradation).

### Admin Panel

Most frameworks leave you to build your own admin interface—user management, analytics, error tracking. That's typically days of work before you ship any actual features. Feather includes a production-ready admin panel out of the box.

**What's included:**

| Feature | Description |
|---------|-------------|
| **User Management** | List, search, paginate users with HTMX-powered UI |
| **User Approval** | Approve pending signups, suspend bad actors |
| **Role Assignment** | Change user roles (user → editor → admin) |
| **Analytics Dashboard** | User growth charts with Apache ECharts, time range filters |
| **Error Logging** | Database-backed error logs with stack traces, tenant-scoped |
| **Tenant Management** | Create/manage tenants, assign admins (multi-tenant only) |

**Enable:**
```bash
feather new myapp
# Choose "single-tenant" or "multi-tenant" app type
```

**Access:** `/admin/` — requires `role="admin"` or `is_platform_admin=True`

**Pages:**
| Page | Route | Description |
|------|-------|-------------|
| Users | `/admin/users` | Searchable user list with pagination |
| User Detail | `/admin/users/<id>` | Profile card, role dropdown, approve/suspend buttons |
| Analytics | `/admin/analytics` | User growth chart with 7d/30d/90d/1y filters |
| Error Logs | `/admin/logs` | Filterable error list (4xx/5xx, searchable) |
| Tenants | `/admin/tenants` | Tenant list with status filters (multi-tenant only) |

**User states:**
- **Pending Approval** — new signup, never approved (`active=False`, `approved_at=None`)
- **Active** — approved and can access the app (`active=True`)
- **Suspended** — was active, now blocked (`active=False`, `approved_at` set)

#### Extending the Admin Panel

The admin is scaffolded into your app as regular routes and templates—not hidden in the framework. You own the code and can modify it freely.

**Files you can customize:**
```
routes/pages/admin.py           # Admin routes and HTMX endpoints
services/admin_service.py       # User queries, analytics data
templates/pages/admin/          # Full page templates
templates/partials/admin/       # HTMX response fragments
static/css/app.css              # Admin CSS classes (admin-header, etc.)
```

**Adding a new admin page:**

1. Add a route in `routes/pages/admin.py`:
```python
@page.get('/admin/reports')
@admin_required
def admin_reports():
    reports = ReportService().get_recent()
    return render_template('pages/admin/reports.html', reports=reports)
```

2. Create the template `templates/pages/admin/reports.html`:
```jinja2
{% extends "pages/admin/base.html" %}
{% block admin_content %}
<h1>Reports</h1>
<!-- Your content here -->
{% endblock %}
```

3. Add navigation in `templates/pages/admin/base.html`:
```jinja2
<a href="{{ url_for('page.admin_reports') }}"
   class="admin-nav-item {{ 'active' if active_page == 'reports' }}">
    Reports
</a>
```

**Adding HTMX interactions** (like the user search):
```python
@page.get('/admin/htmx/reports/filter')
@admin_required
def htmx_filter_reports():
    status = request.args.get('status')
    reports = ReportService().filter_by_status(status)
    return render_template('partials/admin/reports_table.html', reports=reports)
```

The admin uses the same three-layer architecture as the rest of your app: server-rendered templates, HTMX for interactions, and Islands only where needed (the analytics chart).

### Multi-Tenancy

Multi-tenancy is one of the hardest problems in SaaS development. You need to:
- Isolate data so Company A never sees Company B's data
- Handle authentication across organizational boundaries
- Manage two levels of admin (company admins vs. platform operators)
- Scope every database query to the current tenant
- Prevent cross-tenant access even from malicious or buggy code

Most teams spend weeks building this infrastructure. Feather provides production-ready multi-tenancy out of the box.

**Enable:**
```bash
feather new myapp
# Choose "multi-tenant" app type
```

#### How It Works

Feather uses **domain-based tenant isolation**. When a user signs in with `bob@acme.com`:

1. Feather extracts the domain (`acme.com`)
2. Looks up the tenant with that domain
3. Assigns the user to that tenant
4. All subsequent queries are scoped to that tenant

```
User signs in → Domain extracted → Tenant matched → Data scoped
bob@acme.com → acme.com → Acme Corp tenant → Only sees Acme data
```

**Public email domains:** By default, Gmail, Outlook, Yahoo, and other consumer email providers are blocked—users must sign in with their work email. For B2B+B2C apps that need to support both corporate and individual users:

```bash
# .env
FEATHER_ALLOW_PUBLIC_EMAILS=true
```

When enabled, users with public emails (Gmail, etc.) are created with `tenant_id=None`. Use the post-login callback to handle account/tenant creation for these users.

#### Two-Axis Authority Model

Feather separates **tenant authority** (what you can do within your organization) from **platform authority** (cross-organization operator power):

| Axis | Field | Scope | Example |
|------|-------|-------|---------|
| **Tenant Role** | `user.role` | Within one tenant | "admin", "editor", "user" |
| **Platform Authority** | `user.is_platform_admin` | Across all tenants | True/False |

This means:
- A **Tenant Admin** (`role="admin"`) can manage users within their organization, but can't see other tenants
- A **Platform Admin** (`is_platform_admin=True`) can create tenants, view all users, and operate across organizational boundaries

**Key design principle:** Tenant admins do NOT automatically bypass tenant isolation. An admin at Acme Corp cannot access data from Beta Inc—that requires explicit platform admin privileges.

#### Admin Levels Explained

**Tenant Admin** — manages one organization:
- Approve/suspend users in their tenant
- Change user roles within their tenant
- View error logs scoped to their tenant
- Cannot see other tenants or their data

**Platform Admin** — operates the entire platform:
- Create new tenants and assign domains
- Approve/suspend tenants
- View all users across all tenants
- Access platform-wide analytics and logs
- For security, can only be granted via CLI (not web UI)

```bash
# Grant platform admin (requires server access)
feather platform-admin admin@example.com

# Revoke platform admin
feather platform-admin admin@example.com --revoke
```

#### Admin Pages (Multi-Tenant Mode)

| Page | Route | Who Can Access | Description |
|------|-------|----------------|-------------|
| Users | `/admin/users` | Tenant Admin | Users in current tenant |
| User Detail | `/admin/users/<id>` | Tenant Admin | Approve/suspend, change roles |
| Error Logs | `/admin/logs` | Tenant Admin | Errors scoped to tenant |
| **Tenants** | `/admin/tenants` | Platform Admin only | All tenants, create new |
| **Tenant Detail** | `/admin/tenants/<id>` | Platform Admin only | Tenant info, users, approve/suspend |

#### Data Isolation

Feather enforces tenant isolation at multiple layers:

**1. Route layer** — `get_current_tenant_id()` returns the authenticated user's tenant:
```python
from feather import get_current_tenant_id

@api.get('/projects')
@auth_required
def list_projects():
    tenant_id = get_current_tenant_id()
    return Project.query.filter_by(tenant_id=tenant_id).all()
```

**2. Service layer** — `require_same_tenant()` guards against cross-tenant access:
```python
from feather.auth import require_same_tenant

def get_project_or_404(project_id):
    project = Project.query.get_or_404(project_id)
    require_same_tenant(project.tenant_id)  # Raises 403 if mismatch
    return project
```

**3. Model layer** — `TenantScopedMixin` adds tenant_id and scoped queries:
```python
from feather.db.mixins import TenantScopedMixin

class Project(UUIDMixin, TenantScopedMixin, Model):
    __tablename__ = 'projects'
    name = db.Column(db.String(100))

# Query only this tenant's projects
projects = Project.for_tenant(tenant_id).all()
```

**Hard boundary:** `require_same_tenant()` is a hard stop—even tenant admins cannot bypass it. Cross-tenant operations require platform admin routes with explicit `@platform_admin_required` decorators.

#### Tenant Model

The scaffolded Tenant model supports both B2B (domain-based) and B2C (individual) patterns:

```python
class Tenant(Model):
    slug = db.Column(db.String(64), unique=True, nullable=False)
    domain = db.Column(db.String(255), nullable=True)  # Nullable for B2C
    name = db.Column(db.String(255), nullable=False)
    type = db.Column(db.String(50), nullable=True)     # "company", "individual", etc.
    status = db.Column(db.String(20), default="pending")
```

- **B2B tenants:** Set `domain` to auto-assign users by email (e.g., `@acme.com` → Acme tenant)
- **B2C tenants:** Leave `domain` as `None`, create individually via post-login callback
- **type field:** Classify tenants for billing, features, or reporting

#### Tenant Lifecycle

1. **Platform admin creates tenant** via `/admin/tenants`:
   - Sets tenant name, slug, and optionally email domain
   - Creates initial tenant admin (auto-approved)
   - Tenant starts in pending state

2. **Platform admin approves tenant** — tenant becomes active

3. **Users sign up** with matching email domain:
   - Auto-assigned to tenant
   - Created in suspended state (pending approval)

4. **Tenant admin approves users** via `/admin/users`

This flow en