This is the full developer documentation for stk # Ship the app, not the scaffolding. > An async Python framework with the auth, admin, and frontend already done. No build step, no third-party auth, and a gate that tells you, or your agent, the moment something breaks. ```bash git clone git@github.com:level09/stk.git && cd stk && ./setup.sh uv run stk doctor # am I ready? every problem names its fix uv run stk new invoice # model, API, page, and migration, ending at a URL uv run stk verify --watch # lint, sanity, migration drift on every save uv run stk smoke # a real browser, before you ship ``` No build step Vue 3 and Vuetify 4 load straight from `stk/static/`. No npm, no bundler, no node_modules. A polished admin dashboard with dark mode and data tables out of the box. You own your auth Argon2id passwords, TOTP, passkeys, recovery codes, Google and GitHub OAuth, rate limits, and CSRF, in process. Your users’ data never leaves your server. Operable by agents `stk inspect` reports routes, auth guards, models, and commands as JSON. `AGENTS.md` indexes these docs, which ship in the repo and fail `stk verify` when they drift. A gate you can trust `stk verify` and `stk smoke` check lint, migration drift, guarded routes, console errors, and text that renders invisible. Every failure names its fix. ## Start here [Section titled “Start here”](#start-here) [Quick start](/getting-started/)Clone, set up, and sign in [The build loop](/build-loop/)doctor, scaffold, watch, smoke [Working with agents](/ai-coding/)How agents find the truth, and how it stays true [Deployment](/deployment/)One command to a hardened VPS, or Docker Async all the way down: Quart, async SQLAlchemy, Alembic, and native WebSockets. SQLite by default, PostgreSQL and Redis when you want them. MIT licensed, [on GitHub](https://github.com/level09/stk). # Working with Agents > How coding agents find the truth in an stk project, and how it stays true stk is built to be operated by coding agents. Every fact an agent needs has exactly one home, and every surface an agent reads points at it instead of copying it. Copies drift; pointers do not. ## Three layers [Section titled “Three layers”](#three-layers) **1. The running code answers questions about the code.** Routes, auth guards, models, commands, and environment state are read from the live app, never from prose: ```bash uv run stk inspect context --json # Routes with auth and source, models with columns uv run stk inspect commands --json # Every command and option uv run stk doctor --json # Checkout state, each problem with its fix uv run stk new invoice # The canonical CRUD module, as code ``` **2. These docs explain everything else**, and they ship inside the repo at `docs/src/content/docs/`. An agent reads the version that matches the code it is editing, straight from disk. The same files build this site, `/llms.txt`, and a Markdown copy of every page (`/frontend.md`), so a human, a browser agent, and a coding agent all read one source. **3. `AGENTS.md` is the index.** It is short on purpose: what stk is, the rules that fail silently, and a table mapping each task to its docs page. It is always in the agent’s context, so the agent knows which page to open without having to remember to look. `CLAUDE.md` is the same file, for agents that read that name. Skills hold procedures, not facts. Each `SKILL.md` in `.claude/skills/` (mirrored at `.agents/skills/`) is a step-by-step workflow an agent loads when the task matches, and links to the docs for the why: * `stk-blueprint`: scaffold a feature module, customize it, and prove it in a browser. * `stk-api`: add CRUD endpoints to an existing model. * `stk-migrate`: generate, review, and round-trip an Alembic migration. * `stk-deploy`: take an app live on a fresh VPS with HTTPS, systemd, and a hardened OS. ## The gate keeps it honest [Section titled “The gate keeps it honest”](#the-gate-keeps-it-honest) Documentation that nothing checks is a guess with a date on it. `stk verify` fails when: * a docs page, skill, `AGENTS.md`, `README.md`, or `SECURITY.md` names a `stk` command or flag that does not exist, or a `SECURITY_*`/`STK_*` setting the code never reads * one of them points at a repo file that does not exist * the [CLI reference](/cli) differs from the live command tree * `stk/settings.py` reads an environment variable [Configuration](/configuration) does not list * `AGENTS.md` does not index every docs page, or grows past 8KB (skills past 4KB): detail belongs in a docs page Every failure names the fix. Change behavior and its docs page in the same commit, and the gate tells you which one you forgot. ## Verify, don’t guess [Section titled “Verify, don’t guess”](#verify-dont-guess) ```bash uv run stk verify --watch # instant feedback while editing uv run stk smoke --json # real browser: status codes, console errors, contrast ``` See [The Build Loop](/build-loop) for the whole cycle, including test-only browser login for agents. ## Example prompts [Section titled “Example prompts”](#example-prompts) ```bash claude > "Add a Post module with title and content" > "Add a JSON API to list and edit posts" > "Add the posts page to the sidebar for admins only" ``` ## Customizing for your project [Section titled “Customizing for your project”](#customizing-for-your-project) An app built on stk keeps this structure. Add project rules to `CLAUDE.md`, a page under `docs/src/content/docs/` for anything longer than a rule (and a row for it in the index), and a skill for a repeatable workflow. The gate covers your additions the same way. # JSON APIs > List and mutation endpoints, error conventions, CSRF, and webhooks `uv run stk new ` writes a complete, working set of these endpoints in `stk//views.py`. Start from that file; this page explains its conventions. ## List endpoints [Section titled “List endpoints”](#list-endpoints) Lists paginate manually and serialize with `orjson`. The response shape is `{items, total, perPage}`, which is what the frontend’s [server-paginated table](/frontend#server-paginated-table) expects. ```python import orjson as json from quart import Response, g, request from sqlalchemy import func, or_, select PER_PAGE = 25 @bp.get("/api/posts") async def list_posts(): page = request.args.get("page", 1, type=int) per_page = request.args.get("per_page", PER_PAGE, type=int) query = select(Post) if search := request.args.get("search"): query = query.where(or_(Post.title.ilike(f"%{search}%"), Post.content.ilike(f"%{search}%"))) total = await g.db_session.scalar(select(func.count()).select_from(query.subquery())) result = await g.db_session.execute(query.offset((page - 1) * per_page).limit(per_page)) items = [p.to_dict() for p in result.scalars().all()] return Response( json.dumps({"items": items, "total": total, "perPage": per_page}), content_type="application/json", ) ``` Count from `query.subquery()` so filters apply to the total too. ## Mutations [Section titled “Mutations”](#mutations) The frontend sends `{item: {...}}`. Create is `POST /api/post/`, update is `POST /api/post/` (POST, not PUT), delete is `DELETE /api/post/`. Admin mutations are logged with `Activity.register`. ```python @bp.post("/api/post/") async def create_post(): data = (await request.json).get("item", {}) if not data.get("title"): return {"message": "Title is required"}, 400 post = await Post().from_dict(data) g.db_session.add(post) try: await g.db_session.flush() # assigns post.id for the audit entry await Activity.register(current_user.id, "Post Create", post.to_dict()) await g.db_session.commit() return {"message": "Post created"} except Exception: await g.db_session.rollback() log.exception("Error creating post") return {"message": "Error creating post"}, 412 ``` Responses are `{"message": ...}`: 400 for invalid input, 404 for a missing row, 412 for a failed write. An unhandled exception reaches the global error handler in `app.py`, which rolls back the session and returns JSON for API requests. ## Protecting endpoints [Section titled “Protecting endpoints”](#protecting-endpoints) Put auth on the blueprint so no route can forget it: ```python @bp.before_request @auth_required("session") @roles_required("admin") async def before_request(): pass ``` `uv run stk inspect routes --json` reports which routes are guarded, and `stk verify` fails if a route reported as guarded is reachable anonymously. ## CSRF and webhooks [Section titled “CSRF and webhooks”](#csrf-and-webhooks) Every mutation requires a CSRF token, which Axios sends from the shared layout token. A third-party webhook without a session opts out with `@csrf_exempt` from `stk.security`, placed under the route decorator, and must authenticate the caller itself, for example by checking an HMAC signature. See [Authentication](/authentication). # Architecture > How an stk app is laid out, and where each request goes ## Project Structure [Section titled “Project Structure”](#project-structure) ```plaintext stk/ ├── stk/ # Main application package │ ├── app.py # Application factory, blueprint registration │ ├── settings.py # Configuration │ ├── extensions.py # Async SQLAlchemy engine, session factory, Base │ ├── migrations.py # Alembic config behind `stk db` │ ├── security.py # csrf_exempt for webhooks │ ├── cli/ # CLI commands (agent, database, users, doctor, smoke, reports) │ ├── scaffold/ # Templates behind `stk new` │ ├── shell.py # Async REPL behind `stk shell` │ ├── tasks/ # Async background tasks │ ├── websocket.py # WebSocket blueprint │ ├── portal/ # Blueprint: protected dashboard │ ├── public/ # Blueprint: public routes, OAuth callbacks │ ├── user/ # Blueprint: admin user/role/activity management, models │ ├── utils/ # Email, rate limiting │ ├── static/ # Vue, Vuetify, Axios, Tabler Icons, app JS and CSS │ └── templates/ # Jinja2 templates ├── alembic/ # Migration env and versions/ ├── tests/ # unittest suite ├── checks.py # Sanity checks run by `stk verify` ├── docs/ # This site; pages in docs/src/content/docs/ ├── instance/ # SQLite database ├── .claude/skills/ # Agent procedures (also at .agents/skills) ├── CLAUDE.md # Agent index and rules (AGENTS.md links here) ├── pyproject.toml # Dependencies and project config ├── setup.sh # Setup script ├── run.py # ASGI entry point └── docker-compose.yml # Docker configuration ``` ## Blueprints [Section titled “Blueprints”](#blueprints) `register_blueprints()` in `stk/app.py` registers four blueprints: `users`, `public`, `portal`, and the `ws` WebSocket blueprint. Login, registration, 2FA, and passkey pages come from quart-security itself. ### Portal (`portal/`) [Section titled “Portal (portal/)”](#portal-portal) The protected dashboard. A blueprint-level `before_request` requires a session for every route: ```python from quart import Blueprint, render_template from quart_security import auth_required portal = Blueprint("portal", __name__, static_folder="../static") @portal.before_request @auth_required("session") async def before_request(): pass @portal.route("/dashboard/") async def dashboard(): return await render_template("dashboard.html") ``` ### User (`user/`) [Section titled “User (user/)”](#user-user) Admin pages and JSON APIs for users, roles, and activity (`/users/`, `/roles/`, `/activities/`). The whole blueprint requires an admin: ```python bp_user = Blueprint("users", __name__, static_folder="../static") @bp_user.before_request @auth_required("session") @roles_required("admin") async def before_request(): pass ``` It also holds the models (`stk/user/models.py`) and the auth signal handlers that track sessions and log password and 2FA changes. ### Public (`public/`) [Section titled “Public (public/)”](#public-public) Unauthenticated routes: the landing page, `/health`, `robots.txt`, and the Google and GitHub OAuth callbacks. ```python public = Blueprint("public", __name__, static_folder="../static") @public.route("/") async def index(): return await render_template("index.html") ``` To add a module of your own, run `uv run stk new `. It scaffolds the blueprint, model, templates, and migration, and ends at a URL you can open. ## Request lifecycle [Section titled “Request lifecycle”](#request-lifecycle) Each request gets its own database session on `g.db_session`, opened in `before_request` and closed in `after_request` (`stk/app.py`). CLI code and background tasks have no request, so they open their own session from `stk.extensions.async_session_factory`. See [Models and Queries](/models). # Authentication > User authentication and security features in stk ## Overview [Section titled “Overview”](#overview) stk builds authentication on quart-security: session auth, roles, TOTP two-factor, WebAuthn passkeys, recovery codes, and opt-in Google and GitHub OAuth. Production requirements are in [SECURITY.md](https://github.com/level09/stk/blob/master/SECURITY.md); the [deployment guide](/deployment) covers the settings you must set. ## Accounts and passwords [Section titled “Accounts and passwords”](#accounts-and-passwords) Public password registration is disabled by default. Set `SECURITY_REGISTERABLE=true` only if your app needs it. Create users from the CLI instead: ```bash uv run stk install # admin user with the admin role uv run stk create -e user@example.com # prompts for a password uv run stk add-role -e user@example.com -r editor uv run stk reset -e user@example.com # prompts for a new password ``` New passwords are hashed with Argon2id and must be at least 12 characters. Legacy hashes still verify and are upgraded on login. Password creation and reset also check the password against the Have I Been Pwned breach service; if that service is unreachable the password is accepted, so monitor for those failures. After 5 failed logins an account is locked for 15 minutes (quart-security’s `SECURITY_LOGIN_MAX_ATTEMPTS` and `SECURITY_LOCKOUT_MINUTES` defaults). ## Two-factor and passkeys [Section titled “Two-factor and passkeys”](#two-factor-and-passkeys) TOTP authenticator apps are the enabled second factor. Three single-use recovery codes are generated per user, hashed at rest, and shown only once. WebAuthn passkeys work as a first factor or as a second factor. Managing 2FA, recovery codes, or passkeys requires a fresh session: a login within the last 60 minutes, otherwise the request gets a 401. TOTP seeds are encrypted at rest. By default the key is derived from `SECRET_KEY`, so changing `SECRET_KEY` breaks existing 2FA unless you keep the old key. For independent rotation, set `SECURITY_TOTP_ENCRYPTION_KEYS` to a comma-separated list of Fernet keys, newest first: ```bash uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" ``` The first key encrypts new data and every listed key can decrypt. After upgrading or rotating keys, run `uv run stk protect-mfa` to encrypt legacy seeds and re-encrypt existing ones with the current key. Store the keys separately from database backups. A wrong key fails closed. Passkeys need `SECURITY_WAN_RP_ID` set to your DNS name and `SECURITY_WAN_EXPECTED_ORIGIN` set to your HTTPS origin. ## OAuth [Section titled “OAuth”](#oauth) Google and GitHub login are off until enabled: ```bash GOOGLE_AUTH_ENABLED=true GOOGLE_OAUTH_CLIENT_ID=your_client_id GOOGLE_OAUTH_CLIENT_SECRET=your_client_secret GITHUB_AUTH_ENABLED=true GITHUB_OAUTH_CLIENT_ID=your_client_id GITHUB_OAUTH_CLIENT_SECRET=your_client_secret ``` Callbacks are `/login/google/callback` and `/login/github/callback`, built from `STK_PUBLIC_URL` when it is set. Requested scopes are `openid profile email` for Google and `read:user user:email` for GitHub. Each login uses single-use state and PKCE. Only verified provider emails are accepted, a matching local email is never linked automatically, and a user with local 2FA must still complete it. Locked accounts cannot sign in through OAuth either. ## Protecting routes [Section titled “Protecting routes”](#protecting-routes) ```python from quart_security import auth_required, roles_required @app.route("/admin") @auth_required("session") @roles_required("admin") async def admin_dashboard(): return "Admin only content" ``` To protect a whole blueprint, apply the decorator to its `before_request` hook, as `stk/portal/views.py` does: ```python @portal.before_request @auth_required("session") async def before_request(): pass ``` ## CSRF and logout [Section titled “CSRF and logout”](#csrf-and-logout) Every POST, PUT, PATCH, and DELETE request needs the session CSRF token, in a form field or the `X-CSRFToken` header. The shared layout configures Axios for you. Third-party webhooks that have no session can opt out per view with `csrf_exempt` from `stk.security`, placed under the route decorator; such a view must authenticate the caller itself, for example with an HMAC signature. Logout is POST-only. Use `layoutMixin.logout()`; a plain link to `/logout` returns 405. ## Sessions and rate limits [Section titled “Sessions and rate limits”](#sessions-and-rate-limits) `PERMANENT_SESSION_LIFETIME` is 3600 seconds. Cookies default to `Secure`, `HttpOnly`, and `SameSite=Lax`, controlled by `SESSION_COOKIE_SECURE`, `SESSION_COOKIE_HTTPONLY`, and `SESSION_COOKIE_SAMESITE`. Sessions are stored in Redis when `REDIS_URL` is set and the `full` extra is installed, otherwise in the cookie. Each login is recorded with IP and device in the `Session` table. `DISABLE_MULTIPLE_SESSIONS=true` logs out other sessions on every login. Auth endpoints are rate limited per IP with fixed one-minute windows stored in the database, so limits hold across workers. The defaults are 10 authentication attempts and 120 authentication requests per minute, set by the `STK_AUTH_ATTEMPT_LIMIT` and `STK_AUTH_REQUEST_LIMIT` app config values. Limits depend on a trustworthy client IP, so behind a proxy trust forwarded headers only from that proxy. ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) OAuth failing: check that the callback URL registered with the provider matches `STK_PUBLIC_URL` plus the callback path, and that the provider account has a verified email. TOTP codes rejected: check the server clock, and that `SECRET_KEY` or `SECURITY_TOTP_ENCRYPTION_KEYS` still holds the key the seeds were encrypted with. Passkeys failing: `SECURITY_WAN_RP_ID` and `SECURITY_WAN_EXPECTED_ORIGIN` must match the host in the browser. Logged out too soon: check `PERMANENT_SESSION_LIFETIME`, and for Redis sessions, `REDIS_URL`. # Background Work > Fire-and-forget tasks without Celery, and pushing messages over WebSockets ## Background tasks [Section titled “Background tasks”](#background-tasks) Use `run_in_background()` for fire-and-forget async tasks. There is no Celery and no Redis requirement; the task runs on the current event loop and exceptions are logged. ```python from stk.tasks import run_in_background async def send_email(user_id): ... await run_in_background(send_email(user.id)) ``` For tasks that need their own DB session, `run_with_session` opens one, commits on success, and rolls back on error: ```python from stk.tasks import run_with_session async def cleanup_old_records(session): await session.execute(...) await run_with_session(cleanup_old_records) ``` Tasks live only as long as the process. Work that must survive a restart belongs in a table that a scheduled job drains, not in `run_in_background`. `uv run stk cleanup-sessions` deactivates expired sessions and deletes rows older than 30 days. Run it from cron or a systemd timer. ## WebSockets [Section titled “WebSockets”](#websockets) Signed-in pages connect to `/ws` automatically through `layoutMixin`. Push a message to one user with `broadcast`: ```python from stk.websocket import broadcast await broadcast({"type": "notification", "title": "Export ready", "to": "/exports"}, user_id=user.id) ``` There is no broadcast-to-everyone: every message names its recipient. `{type: 'notification'}` messages land in the notification bell; extend `_onWsMessage` in `layout.html` for your own types. `Activity.register()` stores audit events and does not broadcast. The endpoint enforces: * an `Origin` matching `STK_PUBLIC_URL` (or the request host), overridable with `STK_WS_ALLOWED_ORIGINS` * a signed-in session, rechecked every `STK_WS_AUTH_INTERVAL` seconds (5), so logout or deactivation closes the socket * at most `STK_WS_CONNECTIONS_PER_USER` (5) and `STK_WS_MAX_CONNECTIONS` (1000) connections * at most `STK_WS_MESSAGE_LIMIT` (60) client messages per minute, each up to `STK_WS_MESSAGE_SIZE` (65536) bytes * a send queue of `STK_WS_QUEUE_SIZE` (32); a client that falls behind is disconnected These are app config values: set them on your `Config` class, not in `.env`. Connected clients are tracked in process memory, so `broadcast` reaches only sockets held by the same worker. With several uvicorn workers, a message sent from one worker misses users connected to another. Run one worker, or fan out through Redis pub/sub if you need more. # Renaming Your App > Turn stk into your product without editing templates An app built on stk keeps the framework’s templates and changes three settings and two files. Never search and replace “stk” across templates: the next framework update would undo it. ## Name and copy [Section titled “Name and copy”](#name-and-copy) ```bash APP_NAME=Acme APP_TAGLINE=Invoices that send themselves. APP_DESCRIPTION=Acme chases late payments so you do not have to. ``` `APP_NAME` is the page title, the author meta tag, and the heading on exported recovery codes. `APP_TAGLINE` and `APP_DESCRIPTION` fill the sign-up panel. The authenticator label is `SECURITY_TOTP_ISSUER` in `stk/settings.py`. It is not read from the environment, so set it in your settings to make authenticator apps show your product’s name. ## Logo and favicon [Section titled “Logo and favicon”](#logo-and-favicon) * `stk/templates/_brand.html` is the only place the logo is referenced. Replace its `` with your own. Keep `class="logo-adaptive"` if your logo is dark-on-light: dark mode inverts it. * `stk/static/img/favicon.ico` is the favicon for every layout. Then check both themes in a browser: `uv run stk smoke` writes `.stk/smoke/dashboard.png`. # The Build Loop > Scaffold, edit, and know instantly whether you broke something Every stk command lives under `stk`. Run `stk --help` to see them grouped by purpose. ```bash uv run stk --help ``` Prefer bare `stk`? Activate the venv once per shell: ```bash source .venv/bin/activate stk run ``` ## 1. Is everything ready? [Section titled “1. Is everything ready?”](#1-is-everything-ready) ```bash uv run stk doctor ``` ```plaintext ✓ env file .env ✓ secrets SECRET_KEY and password salt look random ✓ database sqlite stk.db (124 KiB) ! migrations database at 20261006_0001, head is 20261006_0002 → uv run stk db upgrade ✓ admin user 1 admin(s) ✓ assets vue 3.5.40 vuetify 4.1.6 ✓ agent login disabled ✓ browser chromium ready for stk smoke ✓ shell ptpython: highlighting and completion Usable, with the warnings above. ``` Every line that is not `✓` tells you the command that fixes it. A `!` is a warning, a `✗` is a failure, and the exit code is `1` only on a failure, so it works in scripts. `--json` for tooling. ## 2. Scaffold a feature [Section titled “2. Scaffold a feature”](#2-scaffold-a-feature) ```bash uv run stk new invoice ``` `NAME` is a lowercase snake_case identifier. This writes the blueprint package (model and views) and its template, registers it in `app.py`, adds a sidebar entry to `navigation.js`, generates the migration for the new model, applies it, and then tells you where to look: ```plaintext Open: http://localhost:5000/invoices/ (start it with: stk run) ``` Pass `--no-migrate` if you would rather write the migration yourself, and `--port` if your dev server is not on 5000. ## 3. Keep the gate running while you edit [Section titled “3. Keep the gate running while you edit”](#3-keep-the-gate-running-while-you-edit) ```bash uv run stk verify --watch ``` It checks everything once, then polls for saved files and re-runs only what the change can break. A `.py` edit re-runs ruff, the sanity checks, and migration drift; a template, JS, or CSS edit re-runs only the sanity checks. ```plaintext changed: stk/invoice/models.py ✗ migration-drift add_column: invoice due_date → uv run stk db revision -m "describe change" && uv run stk db upgrade ✓ ruff ✓ checks ``` Leave it in a split pane. You find out the moment you break something, not ten minutes later, and the fix is on screen. ## 4. Prove it in a real browser [Section titled “4. Prove it in a real browser”](#4-prove-it-in-a-real-browser) ```bash uv run stk smoke ``` Boots a throwaway app on a free port against a temporary database, drives Chromium through the home page, login, dashboard, and admin users page, then visits every other GET page that takes no URL arguments. It fails on any of: an HTTP status of 400 or above, a console or uncaught error, a failed request, or button, chip, and alert text below 3:1 contrast with its background. That last one catches the class of bug no console error ever reports. The dashboard screenshot lands in `.stk/smoke/dashboard.png`; `--json` emits the per-page report. Smoke needs Playwright’s Chromium. `stk doctor` tells you if it is missing. ## 5. Ship [Section titled “5. Ship”](#5-ship) ```bash uv run stk verify && uv run stk smoke uv run stk report # docs/stk-report.html: routes, auth, gate results ``` `stk verify` runs ruff, `checks.py`, and migration drift, and exits `1` if any fail. CI runs both `stk verify --json` and `stk smoke --json` (after the unit tests), so the pair above is the same gate locally. `stk report --json` prints the routes and verify results instead of writing HTML, and `-o` picks another output path. ## Which checks a change needs [Section titled “Which checks a change needs”](#which-checks-a-change-needs) `stk verify` and `stk smoke` are the gate for everything. Some changes need one more look: | You changed | Also run | | --------------------- | -------------------------------------------------------------- | | Templates, JS, or CSS | `uv run stk smoke`, then look at the screenshot with real rows | | Models | `uv run stk db check` and `uv run stk db current` | | Routes or auth | `uv run stk inspect routes --json`, then `uv run stk report` | | Anything with logic | `uv run python -m unittest discover -s tests` | A 200 response and green tests do not prove that columns, labels, or buttons render. Look at the page. ## Signing in as a test user [Section titled “Signing in as a test user”](#signing-in-as-a-test-user) Agents and scripts that drive a browser can skip the login form with a short-lived token: ```bash STK_ENV=development STK_ENABLE_AGENT_LOGIN=1 uv run stk browser-token create --user admin@example.com --ttl 60 --next /dashboard/ ``` Open the printed `/_test/login?token=...` path: it creates a normal session and redirects to `--next`. The route exists only when all of these hold, and startup fails if it is enabled anywhere else: 1. `STK_ENABLE_AGENT_LOGIN=1` 2. `STK_ENV=development`, or the app is in testing mode 3. the token is signed and younger than `STK_AGENT_LOGIN_MAX_TTL_SECONDS` 4. the user’s email ends in `@example.com` `stk smoke` uses the same route with a throwaway `smoke@example.com` admin. ## The whole loop [Section titled “The whole loop”](#the-whole-loop) ```bash uv run stk doctor # am I ready? uv run stk new invoice # scaffold to a clickable URL uv run stk verify --watch # instant feedback while editing uv run stk smoke # real browser before shipping ``` # CLI Reference > Every stk command and option, generated from the code Generated from the live command tree by `uv run stk inspect commands --markdown`. Do not edit it by hand: `stk verify` fails when this page and the code disagree. Agents can read the same data as JSON with `uv run stk inspect commands --json`. ## Develop [Section titled “Develop”](#develop) ### `stk new` [Section titled “stk new”](#stk-new) `stk new [OPTIONS] NAME` Scaffold a new blueprint module, ending at a page you can open. | Option | Description | | ------------------------- | ------------------------------------------------------- | | `--migrate, --no-migrate` | Autogenerate and apply the migration for the new model. | | `--port` | Port used in the URL. | ### `stk routes` [Section titled “stk routes”](#stk-routes) `stk routes [OPTIONS]` Show all registered routes with endpoints and methods. | Option | Description | | --------------- | ------------------------------------------------------------------------------------------------------- | | `--sort, -s` | Method to sort routes by. “match” is the order that Quart will match routes when dispatching a request. | | `--all-methods` | Show HEAD and OPTIONS methods. | ### `stk run` [Section titled “stk run”](#stk-run) `stk run [OPTIONS]` Run a local development server. | Option | Description | | ----------------------- | -------------------------------------------------- | | `--host, -h` | The interface to bind to. | | `--port, -p` | The port to bind to. | | `--certfile, --cert` | Specify a certificate file to use HTTPS. | | `--keyfile, --key` | The key file to use when specifying a certificate. | | `--reload, --no-reload` | Enable or disable the reloader | ### `stk shell` [Section titled “stk shell”](#stk-shell) `stk shell [OPTIONS]` Async REPL with the app, a live DB session, and all models loaded. | Option | Description | | --------------- | ------------------ | | `-c, --command` | Run code and exit. | ## Database [Section titled “Database”](#database) ### `stk create-db` [Section titled “stk create-db”](#stk-create-db) `stk create-db [OPTIONS]` Apply all database migrations. ### `stk db check` [Section titled “stk db check”](#stk-db-check) `stk db check [OPTIONS]` Fail if models have drifted away from the migrations. ### `stk db current` [Section titled “stk db current”](#stk-db-current) `stk db current [OPTIONS]` Show the current database revision. ### `stk db downgrade` [Section titled “stk db downgrade”](#stk-db-downgrade) `stk db downgrade [OPTIONS] REVISION` Downgrade the database to a target revision. ### `stk db history` [Section titled “stk db history”](#stk-db-history) `stk db history [OPTIONS]` Show migration history. ### `stk db revision` [Section titled “stk db revision”](#stk-db-revision) `stk db revision [OPTIONS]` Create a new migration revision. | Option | Description | | ------------------------- | ------------------------------------------------------------ | | `-m, --message` | Revision message | | `--autogenerate, --empty` | Autogenerate from model metadata or create an empty revision | ### `stk db stamp` [Section titled “stk db stamp”](#stk-db-stamp) `stk db stamp [OPTIONS] [REVISION]` Stamp a database with a revision without running migrations. ### `stk db upgrade` [Section titled “stk db upgrade”](#stk-db-upgrade) `stk db upgrade [OPTIONS] [REVISION]` Upgrade the database to a target revision. ## Verify [Section titled “Verify”](#verify) ### `stk doctor` [Section titled “stk doctor”](#stk-doctor) `stk doctor [OPTIONS]` Report the state of this checkout, and what to run for anything wrong. | Option | Description | | -------- | ----------- | | `--json` | Emit JSON. | ### `stk inspect commands` [Section titled “stk inspect commands”](#stk-inspect-commands) `stk inspect commands [OPTIONS]` Inspect every stk command and its options. | Option | Description | | ------------ | --------------------------------- | | `--json` | Emit JSON. | | `--markdown` | Emit the docs CLI reference page. | ### `stk inspect context` [Section titled “stk inspect context”](#stk-inspect-context) `stk inspect context [OPTIONS]` Inspect routes and models as one agent-readable contract. | Option | Description | | -------- | ----------- | | `--json` | Emit JSON. | ### `stk inspect routes` [Section titled “stk inspect routes”](#stk-inspect-routes) `stk inspect routes [OPTIONS]` Inspect registered routes. | Option | Description | | -------- | ----------- | | `--json` | Emit JSON. | ### `stk report` [Section titled “stk report”](#stk-report) `stk report [OPTIONS]` Generate a static project report. | Option | Description | | -------------- | ----------------- | | `-o, --output` | HTML output path. | | `--json` | Emit JSON. | ### `stk smoke` [Section titled “stk smoke”](#stk-smoke) `stk smoke [OPTIONS]` Run a real-browser smoke check against a temporary development app. | Option | Description | | -------- | ----------- | | `--json` | Emit JSON. | ### `stk verify` [Section titled “stk verify”](#stk-verify) `stk verify [OPTIONS]` Run STK verification checks. | Option | Description | | ------------- | ------------------------------------------ | | `--json` | Emit JSON. | | `-w, --watch` | Re-run affected checks on every file save. | ## Accounts [Section titled “Accounts”](#accounts) ### `stk add-role` [Section titled “stk add-role”](#stk-add-role) `stk add-role [OPTIONS]` Adds a role to the specified user. | Option | Description | | ------------- | ----------- | | `-e, --email` | | | `-r, --role` | | ### `stk browser-token create` [Section titled “stk browser-token create”](#stk-browser-token-create) `stk browser-token create [OPTIONS]` Create a signed test-only browser login URL. | Option | Description | | -------- | --------------------- | | `--user` | Test user email. | | `--ttl` | Token TTL in seconds. | | `--next` | Local redirect path. | ### `stk cleanup-sessions` [Section titled “stk cleanup-sessions”](#stk-cleanup-sessions) `stk cleanup-sessions [OPTIONS]` Deactivate expired sessions and delete old rows. ### `stk create` [Section titled “stk create”](#stk-create) `stk create [OPTIONS]` Creates a user using an email. | Option | Description | | ---------------- | ----------- | | `-e, --email` | | | `-p, --password` | | ### `stk install` [Section titled “stk install”](#stk-install) `stk install [OPTIONS]` Install a default admin user and add an admin role to it. | Option | Description | | ---------------- | -------------- | | `-e, --email` | Admin email | | `-p, --password` | Admin password | ### `stk protect-mfa` [Section titled “stk protect-mfa”](#stk-protect-mfa) `stk protect-mfa [OPTIONS]` Encrypt legacy MFA seeds or rotate them to the current key. ### `stk reset` [Section titled “stk reset”](#stk-reset) `stk reset [OPTIONS]` Reset a user password using email | Option | Description | | ---------------- | ----------- | | `-e, --email` | | | `-p, --password` | | # Configuration > Every environment variable stk reads, and the app config values behind them `./setup.sh` copies `.env-sample` to `.env` and generates the secrets. `stk/settings.py` reads the variables below; `stk verify` fails if it starts reading one this page does not list. ## Core [Section titled “Core”](#core) | Variable | Default | Purpose | | ------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SECRET_KEY` | required | Signs sessions; also derives the TOTP encryption key unless `SECURITY_TOTP_ENCRYPTION_KEYS` is set. Startup fails without it. | | `SECURITY_PASSWORD_SALT` | required | Salt for password-related tokens. Startup fails without it. | | `STK_ENV` | `production` | `development` enables development-only features such as agent login. | | `QUART_DEBUG` | unset | `1` for the reloader and debug pages. Never in production. | | `STK_PUBLIC_URL` | unset | Public origin, e.g. `https://app.example.com`. Restricts accepted hosts, builds OAuth callbacks, and sets the allowed WebSocket origin. Set it in production. | | `SQLALCHEMY_DATABASE_URI` | `instance/stk.db` (SQLite) | Use `postgresql+asyncpg://...` for PostgreSQL; plain `postgresql://` will not work. | | `REDIS_URL` | unset | Stores sessions in Redis when the `full` extra is installed. `REDIS_SESSION` is read as a legacy alias. | ## Branding [Section titled “Branding”](#branding) | Variable | Default | Purpose | | ----------------- | -------------------------- | ---------------------------------------- | | `APP_NAME` | `stk` | Page titles and exported recovery codes. | | `APP_TAGLINE` | `Secure. Scalable. Swift.` | Sign-up panel heading. | | `APP_DESCRIPTION` | empty | Sign-up panel text. | See [Renaming Your App](/branding). ## Auth and sessions [Section titled “Auth and sessions”](#auth-and-sessions) | Variable | Default | Purpose | | --------------------------------- | ------- | --------------------------------------------------------------------------------------------- | | `SECURITY_REGISTERABLE` | `False` | Public password sign-up. Keep it off unless the app needs it. | | `SECURITY_TOTP_ENCRYPTION_KEYS` | unset | Comma-separated Fernet keys, newest first, for TOTP seeds. Rotate with `stk protect-mfa`. | | `SECURITY_WAN_RP_ID` | unset | Passkey relying party: your bare DNS name. | | `SECURITY_WAN_EXPECTED_ORIGIN` | unset | Passkey origin: your HTTPS origin. | | `SESSION_COOKIE_SECURE` | `True` | Set `False` only for local HTTP; under plain HTTP a secure cookie breaks login. | | `SESSION_COOKIE_HTTPONLY` | `True` | | | `SESSION_COOKIE_SAMESITE` | `Lax` | | | `DISABLE_MULTIPLE_SESSIONS` | `False` | `True` logs out a user’s other sessions on every login. | | `STK_ENABLE_AGENT_LOGIN` | `False` | Test-only browser login for agents. Startup fails if it is on outside development or testing. | | `STK_AGENT_LOGIN_MAX_TTL_SECONDS` | `60` | Longest lifetime `stk browser-token create` may issue. | ## OAuth [Section titled “OAuth”](#oauth) | Variable | Default | Purpose | | ------------------------------------------------------ | ------- | ---------------------------------------------------------------- | | `GOOGLE_AUTH_ENABLED` | `False` | Turn on Google login. | | `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET` | unset | Google credentials. | | `GOOGLE_OAUTH_REDIRECT_URI` | derived | Override the callback URL; normally built from `STK_PUBLIC_URL`. | | `GITHUB_AUTH_ENABLED` | `False` | Turn on GitHub login. | | `GITHUB_OAUTH_CLIENT_ID`, `GITHUB_OAUTH_CLIENT_SECRET` | unset | GitHub credentials. | | `OAUTHLIB_INSECURE_TRANSPORT` | `0` | `1` allows OAuth over local HTTP. Development only. | See [Authentication](/authentication#oauth). ## Mail [Section titled “Mail”](#mail) | Variable | Default | Purpose | | -------------------------------- | ----------------- | ----------------------------- | | `MAIL_SERVER` | unset | SMTP host; port 465 with SSL. | | `MAIL_USERNAME`, `MAIL_PASSWORD` | unset | SMTP credentials. | | `SECURITY_EMAIL_SENDER` | `info@domain.com` | From address. | ## Docker Compose [Section titled “Docker Compose”](#docker-compose) Read by `docker-compose.yml`, not by the app: `DB_ADMIN_PASSWORD`, `DB_PASSWORD`, `REDIS_PASSWORD`, `STK_HOST`, `TLS_CERTIFICATE`, `TLS_PRIVATE_KEY`, and `DOCKER_UID`. See [Deployment](/deployment#docker-compose). ## App config values [Section titled “App config values”](#app-config-values) Some limits are app config only, with no environment variable. Set them on your `Config` class in `stk/settings.py`: | Key | Default | Purpose | | ----------------------------- | ---------------- | ----------------------------------------------------------- | | `STK_AUTH_ATTEMPT_LIMIT` | `10` | Login attempts per IP per minute. | | `STK_AUTH_REQUEST_LIMIT` | `120` | Auth requests per IP per minute. | | `STK_WS_ALLOWED_ORIGINS` | `STK_PUBLIC_URL` | WebSocket origins to accept. | | `STK_WS_CONNECTIONS_PER_USER` | `5` | Open sockets per user. | | `STK_WS_MAX_CONNECTIONS` | `1000` | Open sockets per worker. | | `STK_WS_MESSAGE_LIMIT` | `60` | Client messages per socket per minute. | | `STK_WS_MESSAGE_SIZE` | `65536` | Largest client message in bytes. | | `STK_WS_QUEUE_SIZE` | `32` | Outgoing messages buffered before a slow client is dropped. | | `STK_WS_AUTH_INTERVAL` | `5` | Seconds between session rechecks on an open socket. | # Deployment > Deploy your stk application to production ## Overview [Section titled “Overview”](#overview) There are three ways to deploy: the one-command VPS script, Docker Compose, or a manual setup. All of them must meet the production requirements below. The full contract lives in [SECURITY.md](https://github.com/level09/stk/blob/master/SECURITY.md). ## Production requirements [Section titled “Production requirements”](#production-requirements) Set these in `.env` before the app serves real traffic. `./setup.sh` writes development values (`QUART_DEBUG=1`, `STK_ENV=development`, `SESSION_COOKIE_SECURE=False`), so change them. ```bash STK_ENV=production QUART_DEBUG=0 SESSION_COOKIE_SECURE=True SECRET_KEY=... # unique, generated SECURITY_PASSWORD_SALT=... # unique, generated STK_PUBLIC_URL=https://app.example.com SECURITY_WAN_RP_ID=app.example.com SECURITY_WAN_EXPECTED_ORIGIN=https://app.example.com ``` `STK_PUBLIC_URL` restricts accepted request hosts and fixes OAuth callback URLs. The WebAuthn values must match the public host and origin or passkeys fail. Keep `SECURITY_REGISTERABLE=False` unless the app needs public sign-up. `SECRET_KEY` also derives the default key for encrypted TOTP seeds, so keep it stable, or move to `SECURITY_TOTP_ENCRYPTION_KEYS` as described in [Authentication](/authentication). Back up the MFA keys separately from the database. Trust proxy headers only from your own proxy. Rate limits key on the client IP, so a misconfigured proxy either collapses all users onto one IP or lets clients spoof theirs. ## Upgrading an existing deployment [Section titled “Upgrading an existing deployment”](#upgrading-an-existing-deployment) Take a protected backup, then before starting the new version: ```bash uv sync --frozen --extra full # drop --extra full if you do not use PostgreSQL or Redis uv run stk db upgrade uv run stk protect-mfa ``` `protect-mfa` encrypts legacy TOTP seeds and rotates existing ones to the current key in one transaction. Version 16 needs the `rate_limit_window` table and version 15 the `quart_security_state` table; `db upgrade` creates both. ## VPS deploy script [Section titled “VPS deploy script”](#vps-deploy-script) On a fresh Ubuntu 22.04 or 24.04 server: ```bash wget -qO /tmp/deploy.sh https://raw.githubusercontent.com/level09/stk/master/deploy.sh && sudo DOMAIN=example.com bash /tmp/deploy.sh ``` `deploy.sh` installs Caddy with automatic TLS, uv-managed Python 3.13, a dedicated app user, UFW, fail2ban, and key-only SSH. It generates a production `.env` with fresh secrets and the public URL and WebAuthn values, applies migrations, creates an admin user, and runs uvicorn as a systemd service named after the domain. SQLite is the default; `DB=postgres` adds PostgreSQL, Redis, and the `full` extra. Admin credentials are saved to `/home//.credentials`. | Variable | Default | Description | | ---------------- | --------------- | ---------------------------------------------- | | `DOMAIN` | (required) | Domain name, or server IP with `SKIP_SSL=true` | | `REPO` | `level09/stk` | GitHub repository to deploy | | `BRANCH` | `master` | Branch to deploy | | `DB` | `sqlite` | `sqlite` or `postgres` | | `ADMIN_EMAIL` | `admin@$DOMAIN` | Admin login email | | `ADMIN_PASSWORD` | (generated) | Admin password | | `SKIP_SSL` | `false` | `true` serves plain HTTP, for IP-only testing | | `PYTHON_PORT` | `5000` | Internal uvicorn port | `deploy.sh` is install-only. It deletes and re-clones the app directory, and with SQLite the database lives inside it at `instance/stk.db`, so running it again on a live server destroys production data. It also clones over plain HTTPS, so a private repository fails at the clone step: make it public, or clone it yourself with a read-only deploy key first. Update a deployed app as the app user, in the app directory: ```bash git pull && uv sync --frozen --no-dev && uv run stk db upgrade && sudo systemctl restart .service ``` Add `--extra full` to the sync on a PostgreSQL install, and run `protect-mfa` as above. The app user’s sudoers entry allows managing only its own service. Check a deploy with `curl -sI https:///` (expect 200 or a redirect to login) and `systemctl is-active .service`, then sign in with the saved credentials, create a record, restart the service, and confirm the record is still there. ## Docker Compose [Section titled “Docker Compose”](#docker-compose) ```bash docker compose up --build ``` The stack runs Redis, PostgreSQL 15, the app, and Nginx. The app container runs `stk db upgrade` before starting uvicorn, and Compose sets `STK_ENV=production`, `QUART_DEBUG=0`, `SESSION_COOKIE_SECURE=True`, the database and Redis URLs, and the public URL and WebAuthn values from `STK_HOST`. Compose refuses to start until `.env` provides: ```bash DB_ADMIN_PASSWORD=... # PostgreSQL superuser DB_PASSWORD=... # app database role REDIS_PASSWORD=... STK_HOST=app.example.com TLS_CERTIFICATE=/absolute/path/fullchain.pem TLS_PRIVATE_KEY=/absolute/path/key.pem ``` Passwords must be unique and URL-safe because they are embedded in connection URLs. `STK_HOST` is the bare DNS name; Compose derives `https://` URLs from it. The certificate paths must be absolute, valid for that host, and readable by the Nginx container user (`DOCKER_UID`, default 1000). Renew certificates and reload Nginx before they expire. Only Nginx is published: port 80 redirects to HTTPS on 443. A new PostgreSQL volume gets a separate non-superuser `stk` role for the app. Initialization scripts do not run on an existing volume, so review that role’s privileges yourself. ## Manual setup [Section titled “Manual setup”](#manual-setup) You need Python 3.11+, uv, and a TLS-terminating reverse proxy. PostgreSQL and Redis are optional. ```bash git clone https://github.com/level09/stk.git cd stk ./setup.sh # deps and .env with generated secrets uv sync --frozen --extra full # only for PostgreSQL or Redis # edit .env with the production requirements above uv run stk create-db uv run stk install ``` SQLite works with no configuration and lives in `instance/stk.db`. For PostgreSQL, set `SQLALCHEMY_DATABASE_URI=postgresql+asyncpg://user:password@localhost/dbname`; the `postgresql://` scheme will not work. For Redis sessions, set `REDIS_URL`. Run uvicorn under systemd, bound to localhost and trusting forwarded headers only from the local proxy: ```ini [Unit] Description=stk After=network.target [Service] User=your_user WorkingDirectory=/path/to/stk EnvironmentFile=/path/to/stk/.env ExecStart=/path/to/stk/.venv/bin/uvicorn run:app --host 127.0.0.1 --port 5000 --ws-max-size 65536 --ws-max-queue 16 --proxy-headers --forwarded-allow-ips=127.0.0.1 Restart=always [Install] WantedBy=multi-user.target ``` The proxy must pass `Host` and `X-Forwarded-Proto`, upgrade WebSocket connections, and can serve `stk/static` directly. With Nginx: ```nginx location /static/ { alias /path/to/stk/stk/static/; } location / { proxy_pass http://127.0.0.1:5000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; } ``` `$connection_upgrade` needs the `map` block from `nginx/stk.conf`. Use Certbot or your own certificates for TLS. ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) Requests rejected or OAuth redirecting to the wrong host: `STK_PUBLIC_URL` must match the URL users visit. Database connection errors: check credentials in `.env` and that the URI uses `postgresql+asyncpg://`. App not starting: `sudo journalctl -u `. Missing `SECRET_KEY` or `SECURITY_PASSWORD_SALT` stops startup. WebSockets not connecting: the proxy needs HTTP/1.1 with the `Upgrade` and `Connection` headers, and the browser origin must match the app’s. # Frontend > Vue 3 and Vuetify with no build step: page shape, conventions, and the components stk uses Pages are Jinja templates that mount a Vue 3 app (Options API) with Vuetify. Vue, Vuetify, Axios, and Tabler Icons are vendored into `stk/static/`, so there is no npm, bundler, or build step. Versions are pinned in `stk/static/VERSIONS.txt`; to bump one, edit `vendor.sh`, run `./vendor.sh`, then `uv run stk smoke`. The fastest way to see the whole pattern is `uv run stk new `: the generated `stk/templates/cms/.html` is a working CRUD page that follows every rule below. ## Page shape [Section titled “Page shape”](#page-shape) ```html {% extends "layout.html" %} {% block css %}{% endblock %} {% block content %} {% endblock %} {% block js %} {% endblock %} ``` The layout shell (app bar, navigation drawer, `v-main`) lives in `layout.html`. Page templates fill `{% block content %}` and never redefine it. Full-width pages suppress the sidebar with `{% block sidebar %}{% endblock %}` and `{% block layout_classes %} align-center {% endblock %}`. ## Rules that fail silently [Section titled “Rules that fail silently”](#rules-that-fail-silently) * **Delimiters are `${expr}`**, never `{{expr}}`, which Jinja renders first. * **Server data goes in `{% block js %}`.** Vue strips `