# Vibetuner > Vibetuner is a production-ready FastAPI project scaffolding tool that generates full-stack web applications with > authentication, flexible database support (MongoDB or SQL), frontend, Docker deployment, and CLI tools > pre-configured in seconds. Built by All Tuner Labs for rapid iteration and modern development. Important notes: - Vibetuner consists of four packages: a Python framework (`vibetuner`), a JavaScript build-deps package (`@alltuner/vibetuner`) which bundles tailwind, daisyui, htmx and an npm-shipped mirror of the framework's jinja templates (`@alltuner/vibetuner-jinja`, pulled in as a transitive — consumers don't install it directly), and a Copier scaffolding template - The framework separates immutable framework code (`vibetuner` package) from your application code (`src/app/`) for clean updates - In all examples, `app` refers to your project's Python package (the directory under `src/`). The actual name depends on your project slug (e.g., `src/myproject/` for a project named "myproject") - HTMX is used instead of React/Vue for simplicity - server-rendered HTML with sprinkles of interactivity - All tools are chosen for speed: uv (Python), bun (JavaScript), Granian (ASGI server), Ruff (linting) - The project is designed to work excellently with AI coding assistants like Claude, Cursor, and ChatGPT ## Quick Start ### Prerequisites - **Python 3.11+**: [python.org/downloads](https://www.python.org/downloads/) - **Docker**: [docker.com/get-started](https://www.docker.com/get-started/) (for containerized development) That's it! Vibetuner handles the rest. ### Create Your First Project #### Using uvx (Recommended) No installation needed: ```bash uvx vibetuner scaffold new my-app ``` #### Install Globally ```bash uv tool install vibetuner vibetuner scaffold new my-app ``` #### Interactive Setup The scaffold command will ask you: - **Project name**: `my-app` - **Company name**: Your company/organization - **Author details**: Name and email - **Features**: OAuth providers, background jobs, etc. #### Skip Prompts Use defaults for everything: ```bash uvx vibetuner scaffold new my-app --defaults ``` ### Start Development ```bash cd my-app just dev ``` For projects using SQL models (SQLModel/SQLite/PostgreSQL), create tables first: ```bash uv run vibetuner db create-schema # Required once before first run just dev ``` This starts: - Database (MongoDB or SQL, if configured) - Redis (if background jobs enabled) - FastAPI application with hot reload - Frontend asset compilation Visit `http://localhost:8000` - your app is running! ### Project Structure ```text my-app/ ├── src/app/ # Your code (edit freely) │ ├── frontend/routes/ # Your HTTP routes │ ├── models/ # Your database models │ └── services/ # Your business logic ├── templates/ # Jinja2 templates ├── assets/ # Static files └── Dockerfile # Production deployment ``` The `vibetuner` framework (auth, database, core services) is installed as a package dependency. ### Justfile Commands All project management tasks use `just` (command runner). Run `just` to see all available commands. **Development:** ```bash just dev # Docker development with hot reload (recommended) just local-dev PORT=8000 # Local development without Docker just worker-dev # Background worker (if background jobs enabled) ``` **Dependencies:** ```bash just install-deps # Install from lockfiles just update-repo-deps # Update root scaffolding dependencies uv add package-name # Add Python package bun add package-name # Add JavaScript package ``` **Code Formatting:** ```bash just format # Format ALL code (Python, Jinja, TOML, YAML) just format-py # Format Python with ruff just format-jinja # Format Jinja templates with djlint just format-toml # Format TOML files with taplo just format-yaml # Format YAML files with dprint ``` **IMPORTANT**: Always run `ruff format .` or `just format-py` after Python changes. **Code Linting:** ```bash just lint # Lint ALL code just lint-py # Lint Python with ruff just lint-jinja # Lint Jinja templates with djlint just lint-md # Lint markdown files just lint-toml # Lint TOML files with taplo just lint-yaml # Lint YAML files with dprint just type-check # Type check at the repo root (template + tooling) just type-check-py # Type check the vibetuner-py framework code with ty ``` **Localization:** ```bash just i18n # Full workflow: extract, update, compile just extract-translations # Extract translatable strings just update-locale-files # Update existing .po files just compile-locales # Compile .po to .mo files just new-locale LANG # Create new language (e.g., just new-locale es) just i18n-fuzzy-audit # Report fuzzy-marked entries across catalogs ``` `update-locale-files` runs `msgmerge --no-fuzzy-matching`: new strings land with an empty `msgstr` (honest untranslated → English fallback) instead of a wrong guess copied from a textually similar entry. The i18n recipes also pass `--no-wrap`, keeping every `msgid`/`msgstr` on a single line so catalogs don't drift when gettext's line-wrapping differs between macOS and the Linux used in CI. **CI/CD & Deployment:** ```bash just build-dev # Build development Docker image just test-build-prod # Test production build locally just build-prod # Build production image just release # Build and release production image just deploy-latest HOST # Deploy to remote host ``` **Scaffolding:** ```bash just update-scaffolding # Update project to latest vibetuner template just deps-scaffolding # Update deps + scaffolding on the current branch (no PR) just deps-scaffolding-pr # Update deps + scaffolding in a worktree and open a PR ``` ## Core Documentation ### Development Guide #### Development Environment Vibetuner supports two development modes: ##### Docker Development (Recommended) Run everything in containers with hot reload: ```bash just dev ``` This starts: - Database (MongoDB or SQL, if configured) - Redis (if background jobs enabled) - FastAPI application with auto-reload - Frontend asset compilation with watch mode Changes to Python code, templates, and assets automatically reload. ##### Local Development Run services locally without Docker: ```bash # Terminal 1: Frontend assets bun dev # Terminal 2: Backend server just local-dev ``` A database (MongoDB or SQL) is required if using database features. Redis is only required if background jobs are enabled. #### Adding New Routes Create a new file in `src/app/frontend/routes/`: ```python # src/app/frontend/routes/blog.py from fastapi import APIRouter router = APIRouter(prefix="/blog", tags=["blog"]) @router.get("/") async def list_posts(): return {"posts": []} ``` Register in `src/app/frontend/__init__.py`: ```python from app.frontend.routes import blog app.include_router(blog.router) ``` **`@render` decorator** — for simple routes, eliminate `render_template()` boilerplate: ```python from vibetuner import render @router.get("/dashboard") @render("dashboard.html.jinja") async def dashboard(request: Request, user=Depends(get_current_user)) -> dict: return {"user": user} ``` Returns `Response` objects unchanged (escape hatch for redirects, conditional logic). #### Adding Database Models Create models in `src/app/models/`. The approach depends on your database choice: ##### MongoDB (Beanie ODM) ```python # src/app/models/post.py from beanie import Document from pydantic import Field class Post(Document): title: str content: str published: bool = Field(default=False) class Settings: name = "posts" ``` Beanie builds queries from class-level field access (`Post.title == "x"`, `Eq(cls.author.id, user.id)`), which static checkers don't model: `ty` resolves `Model.field` from the field's instance annotation, not as a query expression. Most comparisons type-check, but two patterns trip a spurious `unresolved-attribute` in `just lint`: an optional link in a query (`field: Link[X] | None` with `cls.field.id` → `id is not defined on None`, fundamental to how `ty` treats `X | None`, not fixable via field/alias changes), and queries inside a classmethod on a non-`Document` (`BaseModel`) mixin. Silence on the exact line: ```python return await cls.find_one(Eq(cls.author.id, user.id)) # ty: ignore[unresolved-attribute] ``` `ty` reports directives that stop matching (`unused-ignore-comment`), so keep the comment on the erroring line and remove it when no longer needed. ##### SQL (SQLModel) ```python # src/app/models/post.py from sqlmodel import SQLModel, Field class Post(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) title: str content: str published: bool = Field(default=False) ``` For SQL databases, create tables with: `vibetuner db create-schema` Register models in `src/app/models/__init__.py`: ```python from app.models.post import Post __all__ = ["Post"] ``` The model will be automatically registered with the database on startup. ##### Soft Delete (MongoDB) Use `DocumentWithSoftDelete` instead of `Document` to mark documents as deleted instead of removing them: ```python from vibetuner.models import DocumentWithSoftDelete from vibetuner.models.mixins import TimeStampMixin class Post(DocumentWithSoftDelete, TimeStampMixin): title: str content: str class Settings: name = "posts" ``` Soft-deleted documents are automatically excluded from `find()`, `find_one()`, and `get()` queries. Use `find_many_in_all()` to include them. `delete()` sets `deleted_at` instead of removing the document. `hard_delete()` permanently removes it. To restore: `doc.deleted_at = None; await doc.save()`. The CRUD factory's DELETE endpoint automatically performs a soft delete for these models. ##### Encrypted Fields (MongoDB) Encrypt sensitive fields at rest using `EncryptedFieldsMixin` and `EncryptedStr`: ```python from beanie import Document from pydantic import Field from vibetuner.models.mixins import EncryptedFieldsMixin, EncryptedStr class ApiCredential(Document, EncryptedFieldsMixin): provider: str api_key: EncryptedStr = Field(..., description="Encrypted API key") token: EncryptedStr | None = Field(default=None) class Settings: name = "api_credentials" ``` Fields are transparently encrypted before database writes and decrypted on load. Requires `FIELD_ENCRYPTION_KEY` environment variable (Fernet key). Use `vibetuner crypto set-key` to generate a key, `vibetuner crypto rotate-key` to rotate it. #### Creating Templates Add templates in `templates/`: ```html {% extends "base/skeleton.html.jinja" %} {% block content %}

Blog Posts

{% for post in posts %}

{{ post.title }}

{{ post.content }}
{% endfor %}
{% endblock %} ``` #### Skeleton Extension Points `base/skeleton.html.jinja` exposes blocks and context variables so projects can slot in customisations without copying the whole skeleton. Extending it is preferable to overriding it: upstream changes (CSP nonce, theming, etc.) flow through automatically. **Blocks** (override in any child template): - `extra_head_links` — after ``, before `bundle.css`. For `<link rel="alternate">` feeds, RSS, custom meta tags. - `extra_scripts` — after the bundled `<script>` (and optional umami). For per-app scripts that don't replace `bundle.js`. - `before_main` — inside `<body>`, before `block body`. For dev banners, sticky overlays. - `after_main` — inside `<body>`, after `block body`. For persistent mini-players, floating toolbars. Existing blocks already there: `title`, `head`, `scripts`, `start_of_body`, `header`, `body`, `content`, `footer`, `end_of_body`. **Context variables** (set via `register_globals`, a context provider, or per-render `ctx`): - `color_scheme` — `str`, defaults to `"light"`. Sets `<meta name="color-scheme">` content. - `canonical_url` — `str | None`, defaults to `None`. When set, renders `<link rel="canonical" href="…">`. - `font_preloads` — `list[dict]`, defaults to `[]`. Each entry renders `<link rel="preload" as="font" href="…" type="…" [crossorigin="…"]>`. Listed before `bundle.css` so the preload scanner picks them up early. Example: a project skeleton override that adds an RSS feed and a persistent player without touching upstream markup: ```html {% extends "base/skeleton.html.jinja" %} {% block extra_head_links %} <link rel="alternate" type="application/rss+xml" title="My App" href="{{ url_for('rss').path }}" /> {% endblock extra_head_links %} {% block after_main %} {% include "components/player.html.jinja" %} {% endblock after_main %} ``` #### Built-in Template Globals These variables are available in every template automatically: - `request` — current Starlette `Request` - `language` — resolved request language (e.g. `"en"`, `"ca"`) - `DEBUG` — mirrors `settings.debug` - `now` — `datetime.now(timezone.utc)`, timezone-aware UTC datetime - `today` — `date.today().isoformat()`, ISO date string (e.g., `"2026-04-07"`) - `project` — `settings.project` (`ProjectConfiguration` loaded from `config_vars.yaml`); use `project.project_name`, `project.company_name`, `project.copyright`, `project.fqdn`, etc. - `brand` — `settings.brand` (`BrandSettings` from `BRAND_*` env vars); see [Brand Configuration](#brand-configuration) - `csp_nonce` — per-request CSP nonce - `hotreload` — dev-mode hot-reload helper ```html <title>{{ project.project_name }}

Page rendered at {{ now | format_datetime }}

``` #### Built-in Template Filters Vibetuner provides several built-in template filters for common formatting needs: | Filter | Usage | Output | |--------|-------|--------| | `timeago` | `{{ dt \| timeago }}` | "5 minutes ago" | | `timeago(short=True)` | `{{ dt \| timeago(short=True) }}` | "5m ago" | | `format_date` | `{{ dt \| format_date }}` | "January 15, 2025" | | `format_datetime` | `{{ dt \| format_datetime }}` | "January 15, 2025 at 2:30 PM" | | `format_duration` / `duration` | `{{ seconds \| duration }}` | "5 min" or "30 sec" | The `timeago` filter converts a datetime to a human-readable relative time string. Use `short=True` for compact displays like "5m ago", "1d ago", "3mo ago". Short format outputs: < 60 seconds = "just now", < 60 minutes = "Xm ago", < 24 hours = "Xh ago", < 7 days = "Xd ago", < 30 days = "Xw ago", < 365 days = "Xmo ago", < 4 years = "Xy ago", >= 4 years = "MMM DD, YYYY". #### Adding Custom Template Filters Register custom Jinja2 filters via the `template_filters` dict in `VibetunerApp`: ```python # src/app/frontend/templates.py def uppercase(value): """Convert value to uppercase""" return str(value).upper() def format_money(value): """Format value as USD currency""" try: return f"${float(value):,.2f}" except (ValueError, TypeError): return str(value) ``` ```python # src/app/tune.py from vibetuner import VibetunerApp from app.frontend.templates import uppercase, format_money app = VibetunerApp( template_filters={ "uppercase": uppercase, "money": format_money, }, ) ``` Use in templates: ```html

{{ user.name | uppercase }}

Price: {{ product.price | money }}

``` **Important:** If a filter returns HTML, wrap the result with `markupsafe.Markup` to prevent Jinja2 auto-escaping. Always escape user input inside the markup: ```python from markupsafe import Markup, escape def tag_badge(value: str) -> Markup: """Render a badge — escape user input, wrap result in Markup.""" return Markup('{}').format(escape(value)) ``` #### Adding Background Jobs If you enabled background jobs, create tasks in `src/app/tasks/`: ```python # src/app/tasks/emails.py from vibetuner.tasks.worker import get_worker from vibetuner.models import UserModel from vibetuner.services.email import EmailService worker = get_worker() @worker.task() async def send_welcome_email(user_id: str): user = await UserModel.get(user_id) if user: email_service = EmailService() await email_service.send_email( to_address=user.email, subject="Welcome!", html_body="

Welcome!

", text_body="Welcome!", ) return {"status": "sent"} ``` Register tasks in `src/app/tasks/__init__.py`: ```python # src/app/tasks/__init__.py __all__ = ["emails"] from . import emails # noqa: F401 ``` Queue jobs from your routes: ```python from app.tasks.emails import send_welcome_email @router.post("/signup") async def signup(email: str): # Create user user = await create_user(email) # Queue background task task = await send_welcome_email.enqueue(str(user.id)) return {"message": "Welcome email queued", "task_id": task.id} ``` #### Custom Lifespan For custom startup/shutdown logic, create a lifespan and pass to `tune.py`: ```python # src/app/frontend/lifespan.py from contextlib import asynccontextmanager from fastapi import FastAPI from vibetuner.frontend.lifespan import base_lifespan @asynccontextmanager async def lifespan(app: FastAPI): async with base_lifespan(app): # Custom startup logic print("App starting with custom logic") yield # Custom shutdown logic print("App shutting down with custom logic") ``` ```python # src/app/tune.py from vibetuner import VibetunerApp from app.frontend.lifespan import lifespan app = VibetunerApp( frontend_lifespan=lifespan, ) ``` **Worker lifespan** (different signature — takes no arguments, yields context): ```python # src/app/tasks/lifespan.py from contextlib import asynccontextmanager from vibetuner.tasks.lifespan import base_lifespan @asynccontextmanager async def lifespan(): async with base_lifespan() as worker_context: # Custom worker startup logic print("Worker starting with custom logic") yield worker_context # Custom worker shutdown logic print("Worker shutting down") ``` ```python # src/app/tune.py from vibetuner import VibetunerApp from app.tasks.lifespan import lifespan as worker_lifespan app = VibetunerApp( worker_lifespan=worker_lifespan, ) ``` > **Note:** The frontend lifespan receives the `FastAPI` app and yields > nothing. The worker lifespan takes no arguments and yields a `Context` > object. #### Working with HTMX Vibetuner uses HTMX for interactive features without JavaScript: ```html
``` Server endpoint: ```python @router.get("/blog") async def list_posts(page: int = 1): posts = await Post.find().skip((page - 1) * 10).limit(10).to_list() return templates.TemplateResponse("blog/posts.html.jinja", { "posts": posts }) ``` #### HTMX Request Detection Every request has `request.state.htmx` available (via `starlette-htmx` middleware). Use it to serve different responses for HTMX vs regular requests: ```python from fastapi import Request from starlette.responses import HTMLResponse from vibetuner import render_template, render_template_string @router.get("/items") async def list_items(request: Request): items = await Item.find_all().to_list() ctx = {"items": items} if request.state.htmx: html = render_template_string("items/_list.html.jinja", request, ctx) return HTMLResponse(html) return render_template("items/list.html.jinja", request, ctx) ``` Properties: `bool(request.state.htmx)`, `.boosted`, `.target`, `.trigger`, `.trigger_name`, `.current_url`, `.prompt`. HTMX-only routes — use the `require_htmx` dependency from `vibetuner.frontend.deps` to reject non-HTMX requests with a 400. ### Architecture #### High-Level Overview Vibetuner generates full-stack web applications with clear separation between framework code and application code. #### Four-Package Architecture ##### 1. Scaffolding Template **Location**: Root repository (`copier.yml`, `vibetuner-template/`) The Copier-based template that generates new projects: - Interactive project setup - Configurable features (OAuth, background jobs, etc.) - Generates complete project structure - Updates existing projects **Command**: `uvx vibetuner scaffold new my-app` **2. Python Package (`vibetuner`)** **Location**: `vibetuner-py/` Published to PyPI, provides: - Core framework code - Authentication system - Database integration (MongoDB and SQL) - Email and storage services - CLI commands - Blessed dependency stack **Install**: `uv add vibetuner` **3. JavaScript Package (`@alltuner/vibetuner`)** **Location**: `vibetuner-js/` Published to npm, provides: - Frontend build dependencies (Tailwind, esbuild, etc.) - Version-locked with Python package - No runtime dependencies **Install**: `bun add @alltuner/vibetuner` **4. Tailwind Source Package (`@alltuner/vibetuner-jinja`)** **Location**: `vibetuner-jinja/` Published to npm, provides: - An npm-resolvable mirror of `vibetuner-py/src/vibetuner/templates/frontend/` - Ships a `sources.css` containing `@source "./templates"` so tailwind picks up class names used by base layouts, auth pages, the debug toolbar, etc. — without shelling out to Python at frontend build time - Pulled in transitively by `@alltuner/vibetuner`. Scaffolded projects' `config.css` does `@import "@alltuner/vibetuner/core.css"`, which in turn imports this package's `sources.css`. Consumer projects do not install it directly - The template mirror is generated by the package's `prepare` lifecycle hook (no manual sync required when developing in the monorepo); registry consumers receive a tarball with the templates already baked in - Version-locked with the Python and JS packages **Install**: ridden along with `@alltuner/vibetuner` (no direct install in consumer projects) #### Request Flow ##### 1. HTTP Request ```text Client → Nginx/Caddy → FastAPI (Granian) → Route Handler ``` ##### 2. Route Handler ```python # src/app/frontend/routes/blog.py @router.get("/blog/{post_id}") async def view_post(post_id: str): post = await Post.get(post_id) return templates.TemplateResponse("blog/post.html.jinja", { "post": post }) ``` ##### 3. Database Query ```text # MongoDB Route → Beanie ODM → Motor (async) → MongoDB # SQL Route → SQLModel → SQLAlchemy (async) → PostgreSQL/MySQL/SQLite ``` ##### 4. Template Rendering ```text Jinja2 Template → HTML with HTMX → Client ``` ##### 5. HTMX Interaction ```text User Action → HTMX Request → FastAPI → Partial HTML → Update DOM ``` ### Tech Stack #### Backend Stack ##### FastAPI **Why:** Modern, fast, async-first Python web framework. - Automatic API documentation (OpenAPI/Swagger) - Async/await support throughout - Pydantic integration for validation - Type hints and IDE support - High performance (comparable to Node.js/Go) #### Database Options Vibetuner supports multiple database backends. All are optional - choose what fits your project. ##### MongoDB + Beanie ODM **Why:** Flexible document database for rapid prototyping. - Schema flexibility during rapid development - Pydantic models are database models - Type-safe async operations - Automatic validation - Horizontal scaling with sharding ##### SQLModel + SQLAlchemy **Why:** SQL databases when you need relational data. - PostgreSQL, MySQL, MariaDB, SQLite support - Pydantic + SQLAlchemy combined - Type-safe async operations - Full SQL power when needed - CLI command: `vibetuner db create-schema` ##### Granian **Why:** High-performance ASGI server written in Rust. - Faster than Uvicorn/Gunicorn - Lower memory footprint - Built-in process management - Hot reload in development #### Frontend Stack ##### HTMX **Why:** Interactivity without complex JavaScript. - Server-rendered HTML with dynamic updates - Progressive enhancement - Minimal client-side complexity - Works with any backend - Small footprint (~14kb) ##### Tailwind CSS **Why:** Utility-first CSS framework. - Rapid UI development - No naming conventions needed - Automatic purging of unused CSS - Responsive design made simple - Customizable design system ##### DaisyUI **Why:** Beautiful Tailwind CSS components. - Pre-built components - Theming support - No JavaScript required - Semantic class names - Accessibility built-in ##### Jinja2 **Why:** Powerful template engine for Python. - Template inheritance - Macros for reusable components - Filters and functions - i18n integration - Sandboxed execution #### Development Tools ##### uv **Why:** Extremely fast Python package manager. - 10-100x faster than pip - Reliable dependency resolution - Compatible with pip/requirements.txt - Built in Rust ##### bun **Why:** Fast all-in-one JavaScript runtime and toolkit. - 2-10x faster than npm/pnpm - Built-in bundler, transpiler, test runner - Drop-in Node.js replacement - Native TypeScript support ##### just **Why:** Command runner (better Make). - Simple syntax - Cross-platform - Environment variable support - Recipe dependencies ### Authentication #### Overview Vibetuner includes: - **OAuth Authentication**: Google, GitHub, and more via Authlib - **Magic Link Authentication**: Passwordless email-based login - **Session Management**: Secure cookie-based sessions - **User Model**: Pre-configured with OAuth account linking #### OAuth Setup ##### Google OAuth 1. Go to [Google Cloud Console](https://console.cloud.google.com/) 2. Create a new project or select existing 3. Enable Google+ API 4. Create OAuth 2.0 credentials 5. Add authorized redirect URI: `http://localhost:8000/auth/google/callback` Add to `.env`: ```bash GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=your-client-secret ``` ##### GitHub OAuth 1. Go to [GitHub Developer Settings](https://github.com/settings/developers) 2. Create a new OAuth App 3. Set callback URL: `http://localhost:8000/auth/github/callback` Add to `.env`: ```bash GITHUB_CLIENT_ID=your-client-id GITHUB_CLIENT_SECRET=your-client-secret ``` #### Magic Link Authentication Magic links provide passwordless authentication via email. ##### Configuration Magic links are enabled by default. Configure email settings: ```bash # .env # Option A: Resend (recommended) MAIL_RESEND_API_KEY=re_xxxxxxxxxxxx # Option B: Mailjet # MAIL_MAILJET_API_KEY=your-api-key # MAIL_MAILJET_API_SECRET=your-api-secret # Option C: Cloudflare Email Service (public beta) # MAIL_CLOUDFLARE_API_TOKEN=cf-token # MAIL_CLOUDFLARE_ACCOUNT_ID=your-account-id FROM_EMAIL=noreply@example.com ``` When multiple providers are configured, auto-detection prefers Resend, then Mailjet, then Cloudflare. Set `MAIL_PROVIDER=cloudflare` (or `resend` / `mailjet`) to pick explicitly. ##### Resend rate limits and quota Resend caps sends at 5 requests/second per team and enforces a monthly quota. When a send exceeds either limit, Resend returns HTTP 429 and the provider raises a `RateLimitError` — the send fails loudly rather than silently. The framework does **not** retry or queue these; handle bursts upstream (for example, by sending mail from a background task) if you expect to approach 5 req/s. On every send the provider logs Resend's `ratelimit-*` and `x-resend-monthly-quota` response headers at `DEBUG`, and logs a `WARNING` with those headers when a `RateLimitError` is raised. ##### How It Works 1. User enters email address 2. System sends email with unique token 3. User clicks link in email 4. System validates token and logs user in 5. Session created ##### Custom OAuth Providers Add any OAuth provider using `custom_oauth_providers` in `tune.py`: ```python from vibetuner import VibetunerApp from vibetuner.models.oauth import OauthProviderModel app = VibetunerApp( custom_oauth_providers={ "linkedin": OauthProviderModel( identifier="sub", params={ "authorize_url": "https://www.linkedin.com/oauth/v2/authorization", "access_token_url": "https://www.linkedin.com/oauth/v2/accessToken", "userinfo_endpoint": "https://api.linkedin.com/v2/userinfo", }, scopes=["openid", "profile", "email"], config={ "LINKEDIN_CLIENT_ID": "your-client-id", "LINKEDIN_CLIENT_SECRET": "your-client-secret", }, ), }, ) ``` `OauthProviderModel` fields: `identifier` (unique user ID field), `params` (endpoint URLs), `scopes` (list of OAuth scopes), `client_kwargs` (extra client settings), `config` (credentials), `compliance_fix` (optional Authlib compliance fix callback for providers that deviate from the OAuth spec), `login_routes` (bool, default `True`; set to `False` to register a provider for credential management only without creating login/callback routes, useful for account linking flows). ##### Compliance Fixes Some providers need response patching before Authlib can parse them. Pass a `compliance_fix` callable to `OauthProviderModel`: ```python def strip_id_token(session, token): token.pop("id_token", None) return token OauthProviderModel( ..., compliance_fix=strip_id_token, ) ``` The fix is applied to both env-var-based and database-backed OAuth apps using the same provider. ##### Database-Backed OAuth Apps For multiple sets of credentials per provider (e.g., different LinkedIn apps for different organizations), use `OAuthProviderAppModel`: ```python from vibetuner.models.oauth_app import OAuthProviderAppModel app = OAuthProviderAppModel( provider="linkedin", name="Acme Corp", client_id="app-specific-id", client_secret="app-specific-secret", scopes=["openid", "profile", "email", "w_member_social"], ) await app.insert() ``` Fields: `provider`, `name`, `client_id`, `client_secret`, `external_app_id` (optional), `scopes` (overrides provider defaults when non-empty), `capabilities`, `is_active`, `metadata`. Use `resolve_oauth_client` to resolve the correct Authlib client: ```python from vibetuner.frontend.oauth import resolve_oauth_client client_name = await resolve_oauth_client("linkedin", app_id="6801...") ``` The built-in login and callback routes accept an optional `app_id` query parameter that flows through this resolution automatically. Active database-backed apps appear on the login page as additional sign-in buttons (e.g., "Continue with Google · Acme Corp") alongside env-var providers, for any registered provider with `login_routes=True`. The provider must be registered via env-var credentials for its login route to exist; DB-backed apps for unregistered providers will not appear on the login page. #### Protecting Routes ##### Require Authentication Use the `@require_auth` decorator: ```python from vibetuner.frontend.auth import require_auth @router.get("/dashboard") @require_auth async def dashboard(request: Request): user = request.state.user return templates.TemplateResponse("dashboard.html.jinja", { "user": user }) ``` ##### Optional Authentication Access user if authenticated, but don't require it: ```python @router.get("/") async def home(request: Request): user = getattr(request.state, "user", None) return templates.TemplateResponse("home.html.jinja", { "user": user }) ``` ### CRUD Factory Vibetuner includes a CRUD route factory that generates list, create, read, update, and delete endpoints for Beanie Document models with one function call. ```python from vibetuner.crud import create_crud_routes, Operation from app.models import Post # Generate all CRUD routes post_routes = create_crud_routes( Post, prefix="/api/posts", tags=["posts"], sortable_fields=["created_at", "title"], filterable_fields=["status", "author_id"], searchable_fields=["title", "content"], page_size=25, max_page_size=100, ) ``` Register the router in `tune.py`: ```python app = VibetunerApp(routes=[post_routes]) ``` **Generated endpoints:** | Method | Path | Description | |--------|------|-------------| | GET | `/api/posts` | List with pagination, filtering, sorting, search | | POST | `/api/posts` | Create a new document | | GET | `/api/posts/{item_id}` | Read a single document | | PATCH | `/api/posts/{item_id}` | Update a document (partial) | | DELETE | `/api/posts/{item_id}` | Delete a document | **Query parameters for list:** - `offset` / `limit` — Pagination (default page size: 25) - `sort` — Comma-separated fields, prefix with `-` for descending (e.g., `?sort=-created_at`) - `search` — Text search across searchable fields (case-insensitive regex) - `fields` — Comma-separated field names to include in response - Any filterable field as a query param (e.g., `?status=published`) **Limiting operations:** ```python # Only generate list and read (no create/update/delete) post_routes = create_crud_routes( Post, operations={Operation.LIST, Operation.READ}, ) ``` **Custom schemas and hooks:** ```python from pydantic import BaseModel class PostCreate(BaseModel): title: str content: str class PostResponse(BaseModel): id: str title: str async def before_create(data, request): data.author_id = request.state.user.id return data post_routes = create_crud_routes( Post, create_schema=PostCreate, response_schema=PostResponse, pre_create=before_create, post_create=lambda doc, req: print(f"Created {doc.id}"), pre_update=lambda doc, data, req: data, post_update=lambda doc, req: None, pre_delete=lambda doc, req: None, post_delete=lambda doc, req: None, dependencies=[require_auth], ) ``` **Hook signatures reference:** All hooks are async callables with these signatures: | Hook | Signature | Returns | |------|-----------|---------| | `pre_create` | `async (data, request)` | Modified data or `None` | | `post_create` | `async (doc, request)` | `None` | | `pre_update` | `async (doc, data, request)` | Modified data or `None` | | `post_update` | `async (doc, request)` | `None` | | `pre_delete` | `async (doc, request)` | `None` | | `post_delete` | `async (doc, request)` | `None` | `pre_create` and `pre_update` can return the (modified) data object to override input. If they return `None`, the original data is used. ### Server-Sent Events (SSE) Vibetuner provides SSE helpers for real-time streaming with HTMX. Import from `vibetuner.sse` (not `vibetuner.frontend.sse`). ```python from vibetuner.sse import sse_endpoint, broadcast ``` **Channel-based endpoint (auto-subscribe):** ```python from fastapi import APIRouter, Request from vibetuner.sse import sse_endpoint router = APIRouter() @sse_endpoint("/events/notifications", channel="notifications", router=router) async def notifications_stream(request: Request): pass # channel kwarg handles everything ``` **Dynamic channel based on path params:** ```python @sse_endpoint("/events/room/{room_id}", router=router) async def room_stream(request: Request, room_id: str): return f"room:{room_id}" # return channel name ``` **Generator-based (full control):** ```python import asyncio @sse_endpoint("/events/custom", router=router) async def custom_stream(request: Request): while True: yield {"event": "tick", "data": "ping"} await asyncio.sleep(5) ``` **Broadcasting events:** ```python from vibetuner.sse import broadcast # Raw data await broadcast("notifications", "update", data="
New item!
") # With template rendering await broadcast( "feed", "new-post", template="partials/post.html.jinja", request=request, ctx={"post": post}, ) ``` SSE uses in-process channels by default. When Redis is configured, events are automatically bridged via Redis pub/sub for multi-worker support. SSE responses are sent with `Cache-Control: no-cache` and `X-Accel-Buffering: no` so reverse proxies and CDNs (Caddy, nginx, Cloudflare) stream events through immediately instead of buffering them. Without these, a proxy can hold the response open and deliver nothing to the client until the connection closes — so no extra proxy configuration is needed for live updates to work in production. **HTMX client example:** `hx-sse:connect` opens the stream. In htmx v4 a **named** event (the `event` passed to `broadcast()`) is dispatched as a DOM event on the connecting element; consume it with `hx-trigger=" from:#"` to re-fetch current state. An **unnamed** message (empty event name) is swapped into the connecting element via its own `hx-target`/`hx-swap`. The recommended pattern broadcasts a named event as a signal and lets a consumer fetch state: ```html
``` ```python await broadcast("notifications", "update") # signal; consumer re-fetches ``` **Backgrounded tabs drop events.** For `hx-sse:connect`, htmx v4 enables `pauseOnBackground` by default: a hidden tab closes the stream and reopens it when visible, with **no replay**, so events broadcast during the hidden window are lost and the view goes stale until the next event or a reload. Recovering is why the consumer above also triggers on `htmx:after:sse:connection` (fires on every connect/reconnect) — one idempotent re-fetch restores current state. To keep a stream live in the background instead, disable the pause per element with `hx-config="sse.pauseOnBackground:false"` (or globally via `htmx.config.sse`). `sse_endpoint(buffer_size=...)` enables `Last-Event-ID` replay, but event IDs are per-process and monotonic, so replay is unreliable across multiple frontend workers; the resync-on-reconnect trigger is the robust, worker-count-independent answer. ### Response Caching Cache route responses in Redis with `@cache`: ```python from vibetuner.cache import cache @router.get("/api/stats") @cache(expire=60) # cache for 60 seconds async def get_stats(request: Request): return {"users": await count_users()} ``` Parameters: `expire` (TTL in seconds, default 60), `force_caching` (override debug mode disable, default False), `vary_on` (callable for request-dependent keys, default None). Cache key is derived from path + sorted query params. Disabled in debug mode. No-op if Redis is unavailable. **Request-dependent keys with `vary_on`:** ```python # Per-user cache @router.get("/dashboard") @cache(expire=120, vary_on=lambda r: str(r.state.user.id)) async def dashboard(request: Request): return await render_dashboard(request) ``` `vary_on` accepts `Callable[[Request], str]`. The returned string is included in the cache key, so different values produce independent cache entries. **Cache invalidation:** ```python from vibetuner.cache import invalidate, invalidate_pattern await invalidate("/api/stats") await invalidate("/api/stats", query_params="page=1") await invalidate_pattern("/api/*") ``` ### Template Context Providers Register static globals or dynamic context providers available in every template. **Static globals:** ```python from vibetuner.rendering import register_globals register_globals({ "site_title": "My App", "og_image": "/static/og.png", }) ``` **Dynamic providers (called on every render):** ```python from vibetuner.rendering import register_context_provider @register_context_provider def site_context() -> dict: return {"site_title": settings.site_title, "year": 2025} # Also works with parentheses @register_context_provider() def analytics_context() -> dict: return {"analytics_id": "UA-XXX"} ``` Values from providers and globals are merged into every `render_template()` call. User-provided context in the render call takes precedence. #### Passing user context `render_template()`, `render_template_string()`, `render_template_stream()`, `render_template_block()` and `render_template_blocks()` all accept the user context dict positionally, as `ctx=`, or as `context=` (an alias for `ctx=`). ```python render_template("home.html.jinja", request, {"hero": hero}) # positional render_template("home.html.jinja", request, ctx={"hero": hero}) # ctx kwarg render_template("home.html.jinja", request, context={"hero": hero}) # alias ``` Passing both `ctx=` and `context=` raises `TypeError`. Unknown kwargs (typos like `contxt=` or anything Starlette's `TemplateResponse` doesn't recognise) also raise `TypeError` — the signature is explicit, so a misspelled context keyword cannot silently render with an empty context. `render_template()` additionally accepts the response-shaping kwargs forwarded to `TemplateResponse`: `status_code`, `headers`, `media_type`, `background`. ### Build-time Brand Palette DaisyUI's `light` and `dark` themes are emitted by `@plugin "daisyui"` inside `@alltuner/vibetuner/core.css`. To recolor the four DaisyUI role colors across the whole compiled `bundle.css`, override the `[data-theme="…"]` selectors directly in your project's `config.css`: ```css @import "@alltuner/vibetuner/core.css"; @source "templates"; @source "assets/statics/js"; [data-theme="light"] { --color-primary: oklch(64% 0.21 24); --color-primary-content: oklch(98% 0 0); --color-accent: oklch(82% 0.18 95); } [data-theme="dark"] { --color-primary: oklch(64% 0.21 24); --color-primary-content: oklch(98% 0 0); --color-accent: oklch(82% 0.18 95); } ``` DaisyUI emits a rule like `:where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] { … }`. The override above shares specificity with the standalone `[data-theme=light]` matcher and lands later in the file — cascade picks it up. No consumer-side `daisyui` install is required. **Caveat — DaisyUI theme-controller radio inputs.** If your UI relies on DaisyUI's `` switcher pattern *without* setting `data-theme` on the root element, the matching selector is `:root:has(input.theme-controller[value=…]:checked)` which has higher specificity than `[data-theme="…"]`. The override is bypassed for that path. Set `data-theme="…"` on `` directly to keep the override pattern intact. **Brand-new named themes.** DaisyUI's documented `@plugin "daisyui/theme" { name: "my-brand"; … }` form lets projects register entirely new themes. It is **not** resolvable from a consumer-side `config.css` by default: `daisyui` is a private transitive of `@alltuner/vibetuner`, so bun's isolated linker keeps it scoped to that package only — the consumer's `config.css` resolves modules from the project root and fails with `Error: Can't resolve 'daisyui/theme'`. Add it explicitly: ```bash bun add -d daisyui bun install ``` The top-level symlink makes `@plugin "daisyui/theme"` resolvable. For per-request runtime overrides on top of the build-time palette (per-tenant branding), see the next section. ### Light / Dark / System Toggle The skeleton ships a CSP-nonced **no-flash theme setter** (`base/theme_init.html.jinja`) that runs synchronously high in ``, before `bundle.css`. It sets `data-theme` on `` before first paint, so the page never flashes the wrong theme. State: - `localStorage['theme']` — persisted preference (`"light"` / `"dark"`; absent means *system*). - `data-theme-mode` on `` — chosen mode (`system` / `light` / `dark`). - `data-theme` on `` — concrete theme DaisyUI renders. In `system` mode it resolves against `prefers-color-scheme` and live-updates when the OS theme changes. Default mode is `system`, so a fresh visitor sees their OS preference. The setter exposes `window.cycleTheme()`, cycling `system → light → dark` and persisting the choice. Wire a toggle through htmx's nonced `hx-on` path (a raw inline `onclick` would be blocked by `script-src`): ```html ``` Override just `base/theme_init.html.jinja` for different theming logic. Projects replacing the skeleton wholesale must include it *before* the `bundle.css` link. ### Per-Tenant Theming Multi-tenant apps can swap DaisyUI role colors per tenant without rebuilding `bundle.css`. The bundle stays tenant-agnostic and cached; theming happens at request time via a small `` block injected after the stylesheet link. **1. Embed `TenantTheme` on your tenant document:** ```python from beanie import Document from pydantic import Field from vibetuner.models import TenantTheme from vibetuner.models.mixins import TimeStampMixin class TenantModel(Document, TimeStampMixin): name: str slug: str theme: TenantTheme = Field(default_factory=TenantTheme) ``` `TenantTheme` exposes eight optional `#rrggbb` fields — `primary`, `secondary`, `accent`, `neutral`, and their `*_content` foreground variants — mapping to `--color-primary`, `--color-secondary`, etc. The validator rejects anything that isn't a 7-character `#rrggbb` string. Existing tenant documents without the field are unaffected: MongoDB is schema-on-read, the default-constructed `TenantTheme` has all `None` fields, and `keep_nulls=False` keeps it out of the database until a value is set. **2. Register the opt-in provider:** ```python from starlette.requests import Request from vibetuner import register_tenant_theme_provider def _tenant_from_request(request: Request): # Tenant should already be on request.state by upstream middleware/dep. return getattr(request.state, "tenant", None) register_tenant_theme_provider(_tenant_from_request) ``` The getter must be synchronous and cheap — it runs on every render. Do DB work in middleware. Pass `attribute="branding"` if your tenant uses a different attribute name. The provider is fail-soft: a getter exception, missing attribute, or a non-`TenantTheme` value is logged and the request renders with no overrides rather than 500ing. **3. Template partial.** Vibetuner's `base/skeleton.html.jinja` already includes `base/theme.html.jinja` between the bundled stylesheet link and `{% block head %}`. The partial emits: ```html ``` only when `theme_overrides` is non-empty. Apps that haven't registered the provider see no extra markup. If you've replaced the skeleton wholesale in your project, mirror the include order: ```jinja {% include "base/theme.html.jinja" %} {% block head %}{% endblock head %} ``` DaisyUI scopes its theme via `:where(:root)` (zero specificity), so a plain `:root { ... }` block always wins the cascade. **Footguns:** - **Build-time literals in custom tokens.** A `--shadow-glow-primary: 0 0 80px rgba(0, 157, 220, 0.2)` won't follow the theme. Use `color-mix(in oklab, var(--color-primary) 20%, transparent)` so the derived token tracks the role color. - **Hardcoded scale shades mixed into role-driven utilities.** `from-accent/70 to-tiger-flame-700` only themes one half of the gradient. Either keep it role-only (`from-accent/70 to-accent/30`) or accept the scale shade as a brand constant. - **Inline ` ``` CSP is fully enforced in both production and debug mode by default, so "works locally, breaks in prod" violations are caught at write time rather than at deploy time. Set `CSP_ENFORCE_CSP_IN_DEBUG=false` to opt into the legacy soft mode where debug emits `Content-Security-Policy-Report-Only` instead. Configure extra sources via `CSP_EXTRA_SCRIPT_SRC`, `CSP_EXTRA_STYLE_SRC`, `CSP_EXTRA_IMG_SRC`, `CSP_EXTRA_CONNECT_SRC`, `CSP_EXTRA_FONT_SRC`, `CSP_EXTRA_MEDIA_SRC` environment variables. The middleware always sets `X-Content-Type-Options: nosniff`. If a response has no `Content-Type` header (e.g. a bare `Response(status_code=404)` with no `media_type=`), the middleware fills in `text/plain; charset=utf-8`. Without this guard, nosniff combined with a missing `Content-Type` makes Safari/Firefox save the response as a 0-byte download and Chrome show a generic error page. **Strict `style-src` (opt-in, requires `vibetuner` ≥ 10.11.0):** Set `CSP_STYLE_SRC_STRICT=true` to drop `'unsafe-inline'` from `style-src` and use the request nonce instead. Inline `style="..."` attributes will be blocked by the browser and every `