# Cadmus documentation --- # Introduction Cadmus is a batteries-included web framework for Rust. You describe your data once, as **models**, and Cadmus gives you: - **a database table** for each model, kept up to date with migrations; - **an admin site** where people log in and add, edit, search and delete records; - **a REST API** (JSON) so other programs can do the same, with interactive documentation; - **accounts and permissions**: users, groups, two-factor login, login with Google, GitHub and others, and API keys for programs; - **management commands** such as `migrate` and `createsuperuser`; - **file uploads**, to a folder or to cloud storage. It runs on [axum](https://docs.rs/axum), the web server, and [SeaORM](https://www.sea-ql.org/SeaORM/), the database layer, and works with SQLite, PostgreSQL and MySQL. ## Why Cadmus - **Batteries included.** Accounts, permissions, an admin site, an API with documentation, migrations and file uploads come with the framework, configured in one settings file. There is nothing to choose and wire together before you can start. - **Little code to write.** There are no HTML templates for the admin and no handlers for the API: both are generated from the models. Your own code goes where the generated parts stop. - **Caught early.** It is Rust, so a misspelt setting, field or model is reported when the project compiles, before anything runs. - **One program to deploy.** The result is a single fast executable, with no interpreter or runtime to install on the server. ## How the pieces fit ```text your models (src/models/*.rs) │ ┌───────────────┼────────────────┬──────────────────┐ ▼ ▼ ▼ ▼ database tables admin site REST API API documentation (migrations) /admin /api/... /api/docs │ │ └──── accounts and permissions ────┘ ``` Everything shares one set of rules: an account that may not change posts cannot change them in the admin or through the API, and neither sees buttons or endpoints it cannot use. ## Two crates | Crate | What it is | |---|---| | [`cadmus`](https://crates.io/crates/cadmus) | the framework your project depends on ([API reference on docs.rs](https://docs.rs/cadmus)) | | [`cadmus-cli`](https://crates.io/crates/cadmus-cli) | the `cadmus` command, which creates projects and models, and serves these docs to AI agents | ## Status Cadmus is at version 0.1. Everything described here works and is tested on all three databases, but details may still change between versions. What is not there yet is listed under [Limitations](limitations.md). Next: [build your first project](getting-started.md). --- # Getting started This page takes you from nothing to a running site with an admin and an API, in about ten minutes (most of it the first compile). ## What you need [Rust](https://www.rust-lang.org/tools/install), a recent stable version installed with `rustup`. Nothing else: the first project uses SQLite, a database kept in a single file. ## 1. Install the cadmus command ```sh cargo install cadmus-cli ``` This installs `cadmus`, which creates projects and models. Check it with `cadmus --version`. ## 2. Create a project ```sh cadmus new blog cd blog ``` This writes a ready-to-run project (see [Project layout](project-layout.md)), including a `.env` file with a fresh secret key. For PostgreSQL or MySQL instead of SQLite, use `cadmus new blog --database postgres` (or `mysql`) and edit the address in `.env`. ## 3. Describe your data A blog post with a title, a body, a published flag and a view count: ```sh cadmus generate model Post title:string body:text published:bool views:int ``` This writes `src/models/post.rs` and registers it in `src/models/mod.rs`. The field types are `string`, `text`, `int`, `bigint`, `float`, `bool`, `datetime`, `date`, `json`, `file` (an uploaded file) and `references` (a link to another model). Add `?` to make a field optional, as in `rating:float?`. See [Models](models.md) for the details. ## 4. Create the table ```sh cargo run -- makemigrations # writes src/migrations/m0001_initial.rs cargo run -- migrate # creates the table in the database ``` The first run compiles the project, which takes a minute or two; later runs are quick. [Migrations](migrations.md) explains what these do. ## 5. Make an account for yourself ```sh cargo run -- createsuperuser --username admin ``` It asks for a password. A *superuser* can do everything, including managing other accounts. ## 6. Start the server ```sh cargo run -- serve ``` Then open: | Address | What you see | |---|---| | | the admin site: log in, then add and edit posts | | | the API documentation, where you can try every request | | | a public status page: is the service up, does the database answer | ## 7. Change something Give posts an optional summary: in `src/models/post.rs`, add this line to the `Model` struct: ```rust pub summary: Option, ``` Then: ```sh cargo run -- makemigrations # notices the new field, writes m0002_post_summary.rs cargo run -- migrate # adds the column; existing posts keep their data ``` That is the whole loop: change the models, make a migration, apply it. ## Where to go next - [Models](models.md): field types and the admin options for each model. - [The admin site](admin.md): lists, filters, actions and forms. - [The REST API](rest-api.md): what other programs can do, and how they log in. - [Building with AI agents](ai-agents.md): let a coding agent build on Cadmus for you. --- # Project layout `cadmus new blog` writes this: ```text blog/ ├── Cargo.toml Rust's project file: name and dependencies ├── .env secrets for this machine: database address, secret key (not in git) ├── .env.example the same without the secrets, to share ├── .gitignore ├── AGENTS.md how to work on the project, for AI coding agents └── src/ ├── main.rs starts the app: settings, migrations, models, your routes ├── settings.rs how the site behaves: every setting in one place ├── models/ │ ├── mod.rs lists the models, with the admin options for each │ └── post.rs one file per model (cadmus generate model adds them) ├── migrations/ │ ├── mod.rs the list of migrations, kept up to date by makemigrations │ └── m0001_initial.rs └── routes.rs your own pages and endpoints, next to the generated ones ``` ## main.rs ```rust mod migrations; mod models; mod routes; mod settings; use cadmus::{AdminError, App}; #[tokio::main] async fn main() -> Result<(), AdminError> { let app = App::from_settings(settings::settings()?) .await? .migrations(migrations::all()); models::register(app).routes(routes::router()).run().await } ``` `run()` reads the command line, so the same program is the web server (`serve`) and every [management command](commands.md) (`migrate`, `createsuperuser`, ...). ## models/mod.rs ```rust pub mod post; // cadmus:modules (generated modules are added above this line) pub fn register(app: App) -> App { app .model::(cadmus::ModelOptions::new().label("Posts")) // cadmus:models (generated models are registered above this line) } ``` `cadmus generate model` adds a line above each marker comment, so keep the two comments. Change the `ModelOptions` freely: see [Models](models.md). ## settings.rs Every setting of the site, in plain Rust: database, login methods, the admin's look, e-mail, uploads. Secrets are read from `.env`. See [Settings](settings.md). ## migrations/ One file per migration, written by `cargo run -- makemigrations`, and `mod.rs`, which lists them in order. You can edit a migration until it has been applied somewhere. See [Migrations](migrations.md). ## routes.rs Your own axum routes, served next to the generated ones: ```rust pub fn router() -> Router { Router::new().route("/health", get(|| async { "ok" })) } ``` See [Your own code](custom-code.md). ## .env ```sh DATABASE_URL=sqlite://db.sqlite3?mode=rwc SECRET_KEY=... # signs logins; `cadmus secret` prints a new one DEBUG=true # lets the login cookie work over plain http:// on your machine PUBLIC_URL=http://localhost:3000 ``` `.env` is loaded automatically on your machine and is in `.gitignore`. On a server, set the same names as environment variables instead. --- # Models A model describes one kind of record: its fields and their types. Cadmus uses [SeaORM](https://www.sea-ql.org/SeaORM/) entities as models. ## Generating a model ```sh cadmus generate model Post title:string body:text published:bool views:int rating:float? ``` This writes `src/models/post.rs`: ```rust use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] #[sea_orm(table_name = "post")] pub struct Model { #[sea_orm(primary_key)] pub id: i32, pub title: String, #[sea_orm(column_type = "Text")] pub body: String, pub published: bool, pub views: i32, pub rating: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} ``` and registers it in `src/models/mod.rs`. Model names are PascalCase (`BlogPost`); the table is named in snake_case (`blog_post`). An `id` primary key is always added. ## Field types | Type | Rust type | Stored as | |---|---|---| | `string` | `String` | short text (`varchar`) | | `text` | `String` | long text | | `int` | `i32` | 32-bit whole number | | `bigint` | `i64` | 64-bit whole number | | `float` | `f64` | number with a fraction | | `bool` | `bool` | true or false | | `datetime` | `DateTime` | date and time, without a time zone | | `date` | `Date` | a date | | `json` | `Json` | a JSON document | | `file` | `Option` | an uploaded file (see [File uploads](file-uploads.md)) | | `references` | `i32` | a link to another model (see [Relations](relations.md)) | Add `?` to make a field optional (`Option<...>` in Rust, `NULL` in the database): `summary:text?`. File fields are always optional. ## Changing a model Edit the file by hand: add, remove, rename or change fields, then make and apply a migration: ```sh cargo run -- makemigrations cargo run -- migrate ``` Useful SeaORM attributes on a field: | Attribute | Effect | |---|---| | `#[sea_orm(unique)]` | no two rows may have the same value | | `#[sea_orm(indexed)]` | an index, for faster lookups and sorting | | `#[sea_orm(default_value = "draft")]` | the value new rows get; also fills existing rows when the field is added | | `#[sea_orm(column_type = "Text")]` | long text instead of `varchar` | | `#[sea_orm(renamed_from = "body")]` | tells `makemigrations` the field was renamed, so the data is kept | ## Admin options Each model is registered with `ModelOptions`, which decide how it appears in the admin (and partly in the API): ```rust app.model::( ModelOptions::new() .label("Posts") // name in the menu .list_display(["id", "title", "published"]) // columns on the list page .search_fields(["title", "body"]) // what the search box looks in .list_filter(["published", "created_at"]) // filter dropdowns above the list .readonly_fields(["views"]) // shown, but not editable .hidden_fields(["internal_note"]) // never shown or sent anywhere .display_field("title") // how other models name a post .per_page(50) // rows per list page .set_action("publish", "Mark as published", "published", "true"), ) ``` | Option | What it does | |---|---| | `label("Posts")` | the name shown in the menu and headings | | `list_display([...])` | the columns on the list page (default: all visible fields) | | `search_fields([...])` | the text fields the search box looks in; also `?q=` in the API | | `list_filter([...])` | a filter dropdown per field above the list (see [The admin site](admin.md)) | | `readonly_fields([...])` | shown but not editable, in the admin and the API | | `hidden_fields([...])` | never shown or accepted, in the admin, the API or its documentation | | `display_field("email")` | the field that names a record where other models link to it (default: the first text field) | | `per_page(n)` | rows per list page for this model | | `file_fields([...])` | integer fields that hold an uploaded file (set by `field:file`) | | `set_action(name, label, field, value)` | a bulk action setting a field on the selected rows | | `action(name, label, \|ids\| async move { ... })` | a bulk action running your own code with the selected ids | ## Models without the generator Any SeaORM entity can be registered with `.model::(...)`, including ones you wrote by hand. Relations declared with `belongs_to` are picked up automatically. Models must have a single-column primary key to be edited. A resource that is not a database table at all can be served by implementing the `Resource` trait and registering it with `App::resource`; see the [API reference](https://docs.rs/cadmus). --- # The admin site `/admin` is where people manage the data in a browser. It works on phones as well as desktops, has light and dark themes, and shows each account only what it may do. ## Dashboard The first page after logging in lists the models the account can see, with how many records each has and what the account may do with them (view, add, change, delete, spelled out rather than shown by colour alone). Superusers also see account totals, and everyone sees whether their two-factor login is on. ## Lists Each model's list page has: - **search** across the model's `search_fields`; - **sorting** by clicking a column heading; - **pages** of results (25 by default; `ModelOptions::per_page` or `admin.page_size`); - on phones, each row becomes a card. / jumps to the search box, and every control can be reached with the keyboard. ## Filters `ModelOptions::list_filter(["published", "author_id", "created_at"])` adds a dropdown per field above the list: | Field type | Choices | |---|---| | true/false | Yes, No | | link to another model | the related records, by name | | date or date-time | Today, Past 7 days, Past 30 days, This month, This year | | anything else | the values present | | optional fields | also "(empty)" | Filters combine with search, and sorting and paging keep them. A link someone kept from before, with a filter that no longer exists, shows nothing rather than failing. ## Acting on several rows Rows can be ticked one by one, or all on the page, and acted on together: - **Delete selected** is offered to accounts that may delete. It asks first, and keeps rows that other rows still point to, saying how many. - **Setting a field:** `set_action("publish", "Mark as published", "published", "true")` sets `published` to true on every selected row, as editing them would. - **Your own code:** `action(name, label, |ids| async move { ... })` runs with the selected primary keys: ```rust ModelOptions::new().action("archive", "Archive", move |ids| { let db = db.clone(); async move { post::Entity::update_many() .col_expr(post::Column::Archived, Expr::value(true)) .filter(post::Column::Id.is_in(ids)) .exec(&db) .await?; Ok(()) } }) ``` Actions need the change permission, and at most 1000 rows are done at a time. ## Forms - Buttons appear only for what the account may do; a record it cannot change opens read-only. - Deleting asks for confirmation; saving confirms with a short notice. - Links to other models are dropdowns of the related records (see [Relations](relations.md)). - File fields have a file picker, a preview for images, and "Remove the file" (see [File uploads](file-uploads.md)). - Invalid input is shown next to the field, and nothing is saved. ## Accounts pages Superusers also get **Users** and **Groups** in the menu, to manage accounts and their permissions (see [Accounts and permissions](accounts.md)). Every account has its own pages, under the buttons by its name: two-factor login, password, and API keys. ## Changing the look In the `admin` section of [Settings](settings.md): | Setting | What it changes | |---|---| | `site_title` | the name in the header and the browser tab | | `accent_color` | the colour of buttons and links, e.g. `"#0a7f5a"` | | `logo_url` | a logo instead of the initial in the corner | | `theme` | `Theme::Auto` (default: follows the device), `Theme::Light` or `Theme::Dark` | | `custom_css_url` | your own stylesheet, loaded after the built-in one | | `footer_text` | a line at the bottom of every page | | `nav_links` | extra links in the menu | | `web_fonts` | `false` uses the system typeface instead of loading Fira Sans from Google Fonts | | `template_dir` | a folder of templates that replace the built-in ones by name | | `path` | where the admin lives (default `/admin`) | --- # The REST API Everything in the admin can also be done by a program, over HTTP with JSON. Every model gets these endpoints under `/api`, with the model's table name in the path: | Request | What it does | |---|---| | `GET /api/post` | list posts | | `POST /api/post` | create one; answers `201` with the new record and a `Location` header | | `GET /api/post/1` | read one | | `PUT /api/post/1` | replace one: every required field must be sent | | `PATCH /api/post/1` | change only the fields sent | | `DELETE /api/post/1` | delete one; answers `204` | Every request needs a login token or an API key; see [Logging in](authentication.md). The API follows the same permissions as the admin. ## Listing ```sh curl -H "Authorization: Bearer $TOKEN" "http://localhost:3000/api/post?q=rust&sort=-id&page=2&per_page=50" ``` ```json { "data": [{ "id": 12, "title": "Rust tips", "published": true, "views": 40 }], "meta": { "total": 31, "page": 2, "per_page": 50 } } ``` | Parameter | Meaning | |---|---| | `q` | search in the model's `search_fields` (ignores case) | | `sort` | a field to sort by; `-` in front for highest first (`sort=-views`) | | `page` | which page, from 1 | | `per_page` | how many per page, at most 100 | ## Filtering Any visible field can be filtered on, with the same choices as the admin's filters: | Example | Matches | |---|---| | `?published=true` | an exact value | | `?author_id=3&published=false` | several at once (all must match) | | `?views__gt=100`, `__gte`, `__lt`, `__lte` | comparisons | | `?created_at__gte=2026-01-01` | dates and date-times compare too | | `?summary__isnull=true` | empty (or `false`: not empty) | | `?created_at__range=7d` | `today`, `7d`, `30d`, `month` or `year` (UTC) | An unknown field, or a value the field cannot hold, is refused with `422`. A field named `page`, `per_page`, `q` or `sort` cannot be filtered on. ## Creating and changing ```sh curl -X POST http://localhost:3000/api/post \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"title": "Hello", "body": "First post", "published": false, "views": 0}' ``` - Dates and times are ISO 8601: `"2026-03-04"`, `"2026-03-04T05:06:07"`. - Links to other models are sent as the id: `"author_id": 3`. - Fields that are unknown, hidden or read-only are refused rather than silently ignored. - `PUT` needs every required field; `PATCH` changes only what is sent. ## Errors Errors come back as JSON: ```json { "error": { "code": "invalid_field", "message": "title is required", "field": "title" } } ``` | Status | Code | Meaning | |---|---|---| | `400` | `bad_request` | the request is malformed | | `401` | `unauthorized` | no valid token or key was sent | | `403` | `forbidden` | the account may not do this | | `404` | `not_found` | no such record or model | | `409` | `conflict` | conflicts with other data: a duplicate, or a record others point to | | `413` | `too_large` | an upload over the size limit | | `422` | `invalid_field` | a field is missing, unknown, read-only or of the wrong type | | `429` | `rate_limited` | too many login attempts; wait for the `Retry-After` seconds | ## Calling it from a browser app A web app on another domain needs permission to call the API: set `server.cors_origins: vec!["https://app.example.com".into()]` in the settings. Such apps send a token or an API key; the admin's login cookie is never accepted by the API. ## Documentation Every project documents its own API at `/api/docs` (Swagger UI), `/api/redoc` and `/api/openapi.json`. See [API documentation](api-docs.md). --- # Migrations The database's tables must match your models. *Migrations* are the steps that get them there, saved as files, so every copy of the project (your laptop, a colleague's, the server) goes through the same steps. ```sh cargo run -- makemigrations # compares the models with the migrations so far, writes the difference cargo run -- migrate # applies the migrations this database has not had yet ``` ## What a migration looks like `makemigrations` writes a short Rust file into `src/migrations/` and adds it to the list in `src/migrations/mod.rs`: ```rust use cadmus::migrations::*; pub fn migration() -> Migration { Migration::new("0002_post_changes") .rename_column("post", "body", "content") .add_column("post", Column::new("status", Type::String(None)).default("draft")) .drop_column("post", Column::new("views", Type::Integer)) } ``` The same file runs on SQLite, PostgreSQL and MySQL: `migrate` turns each step into the right SQL for the database it runs on. `cargo run -- sqlmigrate 0002` prints that SQL. ## How it applies them - Each migration runs as one transaction, so a failure leaves the database as it was. MySQL is the exception: it saves table changes as it goes. - Applied migrations are recorded in the `cadmus_migration` table; `cargo run -- showmigrations` lists which ones this database has had. - The built-in tables (accounts, uploads) have migrations of their own, applied before yours. - `migrate` warns when the models have changes that no migration makes yet. ## Renames A dropped field and a new one look the same as a rename, but a rename keeps the data. So: - mark the field `#[sea_orm(renamed_from = "body")]`, and `makemigrations` renames it; or - run `makemigrations` in a terminal, and it asks whether a dropped and a new field (or table) of the same shape are a rename. With `--no-input`, or without a terminal, nothing counts as a rename. ## New required fields Rows already in the table need a value for a new required field. Either give the field a default (`#[sea_orm(default_value = "draft")]`), make it optional (`Option<...>`), or type a value when `makemigrations` asks; it is saved in the migration as `add_column_filled`. ## Changing data A migration can be edited until it has been applied somewhere. Add data changes with SQL: ```rust Migration::new("0003_slugs") .sql_reversible( "UPDATE post SET slug = lower(title)", "UPDATE post SET slug = ''", ) ``` or with Rust code, which runs inside the migration's transaction: ```rust use sea_orm::ConnectionTrait; Migration::new("0003_trim").run(|db| { Box::pin(async move { db.execute_unprepared("UPDATE post SET title = trim(title)").await?; Ok(()) }) }) ``` `cargo run -- makemigrations --empty --name slugs` writes an empty migration to start from. ## Rolling back ```sh cargo run -- migrate 0001 # undo every migration after 0001 cargo run -- migrate zero # undo all of them ``` A dropped column comes back empty (`0`, `''`, ...). SQL or code without a reverse cannot be rolled back; `--fake` records or forgets a migration without running anything, for tables you changed by hand. ## SQLite SQLite cannot change or drop most columns in place, so for those steps the table is rebuilt: created anew, rows copied, the old one dropped. Links between tables are checked afterwards. ## In CI `cargo run -- makemigrations --check` fails if a model changed without a migration, so a forgotten migration is caught before it is merged. ## Not handled Database enums and array columns, changing a primary key, and changing a column's type on PostgreSQL when the values cannot be converted. Write those steps with `.sql(...)`. ## Projects without migrations A project that does not call `.migrations(...)` keeps the older behaviour: `migrate` adds missing tables and columns to match the models, and never drops or renames anything. To start keeping migrations, run `makemigrations`, then add `mod migrations;` to `main.rs` and `.migrations(migrations::all())` to the `App`, as the command explains. The tables already exist, so `migrate` just records the first migration as applied. --- # Accounts and permissions Accounts are built in: they live in the `auth_user` table, and superusers manage them in the admin under **Users** and **Groups**. ## The three switches | Switch | Meaning | |---|---| | Active | Can log in. Untick to lock someone out without deleting the account. | | Staff | Can use the admin and the API. | | Superuser | Can do everything, including managing accounts. | ## Permissions Staff who are not superusers can only do what they were given: for each model, any of **view**, **add**, **change** and **delete**. Permissions are given to an account directly, or to a **group** of accounts (such as "Editors"); an account has both. Whoever may change a model may also view it, and file uploads count as a model named `files`. The same rules apply in the admin and the API (`403` with code `forbidden`), and the admin hides links and buttons an account cannot use. - Changes apply at once: permissions are checked on every request, so deactivating an account locks it out immediately, even if it is logged in. - Nobody can remove their own access or delete their own account, so a superuser always remains. If you are locked out anyway, `cargo run -- promote --username U` makes an account superuser again. ## Account details Accounts have a username, an e-mail address (unique, stored in lower case; it can be used to log in), first and last name and phone, and record when they joined and last logged in. Passwords are stored as Argon2id hashes, never as text. For fields of your own, add a model linked to `cadmus::user::Entity`, with one profile row per account: ```rust #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "profile")] pub struct Model { #[sea_orm(primary_key)] pub id: i32, #[sea_orm(unique)] pub user_id: i32, pub bio: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { #[sea_orm(belongs_to = "cadmus::user::Entity", from = "Column::UserId", to = "cadmus::user::Column::Id")] User, } ``` ## Creating accounts - **From the terminal:** `cargo run -- createsuperuser --username U [--email E]`. - **In the admin:** Users → Add. "Send an invitation instead" e-mails a link where the person chooses their own password (see [E-mail](email.md)). - **By logging in through another site,** when sign-up is allowed (see [Login methods](login-methods.md)). ## Commands | Command | What it does | |---|---| | `createsuperuser --username U [--email E] [--password-stdin]` | create a superuser | | `changepassword --username U [--password-stdin]` | change a password | | `promote --username U` | make an existing account an active superuser | | `totp --username U [--disable]` | turn two-factor login on (prints a QR code) or off | | `apikey --username U --name N [--days D] [--read-only]` | make an API key | ## Your own account store To use accounts kept elsewhere, implement the `UserStore` trait and pass it to `App::users`. Every account is then a superuser unless you implement `UserStore::access`, API keys work only if you implement `UserStore::api_key`, and the Users and Groups pages are not shown. --- # Logging in - tokens and API keys There are two kinds of visitors, and each gets its own way in. ## People People log in with a username (or e-mail address) and password, or another [login method](login-methods.md) such as Google, plus their two-factor code if they turned it on. In the admin this happens on the login page. The login is kept in an `HttpOnly`, `SameSite=Strict` cookie, and every form carries a CSRF token. It lasts an hour (`auth.token_lifetime`). An app that people use, such as a mobile app, logs them in through the API and gets a **login token** (a JWT): ```sh curl -X POST http://localhost:3000/api/auth/token \ -H "Content-Type: application/json" \ -d '{"username": "admin", "password": "...", "totp": "123456"}' ``` ```json { "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600 } ``` Then it sends the token with each request, until it expires and the person logs in again: ```sh curl -H "Authorization: Bearer eyJ..." http://localhost:3000/api/post ``` `totp` is needed only for accounts with two-factor login; a recovery code works in its place. The endpoint also accepts a form (`application/x-www-form-urlencoded`). ## Programs Programs running on their own have nobody to type a password or a phone code: a nightly import script, another company's server, a website's backend fetching posts to show. They use an **API key**: a long random string, made once and put in the program's configuration. ```sh curl -H "Authorization: Bearer cadmus_..." https://example.com/api/post curl -H "X-API-Key: cadmus_..." https://example.com/api/post # the same ``` | | Login token | API key | |---|---|---| | For | a person, in an app | a program running on its own | | Made by | logging in with password (and two-factor code) | once, on the account's API keys page | | Lasts | an hour | until revoked, or 30 days / 90 days / a year / never | | If it leaks | change the password | revoke that one key; nothing else changes | | Can be limited | no | read-only | ## API keys - **Making one:** each account has an **API keys** page (the key icon under its name in the admin's menu), or run `cargo run -- apikey --username U --name "import script"`. The key is shown once; only a fingerprint (SHA-256) of it is stored. - **What it can do:** act as its account, with the account's permissions, through the API only, never the admin pages. A **read-only** key can view but not add, change or delete. - **When it stops:** when revoked, when it expires, or when the account is deactivated or deleted. The page shows when each key was last used. - **Safety:** making a key needs a login in the last 15 minutes or the password, so someone who steals a logged-in browser session cannot quietly make one. Superusers see every account's keys on its user page, and can revoke them. - **Turning them off:** `auth.api_keys: false` in the settings. Existing keys then stop working and the page is hidden. ## Which to use - Only people use your site through the admin: nothing to do. - People use an app you build on the API: login tokens. - A script or another service calls the API: an API key, read-only if it only reads. ## Related - [Login methods](login-methods.md): Google, Microsoft, GitHub, Facebook, any OpenID Connect provider, and login links by e-mail. - [Two-factor login](two-factor.md) and [login attempt limits](two-factor.md). --- # Login methods Each project chooses how people log in, in the `auth` section of `src/settings.rs`. The login page shows the chosen methods, in order, and only those. ```rust // Password, or Facebook auth: AuthSettings { methods: vec![ AuthMethod::Password, AuthMethod::Facebook(OAuthClient::from_env("FACEBOOK")?), ], ..Default::default() }, ``` ```rust // Google only, and anyone at acme.com gets an account on first login auth: AuthSettings { methods: vec![AuthMethod::Google(OAuthClient::from_env("GOOGLE")?)], signup: Signup::Domains(vec!["acme.com".into()]), signup_groups: vec!["Staff".into()], ..Default::default() }, ``` ## The methods | Method | What it needs | |---|---| | `AuthMethod::Password` | nothing; leave it out to turn passwords off everywhere, the API's token endpoint included | | `AuthMethod::MagicLink` | [e-mail](email.md); people type their address and get a login link | | `AuthMethod::Google(client)` | an OAuth client from the Google Cloud console | | `AuthMethod::Microsoft { client, tenant }` | an app registration in Microsoft Entra; `tenant` is `common`, `organizations`, `consumers` or your directory's ID | | `AuthMethod::GitHub(client)` | an OAuth app in GitHub's developer settings | | `AuthMethod::Facebook(client)` | an app at developers.facebook.com with Facebook Login | | `AuthMethod::Oidc(OidcProvider::new(name, label, issuer, client))` | any OpenID Connect provider: Okta, Auth0, Keycloak, GitLab, ... | ## Setting up a provider 1. Register an app with the provider. Give it this *redirect URI*: `/admin/auth//callback`, for example `https://shop.example.com/admin/auth/google/callback`. 2. Put its client ID and secret in `.env`: `OAuthClient::from_env("GOOGLE")` reads `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. 3. Set `server.public_url` to the site's address (`PUBLIC_URL` in a generated project). ## Who gets in A login through another site is matched to the account with the e-mail address that site vouches for, and the two are then linked, so later logins work even if the address changes. Addresses the site has not verified are never used. Nobody else gets in unless `signup` allows it: | `signup` | Who may get an account at their first login | |---|---| | `Signup::Closed` (default) | nobody: only existing accounts | | `Signup::Domains(vec!["acme.com".into()])` | people with an address at those domains | | `Signup::Open` | anyone the provider vouches for | New accounts are staff with no permissions of their own, in the `signup_groups`, so those groups decide what they may do. Each account's page lists its links to other sites, and a superuser can remove one. ## Details - Accounts with two-factor login enter their code after coming back from the other site, and `totp_required` applies as for passwords. Inactive accounts cannot log in. - To start a project without passwords, run `cargo run -- createsuperuser --username you --email you@example.com`: it asks for no password, and the account logs in through the provider. - Microsoft's shared directories (`common`, `organizations`, `consumers`) let people choose any address, so their addresses only count if the app sends the optional `xms_edov` claim. With your own directory's ID as `tenant`, its addresses are trusted. - GitHub uses the account's primary verified address. For an OpenID Connect provider that does not say whether addresses are verified, `trust_email: true` trusts them; only do that when users cannot pick their own address. - These logins are for the admin. Programs use an [API key](authentication.md). --- # Two-factor login and limits Two-factor login asks for a code from an app on your phone (Google Authenticator, 1Password, Aegis, ...) as well as the password, so a stolen password alone is not enough. ## Turning it on Each person turns it on for themselves: click your name in the admin menu, scan the QR code with the app, and confirm with a code. From then on, logging in needs the current code: in the login form, or as `"totp"` in an [API token request](authentication.md). - Each code works once, so an intercepted code cannot be reused. - Turning it on needs the password too if the login is more than 15 minutes old; accounts without a password log out and in again instead. - Turning it off asks for a current code from the app, or a recovery code. ## Recovery codes Turning it on shows ten recovery codes, once. Each logs in a single time in place of a phone code (anywhere a code is asked for, the API included), for when the phone is lost. The two-factor page shows how many are left, and makes a new set (replacing the old one) after a current code from the app. Only a fingerprint of each code is stored. ## Lost phone, no recovery codes A superuser ticks "Turn it off" on the person's account page, or run: ```sh cargo run -- totp --username U --disable ``` ## Making it compulsory ```rust auth: AuthSettings { totp_required: true, ..Default::default() }, ``` Nobody can then log in without it, so set up the first account from the terminal: `cargo run -- totp --username U` prints the QR code and the recovery codes. People then cannot turn it off themselves. ## Login attempt limits To stop password guessing: after 5 failed attempts on one account within 5 minutes, further attempts on that account are refused (`429 Too Many Requests`, with a `Retry-After` header) until the 5 minutes are over. A successful login clears the count. One client address gets four times as many attempts across all accounts. Change the numbers with `auth.login_max_attempts` (`0` turns the limit off) and `auth.login_lockout`. - **Behind a reverse proxy** (nginx, a load balancer), every request seems to come from the proxy. Set `server.client_ip_header` to the header the proxy sets (e.g. `X-Forwarded-For`), but only if it always sets it; otherwise clients could pretend to be anyone. - Counts are kept in memory, per running copy of the server: several copies each count separately, and a restart clears them. - Someone who knows a username can keep that account locked by guessing. That is the usual trade-off of per-account limits; shorten `auth.login_lockout` if it becomes a problem. --- # E-mail Login links, password resets and invitations are sent by e-mail. Until `mail.url` is set, messages are written to the server's log instead, so on your own machine you copy the link from the terminal. ## Setting it up ```rust mail: MailSettings { url: env_opt("MAIL_URL"), // smtps://user:password@smtp.example.com from: Some("Acme ".into()), }, ``` | `MAIL_URL` | Connection | |---|---| | `smtps://user:password@host` | TLS from the start, port 465 | | `smtp://user:password@host:587?tls=required` | STARTTLS, port 587 | | `smtp://localhost:1025` | unencrypted, accepted only to this machine (a test inbox such as Mailpit) | Any provider with an SMTP service works. To send through an HTTP API instead, implement `cadmus::Mailer` (one `send` method) and pass it to `App::mailer`. ## What is sent - **Login links** (`AuthMethod::MagicLink`): the login page asks for an address and mails a link that works once, within 15 minutes. Opening it shows a button rather than logging in straight away, because mail scanners open links to check them. - **Forgot your password?** appears under the password field when passwords are on and `server.public_url` is set. The link works for an hour. - **Invitations:** on the new-account form, "Send an invitation instead" e-mails a link, valid once for 7 days, where the person chooses their password (on sites without passwords, the link just logs them in). The account's page can send it again. The answer to "send me a link" is the same whether or not the address has an account, and mail is sent in the background, so nobody can find out who is registered. Requests are limited like login attempts. --- # Relations A link from one model to another (a foreign key) is a `references` field. Generate the model being pointed to first: ```sh cadmus generate model Author name:string email:string cadmus generate model Post title:string author:references editor:references=author? ``` - `author:references` adds an `author_id` field pointing at `author`. - `editor:references=author?` adds an optional `editor_id` that also points at `author`. - A model can point to itself: `parent:references=category?` on `Category`. Migrations create the database constraint that keeps the links valid. ## In the admin - On forms, a link is a dropdown of the related records, by name. - Lists show the related record's name, linked to it, instead of its number. - A record is named by its first text field (`name`, `title`, ...). Choose another with `ModelOptions::new().display_field("email")`. - Above 200 related records there is no dropdown; the form takes the id and says which record it currently points to. - Names are shown only to accounts that may view the related model; others see and edit the plain number. - `list_filter(["author_id"])` filters by the related records, by name. ## In the API The API reads and writes the id itself: `"author_id": 3`. Filter with `?author_id=3`. ## Deleting A record that others still point to cannot be deleted: the admin says so, and the API answers `409`. "Delete selected" keeps such rows and says how many. ## Hand-written relations Any `belongs_to` relation in an entity is picked up the same way: ```rust #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { #[sea_orm( belongs_to = "super::author::Entity", from = "Column::AuthorId", to = "super::author::Column::Id", on_delete = "Cascade" )] Author, } ``` `on_delete = "Cascade"` deletes a post when its author is deleted, instead of refusing. --- # File uploads Set `uploads.storage_url` in the settings to turn uploads on. The address chooses where files are kept: | Address | Where | Cargo feature | |---|---|---| | `file:///absolute/path` | a folder on the server | built in | | `s3://bucket/prefix` | Amazon S3 and compatible services | `s3` | | `az://container/prefix` | Azure Blob Storage | `azure` | | `gs://bucket/prefix` | Google Cloud Storage | `gcs` | ```rust uploads: UploadSettings { storage_url: Some("file:///var/lib/blog/media".into()), ..Default::default() }, ``` Cloud credentials are read from the usual environment variables (`AWS_ACCESS_KEY_ID`, `AWS_REGION`, `AWS_ENDPOINT`, `AZURE_STORAGE_ACCOUNT_NAME`, ...). For the cloud backends, turn the feature on: `cadmus = { version = "0.1", features = ["s3"] }`. ## Limits `uploads.max_file_bytes` (default 1 GiB) and `uploads.max_chunk_bytes` (default 8 MiB, for resumable uploads). ## File fields on forms ```sh cadmus generate model Product name:string photo:file ``` A file field holds a file's id. On the admin form it is a file picker that shows the current file, a preview for PNG, JPEG, GIF, WebP and AVIF images, and "Remove the file". Lists show the file's name, linked to it. Anyone who may view the record may download its file. Files from a form that is then rejected are removed. A hand-written integer field becomes a file field with `ModelOptions::file_fields(["photo"])`. Files sent through an admin form must finish within `server.request_timeout` (30 seconds by default), so very large files should go through the API. Replacing or removing a file on a form leaves the old one stored (it is still listed at `/api/files`). ## Through the API | Request | What it does | |---|---| | `POST /api/files` (multipart form, field `file`) | uploads a file; answers `201` with its record | | `GET /api/files`, `GET /api/files/{id}` | file details | | `GET /api/files/{id}/content` | downloads the file | | `DELETE /api/files/{id}` | deletes it | To fill a file field through the API, upload the file, then send its `id` in the field. Uploads need the `files` permissions (see [Accounts and permissions](accounts.md)). ## Resumable uploads Large files can be sent in pieces, and the upload continues after a dropped connection: 1. `POST /api/uploads` with `{"filename", "size", "content_type"}` gives an upload `id`. 2. `PATCH /api/uploads/{id}` with the header `Upload-Offset: N` and the next bytes, in order. A wrong offset gets `409` with the right one. 3. `GET /api/uploads/{id}` tells how much has arrived: that is where a client resumes. 4. The piece that completes the file answers `201` with the file's record. 5. `DELETE /api/uploads/{id}` cancels. Uploads survive server restarts and can continue on another server. Stored names are chosen by the server; the original file name is kept only as a label. ## In your own code `Storage` (`put`, `get`, `delete`, `sink`) and `Uploads` are available for your own handlers; see the [API reference](https://docs.rs/cadmus). --- # Settings A project is configured in one file, `src/settings.rs`. It is plain Rust, so the compiler catches a misspelt setting, and your editor completes the names and shows what each one does. Anything left out keeps its default. ```rust use cadmus::AdminError; use cadmus::settings::*; pub fn settings() -> Result { let debug = env_or("DEBUG", false)?; Ok(Settings { secret_key: env("SECRET_KEY")?, database: DatabaseSettings { url: env("DATABASE_URL")?, ..Default::default() }, auth: AuthSettings { cookie_secure: !debug, // the login cookie needs HTTPS, except on your machine ..Default::default() }, admin: AdminSettings { site_title: "Acme".into(), accent_color: "#0a7f5a".into(), ..Default::default() }, ..Default::default() }) } ``` ## Secrets stay out of the code The database address, the secret key and passwords are read from the environment: from `.env` on your machine (loaded automatically), from the server's environment variables in production. | Helper | Reads | |---|---| | `env("NAME")?` | a required value; start-up stops, naming it, if it is missing | | `env_or("NAME", default)?` | an optional value of any type (`true/false`, `1/0`, `yes/no` for yes-or-no) | | `env_opt("NAME")` | optional text, as `Option` | Values that differ per machine are ordinary code: `if debug { ... }`, or `bind: env_or("APP_BIND", "127.0.0.1:3000".to_owned())?`. ## Checked at start-up A mistake stops the server with the setting it is about, before it connects to the database, for example: ```text admin.accent_color: accent colour must be #rgb or #rrggbb, got `red` ``` `cargo run -- check` runs the same checks without starting the server. ## Every setting | Section | Settings (defaults in brackets) | |---|---| | `secret_key` | signs logins; at least 32 bytes (`cadmus secret` prints one) | | `database` | `url`, `max_connections`, `min_connections`, `acquire_timeout` | | `server` | `bind` (`127.0.0.1:3000`), `request_timeout` (30 s; `None` for no limit), `cors_origins`, `client_ip_header`, `public_url` | | `auth` | `methods` (password), `signup`, `signup_groups`, `token_lifetime` (1 h), `totp_required`, `cookie_secure` (`true`), `login_max_attempts` (5), `login_lockout` (5 min), `api_keys` (`true`) | | `admin` | `path` (`/admin`), `site_title`, `accent_color`, `logo_url`, `theme`, `custom_css_url`, `footer_text`, `page_size` (25), `nav_links`, `web_fonts`, `template_dir` | | `api` | `path` (`/api`) | | `docs` | `enabled`, `username` + `password`, `title`, `version`, `description`, `swagger_js_url`, `swagger_css_url`, `redoc_js_url` | | `home` | `enabled`, `title`, `tagline` | | `uploads` | `storage_url` (off until set), `max_file_bytes` (1 GiB), `max_chunk_bytes` (8 MiB) | | `mail` | `url` (SMTP; unset writes messages to the log), `from` | | `log_level` | `LogLevel::INFO`; `DEBUG` also shows every SQL statement | Durations are written `Duration::from_secs(600)`. ## What settings cannot hold Things that are code rather than values are set on the `App` after it is built: ```rust App::from_settings(settings::settings()?) .await? .docs(DocsConfig::new().extend(|doc| { /* your routes in the API documentation */ })) .mailer(MyMailer::new()) // your own way of sending e-mail .users(MyUserStore::new()) // accounts kept elsewhere ``` ## Home page `/` is a public status page: the service's name, whether it is up, whether the database answers and how fast, and how long it has been running. It refreshes every 30 seconds and links nowhere. - With `Accept: application/json` it returns `{"status", "database": {"status", "latency_ms"}, "uptime_secs", "checked_at"}`. Both forms answer `503` when the database is down, so a load balancer can use it as a health check. - A `/` route in `src/routes.rs` replaces it automatically. `home.enabled: false` turns it off; `home.title` and `home.tagline` set what it says. - API addresses typed into a browser's address bar are sent to `/`; programs reach the API as usual. --- # Your own code The admin and the API cover a lot, but most apps need some pages or endpoints of their own. They go in `src/routes.rs`, as ordinary [axum](https://docs.rs/axum) routes, served next to the generated ones. ## A route ```rust use axum::Router; use axum::routing::get; pub fn router() -> Router { Router::new().route("/health", get(|| async { "ok" })) } ``` ## Using the database Pass the connection to your router. In `src/main.rs`: ```rust let app = App::from_settings(settings::settings()?).await?.migrations(migrations::all()); let db = app.db().clone(); models::register(app).routes(routes::router(db)).run().await ``` and in `src/routes.rs`: ```rust use axum::extract::{Path, State}; use axum::routing::get; use axum::{Json, Router}; use cadmus::{AdminError, ApiResult}; use sea_orm::{DatabaseConnection, EntityTrait}; use crate::models::post; pub fn router(db: DatabaseConnection) -> Router { Router::new() .route("/posts/{id}/title", get(title)) .with_state(db) } async fn title(State(db): State, Path(id): Path) -> ApiResult> { let post = post::Entity::find_by_id(id) .one(&db) .await? .ok_or_else(|| AdminError::NotFound("post".into()))?; Ok(Json(post.title)) } ``` ## Errors Every failure in Cadmus is an `AdminError`, shown in the right form for where the request went: | Request to | An error looks like | |---|---| | `/api/...` | JSON: `{"error": {"code", "message", "field"}}` | | `/admin/...` | a page in the admin's style (`error.html`, which you can replace) | | anything else | plain text, or whatever your handler returns | Unknown addresses, wrong methods (`405`) and crashes in a handler become proper error responses too. Internal details (SQL, file paths, crash messages) are logged, never sent to the visitor. In your own handlers, return `ApiResult` to get JSON errors. Database, storage and I/O errors convert with `?`, and `AdminError::custom` makes an error with your own status and code: ```rust if item.left == 0 { return Err(AdminError::custom(StatusCode::CONFLICT, "out_of_stock", "sold out").into()); } ``` ## Logging Messages go to the terminal (standard error). `log_level` in the settings, or the `LOG_LEVEL` variable, sets how much: `error`, `warn`, `info` (default), `debug` (also every SQL statement) or `trace`. Each request is logged on one line with method, address, status and duration. If your `main` installs its own `tracing` subscriber before `run()`, that one is used instead. ## In the API documentation Your own routes are not discovered automatically. Add them to the documentation with `DocsConfig::extend` (see [API documentation](api-docs.md)). --- # API documentation Every project documents its own API: | Address | What it is | |---|---| | `/api/docs` | Swagger UI: browse the endpoints and try them out | | `/api/redoc` | ReDoc: the same, as a reference page | | `/api/openapi.json` | the OpenAPI 3.0 description, for generating client code | It is generated from the models: every endpoint, the fields returned, the fields that may be sent and which are required, the permission each needs, and file uploads when they are on. Hidden fields never appear, and read-only fields appear only in what is returned. ## Trying requests In Swagger UI, press **Authorize** and log in with a username and password, or paste an API key under `apiKey`. Accounts with two-factor login can paste a token from `POST /api/auth/token` under `bearer`. ## Protecting it The documentation is public by default; it shows model and field names, never data. To ask for a username and password first (the browser's own prompt), set both, with the password in `.env`: ```rust docs: DocsSettings { username: Some("docs".into()), password: Some(env("DOCS_PASSWORD")?), ..Default::default() }, ``` These only open the documentation; they are not an account. Guesses are limited like logins. `enabled: false` removes the documentation entirely. ## Generating clients ```sh cargo run -- openapi > openapi.json ``` writes the description without starting the server, for client generators such as `openapi-generator` or `openapi-typescript`. ## Your own routes They are not discovered automatically. Add them to the description: ```rust App::from_settings(settings::settings()?) .await? .docs(DocsConfig::new().extend(|doc| { doc["paths"]["/health"] = serde_json::json!({ "get": { "summary": "Health check", "security": [], "responses": { "200": { "description": "ok" } } } }); })) ``` ## Other settings - `docs.title`, `docs.version` and `docs.description` (Markdown) set the heading. - The viewers load from a CDN (jsDelivr, pinned versions), so the two pages need internet access in the browser. To host them yourself, set `docs.swagger_js_url`, `docs.swagger_css_url` and `docs.redoc_js_url`. `openapi.json` needs nothing external. --- # Databases The address in `DATABASE_URL` (the `database.url` setting) chooses the database. Every driver is built in, so switching is a change to `.env`, not to the code: | Database | `DATABASE_URL` | |---|---| | SQLite | `sqlite://db.sqlite3?mode=rwc` (`mode=rwc` creates the file) | | PostgreSQL | `postgres://user:password@host:5432/name` | | MySQL and MariaDB | `mysql://user:password@host:3306/name` | `cadmus new blog --database postgres` (or `mysql`) starts a project with such an address to fill in. Run `cargo run -- migrate` against a new database to create its tables. Oracle and SQL Server are not supported: SeaORM, the database layer, has no open-source driver for them. ## The same behaviour everywhere The admin and the API behave the same on all three: true and false are JSON booleans, dates and times are ISO 8601 (`2026-03-04T05:06:07`), JSON fields come back as JSON, and `?q=` search ignores case. Migrations are written once and turned into the right SQL for each. Cadmus's own tests run on all three. ## Connections ```rust database: DatabaseSettings { url: env("DATABASE_URL")?, max_connections: Some(20), min_connections: Some(2), acquire_timeout: Some(Duration::from_secs(5)), }, ``` ## Smaller builds A project that only uses one database can leave the other drivers out, to compile faster: ```toml cadmus = { version = "0.1", default-features = false, features = ["postgres"] } ``` The features are `sqlite`, `postgres` and `mysql`. An address for a driver that was left out stops start-up and names the feature to turn back on. --- # Deploying A Cadmus project builds into one executable. Copy it to the server with your settings in environment variables, run its migrations, and start it. ## Building ```sh cargo build --release # the program is target/release/ ``` On the server: ```sh export DATABASE_URL=postgres://... export SECRET_KEY=... ./blog migrate # before starting each new version ./blog serve ``` ## What is already taken care of - **Time limit:** a request that takes longer than 30 seconds gets `503` and is stopped (`server.request_timeout`; `None` for no limit). Uploads and downloads are exempt. - **Request log:** one line per request with method, address, status and duration (the query string is left out). - **Protective headers** on every admin and API response: no embedding in other sites, no content-type guessing, no caching of pages that needed a login. - **Cross-origin access (CORS)** is off until you set `server.cors_origins`. - **Graceful shutdown** on Ctrl-C and on the stop signal sent by `docker stop`, systemd and Kubernetes: requests in progress finish first. - **Health check:** `/` answers `503` when the database is down (see [Settings](settings.md)). ## Before going live 1. **HTTPS.** Serve over HTTPS (a reverse proxy such as nginx or Caddy, or your platform's), and leave `DEBUG` unset: the generated settings only allow the login cookie over plain HTTP when `DEBUG=true`. 2. **Behind a proxy,** set `server.client_ip_header` (see [Two-factor login and limits](two-factor.md)), and `server.bind` to `0.0.0.0:3000` if the proxy runs on another machine or container. 3. **Secrets.** Set `DATABASE_URL` and `SECRET_KEY` on the server, keep `.env` out of git, and use a different `SECRET_KEY` for each environment (`cadmus secret` prints one). Changing it logs everyone out. 4. **Migrations.** Run `migrate` as part of each deployment, before starting the new version. 5. **Uploads.** If you use the local folder backend, keep that folder on storage that survives redeploys, or use S3, Azure or Google Cloud. 6. **E-mail.** Set `MAIL_URL` and `PUBLIC_URL` so login links and password resets work. ## Containers A minimal `Dockerfile`: ```dockerfile FROM rust:1 AS build WORKDIR /app COPY . . RUN cargo build --release FROM debian:stable-slim RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* COPY --from=build /app/target/release/blog /usr/local/bin/blog ENV APP_BIND=0.0.0.0:3000 EXPOSE 3000 CMD ["sh", "-c", "blog migrate && exec blog serve"] ``` The generated settings read the address to listen on from `APP_BIND`; inside a container it must be `0.0.0.0:3000` to be reachable from outside. ## Compression Responses are not compressed, on purpose: admin pages contain a security token next to text the visitor controls (the search box), which is what the BREACH attack on compressed HTTPS needs. Let your proxy compress static files only, if anything. --- # Building with AI agents Cadmus offers two [MCP](https://modelcontextprotocol.io) servers. MCP is the standard way to give an AI assistant extra tools; here, the tools are these docs and the `cadmus` command. ## Which one to use | | Hosted docs server | Local server (`cadmus mcp`) | |---|---|---| | **Where it runs** | on the web; nothing to install | on your computer, inside the `cadmus` command | | **Works with** | any app that adds an MCP server by its address: ChatGPT, Claude (web, desktop and mobile), Claude Code, Gemini CLI, Cursor, VS Code | coding agents on your computer: Claude Code, Claude Desktop, Cursor, VS Code, Gemini CLI | | **What the assistant can do** | search and read these docs | read these docs, create projects, add models, make and apply migrations, create an account | | **Use it when** | you want answers and code to paste yourself, or your assistant runs in a browser | you want the agent to build the project for you | In short: **chatting in a browser, use the hosted server; letting a coding agent build the app, use the local one.** The local server includes the docs, so you never need both in the same agent. ## The hosted docs server Its address: ```text https://cadmus-mcp.cadmus-mcp.workers.dev/mcp ``` It needs no login, and it can only read the docs. It always serves the docs on this site. **ChatGPT**: in Settings → Apps & Connectors, create a connector (if there is no button for it, turn on Developer mode under Advanced settings first). Give it a name, paste the address, and choose *No authentication*. Custom connectors need a paid ChatGPT plan. **Claude** (claude.ai, the desktop app and mobile): Settings → Connectors → *Add custom connector*, then paste the address. It is then available in every conversation where you turn it on. **Claude Code**: ```sh claude mcp add --transport http cadmus-docs https://cadmus-mcp.cadmus-mcp.workers.dev/mcp ``` **Gemini CLI** (`~/.gemini/settings.json`): ```json { "mcpServers": { "cadmus-docs": { "httpUrl": "https://cadmus-mcp.cadmus-mcp.workers.dev/mcp" } } } ``` **Cursor** (`~/.cursor/mcp.json`): ```json { "mcpServers": { "cadmus-docs": { "url": "https://cadmus-mcp.cadmus-mcp.workers.dev/mcp" } } } ``` **VS Code** (`.vscode/mcp.json` in your workspace): ```json { "servers": { "cadmus-docs": { "type": "http", "url": "https://cadmus-mcp.cadmus-mcp.workers.dev/mcp" } } } ``` Other apps: add a remote MCP server (it may be called *Streamable HTTP*) with the address above. | Tool | What it does | |---|---| | `search` | finds the sections that match some words, with the page each is on | | `fetch` | reads a whole page, as Markdown | | `list_pages` | lists every page, in reading order | Then ask, for example: > Using the Cadmus docs, show me how to give a `Post` model an author that links to a > `User`, and how it appears in the admin. ## The local server The local server is part of the `cadmus` command. Your agent starts it; you do not run it yourself. ```sh cargo install cadmus-cli # if you have not already ``` ### Connecting your agent **Claude Code** ```sh claude mcp add cadmus -- cadmus mcp ``` **Claude Desktop** (Settings → Developer → Edit Config), **Cursor** (`~/.cursor/mcp.json`) and other clients that use an `mcpServers` list: ```json { "mcpServers": { "cadmus": { "command": "cadmus", "args": ["mcp"] } } } ``` **VS Code** (`.vscode/mcp.json` in your workspace): ```json { "servers": { "cadmus": { "type": "stdio", "command": "cadmus", "args": ["mcp"] } } } ``` If the client cannot find `cadmus`, give the full path, usually `~/.cargo/bin/cadmus`. ### What the agent can do | Tool | What it does | |---|---| | `docs_list` | lists these documentation pages | | `docs_read` | reads one page, as Markdown | | `docs_search` | finds the sections that match some words | | `new_project` | creates a project in a folder | | `generate_model` | adds a model with its fields | | `run` | runs a project command: `makemigrations`, `migrate`, `showmigrations`, `sqlmigrate`, `check`, `routes`, `openapi` or `apikey` | | `create_superuser` | creates an account to log in with | The documentation pages are also offered as MCP resources (`cadmus://docs/`), and the server tells the agent the usual order of work when it connects. `run` only runs those commands, through `cargo run` in the project folder; it cannot run anything else. `makemigrations` runs without asking questions there, so nothing is taken as a rename: if a field was renamed, the agent should mark it with `#[sea_orm(renamed_from = "...")]` first. The agent does not start the server; it tells you to run `cargo run -- serve`. ### Asking for an app Once connected, describe what you want: > Create a Cadmus project called `library` in ~/code with books, authors and loans. Books > have a title, an ISBN and an author; loans link a book to a member's name with a due date. > Show books by title in the admin, searchable by title and ISBN, filterable by author. The agent creates the project, generates the models in the right order, sets the admin options, makes and applies the migration, and creates an account for you. ## AGENTS.md Every project made by `cadmus new` has an `AGENTS.md` file: a short guide to the project for agents, which many of them read automatically. It works without either server. ## For other tools This site also offers [llms.txt](https://arnabgupta-dev.github.io/cadmus/llms.txt), an index of these pages, and [llms-full.txt](https://arnabgupta-dev.github.io/cadmus/llms-full.txt), all of them in one file, for tools that read documentation from the web. --- # Commands ## The cadmus command Installed with `cargo install cadmus-cli`. | Command | What it does | |---|---| | `cadmus new NAME [--database sqlite\|postgres\|mysql] [--framework-path PATH]` | create a project in a new folder | | `cadmus generate model NAME field:type ...` | add a model to the project in the current folder (`cadmus g model` for short) | | `cadmus secret` | print a new secret key | | `cadmus mcp` | serve the MCP protocol for AI agents (see [Building with AI agents](ai-agents.md)) | `--framework-path` uses a local copy of Cadmus instead of the version on crates.io. ## Project commands Every project is also its own command line. Run them as `cargo run -- `, or `./target/release/ ` for a built program. | Command | What it does | |---|---| | `serve [--bind addr]` | start the server | | `makemigrations [--name N] [--empty] [--check] [--no-input]` | write a migration for the model changes into `src/migrations` | | `migrate [target] [--fake]` | apply new migrations, or roll back to `target` (a name, a number such as `0002`, or `zero`) | | `showmigrations` | list migrations and whether this database has had each | | `sqlmigrate NAME [--backwards]` | print the SQL a migration runs on this database | | `createsuperuser --username U [--email E] [--password-stdin]` | create a superuser | | `changepassword --username U [--password-stdin]` | change a password | | `promote --username U` | make an existing account an active superuser | | `totp --username U [--disable]` | turn two-factor login on (prints a QR code) or off | | `apikey --username U --name N [--days D] [--read-only]` | make an API key (90 days by default; `--days 0` for never) | | `openapi` | print the API description as JSON | | `routes` | list the generated addresses | | `check` | check the configuration without starting the server | Add `--help` to any command for its options. ## makemigrations | Option | Effect | |---|---| | `--name N` | name the migration (default: made up from the changes) | | `--empty` | write a migration with no changes, to fill in by hand | | `--check` | write nothing; fail if the models have changes without a migration | | `--no-input` | ask nothing: nothing is taken as a rename, and new required fields need a default | ## migrate | Example | Effect | |---|---| | `migrate` | apply every migration this database has not had | | `migrate 0002` | roll back the migrations after `0002` (or apply up to it) | | `migrate zero` | roll back every migration of the project | | `migrate 0002 --fake` | record or forget migrations without running them | --- # Limitations Cadmus is at version 0.1. These are the gaps known today. ## Accounts and logins - No passkeys yet. - API keys cannot be limited to some models (only read-only). - Logins through other sites are for the admin; programs use API keys or tokens. An account is linked to another site by e-mail address only (there is no "connect" button). - No per-record permissions: permissions are per model. - Login attempt limits are per server process, not shared between instances. - Accounts and groups are managed in the admin only; there is no API for them. ## Admin - No page listing all uploaded files, and a replaced file is not deleted. - No many-to-many widget, no list of the records that point to a record, and no search-as-you-type for long lists of related records. ## Models and migrations - Single-column primary keys only. - UUID, decimal, enum and array fields are read-only in the admin and the API. - Migrations do not handle database enums and array columns, or changing a primary key. ## Uploads - Completing a resumable upload copies the pieces once inside the storage (no direct-to-S3 multipart yet), and abandoned uploads are not cleaned up automatically. - S3, Azure and Google Cloud Storage are built but have not been tested against the real services. ## Server and documentation - No response compression (on purpose; see [Deploying](deploying.md)), request ids or metrics; add them with your own `tower` layers. - Your own routes are not added to the API documentation automatically. - The API documentation viewers load from a CDN rather than being bundled. Found something else, or need one of these? Open an issue on [GitHub](https://github.com/arnabgupta-dev/cadmus/issues).