Metadata-Version: 2.4
Name: feather-framework
Version: 0.9.10
Summary: An opinionated, batteries-included web framework for building production applications with AI.
Author: Roland Selmer
License-Expression: MIT
Project-URL: Homepage, https://github.com/RolandFlyBoy/Feather
Project-URL: Documentation, https://github.com/RolandFlyBoy/Feather#readme
Project-URL: Repository, https://github.com/RolandFlyBoy/Feather
Project-URL: Issues, https://github.com/RolandFlyBoy/Feather/issues
Keywords: flask,web framework,server-first,progressive enhancement,islands architecture,ai-friendly
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Web Environment
Classifier: Framework :: Flask
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: flask>=3.1.3
Requires-Dist: flask-sqlalchemy>=3.1.0
Requires-Dist: flask-migrate>=4.1.0
Requires-Dist: flask-login>=0.6.0
Requires-Dist: flask-wtf>=1.2.0
Requires-Dist: sqlalchemy>=2.0.0
Requires-Dist: alembic>=1.17.0
Requires-Dist: click>=8.1.0
Requires-Dist: python-dotenv>=1.0.0
Requires-Dist: werkzeug>=3.1.5
Requires-Dist: jinja2>=3.1.6
Requires-Dist: urllib3>=2.5.0
Requires-Dist: authlib>=1.6.5
Requires-Dist: requests>=2.32.4
Provides-Extra: pdf
Requires-Dist: weasyprint>=70; extra == "pdf"
Provides-Extra: gcs
Requires-Dist: google-cloud-storage>=3.0.0; extra == "gcs"
Provides-Extra: postgres
Requires-Dist: psycopg2-binary>=2.9.0; extra == "postgres"
Provides-Extra: redis
Requires-Dist: redis>=7.0.0; extra == "redis"
Requires-Dist: rq>=2.0.0; extra == "redis"
Provides-Extra: email
Requires-Dist: resend>=2.0.0; extra == "email"
Provides-Extra: ratelimit
Requires-Dist: flask-limiter>=4.1; extra == "ratelimit"
Provides-Extra: prod
Requires-Dist: gunicorn>=23.0.0; extra == "prod"
Provides-Extra: test
Requires-Dist: pytest>=8.0.0; extra == "test"
Requires-Dist: pytest-cov>=6.0.0; extra == "test"
Provides-Extra: all
Requires-Dist: feather-framework[email,gcs,pdf,postgres,prod,ratelimit,redis,test]; extra == "all"
Provides-Extra: dev
Requires-Dist: feather-framework[all]; extra == "dev"
Requires-Dist: black>=25.0.0; extra == "dev"
Requires-Dist: ruff>=0.14.0; extra == "dev"
Dynamic: license-file

<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` and an identical `AGENTS.md` giving assistants the conventions to follow, and `feather check` enforces the ones that can be enforced, so an assistant can verify its own work rather than hoping.

### 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.11+** — 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          # the CLI, for scaffolding a project
```

That is all you need to run `feather new`. The generated project's
`requirements.txt` then names the [extras](#dependencies) your answers
enabled, pinned to the version that generated it, so builds are reproducible:

```
feather-framework[postgres,redis,prod,test]==0.9.8
```

**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` and an `AGENTS.md` with the same
content, written together so they cannot drift, plus a `.claude/settings.json`
that pre-approves the read-only commands an assistant needs. `AGENTS.md` is
the vendor-neutral name other tools look for. Both carry the full rules rather
than one linking to the other, because an assistant that has to follow a link
often does not.

They are a starting point—add your project's own domain rules and preferences
as the app grows. What makes them useful is that the rules are checkable:

```bash
feather check        # are the conventions being followed?
feather components   # what arguments does this macro take?
feather routes       # what is actually registered?
feather test         # does it still work?
```

### 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: see [Components](#components) for the full list
- 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 macros** — every one lives in `feather/templates/components/` and is
imported from `components/<file>.html`:

| Macro | File | Signature |
|-------|------|-----------|
| `alert` | `alert.html` | `alert(message, class="")` |
| `button` | `button.html` | `button(text, type="button", variant="primary", icon=None, class="")` |
| `card` | `card.html` | `card(class="")` — call block |
| `confirm_modal` | `confirm_modal.html` | `confirm_modal()` — backs `hx-confirm` |
| `dropdown` | `dropdown.html` | `dropdown(name, options, selected=None, placeholder=None, label=None, inline=False, required=False, class="")` |
| `htmx_indicator` | `htmx_indicator.html` | `htmx_indicator(color="#6366f1")` |
| `icon` | `icon.html` | `icon(name, size="md", class="")` |
| `input` | `input.html` | `input(name, type="text", placeholder="", required=False, class="")` |
| `textarea` | `input.html` | `textarea(name, rows=3, placeholder="", required=False, class="")` |
| `modal` | `modal.html` | `modal(id, class="")` — call block |
| `page_loader` | `page_loader.html` | `page_loader(color="#6366f1", bg="#f9fafb")` |
| `prompt_modal` | `prompt_modal.html` | `prompt_modal()` — backs `window.showPrompt()` |
| `spinner` | `spinner.html` | `spinner(size="md", color="currentColor")` |
| `toast` | `toast.html` | `toast()` — the toast container |

`card` and `modal` wrap their contents, so use `{% call %}`:

```html
{% from "components/card.html" import card %}
{% call card(class="mt-4") %}
    <h2>Title</h2>
{% endcall %}
```

### 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="p-2 rounded-lg text-gray-500 hover:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 transition-colors"
        title="Toggle dark mode">
    <span class="dark:hidden"><span class="material-symbols-outlined">bedtime</span></span>
    <span class="hidden dark:inline"><span class="material-symbols-outlined">sunny</span></span>
</button>
```

The only thing `dark-mode.js` looks for is the `data-toggle-dark-mode`
attribute; which icon shows is plain `dark:` variants on the two spans. If you
would rather keep the markup clean, move the swap into `app.css` and give the
button a class of your own:

```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 ensures both platform-level and tenant-level approval gates.

### Background Jobs

Many web apps need to do work outside the request cycle - sending emails, processing uploads, calling external APIs. Feather provides three job backends, each designed for different goals:

#### Choosing the Right Backend

The choice isn't about "development vs production" - all three work in production. It's about what you're trying to achieve:

| Goal | Backend | Trade-off |
|------|---------|-----------|
| **Simplicity** - No infrastructure, no complexity | `sync` | Blocks the request |
| **Speed** - Return fast, process later | `thread` | Jobs lost on restart |
| **Reliability** - Never lose a job, even if server crashes | `rq` | Requires Redis + workers |

**Sync** is for when blocking the request is acceptable. You might use this for:
- Simple apps where job execution is fast enough
- Debugging job logic (errors appear in the request)
- Apps where infrastructure simplicity matters more than response time

**Thread** is for when you need fast responses without infrastructure. Jobs run in a thread pool managed by Python. You'd choose this when:
- You want sub-second response times
- You don't want to run Redis
- Jobs are "fire and forget" (losing some on crash is acceptable)
- You need concurrency control for memory-intensive tasks (ML, transcription)

**RQ** is for when reliability is critical. Jobs are persisted to Redis before acknowledgement, and workers run as independent services alongside your web app. Choose this when:
- Losing a job would cause real problems (payments, notifications)
- You need job visibility (retry failed jobs, see job history)
- You're running multiple servers (distributed workers)
- You need scheduled/recurring tasks
- You want self-healing background processing that survives deploys and crashes

#### Configuration

```bash
# .env

# Sync - blocks request, no background processing
JOB_BACKEND=sync

# Thread (default) - background threads, no infrastructure
JOB_BACKEND=thread
JOB_MAX_WORKERS=4              # Thread pool size
# JOB_ENABLE_MONITORING=true   # Enable psutil resource tracking

# RQ - Redis workers with persistence
JOB_BACKEND=rq
REDIS_URL=redis://localhost:6379/0
```

**Important for development:** When using the `thread` backend, set `FLASK_DEBUG=0` in your `.env` file. Flask's auto-reloader restarts the process on every file change, which kills any running background threads. Your jobs will be terminated mid-execution whenever you save a file.

**Define a job:**
```python
from feather import job

@job
def send_welcome_email(user_id, email):
    # Runs in background thread
    send_email(email, 'Welcome!', render_template('emails/welcome.html'))
```

**Enqueue:**
```python
@api.post('/users')
@inject(UserService)
def create_user(user_service, email: str):
    user = user_service.create(email=email)
    send_welcome_email.enqueue(user.id, user.email)  # Returns immediately
    return {'user': user.to_dict()}, 201

# With delay (seconds)
send_welcome_email.enqueue(user.id, user.email, delay=60)  # Run in 60 seconds
```

#### Concurrency Control

Limit concurrent executions to prevent resource exhaustion - essential for memory-intensive tasks like ML inference:

```python
@job(concurrency=2)  # Max 2 concurrent executions
def transcribe_audio(file_path):
    """Whisper transcription - memory intensive."""
    result = whisper.transcribe(file_path)
    return result['text']

@job(concurrency=1)  # Singleton - only 1 at a time
def rebuild_search_index():
    """Expensive operation - run exclusively."""
    pass
```

**How it works:**
- Jobs wait in a queue when the concurrency limit is reached
- First-in-first-out (FIFO) ordering within each task type
- Different tasks have independent limits

**Use cases:**
- Audio/video transcription (Whisper) - high memory footprint
- ML model inference - GPU/memory constrained
- External API calls - rate limited by provider
- Database-heavy operations - connection pool limits

#### Retry Logic

Automatically retry failed jobs with exponential backoff:

```python
@job(retry=3)  # Retry up to 3 times
def call_external_api(data):
    # Backoff: 2s, 4s, 8s between retries
    response = requests.post('https://api.example.com', json=data)
    response.raise_for_status()

@job(concurrency=2, retry=2)  # Combined with concurrency
def transcribe_with_retry(video_id):
    # Max 2 concurrent, retry twice on failure
    pass
```

#### Resource Monitoring

Enable psutil to capture memory/CPU metrics on job failures:

```bash
# .env
JOB_ENABLE_MONITORING=true
```

```bash
pip install psutil  # Optional dependency
```

When a job fails, error logs include:
```
Memory Mb: 256.5
Memory Percent: 3.2%
Cpu Percent: 45.0%
Thread Count: 8
```

#### Scheduled Tasks

For recurring jobs on a schedule (cron-style or interval-based), use the RQ backend with rq-scheduler:

```python
from feather import scheduled

@scheduled(cron='0 9 * * *')  # Every day at 9 AM
def daily_digest():
    send_daily_digest_emails()

@scheduled(interval=3600)  # Every hour
def cleanup_temp_files():
    delete_old_temp_files()
```

#### Workers as Services

With the RQ backend, workers are independent processes that share your app's codebase but run separately from the web server. Think of them as sidecars — they have full access to your models, services, and config, but they operate on their own lifecycle.

This matters because workers are **self-healing**. If your web server crashes, jobs already in Redis keep waiting. When the worker restarts, it picks up right where it left off. If a worker crashes mid-job, RQ marks the job as failed and it can be retried — nothing is silently lost. This makes workers suitable for operations that must eventually complete: billing cycles, subscription renewals, webhook delivery, report generation.

Workers also replace cron jobs. Instead of configuring external schedulers, you enqueue delayed or recurring work through your application code. The worker's built-in scheduler promotes delayed jobs automatically — a billing job enqueued with `delay=55` fires exactly when it should, even if the web server restarted in between.

**Start a worker:**

```bash
pip install rq

# Start processing the default queue
feather worker

# Process specific queues in priority order
feather worker high default low

# Run in burst mode (exit when queue is empty — useful for one-off batch work)
feather worker --burst
```

The `feather worker` command handles the setup that would otherwise require a custom script:
- Creates the Flask app and pushes app context (so jobs can query the database, read config, etc.)
- Uses `SimpleWorker` on macOS (avoids the `fork()` crash with Obj-C runtime)
- Enables the built-in scheduler by default (required for delayed jobs)

**Options:**

| Flag | Description |
|------|-------------|
| `--burst` | Exit when queue is empty |
| `--simple` | Force SimpleWorker — one process, no fork (the default on macOS) |
| `--fork` | Force the forking worker — one process per job, so a crashing job can't take the worker down (the default on Linux, and what the Docker `worker` target runs) |
| `--no-scheduler` | Disable delayed job scheduler |
| `--name` | Worker name (for identification in logs) |
| `--log-level` | DEBUG, INFO, WARNING, ERROR (default: INFO) |

#### Deploying Workers

In production, workers run as separate services that share the same Docker image (or codebase) as your web server — just with a different start command.

The generated `docker-compose.yml` already has one when you enable background
jobs — same image, different build target:

```yaml
services:
  web:
    build:
      context: .
      target: web
    env_file: .env
    environment:
      DATABASE_URL: postgresql://myapp:${POSTGRES_PASSWORD}@db:5432/myapp
      REDIS_URL: redis://redis:6379/0

  worker:
    build:
      context: .
      target: worker      # same Dockerfile, CMD ["feather", "worker", "--fork"]
    env_file: .env        # same secrets, same database
    environment:
      JOB_BACKEND: rq
      JOB_SERIALIZER: json
      DATABASE_URL: postgresql://myapp:${POSTGRES_PASSWORD}@db:5432/myapp
      REDIS_URL: redis://redis:6379/0
```

Run more of them with `docker compose up -d --scale worker=3`, unless your jobs
include a singleton loop (a scheduler, a billing tick) that must not run twice.

**Key points:**
- Workers share the same image, env vars, and database as the web service
- Scale workers independently — add more for throughput, or dedicate workers to specific queues
- Each worker connects to Redis for job pickup, and to your database for business logic
- Workers survive web deploys — restarting the web service doesn't interrupt running jobs

For scheduled/recurring jobs, also install rq-scheduler:

```bash
pip install rq-scheduler
rqscheduler --url redis://localhost:6379/0
```

### Caching

Response and function caching with automatic invalidation.

**Configuration:**
```bash
# .env
CACHE_BACKEND=memory   # In-memory (single process, resets on restart)
# or
CACHE_BACKEND=redis    # Redis (shared across processes, persistent)
CACHE_URL=redis://localhost:6379/0
CACHE_DEFAULT_TTL=300  # Default TTL in seconds
```

**Cache function results:**
```python
from feather import cached

@cached(ttl=60)  # Cache for 60 seconds
def get_user_stats(user_id):
    # Expensive database query
    return calculate_stats(user_id)

# Results are cached by function arguments
stats = get_user_stats(123)  # First call: executes function
stats = get_user_stats(123)  # Second call: returns cached result

# Invalidate when data changes
get_user_stats.invalidate(user_id=123)
```

**Cache route responses:**
```python
from feather import cache_response

@api.get('/products')
@cache_response(ttl=300)  # Cache for 5 minutes
def list_products():
    return {'products': Product.query.all()}

# Custom cache key using URL params
@api.get('/users/<user_id>')
@cache_response(ttl=60, key='user:{user_id}')
def get_user(user_id):
    return {'user': User.query.get(user_id)}

# Skip cache conditionally
@api.get('/dashboard')
@cache_response(ttl=300, unless=lambda: current_user.is_admin)
def dashboard():
    return {'stats': get_stats()}
```

**Direct cache access:**
```python
from feather import get_cache

cache = get_cache()
cache.set('key', {'data': 'value'}, ttl=60)
value = cache.get('key')  # Returns None if expired/missing
cache.delete('key')
```

### File Storage

Unified file handling with local filesystem or Google Cloud Storage.

**Configuration:**
```bash
# .env
STORAGE_BACKEND=local   # Saves to ./uploads/ directory

# or Google Cloud Storage
STORAGE_BACKEND=gcs
GCS_BUCKET=my-bucket

# GCS credentials (choose one):
# Option 1: Inline JSON (recommended for deployment - single line)
GCS_CREDENTIALS_JSON={"type":"service_account","project_id":"...","private_key":"..."}

# Option 2: File path (local development)
GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json

# Option 3: Default credentials (GCE/GKE or gcloud auth application-default login)
# No extra config needed
```

**Usage:**
```python
from feather.storage import get_storage

storage = get_storage()

# Upload a file
url = storage.upload(file, 'uploads/photo.jpg', content_type='image/jpeg')

# Download file contents
data = storage.download('uploads/photo.jpg')

# Get URL (local returns path, GCS returns signed URL)
url = storage.get_url('uploads/photo.jpg', expires_in=3600)  # 1 hour expiry

# Check existence and delete
if storage.exists('uploads/photo.jpg'):
    storage.delete('uploads/photo.jpg')
```

**In a route:**
```python
from flask import request
from feather.storage import get_storage

@api.post('/upload')
@auth_required
def upload_file():
    file = request.files['image']
    storage = get_storage()
    url = storage.upload(file, f'uploads/{current_user.id}/{file.filename}')
    return {'url': url}
```

### Email

Transactional email using Resend. Available for authenticated apps (single-tenant or multi-tenant).

**Configuration:**
```bash
# .env
RESEND_API_KEY=re_xxxx                    # Get from https://resend.com/api-keys
RESEND_FROM_EMAIL=noreply@yourdomain.com  # Must be verified in Resend
```

**Usage:**
```python
from services.email_service import EmailService

email_service = EmailService()

# Send plain text email
result = email_service.send(
    to="user@example.com",
    subject="Welcome!",
    body="Thanks for signing up."
)

# Send HTML email
result = email_service.send(
    to="user@example.com",
    subject="Your Report",
    body="<h1>Monthly Report</h1><p>...</p>",
    html=True
)

# Return response with toast notification
response = make_response(render_template("partials/email_sent.html"))
if result["success"]:
    response.headers["HX-Trigger"] = json.dumps({"showToast": {"message": result["message"], "type": "success"}})
else:
    response.headers["HX-Trigger"] = json.dumps({"showToast": {"message": result["error"], "type": "error"}})
```

**Admin Tools:** When email is enabled, the admin panel includes a "Send Email" form at `/admin/tools` with user search dropdown.

### Events

Pub/sub pattern for decoupling application components.

**Define an event:**
```python
from feather.events import Event

class UserCreatedEvent(Event):
    def __init__(self, user_id: str, email: str):
        super().__init__(user_id=user_id)
        self.email = email
```

**Listen for events:**
```python
from feather.events import listen

# Synchronous listener (runs in request thread)
@listen(UserCreatedEvent)
def send_welcome_email(event):
    send_email(event.email, 'Welcome!')

# Async listener (runs in background thread pool)
@listen(UserCreatedEvent, async_=True)
def track_signup_analytics(event):
    # Doesn't block the response
    analytics.track('signup', user_id=event.user_id)
```

**Dispatch events:**
```python
from feather.events import dispatch

@transactional
def create_user(self, email: str):
    user = User(email=email)
    self.db.add(user)
    # Dispatch after the transaction commits
    dispatch(UserCreatedEvent(user_id=user.id, email=user.email))
    return user
```

Async listeners run in a ThreadPoolExecutor (4 workers). Use for non-critical tasks like analytics, logging, or notifications.

### PDF Generation

Generate PDF documents with WeasyPrint (included in Feather). WeasyPrint converts HTML/CSS to PDF, letting you use familiar web technologies for document layout:

**Basic usage:**
```python
from io import BytesIO
from weasyprint import HTML

def generate_report(title, data):
    html_content = f"""
    <!DOCTYPE html>
    <html>
    <head>
        <style>
            body {{ font-family: sans-serif; margin: 40px; }}
            h1 {{ color: #1f2937; }}
            table {{ border-collapse: collapse; width: 100%; margin-top: 20px; }}
            td, th {{ border: 1px solid #d1d5db; padding: 8px; text-align: left; }}
            th {{ background-color: #f3f4f6; }}
        </style>
    </head>
    <body>
        <h1>{title}</h1>
        <table>
            <tr><th>Item</th></tr>
            {''.join(f'<tr><td>{row}</td></tr>' for row in data)}
        </table>
    </body>
    </html>
    """

    buffer = BytesIO()
    HTML(string=html_content).write_pdf(buffer)
    buffer.seek(0)
    return buffer
```

**With file storage:**
```python
from feather.storage import get_storage

@api.get('/reports/<id>/pdf')
@auth_required
def download_report(id):
    pdf_buffer = generate_report("Report", get_data(id))

    # Save to storage
    storage = get_storage()
    filename = f'reports/{id}.pdf'
    storage.upload(pdf_buffer, filename, content_type='application/pdf')

    # Return download URL
    url = storage.get_url(filename, expires_in=3600)
    return {'url': url}
```

**With background jobs:**
```python
from feather import job

@job
def generate_report_async(report_id, user_id):
    pdf_buffer = generate_report("Report", get_data(report_id))
    storage = get_storage()
    filename = f'reports/{user_id}/{report_id}.pdf'
    storage.upload(pdf_buffer, filename, content_type='application/pdf')
    return {'filename': filename}

# Enqueue and poll for completion
result = generate_report_async.enqueue(report_id, user_id)
```

### Rate Limiting

Protect routes from abuse with configurable limits.

**Usage:**
```python
from feather.auth import rate_limit

# 5 login attempts per minute per IP
@api.post('/login')
@rate_limit(5, 60)
def login():
    pass

# 100 API calls per minute per authenticated user
@api.get('/search')
@rate_limit(100, 60, key='user')
def search():
    pass

# Strict: limit by both IP and user
@api.post('/expensive')
@rate_limit(10, 3600, key='ip+user')
def expensive_operation():
    pass

# Custom error message
@api.post('/comments')
@rate_limit(10, 3600, message='You can only post 10 comments per hour')
def create_comment():
    pass
```

**Options:**
| Parameter | Description | Default |
|-----------|-------------|---------|
| `limit` | Max requests in period | required |
| `period` | Time window (seconds) | 60 |
| `key` | Rate limit by `'ip'`, `'user'`, or `'ip+user'` | `'ip'` |
| `message` | Custom error message | "Rate limit exceeded" |

**Note:** `@rate_limit` keeps its counters in the process. Under
`gunicorn --workers 4` a limit of ten per minute is really forty per minute,
so treat it as a development guard and a convenience for single-process
deployments, not as protection.

#### In production: Flask-Limiter

Scaffolded apps with authentication ship a `rate_limits.py` that does this
properly. It limits the Google OAuth login and callback and every admin POST
route through Flask-Limiter, sharing counters across workers via Redis:

```bash
pip install "feather-framework[ratelimit]"
```

```bash
# .env — counters are shared when this points at Redis
RATELIMIT_STORAGE_URI=redis://localhost:6379/1
```

Without it the limiter falls back to memory and logs a warning saying so.
The limits themselves live in `config.py` as `RATELIMIT_DEFAULT`,
`RATELIMIT_LOGIN` and `RATELIMIT_ADMIN`, each overridable by environment
variable. `flask limiter limits` prints which routes are actually limited.

**Two things that fail silently if you wire this up by hand.** Both cost real
debugging time in production:

- **Assign the wrapper back.** Flask-Limiter enforces a limit only through
  the function it returns, so `limiter.limit(rule)(app.view_functions[ep])`
  with the result discarded does nothing — and worse, drops that endpoint out
  of the default limit too. Write `app.view_functions[ep] = limiter.limit(rule)(view)`.
- **Exempt static endpoints.** One page load fetches ten or more scripts and
  fonts from Flask. Without a `limiter.request_filter` exempting `static` and
  `feather_static`, a busy user gets a 429 on the app's own JavaScript while
  the page is still loading.

### Serializers

Convert model objects to JSON with automatic snake_case to camelCase conversion.

**Basic usage:**
```python
from feather.serializers import Serializer
from models import User

class UserSerializer(Serializer):
    class Meta:
        model = User
        fields = ['id', 'email', 'created_at']
        camel_case = True

# Serialize
user = User.query.first()
data = UserSerializer().serialize(user)
# {'id': '...', 'email': '...', 'createdAt': '2024-01-15T10:30:00Z'}

# Serialize multiple
users = User.query.all()
data = UserSerializer().serialize_many(users)
```

**Field types:**
```python
from feather.serializers import (
    Serializer, StringField, IntegerField, FloatField,
    BooleanField, DateTimeField, MethodField, NestedField
)

class UserSerializer(Serializer):
    class Meta:
        model = User
        fields = ['id', 'email', 'status', 'balance', 'created_at', 'full_name', 'posts']

    status = StringField()                          # Coerce to string
    balance = FloatField()                          # Coerce to float
    created_at = DateTimeField(format='%Y-%m-%d')   # Custom date format
    full_name = MethodField()                       # Computed field
    posts = NestedField(PostSerializer, many=True)  # Nested objects

    def get_full_name(self, obj, **context):
        return f"{obj.first_name} {obj.last_name}"
```

`camel_case` defaults to `True`, so `created_at` is serialized as `createdAt`.
Set it to `False` in `Meta` to keep the Python names.

`feather generate serializer UserSerializer id email created_at` writes exactly
this shape, including the `Meta` block and a commented example of a computed
field.

**Available field types:**
| Field | Description |
|-------|-------------|
| `StringField()` | Coerce to string |
| `IntegerField()` | Coerce to integer |
| `FloatField()` | Coerce to float |
| `BooleanField()` | Coerce to boolean |
| `DateTimeField(format=None)` | Format datetime (default: ISO 8601) |
| `NestedField(serializer, many=False)` | Nested object/collection |
| `MethodField()` | Computed via `get_<field_name>()` method |

### Request Tracking

Unique request IDs and structured logging for debugging and observability.

**Configuration:**
```bash
# .env
LOG_LEVEL=INFO        # DEBUG, INFO, WARNING, ERROR, CRITICAL
LOG_FORMAT=json       # Enable JSON logging (auto-enabled when FLASK_ENV=production)
```

**Usage:**
```python
from feather import get_request_id

@api.get('/users')
def list_users():
    # Trace requests across services
    app.logger.info(f"Listing users [{get_request_id()}]")
    return {'users': [...]}
```

**How it works:**
- Unique ID per request (UUID)
- Uses incoming `X-Request-ID` header if present (for distributed tracing)
- Added to response headers automatically
- Available via `get_request_id()` or `g.request_id`

**JSON log format:**
```json
{
  "timestamp": "2024-01-15T10:30:00.000Z",
  "level": "INFO",
  "message": "Listing users",
  "request_id": "abc-123-def",
  "logger": "myapp.routes"
}
```

### Health Checks

Health endpoints for load balancer routing, Kubernetes probes, and monitoring systems.

Feather provides three endpoints out of the box:

| Endpoint | Purpose | What It Checks |
|----------|---------|----------------|
| `/health` | Full health check | Database connectivity, app running |
| `/health/live` | Liveness probe | App process is alive (always 200 if responding) |
| `/health/ready` | Readiness probe | App can serve traffic (database connected) |

**Liveness vs Readiness:**
- **Liveness** answers "is the process alive?" — if this fails, the container should be restarted
- **Readiness** answers "can it handle requests?" — if this fails, stop sending traffic but don't restart

Example: your app is running but the database is down. Liveness passes (process is alive), readiness fails (can't serve requests). The load balancer stops routing to this instance while it recovers.

**Response format:**
```json
{
  "status": "healthy",
  "timestamp": "2024-01-15T10:30:00.000Z",
  "checks": {
    "database": "ok"
  }
}
```

Returns `200 OK` when healthy, `503 Service Unavailable` when unhealthy.

**Load balancer configuration (AWS ALB, GCP, etc.):**
- Health check path: `/health`
- Healthy threshold: 2
- Unhealthy threshold: 3
- Interval: 30 seconds

**Kubernetes:**
```yaml
livenessProbe:
  httpGet:
    path: /health/live
    port: 8000
  initialDelaySeconds: 5
  periodSeconds: 10

readinessProbe:
  httpGet:
    path: /health/ready
    port: 8000
  initialDelaySeconds: 5
  periodSeconds: 10
```

**Docker:** the generated Dockerfile already has `HEALTHCHECK ... curl /health`, and `deploy/deploy.sh` waits on it before declaring a deploy finished. Most PaaS hosts either detect `/health` or take it as a configured health-check path.

### Error Logging

Automatic error capture with tenant scoping for multi-tenant apps.

**How it works:**
- Errors are automatically logged to the database with stack traces
- Each error is associated with the current user and tenant
- Tenant admins see only their tenant's errors
- Platform admins see all errors

**View errors:** Navigate to `/admin/logs` in the admin panel.

**ErrorLog model:**
```python
class ErrorLog(Model):
    error_type    # NotFoundError, ValidationError, etc.
    message       # Error message
    path          # Request path
    method        # HTTP method
    user_id       # User who triggered it (if authenticated)
    tenant_id     # Tenant scope
    stack_trace   # Full traceback (for 500 errors)
    created_at    # When it occurred
```

### Security Headers

Feather automatically adds security headers to all responses in production (`DEBUG=False`). No configuration needed — headers are applied by default and skipped in development.

**Headers applied:**

| Header | Value | Purpose |
|--------|-------|---------|
| `Content-Security-Policy` | Configurable directives | Controls resource loading |
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | Forces HTTPS |
| `X-Content-Type-Options` | `nosniff` | Prevents MIME-type sniffing |
| `X-Frame-Options` | `DENY` | Prevents clickjacking |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Controls referrer info |
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), payment=()` | Restricts browser APIs |

**Default CSP directives:**
```
default-src 'self'
script-src  'self'
style-src   'self' 'unsafe-inline' https://fonts.googleapis.com
font-src    'self' https://fonts.gstatic.com
img-src     'self' data: https://*.googleusercontent.com
connect-src 'self'
frame-ancestors 'none'
```

**Extending CSP** (e.g., for Stripe):
```python
# config.py
class ProductionConfig(Config):
    FEATHER_CSP_DIRECTIVES = {
        "script-src": "'self' https://js.stripe.com",
        "frame-src": "'self' https://js.stripe.com",
    }
```

Custom directives are merged with defaults — you only need to specify the ones you're changing.

**Disabling** (not recommended):
```python
FEATHER_SECURITY_HEADERS = False
```

### Checking Conventions

The rules in this document are not only advice. `feather check` enforces the
ones that can be enforced, and reports a file, a line and a remedy for each
problem it finds.

```bash
feather check                   # everything
feather check --only templates  # one group
feather check --strict          # warnings fail too
feather check --json            # for tooling
```

| Rule | Severity | What it catches |
|------|----------|-----------------|
| `inline-script` | error | A `<script>` block in a template rather than a file under `static/` |
| `inline-handler` | error | `onclick=` and friends instead of an `hx-*` attribute or an island |
| `inline-style` | error | A `style=` attribute instead of a class |
| `inline-tailwind` | warning | Utility classes in markup instead of `@apply` in `app.css` |
| `native-dialog` | error | `alert()`, `confirm()` or `prompt()` |
| `raw-fetch` | error | `fetch()` instead of `ApiUtility`, which handles CSRF and retries |
| `google-image-referrer` | error | A Google avatar without `referrerpolicy="no-referrer"` |
| `unprotected-route` | warning | A route with no auth decorator |
| `fat-route` | warning | A route handler doing work that belongs in a service |
| `tenant-isolation` | error | A query on a tenant-scoped model with no tenant filter |
| `orphan-island` | warning | An island no template mounts |
| `missing-island` | error | A template mounting an island that does not exist |
| `syntax-error` | error | A module Feather's discovery would fail to import |

It exits non-zero on any error, so it works as a pre-commit hook or a CI
step. A route that is deliberately public is exempted with a
`# feather: public` comment in its module, which records the decision rather
than hiding it.

### Component Catalogue

Never guess a macro's arguments. `feather components` reads them from the
macros themselves, so it is right even when documentation is not.

```bash
feather components                      # signatures and import lines
feather components --json               # for tooling
feather components --markdown -o docs/components.md
```

Components your app defines under `templates/components/` are listed
alongside the framework's, and one that shares a filename with a framework
component is marked as overriding it, which is how the template loader
resolves it at runtime.

### Security Check

`feather security-check` audits an app the way a reviewer would, and exits
non-zero if anything fails. Run it in CI and before every deploy.

```bash
feather security-check                     # audit the current project
feather security-check --json              # machine-readable, for CI
feather security-check --env-file prod.env # audit an env file without importing the app
```

It checks the secret key's strength and that it is not the development
default, that an environment is explicitly selected and debug is off, session
and remember-me cookie flags, CSRF, the RQ job serializer, Redis URLs without
a password, `OAUTH_CALLBACK_URL` and `TRUSTED_HOSTS`, security headers, that
`.env` is gitignored, and that installed dependencies meet the framework's
minimum versions.

**What to set in production**, beyond a strong `SECRET_KEY`:

- `TRUSTED_HOSTS` to the hostnames you serve. Without it a client-supplied
  `Host` header determines your OAuth redirect URI and every external URL.
- `OAUTH_CALLBACK_URL` to the exact callback you registered with Google.
- `JOB_SERIALIZER=json` if you use RQ. Pickle payloads are code execution for
  anyone who can write to your Redis.
- A password on Redis, and `rediss://` if it crosses a network.

**Uploads.** `LocalStorage` serves files from `static/`, where the browser
takes the content type from the extension. Script-capable extensions are
refused by default, so an uploaded page cannot run on your origin. To accept
SVG, set `STORAGE_ALLOWED_EXTENSIONS` to the list you do want.

### Interactive Shell

The `feather shell` command launches an interactive Python shell with your Flask application context pre-loaded. This is invaluable for debugging, data exploration, and administrative tasks—especially in production environments.

**What's auto-loaded:**
- `app` — Flask application instance
- `db` — SQLAlchemy database instance
- All models from your `models/` directory (User, Post, etc.)

**Usage:**
```bash
feather shell
```

**Example session:**
```python
Feather Interactive Shell
========================================

Available variables:
  User: type
  Post: type
  app: Flask
  db: SQLAlchemy

>>> User.query.count()
42
>>> user = User.query.filter_by(email='admin@example.com').first()
>>> user.role = 'admin'
>>> db.session.commit()
```

**Production use cases:**

The shell is particularly valuable in production, whether you reach it through `docker compose exec web feather shell` or plain SSH access to your server:

| Task | Example |
|------|---------|
| Check user status | `User.query.filter_by(email='...').first()` |
| Count records | `Post.query.count()` |
| Fix data issues | `user.active = True; db.session.commit()` |
| Debug queries | `User.query.filter_by(role='admin').all()` |
| Run one-off migrations | Direct database operations when needed |

**IPython support:** If IPython is installed (`pip install ipython`), the shell uses it for enhanced features like tab completion and syntax highlighting.

---

## Testing

Feather scaffolds a working test setup so you can start testing immediately. No configuration needed—just write tests and run them.

### What's Included

When you run `feather new myapp`, you get:

```
tests/
├── conftest.py          # Fixtures: client, csrf_client, db setup
├── test_home.py         # Page route tests (working example)
├── test_auth.py         # Auth flow tests (if auth enabled)
└── test_admin.py        # Admin panel tests (if auth enabled)
```

These aren't placeholder files—they're real tests that pass out of the box. Use them as patterns for your own tests.

### Running Tests

```bash
feather test                       # Run all tests
feather test -v                    # Verbose output
feather test -p tests/test_api.py  # Specific file
feather test -- -k "test_user"     # Filter by test name
feather test --no-coverage         # Skip coverage report
```

### Fixtures

The scaffolded `conftest.py` provides two test clients:

| Fixture | Use For | CSRF Handling |
|---------|---------|---------------|
| `client` | GET requests, public endpoints | Not needed |
| `csrf_client` | POST/PUT/DELETE requests | Automatic |

```python
def test_public_page(client):
    """GET requests use the basic client."""
    response = client.get('/health')
    assert response.status_code == 200

def test_create_item(csrf_client):
    """POST/PUT/DELETE use csrf_client - CSRF token is automatic."""
    response = csrf_client.post('/api/items', json={'name': 'Test'})
    assert response.status_code == 201
```

**Why two clients?** Feather enables CSRF protection by default. The `csrf_client` fixture automatically fetches and includes the CSRF token, so your tests don't need to handle it manually.

### Testing Patterns

**Route tests** — test HTTP behavior:
```python
def test_list_items_requires_auth(client):
    response = client.get('/api/items')
    assert response.status_code == 401

def test_list_items_when_authenticated(csrf_client, authenticated_user):
    response = csrf_client.get('/api/items')
    assert response.status_code == 200
    assert 'items' in response.json
```

**Service tests** — test business logic directly:
```python
from services import ItemService
from feather.exceptions import ValidationError
import pytest

def test_create_item_validates_name(app):
    with app.app_context():
        service = ItemService()
        with pytest.raises(ValidationError):
            service.create(name='')  # Empty name should fail

def test_create_item_success(app):
    with app.app_context():
        service = ItemService()
        item = service.create(name='Valid Name')
        assert item.id is not None
```

**Model tests** — test data layer:
```python
def test_item_defaults(app):
    with app.app_context():
        item = Item(name='Test')
        db.session.add(item)
        db.session.commit()

        assert item.id is not None
        assert item.created_at is not None
```

### Adding Test Fixtures

Extend `conftest.py` for common test data:

```python
# tests/conftest.py
import pytest
from models import User, Item

@pytest.fixture
def authenticated_user(app):
    """Create and login a test user."""
    with app.app_context():
        user = User(email='test@example.com', active=True)
        db.session.add(user)
        db.session.commit()

        with app.test_client() as client:
            # Simulate login (adjust based on your auth setup)
            with client.session_transaction() as sess:
                sess['_user_id'] = user.id
            yield client

@pytest.fixture
def sample_items(app):
    """Create sample items for testing."""
    with app.app_context():
        items = [Item(name=f'Item {i}') for i in range(3)]
        db.session.add_all(items)
        db.session.commit()
        return items
```

### Test Database

Tests run against a separate test database (automatically configured). Each test gets a fresh database state:

1. **Before each test:** Tables are created
2. **After each test:** Tables are dropped and the engine is disposed

This means tests are isolated—one test can't affect another.

**A fixture gotcha worth knowing.** Flask-Login caches the current user on
`g` for the lifetime of an application context. A fixture that holds one
`app.app_context()` open across requests from several test clients will
resolve every request to the first user loaded. Seed your data inside a
context, then make requests outside it:

```python
@pytest.fixture
def seeded(app):
    with app.app_context():
        db.session.add(User(email='a@example.com'))
        db.session.commit()
    # context closed before the test makes any request
    return app
```

### Framework Tests (Contributors)

If you're contributing to Feather itself (not building an app), run the framework test suite:

```bash
feather test --framework           # Full suite
feather test -f --fast             # Skip slow tests
feather test -f -m unit            # Run by marker
feather test -f --clean            # Remove test artifacts
```

**Markers:**
| Marker | What It Tests |
|--------|---------------|
| `unit` | Pure functions, no I/O |
| `integration` | Database, services |
| `e2e` | Full request/response cycles |
| `scaffolding` | `feather new` output |
| `jobs` | Background job system |
| `api_contract` | API response formats |

Most app developers won't need these—they're for testing the framework code in `feather/`.

---

## Reference

### CLI Reference

```bash
# Project Commands
feather new <name>              # Create project (interactive)
feather new <name> --no-prompt  # Use minimal defaults
feather dev                     # Dev server with Vite HMR (port 5173)
feather dev --no-vite           # Flask only (port 5000)
feather build                   # Build assets for production
feather start                   # Start production server (Gunicorn)
feather start --workers 8       # Multiple workers
feather start --worker-class gevent  # Async workers

# Development Commands
feather routes                  # List all registered routes
feather shell                   # Python shell with app context
feather check                   # Check the project against Feather's conventions
feather check --json            # Machine-readable findings for CI
feather check --only templates  # One group of rules
feather check --strict          # Warnings fail too
feather components              # Every component macro with its signature
feather components --markdown -o docs/components.md

# Security
feather security-check          # Audit config, cookies, secrets, dependencies
feather security-check --json   # Machine-readable output for CI
feather security-check --env-file prod.env  # Audit an env file alone

# Testing (App)
feather test                    # Run project tests
feather test -v                 # Verbose output
feather test --no-coverage      # Skip coverage report
feather test -p tests/test_api.py  # Test specific file
feather test -- -k "test_user"  # Pass args to pytest

# Testing (Framework Contributors)
feather test --framework        # Run all framework tests
feather test -f -m unit         # Run by marker
feather test -f --fast          # Skip slow tests
feather test -f --list-markers  # Show available markers
feather test -f --clean         # Clean test artifacts

# Database Commands
feather db init                 # Create a migrations/ directory (see note below)
feather db migrate -m "msg"     # Generate migration from model changes
feather db upgrade              # Apply pending migrations
feather db downgrade            # Revert last migration
feather db seed                 # Run seeds.py

# Code Generation
feather generate model Post title:string content:text
feather generate model Post --soft-delete   # Add SoftDeleteMixin
feather generate model Card --ordered       # Add OrderingMixin
feather generate service PostService
feather generate island like-button
feather generate route users --model User   # API CRUD routes
feather generate route dashboard --page     # Page route with template
feather generate serializer UserSerializer id email

# Worker Commands (RQ backend)
feather worker                  # Start RQ worker (default queue)
feather worker high default low # Process specific queues (priority order)
feather worker --burst          # Exit when queue is empty
feather worker --simple          # Force SimpleWorker (no fork; default on macOS)
feather worker --fork            # Force the forking worker (default on Linux)
feather worker --no-scheduler   # Disable delayed job scheduler

# Job Queue Management (thread and RQ backends)
feather jobs status             # Show queue status and counts
feather jobs list               # List all jobs
feather jobs list --status failed    # Filter by status
feather jobs list --queue high  # List jobs in specific queue (RQ)
feather jobs list --stuck       # Show jobs running too long (thread)
feather jobs info <job_id>      # Show job details
feather jobs failed             # List failed/timed-out jobs
feather jobs retry <job_id>     # Re-queue a failed job
feather jobs clear              # Clear job history

# Administration (multi-tenant)
feather platform-admin <email>          # Grant platform admin
feather platform-admin <email> --revoke # Revoke platform admin

# Deployment
feather docker init             # Write Dockerfile, compose files, deploy/ scripts, .env.example
feather docker init --force     # Overwrite files that already exist
feather docker init --domain example.com   # Seed DOMAIN in .env.example
feather docker init --no-worker # Skip the background-job worker service

# Environment
feather env check               # Which env keys config.py reads, and which are missing
feather env check --env-file prod.env      # Check a production env file
feather env check --json        # Machine-readable output for CI
```

> **`feather db init` is for apps that have no `migrations/` directory.**
> `feather new` already scaffolds one, so running it in a fresh app fails with
> "directory migrations already exists". You need it when you add a database to
> an app scaffolded without one, or after deleting `migrations/` to start the
> migration history over.

### Configuration

The scaffolded `config.py` includes sensible defaults:

```python
# config.py
import os

class Config:
    SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key')
    DATABASE_URL = os.environ.get('DATABASE_URL', 'postgresql://localhost/myapp')
    SQLALCHEMY_TRACK_MODIFICATIONS = False

    # Session cookies (for OAuth)
    SESSION_COOKIE_SAMESITE = "Lax"
    SESSION_COOKIE_HTTPONLY = True

class DevelopmentConfig(Config):
    DEBUG = True
    SESSION_COOKIE_SECURE = False   # Allow HTTP
    SESSION_PROTECTION = "basic"    # Relaxed for Vite proxy

class ProductionConfig(Config):
    DEBUG = False
    SESSION_COOKIE_SECURE = True    # HTTPS only
    SESSION_PROTECTION = "basic"    # Marks session non-fresh on IP/UA change
```

Environment variables (`.env`):

```bash
# Required
SECRET_KEY=your-production-secret-key
DATABASE_URL=postgresql://user:pass@localhost/myapp

# Authentication
GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret
SESSION_LIFETIME_DAYS=7           # Session expiry (default: 7)

# Multi-tenancy
FEATHER_MULTI_TENANT=true              # Enable multi-tenant mode
FEATHER_ALLOW_PUBLIC_EMAILS=true       # Allow Gmail, Outlook, etc. (B2B+B2C)

# Storage
STORAGE_BACKEND=local             # 'local' or 'gcs'
GCS_BUCKET=my-bucket              # Required for gcs backend

# Caching
CACHE_BACKEND=memory              # 'memory' or 'redis'
CACHE_URL=redis://localhost:6379/0

# Background Jobs
JOB_BACKEND=thread                # 'sync', 'thread', or 'rq'
JOB_MAX_WORKERS=4                 # Thread pool size (thread backend)
REDIS_URL=redis://localhost:6379/0  # Required for rq backend

# Logging
LOG_LEVEL=INFO                    # DEBUG, INFO, WARNING, ERROR
LOG_FORMAT=json                   # Enable JSON logs (auto in production)
```

#### Configuration Reference

Every key Feather reads, with its default. Set them in `config.py` or the
environment; `config.py` wins.

**Core**

| Key | Default | Purpose |
|-----|---------|---------|
| `SECRET_KEY` | dev key | Signs sessions. Refused outside debug if left at the default. |
| `DATABASE_URL` | sqlite | SQLAlchemy connection string. |
| `FLASK_CONFIG` / `FLASK_ENV` | development | Which config class to load. Accepts `production`, `prod`, `development`, `dev`, `testing`, `test`. Leaving both unset logs a warning and selects development. |
| `SESSION_LIFETIME_DAYS` | `7` | Session expiry. |
| `REMEMBER_COOKIE_DAYS` | `365` | Remember-me cookie lifetime. |
| `WTF_CSRF_TIME_LIMIT` | `None` | Token lifetime. `None` means the session bounds it. |
| `LOG_LEVEL`, `LOG_FORMAT` | `INFO`, plain | Logging. JSON is automatic in production. |

**Hosting and proxies**

| Key | Default | Purpose |
|-----|---------|---------|
| `TRUSTED_HOSTS` | unset | Hostnames this app answers to, comma-separated or a list. A request with any other `Host` gets 400. Set this in production. |
| `FEATHER_PROXY_FIX` | `True` | Install ProxyFix so forwarded headers are honoured. Turn it off when nothing in front of the app normalises them. |
| `FEATHER_PROXY_FIX_NUM` | `1` | Number of trusted proxy hops. |
| `OAUTH_CALLBACK_URL` | unset | Pins the OAuth redirect URI. Without it the URI comes from the request Host header. |
| `FEATHER_SECURITY_HEADERS` | `True` | Send CSP, HSTS and friends in production. |
| `FEATHER_PERMISSIONS_POLICY` | camera and microphone denied | Permissions-Policy header value. |

**Authentication and tenancy**

| Key | Default | Purpose |
|-----|---------|---------|
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | unset | Google OAuth credentials. |
| `FEATHER_MULTI_TENANT` | `False` | Enable multi-tenant mode. |
| `FEATHER_ALLOW_PUBLIC_EMAILS` | `False` | Allow Gmail, Outlook and similar domains. |
| `FEATHER_PRE_REGISTER_CALLBACK`, `FEATHER_POST_LOGIN_CALLBACK` | unset | Dotted paths to hooks run around sign-up and login. |
| `SESSION_PROTECTION` | `basic` | Flask-Login session protection. |

**Storage, cache and jobs**

| Key | Default | Purpose |
|-----|---------|---------|
| `STORAGE_BACKEND` | `local` | `local` or `gcs`. |
| `GCS_BUCKET` | unset | Required for the `gcs` backend. |
| `STORAGE_BLOCKED_EXTENSIONS` | `html, htm, svg, xhtml, xml, js, mjs, php, phtml` | Extensions `LocalStorage.upload` refuses, because `static/` serves them with script-capable content types on your own origin. Setting this replaces the list. |
| `STORAGE_ALLOWED_EXTENSIONS` | unset | Allow-list. Wins over the block list, so this is how you permit SVG. |
| `CACHE_BACKEND`, `CACHE_URL` | `memory` | `memory` or `redis`. |
| `JOB_BACKEND` | `thread` | `sync`, `thread` or `rq`. |
| `JOB_MAX_WORKERS` | `4` | Thread pool size for the thread backend. |
| `JOB_SERIALIZER` | pickle | Set to `json` for RQ. Pickle payloads are code execution for anyone who can write to Redis. |
| `RATELIMIT_STORAGE_URI` | unset | Where Flask-Limiter keeps counters in a scaffolded app. Falls back to `REDIS_URL`, then to memory with a warning. |
| `REDIS_URL` | unset | Required for the `rq` backend. |

**Development**

| Key | Default | Purpose |
|-----|---------|---------|
| `VITE_DEV_SERVER` | `http://localhost:5173` | Where debug-mode island scripts are loaded from. |
| `FEATHER_NO_VITE` | unset | Serve built island assets instead. `feather dev --no-vite` sets it. |
| `FEATHER_LENIENT_DISCOVERY` | `False` | Warn and continue when a `models/`, `services/` or `routes/` module fails to import, instead of failing startup. |
| `FEATHER_NO_UPDATE_CHECK` | unset | Skip the CLI's PyPI version check. |

---

## Production

### Dependencies

The core install is what every Feather app uses: Flask, Flask-SQLAlchemy,
Flask-Migrate, Flask-Login, Flask-WTF, Werkzeug, SQLAlchemy, Alembic, Authlib,
Requests, Jinja2 and urllib3.

Everything else is an extra, so an app that renders no PDFs does not install a
PDF renderer:

| Extra | Brings | Enable it when |
|-------|--------|----------------|
| `postgres` | psycopg2-binary | `DATABASE_URL` is a `postgresql://` URL |
| `redis` | redis, rq | `CACHE_BACKEND=redis` or `JOB_BACKEND=rq` |
| `email` | resend | Sending transactional email |
| `gcs` | google-cloud-storage | `STORAGE_BACKEND=gcs` |
| `pdf` | WeasyPrint | Generating PDFs |
| `ratelimit` | flask-limiter | Rate limiting that works across workers (auth apps) |
| `prod` | gunicorn | Running `feather start` or the Docker web process |
| `test` | pytest, pytest-cov | Running the app's own tests |
| `all` | all of the above | Reproducing the pre-0.9.8 install |

```bash
pip install "feather-framework[postgres,redis,prod,test]"
```

`feather new` writes a `requirements.txt` naming the extras your answers
enabled, so this is usually handled for you. `feather --version` and
`feather security-check` report which extras are installed.

Importing a feature whose extra is missing raises an error naming the exact
install command, rather than an import traceback.

**Frontend libraries** (bundled via npm, no CDN):
- HTMX, Idiomorph, Apache ECharts

**External resources** (loaded from Google):
- Google Fonts and Material Icons

### DEBUG Mode Behavior

Understanding DEBUG mode is crucial for production:

| Setting | Asset Loading | Description |
|---------|--------------|-------------|
| `DEBUG=True` | Vite dev server (`localhost:5173`) | Hot reload, no build needed |
| `DEBUG=False` | Built assets from `static/dist/` | Requires `feather build` first |

**Common issue:** Unstyled pages in production happen when:
1. `DEBUG=False` but `feather build` wasn't run
2. The `static/dist/` directory is missing or outdated

**Solution:** Always run `feather build` before deploying.

### Configuration Shorthand

You can use shorthand config names with `FLASK_CONFIG`:

```bash
# These are equivalent:
FLASK_CONFIG=production
FLASK_CONFIG=ProductionConfig
FLASK_CONFIG=prod
```

Supported shorthands: `development`/`dev`, `production`/`prod`, `testing`/`test`

### Health Check Endpoint

Feather registers three endpoints on every app — you don't write them:

| Endpoint | Checks | Use for |
|----------|--------|---------|
| `/health` | Process **and** database connectivity. 200 when healthy, 503 when not | Load balancers, Docker `HEALTHCHECK`, uptime monitors |
| `/health/live` | Process only — always 200 if Python is running | Kubernetes liveness probe |
| `/health/ready` | Same checks as `/health` | Kubernetes readiness probe |

```bash
curl https://example.com/health
# {"status": "healthy", "timestamp": "...", "checks": {"database": "ok"}}
```

Prefer `/health` over a hand-written route: a container with a broken
`DATABASE_URL` reports unhealthy instead of quietly serving 500s.

> Apps scaffolded before 0.9.7 also have a `routes/api/health.py` giving
> `/api/health`. That route is yours, not the framework's, and it only proves
> the process is listening. Point new health checks at `/health`.

---

### Deploying with Docker

Feather deploys as a Docker image behind [Caddy](https://caddyserver.com/), on
one machine. A €7/month VPS runs the app, Postgres, Redis and TLS termination
with room to spare, and the whole thing is eight files in your repository.

`feather new` scaffolds them. To add them to an existing app:

```bash
feather docker init                     # writes the files below, never overwrites
feather docker init --force             # overwrite existing files
feather docker init --domain example.com
feather docker init --no-worker         # no background-job worker service
```

It inspects your project to decide what to generate — a `db` service if you have
a database, `redis` and a `worker` if you use background jobs — and it never
overwrites a file that already exists unless you pass `--force`.

#### What gets generated

| File | What it does |
|------|--------------|
| `Dockerfile` | Multi-stage build. A `base` stage (python:3.11-slim, non-root `app` user), a `frontend` stage (node:22-alpine, `npm ci --ignore-scripts`, `npm run build`), a `worker` target and a `web` target. No Node in the runtime image. |
| `.dockerignore` | Keeps `venv/`, `node_modules/`, `.git/`, `.env*`, `logs/`, `static/dist/` and tests out of the build context. |
| `docker-compose.yml` | Production stack: `caddy`, `web`, `worker` (if you enabled jobs), `db` (postgres:16), `redis` (valkey:8). Only Caddy publishes ports. |
| `docker-compose.dev.yml` | Postgres and Redis on localhost for local development. Nothing else. |
| `deploy/Caddyfile` | TLS, compression, `reverse_proxy web:8000`, the proxy header contract. |
| `deploy/deploy.sh` | Build, migrate once, swap containers, wait for health. |
| `deploy/backup.sh` | Nightly `pg_dump` with retention, for cron. |
| `.env.example` | Every key this app reads, secrets blanked, with the compose-provided ones marked. |

The `web` target is last in the Dockerfile, so a plain `docker build .` produces
the web image; the worker is `docker build --target worker .`. Both come from
one Dockerfile and share a layer cache.

Two details in the Dockerfile that are easy to break:

- Feather is installed on its own layer, **before** the rest of
  `requirements.txt`, because the frontend stage copies the framework's
  templates out of that layer. Tailwind scans them for the class names the
  built-in components use. Remove that copy and every framework component
  renders unstyled in production.
- **Migrations do not run in `CMD`.** Two web containers starting at once would
  race on `feather db upgrade`. `deploy/deploy.sh` runs them exactly once.

#### Local development

The app itself stays on your machine so `feather dev` keeps Vite's hot reload.
Only the dependencies go in containers:

```bash
docker compose -f docker-compose.dev.yml up -d    # Postgres + Redis
feather dev                                        # Flask + Vite on the host
```

The ports and credentials match the `DATABASE_URL` and `REDIS_URL` in the
generated `.env`, so nothing else needs configuring. Bring up one service alone
with `docker compose -f docker-compose.dev.yml up -d db`. Stop them with `down`;
add `-v` to throw the local data away.

You can still build and run the production image locally to check it:

```bash
docker compose build web
docker compose run --rm web feather security-check
```

#### First deploy to a VPS

Any Ubuntu 24.04 box works — Hetzner, DigitalOcean, Vultr. Four vCPU and 8 GB
is comfortable for an app plus its database.

**1. Point DNS at the server.** An `A` record for the hostname you'll put in
`DOMAIN`, TTL 300 until you're happy. Caddy cannot issue a certificate before
DNS resolves to the machine, so do this first.

**2. Create a deploy user and install Docker.** As root on the fresh box:

```bash
adduser deploy && usermod -aG sudo deploy
# copy your SSH key to /home/deploy/.ssh/authorized_keys, then disable
# root login and password auth in /etc/ssh/sshd_config

apt update && apt install -y ca-certificates curl git ufw fail2ban unattended-upgrades
curl -fsSL https://get.docker.com | sh
usermod -aG docker deploy

ufw allow OpenSSH && ufw allow 80/tcp && ufw allow 443/tcp && ufw allow 443/udp
ufw enable
```

**3. Get the code onto the server.** As `deploy`:

```bash
sudo mkdir -p /opt/myapp && sudo chown deploy:deploy /opt/myapp
git clone git@github.com:you/myapp.git /opt/myapp
```

**4. Write `.env` on the server.** This file never enters git and never goes
into the image. It is the single source of truth for production secrets.

```bash
cd /opt/myapp
cp .env.example .env
chmod 600 .env
nano .env
```

At minimum:

```bash
DOMAIN=example.com
POSTGRES_PASSWORD=          # python -c "import secrets; print(secrets.token_urlsafe(32))"
SECRET_KEY=                 # python -c "import secrets; print(secrets.token_urlsafe(48))"
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
```

`docker-compose.yml` reads `DOMAIN` and `POSTGRES_PASSWORD` itself for `${...}`
interpolation, and passes the whole file into the containers with
`env_file: .env` — so `POSTGRES_PASSWORD` is written once and `DATABASE_URL` is
built from it. Don't set `FLASK_CONFIG`, `PORT`, `WEB_CONCURRENCY`,
`DATABASE_URL`, `REDIS_URL` or `JOB_BACKEND` here; compose sets those on the
container and a duplicate in `.env` only creates a way for them to disagree.

Run `feather env check` to see which keys your `config.py` actually reads and
which are still missing from `.env`. It exits non-zero when a key that has no
fallback is unset, so it works as a CI or deploy gate.

**5. Deploy.**

```bash
./deploy/deploy.sh
```

Watch the first run — Caddy requests a certificate while the app starts, and
`docker compose logs -f caddy` shows whether issuance worked. When the script
prints `Healthy. Deploy complete.` the site is live over HTTPS.

#### What `deploy/deploy.sh` does

```bash
./deploy/deploy.sh          # deploy the current checkout
./deploy/deploy.sh --pull   # git pull --ff-only first
```

In order:

1. **`docker compose build`** — every service. Web and worker are separate
   images even though they share a Dockerfile; building only `web` leaves the
   worker running last week's code.
2. **`docker compose up -d db redis`** — dependencies first, so the migration
   step has something to talk to.
3. **`docker compose run --rm web feather db upgrade`** — migrations, once, in a
   throwaway container built from the *new* image. The old containers are still
   serving while this runs, so a migration that fails leaves the site up.
4. **`docker compose up -d --remove-orphans`** — swap the containers.
5. **Wait for health.** It polls Docker's own health status for the `web`
   container (which runs `curl /health`, which checks the database) for up to
   120 seconds. Healthy: prune dangling images and exit 0. Unhealthy or timed
   out: dump the last 50 log lines and exit 1.

The ordering is the whole point. Migrations run exactly once, against the image
about to serve traffic, before any long-lived container starts — so two web
containers can never race on the same Alembic upgrade, and a migration failure
is not a partial deploy.

There is no automatic rollback. If a deploy goes bad, check out the previous
commit and run `./deploy/deploy.sh` again; migrations already applied are not
reverted, so undo those deliberately with `feather db downgrade`. Confirm what
the database is actually at:

```bash
docker compose exec -T db psql -U myapp -d myapp -c "SELECT version_num FROM alembic_version;"
```

#### TLS and the proxy headers

Caddy obtains and renews Let's Encrypt certificates automatically for every
hostname in `deploy/Caddyfile`. There is nothing to run, no certbot cron, no
renewal to forget. The generated file is:

```
{$DOMAIN} {
	encode gzip zstd

	reverse_proxy web:8000 {
		header_up X-Real-IP {remote_host}
	}
}
```

`{$DOMAIN}` comes from `.env` via compose. To serve `www` as well, add a second
block — a certificate is issued for each hostname that appears in the file:

```
www.example.com {
	redir https://example.com{uri} permanent
}
```

Two headers are load-bearing:

- **`X-Forwarded-Proto` and `Host`** — Caddy sets these by default and Feather's
  ProxyFix reads them. Without them Google OAuth builds an `http://`
  `redirect_uri` and secure session cookies are dropped on every response.
- **`X-Real-IP`** — Caddy does *not* set this one, which is why the generated
  config does. Rate limiting and any geo logic read it; when it goes missing
  they fail silently rather than loudly.

After editing the Caddyfile, validate before reloading:

```bash
docker compose exec -T caddy caddy validate --config /etc/caddy/Caddyfile
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile
```

#### Backups

`deploy/backup.sh` dumps Postgres in `pg_dump` custom format (compressed and
restorable table by table) and keeps 14 days. Run it from cron on the host:

```bash
0 3 * * * /opt/myapp/deploy/backup.sh >> /var/log/myapp-backup.log 2>&1
```

Tune with `BACKUP_DIR` and `BACKUP_KEEP_DAYS`. Restore:

```bash
docker compose exec -T db pg_restore -U myapp -d myapp --clean < backups/myapp-<stamp>.dump
```

A backup on the same disk as the database is not a backup. Add a step that
copies the dump off the machine — object storage, another host, anywhere — and
consider encrypting it on the way out:

```bash
openssl enc -aes-256-cbc -pbkdf2 -pass file:/root/.backup.key \
    -in "$dump" -out "$dump.enc" && rm "$dump"
```

Everything that matters lives in Postgres and your object storage. The
containers and Redis are disposable; the dumps are not.

#### Continuous deployment with GitHub Actions

The shape that works: test on a runner with real service containers, then SSH
in and run the same `deploy/deploy.sh` you'd run by hand. The server builds its
own images, so CI needs no registry and holds no application secrets — two
repository secrets total, `SSH_KEY` (the private key for `deploy@`) and
`SSH_HOST`.

```yaml
# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]
    paths-ignore: ["**.md"]
  workflow_dispatch:

# Never cancel a deploy in flight: a half-applied migration is worse
# than a queued release.
concurrency:
  group: deploy-production
  cancel-in-progress: false

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: myapp_ci
        options: >-
          --health-cmd pg_isready --health-interval 10s
          --health-timeout 5s --health-retries 5
        ports: ["5432:5432"]
      redis:
        image: valkey/valkey:8
        options: >-
          --health-cmd "valkey-cli ping" --health-interval 10s
          --health-timeout 5s --health-retries 5
        ports: ["6379:6379"]
    env:
      DATABASE_URL: postgresql://postgres:postgres@localhost:5432/myapp_ci
      REDIS_URL: redis://localhost:6379/0
      SECRET_KEY: ci-not-a-real-secret
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.11", cache: pip }
      - uses: actions/setup-node@v4
        with: { node-version: "22", cache: npm }
      - run: pip install -r requirements.txt
      - run: npm ci --ignore-scripts && npm run build
      - run: feather db upgrade      # proves migrations apply from scratch
      - run: feather test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - name: Load the deploy key
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          ssh-keyscan -H "${{ secrets.SSH_HOST }}" >> ~/.ssh/known_hosts
      - name: Build, migrate and restart
        run: ssh deploy@${{ secrets.SSH_HOST }} 'cd /opt/myapp && ./deploy/deploy.sh --pull'
```

Running `feather db upgrade` against an empty Postgres in CI is the cheapest
migration test there is: it catches a migration chain that no longer applies
before the chain reaches production.

**Hardening the CI key.** A key that can run any command is a key that can read
your `.env`. Lock it to one command with a forced command in the server's
`~deploy/.ssh/authorized_keys`:

```
command="/opt/myapp/deploy/deploy.sh --pull",no-agent-forwarding,no-port-forwarding,no-pty ssh-ed25519 AAAA... github-actions
```

The workflow's `ssh` argument is then ignored and the key cannot open a shell.

#### Production checklist

Before the first real user:

- [ ] `feather security-check` passes (run it on the server against the live
      file: `feather security-check --env-file .env`)
- [ ] `SECRET_KEY` is a real random value, not the scaffolded placeholder
- [ ] `FLASK_CONFIG=production` — set by compose; confirm with
      `docker compose exec web printenv FLASK_CONFIG`
- [ ] `TRUSTED_HOSTS` lists the hostnames you serve, so Host-header spoofing
      cannot forge absolute URLs in emails and redirects
- [ ] `OAUTH_CALLBACK_URL` (and the matching redirect URI in Google Cloud
      Console) uses `https://` and the exact hostname, `www` included
- [ ] `JOB_SERIALIZER=json` — the default in the generated compose file. `pickle`
      will unpickle arbitrary objects off Redis; only switch if a job argument
      genuinely cannot be JSON-encoded
- [ ] `.env` on the server is `chmod 600` and not in git
- [ ] `feather env check` reports no missing keys
- [ ] A database that isn't SQLite, with `deploy/backup.sh` in cron and one
      restore actually tested
- [ ] An external uptime monitor pointed at `https://<domain>/health`
- [ ] `docker compose logs` capped (the generated file sets `max-size: 20m`,
      `max-file: 3` — Docker's default grows until the disk fills)
- [ ] Server backups or snapshots enabled at the provider as well

#### Upgrading from `feather deploy render`

0.9.7 removed `feather deploy render`. Your existing `Dockerfile` and
`render.yaml` are your files and keep working — nothing was deleted from your
repo. To adopt the new layout, run `feather docker init`; it will not overwrite
anything without `--force`.

One change matters even if you stay where you are: replace the absolute
`@source` line in `static/css/app.css` with

```css
@source "../../.feather-templates/**/*.html";
```

and add `.feather-templates` to `.gitignore`. Older scaffolds baked the absolute
path of the installed Feather package into that file, so images built anywhere
but the machine that ran `feather new` silently lack every framework component
style.

---

## Upgrading

Apps pin the framework (`feather-framework[extras]==X.Y.Z` in
`requirements.txt`) and move when they bump the pin, so an upgrade never
happens by surprise. `CHANGELOG.md` carries an "Upgrade notes" block for every
release that needs one; read the blocks between your pin and your target.

The upgrade itself:

```bash
# 1. bump the pin, then
pip install -r requirements.txt
feather check            # conventions, exit 1 on error
feather security-check   # secrets, cookies, debug, dependency floors
feather env check        # env keys config.py reads vs what is set
pytest                   # the app's own suite
```

Two changes account for most upgrade breakage:

**Extras (0.9.8).** A bare install no longer brings WeasyPrint,
google-cloud-storage, psycopg2, redis, rq, resend, gunicorn or pytest. An app
that used any of them must name the extra, or the first import fails with a
message naming the install command. See [Dependencies](#dependencies) for the
table. `feather-framework[all]` reproduces the pre-0.9.8 install if you would
rather not work out the list now.

**The Tailwind `@source` path (0.9.7).** Covered just above. It fails silently
rather than loudly, which is why it is worth checking even if everything looks
fine locally.

After that, the things worth grepping for, all of which the changelog explains
in full:

| Look for | Because |
|----------|---------|
| `_queue_instance`, `_cache_instance` | Backends moved to `app.extensions["feather"]`; assigning to the old names does nothing. Use `feather.core.registry.set_backend` |
| `href="/auth/logout"` | `GET /auth/logout` is deprecated; POST it |
| `"AUTHORIZATION_ERROR"` in string comparisons | A suspended user now reports `ACCOUNT_SUSPENDED` |
| `@job` functions taking `timeout`, `retry`, `concurrency`, `delay` or `queue_name` | Those names are reserved by the enqueue call |
| `cache_response` on public pages | It varies on the current user by default now; pass `vary_on_user=False` |
| A broken module under `models/`, `services/` or `routes/` | Discovery is strict: it stops the app rather than silently dropping the module. `FEATHER_LENIENT_DISCOVERY=1` restores the old behaviour while you fix it |

A framework upgrade is a good moment to run `feather security-check`, which
fails on dependency versions below the floors each release sets.

---

## Troubleshooting

**Tail logs in a second terminal:** `tail -f logs/app.log` — shows detailed Flask output.

**Flask won't start:** Run `python app.py` directly to see the full traceback.

**Port in use:**
```bash
lsof -ti:5000 | xargs kill -9  # Flask
lsof -ti:5173 | xargs kill -9  # Vite
```

---

## Tutorials

Step-by-step guides for building complete applications with Feather. Each tutorial builds on the previous one, covering every major feature.

**[Kanban Tutorial Series](tutorials/index.md)** - Build a production-ready Kanban board:

| Part | Title | Features Covered |
|------|-------|------------------|
| 1 | [Static Board UI](tutorials/01-static-board-ui.md) | Templates, Components, Tailwind |
| 2 | [Persistent Boards](tutorials/02-persistent-boards.md) | Models, HTMX, Partials |
| 3 | [Drag-and-Drop](tutorials/03-drag-and-drop.md) | Islands, OrderingMixin, Optimistic Updates |
| 4 | [Personal Kanban](tutorials/04-personal-kanban.md) | Auth, Admin, GCS Storage, Jobs |
| 5 | [SaaS Kanban](tutorials/05-saas-kanban.md) | Multi-tenancy, Platform Admin |
| 6 | [Deploying](tutorials/06-deploying.md) | Docker, Caddy, VPS, backups, CI/CD |

---

## License

MIT
