The Arcature Guide
Arcature is a full-stack web framework for Rust. One crate, batteries included:
HTTP routing, the Inertia protocol, a database layer, authentication,
validation, background jobs, events, cache, storage, mail, and the arc
command-line tool. One dependency line is the whole install – see
Getting started, and the Status section below for
what “one crate” currently buys you.
It is opinionated. It integrates proven components — Axum, Tower, Tokio, SeaORM, SQLx, OpenDAL, lettre, tracing — and owns what sits between them: the application lifecycle, the request pipeline, the conventions, and the vocabulary. Where the opinions run out, the underlying crates are re-exported and reachable.
What this guide assumes
That you know Rust, and that you have written a web application before in something. It does not re-teach async, and it does not explain what a migration is. It does explain what Arcature does differently from what you would expect, and why.
How to read the examples
Every code sample in this guide was written against the code as it exists, not against the API as it is planned. Where a feature is documented but not built, the chapter says so under a Not yet implemented heading and shows what works today instead. A missing example costs you a search; a wrong one costs you an afternoon.
Samples elide use statements when the prelude covers them:
use arcature::prelude::*;
Status
arcature 0.1.2 is on crates.io. 0.1.0 was the first release of this
codebase, there is no upgrade path from anything earlier, and the API is still
moving. Neither patch since removes anything or changes a signature, so
arcature = "0.1" carries you to both: 0.1.1 added fourteen subsystems, all
behind feature flags that are off by default, and 0.1.2 fixes two controls
that were not doing what their configuration said. Read
the changelog
and the upgrade note before relying on anything here.
The 2026.x versions under the arcature* names on crates.io come from an
abandoned predecessor repository. They are yanked and share nothing with this
codebase but the name.
Versioning is semantic, starting at 0.1.0. Under 0.x the fields shift one
place left: a minor bump is the breaking one, a patch bump is compatible, and
Cargo reads them that way too, so arcature = "0.1" will not silently carry
you to 0.2.
Where the reasoning lives
Doc comments explain what a type does. This guide explains how the pieces fit. Decisions explains why some of them are shaped in ways that will surprise you: no npm package, one TCP port in development, a fixed layer order, no hidden registry.
Getting started
Requirements
- Rust 1.97.1 or newer (edition 2024).
rust-toolchain.tomlpinsstable. - PostgreSQL 17 for anything using the database or the job queue.
- Node.js, only if you are building a frontend with Vite. Arcature itself publishes no npm package.
Installing
cargo add arcature
That is the whole install. To follow main ahead of a release instead, depend
on the repository and pin a revision – a branch reference will move under you.
[dependencies]
arcature = { git = "https://github.com/ArcatureLabs/Arcature", rev = "..." }
The smallest application
use arcature::application::EngineResult;
use arcature::prelude::*;
#[arcature::main]
async fn main() -> EngineResult<()> {
Application::new()
.routes(Routes::new([Route::get("/", index).name("home")]))
.build()
.run()
.await
}
async fn index() -> Result<Response> {
Ok(text(StatusCode::OK, "hello"))
}
Three things to notice.
.build() is required. Application::new() returns an ApplicationBuilder;
.run() lives on Application. Forgetting .build() is a type error, not a
runtime surprise.
run() returns EngineResult<()>, not the framework’s Result<()>, and
EngineResult is not in the prelude — it lives at
arcature::application::EngineResult. Engine failures (a port already bound, a
database that will not connect) are a different kind of failure from a
handler’s, and they deliberately do not share an error type.
Handlers return Result<Response>, where Result is Arcature’s. text,
json, redirect and no_content build the common shapes.
The generated application
arc new writes a Laravel-shaped project rather than a single file:
app/
controllers/ models/ services/
requests/ policies/ resources/
bootstrap/
app.rs state.rs
config/
database/migrations/
routes/mod.rs
resources/js/ resources/css/
public/
storage/
src/main.rs src/lib.rs
tests/smoke.rs
.env
bootstrap/app.rs is the composition root. It loads .env, reads typed
configuration, and wires the subsystems:
pub fn app() -> Result<Application<crate::bootstrap::AppState>> {
dotenvy::dotenv().ok();
let config = crate::config::load()?;
Ok(Application::new()
.routes(crate::routes::routes())
.bind(&config.bind_addr)
.port(config.port)
.database(config.database)
.cache(config.cache)
.storage(config.storage)
.mail(config.mail)
.jobs(jobs_registry())
.build())
}
bootstrap/state.rs defines AppState, the cloneable bundle every handler
reaches through State<AppState>. Each field is an Option, because a
subsystem that was never configured contributes None rather than a panic:
#[derive(Clone)]
pub struct AppState {
pub db: Option<Db>,
pub jobs: Option<Jobs>,
pub cache: Option<Cache>,
pub storage: Option<Storage>,
pub mail: Option<Mailer>,
}
The state is produced after startup, from the started Resources, which is
why it is a closure rather than a value:
pub fn state_fn() -> Arc<dyn Fn(&Resources, &Lifecycle) -> AppState + Send + Sync> {
Arc::new(|res, _lc| AppState {
db: res.db().cloned(),
jobs: res.jobs().cloned(),
cache: res.cache().cloned(),
storage: res.storage().cloned(),
mail: res.mail().cloned(),
})
}
src/lib.rs puts the two together with run_with_state.
Features
Arcature’s features reduce the compile surface; they are not a self-assembly
kit. default is a working full-stack application. Turn features off to
compile less, not on to reach a usable state.
# The whole framework.
arcature = { git = "...", features = ["fullstack"] }
# An API server: no Inertia, no static assets pipeline.
arcature = { git = "...", default-features = false, features = ["api", "database", "auth", "validation"] }
The database driver is split three ways — db-postgres, db-sqlite,
db-mysql — so a SQLite application does not compile the PostgreSQL protocol.
The job queue requires PostgreSQL.
Next
Your first module to add a feature to the application you just generated, Routing for how requests reach handlers, or Inertia if you are building a page-driven frontend.
Your first module
A fresh application is laid out by kind: every controller in
app/controllers/, every service in app/services/, every page in
app/pages/. That is the right shape while there is one feature. It stops
being the right shape at ten, when “show me everything billing does” means
opening seven directories and knowing which files in each are billing’s.
A module is the other shape. One directory owns one feature’s controller,
service and routes, and a module! block at its root is the index of what is
inside. The two layouts coexist deliberately — the scaffold’s own Web module
keeps the by-kind directories, and an application is free to use one, the
other, or both.
This page goes from arc new to a route that answers, without editing a file
by hand.
Generate it
$ arc new acme --stack react --db sqlite
$ cd acme
$ arc make:module billing
created app/modules/billing/mod.rs
updated app/modules/mod.rs
created app/modules/billing/controller.rs
created app/modules/billing/service.rs
created app/modules/billing/routes.rs
Four files written, one updated. The module is registered: nothing else has to be touched before it serves.
$ arc serve --port 3000
$ curl localhost:3000/billing
BillingController::index
What landed
app/modules/billing/mod.rs
The index. module! records what this feature contains, so the application
graph — and through it arc routes, arc typegen and arc build — can see
it.
pub mod controller;
pub mod routes;
pub mod service;
use arcature::prelude::*;
use controller::BillingController;
module! {
pub Billing {
controllers: [BillingController],
services: [BillingService],
routes: routes::BILLING_ROUTES,
}
}
BillingService is named but not imported, and that is not an oversight.
controllers: and routes: are resolved at this site — the first reads the
controller’s ControllerMetadata::METHODS, the second is a path to a const —
while services: and policies: are recorded as names only. Importing a type
the macro never resolves would be an unused import.
app/modules/billing/controller.rs
use arcature::prelude::*;
pub struct BillingController;
#[controller]
impl BillingController {
pub async fn index() -> Result<Response> {
Ok(text(StatusCode::OK, "BillingController::index"))
}
}
Result, Response, StatusCode and text all come from the prelude, which
is the point: a controller should not need a use list before it does any
work.
app/modules/billing/service.rs
#[service]
pub struct BillingService {
db: Db,
}
#[service] generates Resolve<S>, which composes the struct from the
application’s resources per request: each field type is resolved in turn, so a
service may hold another service as a field. Keep the methods
framework-agnostic — take domain values, return domain values, and let the
controller map the result to HTTP.
app/modules/billing/routes.rs
use super::controller::BillingController;
use crate::bootstrap::AppState;
routes! {
pub billing {
state: AppState;
get "/billing" => BillingController::index { name: billing.index }
}
}
The path is absolute. Living in a module adds no prefix — a module is a unit of source organisation here, not a mount point. Reach for a route group when you want a shared prefix, inside the module or outside it.
The route name carries the module’s own, and that matters more than it
looks. app/modules/mod.rs merges every module’s routes into one table. Two
modules claiming the same path is a panic at boot, from Axum, so it cannot
reach production unnoticed. Two modules claiming the same name is not: the
later one silently wins, and url_for("index", ..) starts resolving somewhere
else. Namespacing the name is what makes ten modules safe to merge.
How it is wired
arc make:module adds three lines to app/modules/mod.rs: the pub mod
declaration, one entry in the descriptor list, and one in the route list.
pub fn modules() -> Vec<ModuleDescriptor> {
vec![
// arc:modules descriptors
billing::billing_module().clone(),
// arc:end
]
}
pub fn routes() -> Routes<AppState> {
let collections: Vec<Routes<AppState>> = vec![
// arc:modules routes
billing::routes::billing_routes(),
// arc:end
];
collections.into_iter().fold(Routes::empty(), Routes::merge)
}
app/mod.rs appends modules() to the scaffold’s Web module before handing
the list to ApplicationGraph::new; bootstrap/app.rs merges routes() into
the application’s table. Both were already written by arc new — the
generator only ever inserts into the two marked regions.
Editing the file by hand is fine. Reorder the entries, reformat them, add one
yourself. The generator matches the arc:modules / arc:end markers and
nothing else about the surrounding text, and it skips an entry that is already
there, so re-running it after deleting a directory does not leave a duplicate
behind.
Deleting a marker is the one thing that breaks it. When that happens it says so and writes the module’s files anyway, leaving one line for you to paste:
$ arc make:module billing
created app/modules/billing/mod.rs
updated app/modules/mod.rs
note: app/modules/mod.rs has no `// arc:modules routes` marker -- add `billing::routes::billing_routes(),` to its routes list by hand
created app/modules/billing/controller.rs
...
Nested names
A name with slashes nests, and the intermediate mod.rs files are created as
needed:
$ arc make:module admin/reports
created app/modules/admin/reports/mod.rs
updated app/modules/admin/mod.rs
updated app/modules/mod.rs
The registration follows the nesting — admin::reports::reports_module() —
and so does the route name, which becomes admin.reports.index. The URL does
not: routes.rs still declares an absolute path, so the module serves
/admin/reports because that is what is written there, not because of where
it sits on disk.
What the graph checks
ApplicationGraph::new runs at boot and rejects three wiring mistakes:
GraphError | Means |
|---|---|
DuplicateModule | two modules declared the same name |
UnknownImport | an imports: entry names a module the graph does not hold |
CircularDependency | modules import each other in a loop; the error lists the cycle in order |
app/mod.rs calls .expect(..) on the result, so all three are a panic at
boot in the scaffold. That is the honest answer: each one is the same on every
run and has nothing to do with the request, so surfacing it on one unlucky
request later would only make it harder to place.
A type missing from a module! block still compiles and still serves. It is
simply invisible to the graph, and therefore to arc routes, arc typegen
and arc build. That is the trap module! exists to close, one level up.
What is deliberately not scaffolded
A module gets four files, not five. arc make:policy is one command away, but
a generated policy cannot compile until it is pointed at a model and a user
type — Policy<M> bounds its associated User by AuthUser, so there is no
placeholder that type-checks. Shipping one inside a module would mean
arc make:module billing produces a project that does not build.
Add one once the feature has a model and a user type to point it at:
$ arc make:policy invoice
created app/policies/invoice.rs
updated app/policies/mod.rs
note: invoice names `Invoice` and `User`; point them at the model this policy guards and the application's user type
That lands in app/policies/, not in the module — nearly every make kind
writes to the by-kind directory it belongs to. The two exceptions are
make:module, because a module is a directory, and make:auth, which
writes a feature’s worth of files into app/auth/. Move the file under
app/modules/billing/policy.rs if you want the feature to own it, add the
pub mod policy; line, and name the type in the module’s policies: list.
Nothing in the generator or the graph depends on where the file sits; the list
is what the graph reads.
The module! sections
| Section | Holds | Resolved at the call site? |
|---|---|---|
imports | module names this one depends on | no — names |
exports | module names this one provides | no — names |
controllers | controller types | yes — reads ControllerMetadata::METHODS |
services | service type names | no — names |
policies | policy type names | no — names |
routes | a path to a &'static [RouteDescriptor] const | yes |
listeners | event → listener name pairs | no — names |
jobs | job kind, version and handler name | no — names |
commands | command name → function name pairs | no — names |
schedules | job kind, version and cadence | no — names |
pages | paths to #[page] types | yes — reads PAGE_CONTRACT_ENTRY |
Order between sections is free, every section is optional, and the trailing
comma is optional. The right-hand column is the whole rule for what has to be
in scope: a name is a string the graph compares, a resolved entry is a type or
const the macro reads through. Which way a section falls decides what a typo
costs. A misspelled entry in controllers: is an ordinary “cannot find type”
at the module! line. A misspelled entry in services: compiles, because the
macro only ever records the string — the graph reports the name you wrote, and
nothing anywhere disagrees with it.
Next
Routing for groups, middleware and the route table;
Controllers for what #[controller] emits;
Testing for driving a module’s routes without a server.
The dev loop
One number decides whether a framework is pleasant to work in: the time
between saving a Rust file and seeing the result in the browser. arc dev
exists to make that number small, and this page says what it currently is,
how it was measured, and where the time goes.
Numbers without a method are folklore, so the method is here in full and the machine is named. Reproduce it before believing it.
What one save costs
arc dev holds the TCP port itself and runs the application as a child
process, so a rebuild replaces only the child. The supervisor prints what each
part of the trip took:
cargo 7.85s (check 2.73s, codegen+link 5.06s) swap 0.21s spawn 1.44s boot 0.31s total 9.81s
Those stages are:
| Stage | What it is |
|---|---|
cargo | cargo build --features dev, spawn to exit. |
check | Start of the build to the last non-executable artifact. |
codegen+link | That boundary to the linked executable. |
swap | Stopping the old process and staging the new binary. |
spawn | Asking the operating system to start it. |
boot | The new process starting to its first accepted IPC connection. |
| Vite | Nothing. A .tsx, .vue or .css edit never reaches this loop. |
cargo dominates, so it is what the measurement below isolates.
How this was measured
arc new demo --stack react --db postgres, witharcaturepatched to the working tree so the framework under test is the one in the repository.cargo build --features devonce, cold, to filltarget/.- Change one line in
app/controllers/home_controller.rs– the string the welcome page renders – and timecargo build --features dev. Three times, each with a different string, so no run can be answered from a previous run’s cache. - A fourth run with
--timings, for the per-unit breakdown and the fresh/dirty split.
--features dev is what arc dev itself runs, so this is the same build the
loop performs, not an approximation of it.
The baseline
Measured 2026-08-21 on the machine described below.
| Measurement | Result |
|---|---|
Cold build, empty target/ | 52m 38s |
cargo build with nothing to do | 4.1s |
| One-line handler change | 33.2s / 34.3s / 37.6s |
demo.exe | 18.8 MB |
demo.pdb | 71.2 MB |
The --timings run breaks the rebuild into exactly two units of work out of
489 in the graph:
| Unit | Time | Of which |
|---|---|---|
demo lib | 50.6s | frontend 6.9s, codegen 43.7s |
demo bin | 39.4s | codegen of a nine-line main.rs, then the link |
Two dirty units, 487 fresh. That is the first thing the numbers settle: on a
Rust-only change nothing is rebuilt that need not be. Not arcature, not
arcature-macros, not the embedded scaffold templates, not a dependency. The
loop is not slow because it recompiles too much; it is slow because the two
units it does compile are expensive.
The second thing they settle is where inside those two units the time is. Type-checking the application crate – the part a developer thinks of as “compiling” – is 6.9 seconds of a 90-second trip. Everything else is code generation and linking, and the 71 MB of debug information is why: every frame of it has to be written by rustc, read by the linker, and merged into a program database on each save.
The machine
This is a small, busy machine, and the absolute numbers are worse than a developer laptop would show:
- Windows 11, x86_64-pc-windows-msvc, 4 logical CPUs.
rustc 1.98.0,cargo 1.98.0.- Microsoft Defender watching
target/. - Other Cargo builds running concurrently throughout. Cargo reported
Max concurrency: 1 (jobs=4 ncpu=4)for the timed run, and that run took 96.8s against 33-38s for the same work untimed – a two-to-three times spread from contention alone.
Treat the absolute figures as an upper bound and the shape – 5% frontend, 95% codegen and link, nothing spurious rebuilt – as the finding. The shape is what any change has to move.
Because the load varied, only measurements taken under --timings are
compared against each other below: those report per-unit compile time rather
than wall clock, and both the before and the after run reported the same
Max concurrency: 1 (jobs=4 ncpu=4). Plain wall-clock series taken minutes
apart on this machine differ by more than any change being measured, and are
not used as evidence for anything.
What was cut
The baseline points at one thing: debug information. Not the application’s
own – the scaffold has always built it with line-tables-only – but its
dependencies’.
The instinct is that a dependency compiles once and then sits in target/,
so its profile is a one-time cost. That is wrong for generic code. Every
Vec<MyThing>, every tokio combinator, every sea-orm query builder used
with the application’s own types is monomorphised into the application’s
crate, and its debug information is emitted by rustc and merged by the
linker there – on every save, for as long as the project exists. Nobody
steps through tokio while debugging a controller, so the scaffold now sets:
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev.build-override]
opt-level = 2
debug = false
Same machine, same application, same one-line change, both runs under
--timings:
| Before | After | |
|---|---|---|
demo lib | 50.6s (frontend 6.9s, codegen 43.7s) | 25.5s (frontend 4.9s, codegen 20.6s) |
demo bin | 39.4s | 19.8s |
| Both dirty units | 90.0s | 45.3s |
demo.pdb | 71.2 MB | 29.5 MB |
demo.exe | 18.8 MB | 18.8 MB |
Half, and the executable is byte-for-byte the same size, because none of this
was ever in it. Backtraces still carry file and line: the application’s own
crates were never touched. A developer who wants a step debugger through a
dependency can have it for one run with
CARGO_PROFILE_DEV_PACKAGE_tokio_DEBUG=2.
Three levers that look obvious are not taken, and the manifests say why:
opt-level = 0and a highcodegen-unitsare Cargo’s dev defaults. Writing them down changes nothing.split-debuginfois target-specific.rustc --print split-debuginforeportspackedas the only stable value on*-pc-windows-msvc, which is what MSVC already does by writing a.pdb. A fixed value in the manifest would be a no-op for some developers and a hard error for others.- A fast linker is already configured.
.cargo/config.tomlputs Windows on the toolchain’s ownrust-lld.exe, and leaves Linux and macOS on the system linker withmoldandwildas commented opt-ins – a config that fails on a machine without the tool is worse than a slow link.
What is left
The 2.5 second target is not met on this machine, and halving the cost was not enough to meet it. What remains, in order:
- Linking the executable. Even with a third of the debug information,
the
demobin unit is 19.8s for amain.rsof nine lines. Almost all of that isrust-lldpulling every rlib in the graph together. It is proportional to the size of the program, not to the size of the change, so it does not shrink as the diff shrinks. - Code generation for the application crate, 20.6s. This is
monomorphisation: the application instantiates a large amount of generic
machinery from
axum,tokioandsea-orm, and each instantiation is compiled into this crate.
Type-checking – 4.9s, and the only part proportional to what was actually edited – is already inside the budget. The loop is not slow because the compiler is slow at understanding the change; it is slow because the whole program is rebuilt around it.
The distance to the target, measured rather than scaled
The seconds above are per-unit compile time on a saturated four-core machine. They are the right numbers for comparing the before and after of this change, because both runs were taken the same way, and they are the wrong numbers for deciding how far 2.5s is. An earlier version of this page scaled them against issue #8’s quiet reading and put a post-change Cargo invocation near 3.8s, while saying plainly that the estimate was not settled and that somebody should re-measure on an idle machine.
Somebody has. Six one-line handler edits on an otherwise idle machine, warm
target, cargo build --features dev each time:
| Run | Wall clock |
|---|---|
| no-op build (nothing changed) | 1.2s |
| rebuild 1 | 5.8s |
| rebuild 2 | 4.3s |
| rebuild 3 | 4.5s |
| rebuild 4 | 4.3s |
| rebuild 5 | 4.4s |
| rebuild 6 | 5.5s |
warm cargo check (type-check only) | 1.6s |
So the Cargo half of the loop is about 4.4s, and the estimate was optimistic by roughly fifteen per cent – close enough to have been worth making, wrong enough to have been worth checking. Against a 2.5s target that is over by about 1.8x, not the fourfold the saturated per-unit figures suggest.
The split holds up and is the useful part. Type-checking a one-line change is 1.6s, comfortably inside the budget; everything above that is code generation and linking for the whole program, which does not shrink when the diff does. The loop is not slow because the compiler is slow at understanding the edit.
One trap for whoever measures next. A cargo check taken straight after a
cargo build reads about 90s on this project, and it is not the
type-check cost – check keeps its own fingerprints and artifacts, so the
first one after a build is cold. Run it twice and take the second; the 1.6s
above is a second run. A measurement script that interleaves build and
check will report the cold number every time and make type-checking look
like the bottleneck it is not.
The second thing is larger, and is missing from the list above because
--timings cannot see it. Issue #8’s own breakdown has spawn at 5.55s of
an 11.20s loop – bigger than the entire Cargo invocation – and identifies
it as Microsoft Defender scanning the 18.9 MB executable that was just
linked, reproducibly, at roughly 80x the cost of running a file it has
already seen. Nothing in this change touches it, and no profile setting can:
the scan happens after Cargo has exited. arc doctor already reports it with
the remediation. Anyone reading this page as the state of the dev loop should
read that stage as still the single largest one.
Getting to 2.5s therefore needs a structural change rather than another profile flag, and the candidates all have real costs:
- Fewer generics crossing the boundary.
-Zshare-genericsis nightly. Doing it by hand means erasing types at the framework’s public edges, which trades compile time against the type safety the framework exists to provide. - A different codegen backend.
rustc_codegen_craneliftis dramatically faster at-O0and is nightly-only, x86-64 Linux first. - Not relinking at all. Hot-patching the running process, as
subseconddoes, skips both remaining costs. It is a large piece of machinery and it does not survive every kind of change.
None of these is a patch-release change, so none of them is here. Issue #8 stays open with a measured number against it instead of a quoted one.
Routing
A route is a method, a path, a handler, and optionally a name. Routes is a
collection of them, and it is what Application::routes takes.
use arcature::prelude::*;
pub fn routes() -> Routes {
Routes::new([
Route::get("/", home).name("home"),
Route::get("/links", index).name("links.index"),
Route::post("/links", store).name("links.store"),
])
}
Constructors exist for get, post, put, patch, delete, head and
options. Anything else falls through to any.
Paths and parameters
Paths are Axum paths, because the router is Axum’s. /links/{id} captures a
segment; the handler takes it with Path:
use arcature::axum::extract::Path;
async fn show(Path(id): Path<i64>) -> Result<Response> {
Ok(text(StatusCode::OK, format!("link {id}")))
}
Names and URL generation
A named route can be turned back into a URL. Parameters are filled in declaration order:
let url = routes.url_for("links.show", &["42"])?; // "/links/42"
url_for returns Err(Error::NotFound(..)) for a name that was never
declared, so a typo is a runtime error rather than a silently broken link.
Routes::named() iterates every name and its path template.
Groups
RouteGroup shares a path prefix and, optionally, middleware:
Routes::new([
RouteGroup::new("/admin", [
Route::get("/panel", panel).name("admin.panel"),
Route::get("/users", users).name("admin.users"),
])
.middleware(RequireAuth),
])
The prefix is joined onto each route’s path when the group is flattened.
Where middleware can attach
Middleware attaches at three scopes, and the difference between them is not cosmetic:
| Scope | Call | Reaches |
|---|---|---|
| One route | Route::middleware | that route only |
| One group | RouteGroup::middleware | each route in the group, individually |
| A collection | Routes::middleware | every route present at the time of the call |
Route owns a MethodRouter, not a folded Router. That is deliberate: a
Router::layer fold applies to everything in the router, so per-route
middleware written that way leaks onto sibling routes. Holding a
MethodRouter per route makes the leak impossible to express.
Routes::middleware is the one that folds a whole router, and its scope rule
is stated in the API: routes merged in afterwards are not covered. That is
what lets a guarded collection and a public collection be merged without the
guard spreading:
let guarded = Routes::new([...]).middleware(RequireAuth);
let public = Routes::new([...]);
let all = guarded.merge(public); // `public` is still public
Writing middleware
Middleware is a Clone type whose handle returns a boxed future.
#[middleware] writes that plumbing for an ordinary async function:
use arcature::routing::{Request, Response};
use arcature::{Next, Result, middleware};
#[middleware]
pub async fn require_auth(request: Request, next: Next) -> Result<Response> {
Ok(next.run(request).await)
}
The function is emitted unchanged, so it stays callable directly from a test.
Alongside it the macro generates a unit struct named after the function in
PascalCase — RequireAuth here — implementing Middleware. Override the name
with #[middleware(RequireAdmin)].
Returning Err maps the framework error to a response instead of continuing.
Not calling next.run short-circuits.
For middleware that is not a Middleware — a tower_http layer, say —
Route::layer, RouteGroup::layer and Routes::layer take a raw
tower::Layer.
The routes! macro
The builder API above is the whole runtime. routes! is a declarative front
end over it that additionally emits &'static metadata and typed URL
helpers. This block compiles today:
routes! {
pub app {
get "/" => home { name: home, page: "Home" }
group "/auth" {
get "/login" => login { name: auth.login }
post "/login" => store { name: auth.store }
}
group "/admin" {
middleware: [RequireAuth];
get "/panel" => panel { name: admin.panel }
}
}
}
It generates three things from pub app:
app_routes() -> Routes— the collection, built with the same builder API.APP_ROUTES: &[RouteDescriptor]— a&'staticconst describing every route: method, path, handler name, declared page names. Nothing registers itself at startup; the array is the whole registry.app_route— a module of URL functions mirroring the dotted names:app_route::home(),app_route::auth::login(),app_route::admin::panel(). A path parameter becomes a function argument, soapp_route::links::show(42)is"/links/42"and a missing parameter is a compile error rather than a broken URL.
Declare state with a state: line:
routes! {
pub api {
state: AppState;
get "/health" => health { name: api.health }
}
}
And expand a controller into its conventional actions with resource:
routes! {
pub web {
resource "/links" => LinksController {
name: links,
only: [index, show, destroy]
}
}
}
That produces links.index (GET /links), links.show (GET /links/{id})
and links.destroy (DELETE /links/{id}), each with its helper.
Falling through to Axum
Routes::into_router hands back the axum::Router, and Routes::router
borrows it. Route::layer takes a tower::Layer. The escape hatch is not
hidden and using it is not a defeat; the framework’s opinions are meant to
run out somewhere visible.
Where routing sits in the pipeline
The router is stage 19 of 20. Everything a request passes through before it —
security headers, CORS, request id, panic catching, error mapping, body
limit, timeout, sessions, CSRF, Inertia, page contracts — is fixed and
documented in Deployment and in
src/application/pipeline.rs. User .layer() calls go in at stage 18, just
outside the router; static files are stage 20, reached only when the router
does not match.
Controllers
A handler is an ordinary async fn. A controller is a struct with an
impl block full of them, which is a convention rather than a requirement —
Route::get("/", index) takes a free function just as happily.
pub struct HomeController;
#[arcature::controller]
impl HomeController {
pub async fn index() -> String {
"hello".to_string()
}
pub async fn show(id: u64) -> String {
format!("show {id}")
}
}
The impl block is emitted unchanged. HomeController::index() is still a
plain async function a test can call directly, and still a genuine Axum
handler.
What the macro adds
#[controller] additionally emits impl ControllerMetadata, whose METHODS
const carries one entry per handler: the method name, its parameter names,
and the page it renders.
use arcature::ControllerMetadata;
let methods = <HomeController as ControllerMetadata>::METHODS;
assert_eq!(methods[0].name, "index");
assert_eq!(methods[1].params, ["id"]);
METHODS is a &'static const. Nothing registers itself at startup and
nothing is looked up by TypeId; see
Decisions.
The contract the macro enforces
Every method in the block must be pub, async, have a return type, and
take no self receiver — an Axum handler is a free function. Breaking any of
those produces error[ARC-M004] at the method, not a page of trait-bound
noise.
The page edge
A handler that returns Page<T> has its page identity read off the return
type:
#[arcature::page("Dashboard")]
pub struct DashboardPage {
pub title: String,
}
#[arcature::controller]
impl DashboardController {
pub async fn index() -> arcature::Page<DashboardPage> {
arcature::dx::page(DashboardPage {
title: "Dashboard".to_string(),
})
}
#[page("Reports")]
pub async fn reports() -> String {
"reports".to_string()
}
}
methods[0].page is Some("Dashboard"), derived from the signature and
never from the body. The derivation compiles to <T>::PAGE_CONTRACT.name(),
a const that exists only for #[page] types — so a handler that tries to
return a non-page type as a page fails to compile. That is the Client
Exposure Firewall applied to the return type; Inertia covers
the rest of it.
Any other return shape (Response, Json<T>, String, impl IntoResponse) yields page: None. A handler that renders a page without
returning Page<T> declares the identity with an explicit #[page("Name")]
helper attribute, as reports does above.
Extractors
Handler arguments are Axum extractors, unchanged, because the router is Axum’s:
use arcature::axum::extract::{Path, Query, State};
pub async fn show(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Response> {
Ok(json(&id))
}
Arcature adds its own: Auth and Current for the signed-in user,
Session, Flash, CsrfToken, Validated<T> and its typed variants, and
RequestCache. Each is documented in the chapter that owns it.
Responses
Four builders cover the common shapes.
| Call | Produces |
|---|---|
text(StatusCode::OK, "hello") | a text/plain response |
json(&value) | an application/json response, status 200 |
no_content() | 204 |
redirect().to("/dashboard") | 303, or 308 after .permanent() |
json takes one argument. It does not take a status; build the response
directly if you need a different one.
redirect() takes no arguments — it returns a builder. .to(path),
.back(), .permanent(). Unlike the other three it returns a
RedirectResponse, not a Response, so a handler declared
-> Result<Response> finishes with .into_response():
Ok(redirect().to("/dashboard").into_response())
Redirect targets are validated against open redirects: an absolute URL to another host is rejected rather than followed.
redirect().route("links.show", 42) resolves the name against the
application’s route table, and redirect().with("status", "saved") writes
flash data through the session. Neither can be finished by into_response,
which sees no request and so has neither the table nor the session: the
builder rides along in the response extensions and RedirectMapper – stage
20 of the pipeline, installed by default – takes it out and completes it.
.back() works the same way, reading Referer through the same open-redirect
validation.
The one thing to know is what happens without that layer. An application that
assembles its own pipeline instead of using the builder, and does not install
RedirectMapper, gets the fallback response unchanged: a literal path still
redirects, a named route answers 400, and flash data is dropped.
Errors
Handlers return Result<Response> — Arcature’s Result, whose error type
converts into an HTTP response. bad_request, forbidden and not_found
build the common ones; Problem builds an RFC 9457 body. The pipeline’s
error-mapping stage gives a body to errors that were returned bodiless, and
in release builds it redacts 5xx detail rather than leaking it.
Grouping controllers into a module
module! names the controllers, services, jobs and listeners that belong
together and aggregates their metadata into one ModuleDescriptor:
arcature::module! {
pub Dashboard {
controllers: [DashboardController],
}
}
let descriptor = dashboard_module();
assert_eq!(descriptor.controllers, ["DashboardController"]);
The descriptor is built from the same &'static consts the macros emit, so
the module is a description of wiring rather than a container that resolves
things at runtime.
arc make:module dashboard writes this block, a controller, a service and a
routes table into one directory and registers the lot. See
Your first module.
Validation
Validation is the trust boundary. At the point a handler receives a validated
value, it has passed validator::Validate::validate, and the handler does not
re-check it.
Validation does not imply authorization. A validated request is a well-formed one, not a permitted one; authorization is a separate explicit step, covered in Authentication.
Declaring a request
use arcature::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[arcature::request]
pub struct StoreLinkRequest {
#[validate(url)]
pub url: String,
#[validate(length(min = 1, max = 120))]
pub title: String,
}
Two details that are easy to get wrong.
The rule attribute is #[validate(...)], not #[rule(...)]. #[request]
prepends #[derive(::arcature::validator::Validate)], and #[validate] is
that derive’s helper attribute, so the rule vocabulary is the validator
crate’s: required, email, url, length, range, regex, contains,
custom, nested.
You derive Deserialize yourself. The macro deliberately does not add it, to
avoid a duplicate derive when you also want Serialize or Debug. The
#[arcature::request] attribute goes after the derives.
Because the macro re-exports validator through Arcature, an application
does not need validator as a direct dependency.
What the macro emits
Three things beside the struct:
#[derive(Validate)]and#[validate(crate = "::arcature::validator")].impl arcature::Request, the marker that makes the type first-class to tooling.impl arcature::RequestMetadata, a&'static [FieldShape]describing the fields, whichroutes!resolves when a route declaresaction: Tso the typed input shape lands in theRouteDescriptor.
Using it in a handler
use arcature::Validated;
pub async fn store(input: Validated<StoreLinkRequest>) -> Result<Response> {
let data = input.into_inner();
Ok(redirect().to("/links").into_response())
}
Validated<T> extracts a JSON body, deserializes it, and validates it before
the handler body runs. A failure never reaches the handler; it becomes a
response.
Four narrower extractors exist for the other sources:
| Extractor | Source |
|---|---|
ValidatedJson<T> | JSON body |
ValidatedForm<T> | form body |
ValidatedQuery<T> | query string |
ValidatedPath<T> | path parameters |
Validated<T> delegates to ValidatedJson<T>.
For a value you extracted yourself, validate_or_problem(&value) validates
it and returns Err(Problem) on failure.
What a failure looks like
A validation failure is an RFC 9457 problem document, 422, with the field
errors under an errors extension:
{
"type": "urn:arcature:problem:validation",
"status": 422,
"detail": "Request validation failed",
"errors": {
"url": [{ "code": "url" }],
"title": [{ "code": "length" }]
}
}
Extractor rejections — malformed JSON, a missing query parameter, a path
segment that will not parse — are mapped to problem documents too, by
from_json_rejection and friends, so a client sees one error shape rather
than two.
validation_problem(errors) builds the document from a
validator::ValidationErrors directly if you need to raise one by hand.
Inertia
Arcature implements the server side of the Inertia v3 protocol natively. A
stock official @inertiajs/react or @inertiajs/vue3 client talks to it
without knowing Arcature exists.
There is no @arcature/client package, and there will not be one. The
reasoning is in ADR 0001: everything Rust hands JavaScript
goes as generated .ts files on disk under resources/js/generated/, not
through a bundler plugin the framework has to keep alive.
The mental model
The browser’s Inertia client makes ordinary HTTP requests. On a first visit
Arcature renders the initial HTML document with the page object embedded in
it. On subsequent visits — requests carrying X-Inertia — it returns the page
object as JSON. Same route, same handler, two representations.
Configuring it
InertiaConfig::new takes an asset version and a root-document renderer:
use arcature::assets::{Assets, AssetsConfig};
use arcature::inertia::{InertiaConfig, vite_root_document};
let assets = Assets::detect(&AssetsConfig::new())?;
let config = InertiaConfig::new(
env!("CARGO_PKG_VERSION"),
vite_root_document("Acme", &assets, "resources/js/app.tsx"),
)?;
Application::new()
.routes(routes())
.inertia(config)
.build()
default_root_document(title) is the minimal renderer if you are not using
Vite. with_shared(shared_props) registers props every page receives.
A root document is any Fn(ScriptBody) -> String. ScriptBody displays as
the <script data-page> payload plus the mount <div>, and it also carries
this request’s CSP nonce when
SecurityHeaders::with_csp_nonce is installed.
Both built-in renderers stamp it onto every tag they emit; a hand-written one
has to stamp its own, and body.nonce_attribute() is the attribute (with its
leading space, or empty when there is no nonce) to interpolate:
let config = InertiaConfig::new(env!("CARGO_PKG_VERSION"), |body: ScriptBody| {
let nonce = body.nonce_attribute();
format!(
"<!doctype html><html><body>{body}\
<script{nonce} type=\"module\" src=\"/js/app.js\"></script>\
</body></html>"
)
})?;
.inertia(config) is what installs InertiaLayer. Without it the Inertia
extractor fails: a handler taking inertia: Inertia in an application that
never called .inertia(..) returns 500 inertia adapter error. That is
documented on the builder method and is worth remembering, because the
failure looks like a handler bug rather than a wiring one.
Rendering
The untyped path takes any Serialize:
pub async fn index(inertia: Inertia) -> Result<Response> {
let response = inertia
.render("users/index", serde_json::json!({ "users": [] }))
.await?;
Ok(response)
}
The inertia! macro is sugar over it. It requires an in-scope binding
literally named inertia, because it expands to a call on that name:
pub async fn index(inertia: Inertia, State(state): State<AppState>) -> Result<Response> {
let db = state.db.as_ref().ok_or_else(|| not_found("no database"))?;
let users = user::Entity::query(db).all().await?;
inertia!("users/index", { users })
}
render_with_options adds page-level options (history flags, flash data);
render_advanced takes a Props value for per-prop behaviour.
The first argument to InertiaConfig::new is the asset version — any string
that changes when the built assets change. The Inertia client compares it and
does a full page reload when it moves. A release tag or a manifest hash both
work; a constant means the client never reloads on deploy.
The Client Exposure Firewall
Serialize does not mean “safe to send to a browser”. A domain model derives
Serialize for a hundred reasons, and any one of them makes it one field
reference away from the wire. Arcature makes browser exposure a separate,
explicit opt-in.
Two macros grant it.
#[page("name")] declares a page’s prop struct:
#[arcature::page("users/show")]
pub struct ShowUserPage {
pub user: UserResource,
pub can_edit: bool,
}
#[resource] declares a value that nests inside page props:
#[arcature::resource]
pub struct UserResource {
pub id: String,
pub name: String,
pub avatar: Option<AvatarResource>,
}
Both emit impl ClientData, whose exposure_schema() is built from the named
fields. A non-primitive field type maps to PropsSchema::nested::<FieldType>,
which requires FieldType: ClientData. So nesting an internal model inside a
page does not compile — the failure is a trait bound at build time, not a leak
in production.
#[page] additionally emits a PAGE_CONTRACT const (the typed handle) and a
PAGE_CONTRACT_ENTRY const (the non-generic one module! aggregates). Both
are &'static. Nothing registers itself; application! builds the
PageContracts registry from the graph.
#[resource] emits no PAGE_CONTRACT: resources are values inside pages, not
pages.
A database model is not a resource. A SeaORM entity stays an entity, and
application code converts explicitly with impl From<User> for UserResource.
The conversion is the place where you decide what the browser sees, which is
the point of writing it out.
Rendering through the firewall
pub async fn show(inertia: Inertia) -> Result<Response> {
let page = ShowUserPage {
user: UserResource { id: "1".into(), name: "Ada".into(), avatar: None },
can_edit: true,
};
Ok(inertia.render_page(ShowUserPage::PAGE_CONTRACT, page).await?)
}
render_page is render with a ClientData bound. The component name comes
from the contract rather than a string literal, so a renamed page cannot
drift from its route.
A controller method may instead return Page<T> and let #[controller] read
the page identity off the return type — see Controllers.
page!(ShowUserPage { .. }) constructs one with a compile-time ClientData
assertion at the call site.
Prop behaviours
Props carries per-prop evaluation strategy, matching the Inertia protocol:
| Constructor | Behaviour |
|---|---|
eager(value) | always serialized |
always(value) | included even in partial reloads |
lazy(f) | resolved only when requested |
optional(f) | omitted unless the client asks for it |
deferred(f) | sent in a follow-up request |
deferred_group(name, f) | deferred, batched under a group |
merge(prop), prepend(prop) and deep_merge(prop) set the client-side
merge strategy for a prop that accumulates across visits.
Contracts as an artifact
.page_contracts(artifact) publishes the collected page contracts as a
request extension. It changes no response; it is data for the dev-only UAG
endpoint and for arc typegen to read when generating TypeScript.
arc typegen reads that artifact and writes the TypeScript, which is the
generated-types pipeline ADR 0001 describes and the reason the contracts are
collected at all.
Redirects
inertia.redirect(location) builds an Inertia-aware redirect. external(url)
produces a 409 with X-Inertia-Location, which is how the protocol tells
the client to leave the SPA. fragment(..) targets a fragment.
One port
In development, Vite runs in middlewareMode with no TCP port of its own and
the Rust process forwards to it over an IPC endpoint. There is one port in
development and one in production, and no localhost:5173 fallback. See
ADR 0003 and Deployment.
When a page does not need JavaScript
Inertia is for the application. For pages that are just HTML – a marketing
page, a confirmation screen, an email body – the views feature renders
Askama templates instead, and costs the client nothing.
Askama compiles a template into Rust at build time, so there is no expression
evaluator anywhere in the request path and server-side template injection is
structurally absent rather than defended against. The trade is that editing a
template means rebuilding, and that a Dockerfile has to COPY templates
before cargo build. arc make:view <name> writes a view struct and its
template together.
The two mix freely: an application can serve Inertia pages behind sign-in and compiled views in front of it.
More than one language
The i18n feature adds Fluent translation catalogs and puts the negotiated
locale where both renderers can reach it – Inertia props and view context
alike, so a page does not have to know which one it is.
Fluent rather than a HashMap<String, String>, because the map is wrong the
moment a language has more than two plural forms. The negotiation reads
Accept-Language and any override you allow, and matches it against the
locales you registered: a locale string never becomes a path, so a hostile
Accept-Language selects nothing rather than reaching the filesystem.
Views
Server-rendered HTML from templates that are Rust code by the time the binary exists.
#[derive(Template)] reads the .html file when the crate compiles and emits
the write! calls that produce the page. What ships is a function. There is
no template text in the process, no loader, and no evaluator.
Turning it on
The feature is views, and it is off in the framework’s default set:
arcature = { version = "0.1", features = ["views"] }
views = ["dep:askama"]. It pulls nothing else.
A generated application already has it on — arc new writes app/views/,
templates/layout.html and templates/welcome.html, and lists "views" in
the app’s Cargo.toml. If every screen in the application is an
Inertia page, remove the feature and those two directories
together.
| Where | State |
|---|---|
framework default | off |
framework fullstack | on |
| generated application | on |
Askama is pinned at 0.16.0 with default-features = false and only two of
its features enabled: derive and std. The omissions have consequences you
can see from application code.
| Askama feature | Consequence of it being off |
|---|---|
config | TOML parsing is off, so an askama.toml is a compile error rather than a config file. Arcature ships none, so the template directory, the syntax and the escaper table are the defaults. |
urlencode | There is no urlencode or urlencode_strict filter. |
serde_json | There is no json or json_pretty filter. |
code-in-doc | #[template(in_doc = true)] is unavailable. |
config and urlencode are two of the four askama defaults, so this is a
narrowing, not an unchanged baseline. config would add basic-toml, glob,
serde and serde_derive to the build-time graph to read a file the framework
does not ship; urlencode would add percent-encoding for a filter an HTML
template does not need, since escaping is the autoescaper’s job.
askama_axum is deliberately absent as well. It was folded into askama and
then dropped; the IntoResponse impl lives in src/view/response.rs, where
it can answer a render failure the way the rest of the framework answers one.
views does not imply observe. That default matters and it is covered under
Render failures.
Why the templates are compiled
This is the decision the feature exists to make, so it is worth arguing rather than asserting.
A runtime template engine — minijinja, tera, handlebars — is two programs shipped as a library: a parser that turns template text into a tree, and an evaluator that walks the tree against a context and produces output. Both of them run inside the request path, because that is when the template is rendered.
That arrangement is what server-side template injection is. SSTI is not a
parsing bug; it is the engine doing exactly its job on input that reached it
from the wrong direction. If any string a request controls is handed to the
parser — a template chosen by name from a query parameter, a page fragment
stored in a database and rendered as a template, a subject line assembled with
format! and then passed through the engine — then the evaluator will
evaluate it. An expression language with attribute access and method calls is
one hop from the host process, which is why SSTI is the shortest route there
is from a form field to remote code execution.
The usual answer is discipline: never render user input as a template, audit the places templates are loaded from, keep the sandbox on. That is a defence, and a defence is a thing that can be forgotten in one commit.
Askama makes the class of bug unreachable instead. The parser runs in the proc-macro at build time. The output is Rust. At runtime there is no parser to reach, no evaluator to abuse, and no template text in the binary to be substituted — only the statements the compiler emitted. There is nothing to forget, because there is nothing there.
The same property has a second effect, which is smaller but is felt daily. A
runtime engine binds names when it renders, so a name the template uses and
the data does not supply is discovered when a page is served. A compiled
template resolves names against the struct’s fields when the crate compiles: a
{{ subtitle }} with no subtitle field is a build failure, not a blank space
on a page nobody looked at.
| Runtime engine | Compiled templates | |
|---|---|---|
| Parser in the request path | yes | no |
| Expression evaluator in the request path | yes | no |
| SSTI | defended against | structurally absent |
| Unknown name in a template | render time | compile time |
| Template must be on disk at runtime | yes | no |
| Editing a template | reload | rebuild |
The last row is the price, and it is a real one. It is paid in full under Costs.
Localization is the one place the framework accepted a runtime parser anyway,
and src/i18n/mod.rs states the boundary: a Fluent catalog is a file a
developer wrote and a request never names, supplies or selects. That is a
different input from a template rendering attacker-supplied values.
Writing a view
A view is a struct whose fields are the values its template names.
use arcature::view::Template;
/// `templates/welcome.html`.
#[derive(Template)]
#[template(path = "welcome.html", askama = arcature::askama)]
pub struct WelcomeView {
pub title: String,
pub message: String,
}
path resolves against templates/ in the crate root — askama’s default
directory, and with the config feature off there is no askama.toml that
could move it.
askama = arcature::askama is not decoration. #[derive(Template)] writes
code that says askama::, which does not resolve in a crate that depends only
on Arcature. Pointing the derive at the re-export means the application
compiles against the askama the framework pins and cannot drift to a second
version of it. An application that would rather write a bare
#[derive(Template)] can add askama to its own Cargo.toml; the price is a
version number to keep in step by hand.
For a template short enough to read in place, source and ext replace
path:
use arcature::view::{Template, view};
#[derive(Template)]
#[template(
source = "<h1>{{ title }}</h1>",
ext = "html",
askama = arcature::askama
)]
struct Welcome {
title: String,
}
let html = view(Welcome { title: "Hello".into() }).render().unwrap();
assert_eq!(html, "<h1>Hello</h1>");
The #[template(..)] keys this chapter relies on:
| Key | Meaning |
|---|---|
path | template file, resolved under templates/ |
source | template text written inline; requires ext |
ext | the extension source should be treated as having |
escape | override the escaper the extension would select |
askama | the path to the askama crate the derive should name |
The template
The scaffold ships a base and one page that extends it. templates/layout.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Acme{% endblock %}</title>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>
templates/welcome.html:
{% extends "layout.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<main>
<h1>{{ title }}</h1>
<p>{{ message }}</p>
</main>
{% endblock %}
Template syntax, inheritance and filters are askama’s, and arcature::view
puts nothing in front of them. It is a seam, not a wrapper. The askama crate
is re-exported as arcature::askama, and Template — both the trait and the
derive macro, which share one name — comes from arcature::view::Template or
from the prelude.
arc make:view
arc make:view admin/receipt
writes two files:
| Path | Contents | Registered in a mod.rs |
|---|---|---|
app/views/admin/receipt_view.rs | pub struct ReceiptView with #[template(path = "admin/receipt.html", askama = arcature::askama)] | yes — pub mod receipt_view; |
templates/admin/receipt.html | a template extending layout.html | no |
The struct is the file stem plus View; the template keeps the base name,
because path names a template and nothing in askama makes it a type’s name.
The generated struct has two fields, title: String and message: String, and
the generated template uses both.
Only the Rust half is declared to rustc. There is no templates/admin/mod.rs,
because templates/ is read by the askama derive rather than walked by the
compiler, and a mod.rs there would be a Rust file in a directory that has no
Rust in it.
Two files rather than one is the point of this generator. Askama reads the
template when the crate compiles, so a view struct whose path names a file
that is not there is not a scaffold with a gap in it — it is a compile error,
and the project stops building until somebody writes the half the generator
declined to. The pair is the artifact.
Rendering a view as a response
view(template) wraps a template value; View::new(template) is the same
thing with a name that is easier to use in generic code.
use arcature::prelude::*;
use crate::app::views::WelcomeView;
use crate::bootstrap::AppState;
pub struct HomeController;
#[controller]
impl HomeController {
/// `GET /welcome`
pub async fn welcome(State(state): State<AppState>) -> Result<Response> {
Ok(view(WelcomeView {
title: state.app_name.clone(),
message: "Rendered on the server.".to_string(),
})
.into_response())
}
}
The return type is Response, not Page<..>: there is no client component
behind a view, and the HTML is finished when it leaves the server.
A fresh View carries three things a compiled template does not know:
| Property | Default | Set with |
|---|---|---|
| status | 200 OK | .status(StatusCode) |
| content type | text/html; charset=utf-8 | .content_type(HeaderValue) |
Content-Language | absent | .in_locale(&Locale) (feature i18n) |
HTML is the default rather than a guess from the template’s extension because
askama 0.16 does not keep the extension on the compiled type — Template has
no MIME_TYPE to read. A view over a .txt or .xml template has to say so:
use arcature::axum::http::HeaderValue;
use arcature::prelude::*;
use arcature::view::{Template, view};
#[derive(Template)]
#[template(
source = "User-agent: *\nDisallow: {{ path }}\n",
ext = "txt",
askama = arcature::askama
)]
struct Robots {
path: String,
}
let response = view(Robots { path: "/admin".into() })
.content_type(HeaderValue::from_static("text/plain; charset=utf-8"))
.into_response();
assert_eq!(response.headers()["content-type"], "text/plain; charset=utf-8");
The rest of the surface is small: .render() produces a String,
.template() borrows the wrapped value, and .into_template() gives it back.
View<T> is Debug + Clone and #[non_exhaustive].
arcature::view exports View, ViewError, view and Template; the crate
root re-exports View, ViewError and view; the prelude carries Template,
View and view under the views feature, which is why the controller above
imports nothing else.
Render failures
ViewError has one variant, Render { source: askama::Error }. That is the
whole runtime failure surface: askama resolved the parse and the names at build
time, so what is left is a value whose Display impl returned Err, or a
writer that refused the bytes.
When IntoResponse hits one, the response is a plain 500 with the
framework’s ordinary internal error body. It says nothing, and it is worth
being precise about what “nothing” excludes, because the obvious
implementation leaks all three:
- the template’s own text, which is application source, and on an error page is often the half somebody was mid-edit;
- the template’s path, which is a map of the source tree and of the filesystem the process runs on;
- the value that would not format — whatever the failing
Displayhad already written before it gave up, plausibly a session token or a database row.
The conversion From<ViewError> for Error produces Error::Other("view rendering failed"), which answers status 500 with code internal_error.
There is no development-mode variant that shows more, because there is no
build in which a template’s contents are a reasonable thing to send to a
browser. A Content-Language a handler declared does not survive onto the
failure response either: the body is the framework’s error document, not the
page that failed.
The askama message goes to tracing::error! — and this is where the feature
graph bites. tracing arrives with observe, and views does not imply it.
In a build with views and without observe, the askama message is
discarded with nothing recorded anywhere. The client still gets its
uninformative 500; the operator gets silence. If you enable views, enable
observe.
Declaring a language
in_locale exists only under the i18n feature and sends Content-Language:
let response = view(Greeting { locale: locale.clone() })
.in_locale(&locale)
.into_response();
The framework does not infer this header, in either direction. A compiled
template carries no language — askama resolved it to write! calls — and the
locale LocaleLayer negotiated is what the request asked for, which is not
the same claim as what the bytes in the response are actually in. A handler
that renders a French template says so; one that renders a template it did not
translate says nothing, which is better than an untrue header.
Translation itself stays in the template: give the struct a Locale field and
call it. There is no filter and no {{ t("key") }} syntax, because adding one
would mean a lookup the compiler cannot check — the opposite of the reason
this module exists.
Mail bodies from the same templates
With views on, the Mail builder grows two terminators that take
compiled templates instead of strings.
use arcature::mail::Email;
use arcature::view::Template;
#[derive(Template)]
#[template(
source = "Hello {{ name }}, your invoice is ready.",
ext = "txt",
askama = arcature::askama
)]
struct InvoiceText {
name: String,
}
#[derive(Template)]
#[template(
source = "<p>Hello {{ name }}, your invoice is ready.</p>",
ext = "html",
askama = arcature::askama
)]
struct InvoiceHtml {
name: String,
}
let message = Email::builder()
.from("Billing <billing@example.com>".parse().unwrap())
.to("ada@example.com".parse().unwrap())
.subject("Your invoice")
.templated(
&InvoiceText { name: "Ada".into() },
&InvoiceHtml { name: "Ada".into() },
)
.unwrap();
let raw = String::from_utf8(message.formatted()).unwrap();
assert!(raw.contains("multipart/alternative"));
| Terminator | Produces |
|---|---|
templated(plain, html) | multipart/alternative from both templates |
templated_with_attachments(plain, html, attachments) | the same, inside multipart/mixed |
Plain first, matching Email::alternative.
Both halves are taken in one call on purpose. A multipart/alternative mail
carries the same message twice, and the two copies drifting apart is the
ordinary way mail templating goes wrong: the HTML half gets the new wording,
the plain half keeps the old, and only the readers on the text client ever see
it. Taking the pair together makes a change to a message a change to a pair.
They are two templates rather than one because escaping is chosen by
extension. The .html template escapes its values; the .txt one does not.
Rendering a text body through an HTML template would send & to somebody
reading plain text.
Both halves render before the message is assembled, so a template that cannot
render stops before a Message exists. The error is MailViewError:
| Variant | Cause | Becomes |
|---|---|---|
Render { source: ViewError } | either template failed to render | the same generic 500, through From<ViewError> for Error |
Build { source: EmailError } | lettre could not assemble the message | Error::Mail(..) |
The render path deliberately goes through the view conversion, so a template’s text cannot reach a response body by way of the mail subsystem either.
These terminators do not fit Mailable
Mailable::build is declared fn build(&self, email: Email) -> Result<Message, EmailError>. templated returns Result<Message, MailViewError>, and there
is no From<MailViewError> for EmailError — EmailError is not
#[non_exhaustive], so it cannot grow a variant without breaking every
downstream match.
The consequence is concrete: a Mailable implementation cannot call
templated and use ?. This does not compile, and no import fixes it.
arc make:mail writes a plain-text Mailable, and its comment gives a
related but different reason — that a format! into an HTML body escapes
nothing, so the HTML half should come from a template. The templated
incompatibility above is not mentioned there. To send a templated mail, build the Message outside the trait and
hand it to the mailer:
let message = Email::builder()
.from(from_mailbox)
.to(to_mailbox)
.subject("Your invoice")
.templated(&InvoiceText { name }, &InvoiceHtml { name })?;
mail.mailer().send(&message).await?;
Mail::mailer() borrows the transport and Mailer::send(&Message) takes a
finished message, so this path keeps the configured transport and the capture
mailers used in tests. What it gives up is Mail::to(..).send(..) setting
From and To for you.
How escaping works, and on what basis
Escaping is selected by the template’s extension, at compile time, from a fixed table. It is not a runtime decision and not a per-value one.
| Extension | Escaper | Effect |
|---|---|---|
askama, html, htm, j2, jinja, jinja2, rinja, svg, xml | Html | five characters replaced with entities |
md, none, txt, yml, and no extension | Text | nothing is escaped |
| anything else | none exists | compile error |
That last row is worth reading twice. An extension in neither list is not a
silent fall-through to “no escaping” — the derive fails with no escaper defined for extension '...'. A .rss or .csv template does not build until
you say what it is, with escape = "html" or escape = "none".
The HTML escaper replaces exactly five characters:
| Character | Output |
|---|---|
" | " |
& | & |
' | ' |
< | < |
> | > |
So {{ value }} in an .html template with <script>alert(1)</script> in it
produces text on the page and not markup. There is a test in src/view/mod.rs
asserting exactly that, and a matching one asserting that a .txt template
leaves a < b alone, so neither half is taken on trust.
{{ value|safe }} opts out. Write it for markup you produced yourself, never
for a value that arrived on a request.
Escaping is not context-aware
This is the part that a five-character replacement table cannot do, and it is the same limitation every non-contextual autoescaper has.
The escaper does not know where in the document a value lands. It escapes the
same five characters whether the value is body text, an attribute value, a URL,
or the inside of a <script> block. Three consequences follow, and all three
are the template author’s to handle:
- URLs.
<a href="{{ url }}">withurlset tojavascript:alert(1)contains none of the five characters. It is emitted unchanged and it runs. Check the scheme in Rust before the value reaches the template. - Script blocks. Inside
<script>, HTML entities are not decoded the way they are in markup, and the five-character table is not a JavaScript string escape. Do not interpolate request data into a<script>body. Askama’sjsonfilter would be the tool for handing data to JavaScript, and this build does not have it —serde_jsonis one of the askama features Arcature leaves off. Encode the value in Rust, or use an Inertia page, which is what the prop channel is for. - Unquoted attributes.
<div class={{ value }}>is injectable through a space, since space,/,=and backtick are all left alone. Quote every attribute.
Nothing here is specific to Arcature and nothing here is a defect in askama. It is the boundary of what “the template escapes its values” means, and a chapter that did not say so would be lying by omission.
Costs
Three, and none of them is hypothetical.
Editing a template means rebuilding
There is no reload. The template is in the binary as emitted code, so a change
to templates/welcome.html reaches a running process only after the crate is
compiled again.
cargo build does notice. Askama emits a
const _: &[u8] = include_bytes!("<template path>") for every template file it
reads, which makes rustc track the file as a dependency of the crate: touching
the .html alone is enough to make the next cargo build recompile.
arc dev does not notice. The supervisor’s watcher — see
The dev loop — classifies a change, and only three kinds of
file mean anything to it:
| Change | Action |
|---|---|
any *.rs | rebuild, then restart |
Cargo.toml, Cargo.lock | rebuild, then restart |
.env, .env.* | restart, no compile |
| everything else, templates included | nothing |
That filter is deliberate for the frontend — a .tsx or .css edit is Vite’s
business and costs no Rust rebuild — and an askama template falls on the same
side of it. Saving a template during arc dev produces no rebuild and no
visible change on refresh. Touch any .rs file, or run cargo build, to pick
it up. (The comment in the generated app/views/mod.rs says arc dev already
rebuilds on save. It does not.)
A Dockerfile has to COPY templates before cargo build
templates/ is a source directory, in the same sense src/ and app/ are. It
is read by the compiler, not by the process.
This is the failure mode worth knowing before you meet it: the local build succeeds, because the templates are on the developer’s disk, and the image build fails on a template it cannot find. The error names a path that plainly exists, which is what makes it confusing rather than obvious.
The generated Dockerfile — described in full under
Deployment — copies it alongside the rest of the sources:
COPY src ./src
COPY app ./app
COPY bootstrap ./bootstrap
COPY config ./config
COPY database ./database
COPY routes ./routes
# Askama reads the templates at *build* time and compiles them into the
# binary, so this is a source directory like the ones above, not runtime data.
COPY templates ./templates
RUN cargo build --release --locked
The runtime stage copies the binary, public/ and storage/, and no
templates. Nothing at runtime reads them back, and a template shipped into a
production image is dead weight at best.
Views and Inertia are both on, and that is fine
A generated application serves both. Inertia renders the application — the screens behind sign-in, where the client is already loaded and a JSON page object is the cheap answer. Views render the pages that have to work with no JavaScript at all: an unsubscribe confirmation, an emailed receipt, an RSS feed, a marketing page, a fallback error page.
They do not interact. A view is a plain Response and never carries a page
object; an Inertia page never goes through View. The scaffold demonstrates
the split in one controller: GET / returns Page<HomePage> and GET /welcome
returns Response from view(WelcomeView { .. }).
The one shared cost is the two directory trees, app/pages/ plus
resources/js/pages/ for one and app/views/ plus templates/ for the other.
If an application never serves HTML from the server, delete app/views/ and
templates/ and drop "views" from its feature list; if it never serves an
SPA, the same applies to inertia. Keeping both is a choice, not a default you
are stuck with.
What views do not do
Collected, because a chapter that lists only what works is useless to somebody deciding whether to depend on this.
- No runtime template loading. There is no
render("name", context)taking a template chosen at runtime, and there cannot be one. That is the feature. - No hot reload, and no rebuild from
arc devon a template edit. - No
askama.toml. Theconfigfeature is off, so the template directory, the syntax and the escaper table are the defaults. - No
urlencodefilter, and nojsonfilter. Those askama features are off too. - No content-type inference. A
.txtor.xmlview answerstext/html; charset=utf-8until you call.content_type(..). - No
Content-Languageunless you declare one, and no inference from the negotiated locale. - No translation filter. No
{{ t("key") }}; put aLocaleon the struct. - No context-aware escaping. Five characters, everywhere, regardless of where the value lands.
- No render-failure detail anywhere without
observe. The message is dropped, not logged. - No
Mailableintegration.templatedreturns an error typeMailable::buildcannot return.
Localization
Translation catalogs in Mozilla Fluent, a locale
negotiated per request from Accept-Language and from overrides the
application opts into, and an active locale a handler, a view or an Inertia
page can read.
A locale tag is matched against the catalogs the application registered. It never becomes a filesystem path, because there is no filesystem access in the subsystem at all.
Turning it on
The feature is i18n, and it is off everywhere:
arcature = { version = "0.1", features = ["i18n"] }
| Where | State |
|---|---|
framework default | off |
framework fullstack | off |
| generated application | off, and arc new scaffolds nothing for it |
i18n = ["dep:fluent-bundle", "dep:unic-langid"], both with
default-features = false. fluent-bundle is the reference Rust
implementation of Fluent — the .ftl parser, the formatter, and the CLDR
plural-rule selection. unic-langid is the BCP-47 language-identifier type
that API is written in; it is a direct dependency because fluent-bundle does
not re-export it and a locale has to be parsed before a bundle can be built.
The features left off are all-benchmarks on fluent-bundle, and macros,
likelysubtags and serde on unic-langid: a benchmark harness, a
proc-macro, a data table and a derive, none of which this crate uses.
Nothing enables i18n for you, and nothing in the framework installs the
locale layer on your behalf. There is no Application::i18n(..) builder
method. Wiring is described under Installing the
layer.
Where the names live:
| Import | Names |
|---|---|
arcature:: (crate root) | Catalog, Catalogs, I18nError, Locale, LocaleId, LocaleLayer |
arcature::i18n:: | those, plus ArgValue, LocaleMiddleware, LocaleNegotiator, LocaleRejection, LocaleSource, TranslationArgs |
arcature::prelude::* | nothing — the prelude carries no i18n name |
A handler that takes a Locale writes use arcature::i18n::Locale; even with
the prelude imported.
Why Fluent and not a map
The obvious implementation of translation is HashMap<String, String> keyed by
locale. It is wrong for every language whose grammar is not English’s, in three
separate ways, and this feature exists to pick the engine that gets all three
right.
Plurals. English has two categories, so a map with a _one key and an
_other key looks complete. It is not a property of the message; it is a
property of the language, and CLDR gives Polish four categories, Arabic six and
Japanese one. The selection cannot be an if n == 1 in the calling code,
because the calling code does not know which language it is rendering, and the
rule for that language is a table rather than an arithmetic expression a
developer can guess.
Agreement. “the file was deleted” has a gendered participle in French and in Russian. The correct string depends on a property of an argument, not only on the key, so no amount of keys fixes it.
Numbers and dates. 1,234.5 is 1 234,5 in French and 1.234,5 in
German. A value formatted before it reaches the map is formatted in the
server’s locale rather than the reader’s.
Fluent puts the decision inside the catalog, where the translator can see it
and change it, instead of inside the calling code, where they cannot. Adding a
language with four plural categories is an edit to one .ftl file and to
nothing else:
use arcature::i18n::{Catalog, LocaleId, TranslationArgs};
let catalog = Catalog::parse(
LocaleId::parse("pl").unwrap(),
r"files = { $count ->
[one] plik
[few] pliki
[many] plikow
*[other] pliku
}",
)
.unwrap()
.isolating(false);
let of = |n: i64| {
catalog
.translate("files", &TranslationArgs::new().with("count", n))
.unwrap()
};
assert_eq!(of(1), "plik");
assert_eq!(of(2), "pliki");
assert_eq!(of(5), "plikow");
assert_eq!(of(22), "pliki");
The calling code passed an i64 three times and knew nothing about Polish.
The price is a runtime parser, which the Views feature rejected on purpose. That is a real tension and it gets its own answer under Security.
Writing a catalog
Catalog::parse(locale, ftl) turns .ftl source into a catalog. In an
application the source is include_str!, so the bytes are in the binary and
the parse is a startup cost:
use arcature::i18n::{Catalog, LocaleId};
let english = Catalog::parse(
LocaleId::parse("en")?,
include_str!("../locales/en.ftl"),
)?;
locales/en.ftl is an ordinary file in the repository. Nothing at runtime
reads it; nothing at runtime can be told to read a different one.
| Method | Behaviour |
|---|---|
Catalog::parse(locale, ftl) | parse one source into a new catalog |
catalog.with_source(ftl) | fold another source into the same catalog |
catalog.isolating(bool) | Unicode bidi isolation of placeables; on by default |
catalog.locale() | the &LocaleId this catalog is for |
catalog.has(key) | whether the catalog defines a message under key |
catalog.message(key) | format a message that takes no arguments |
catalog.translate(key, &args) | format a message with arguments |
catalog.attribute(key, attr, &args) | format one attribute of a message |
with_source is how a large application splits its messages by area without
splitting the locale. It refuses to redefine a message or a term the catalog
already has — a silent overwrite would make a key’s meaning depend on the
order the files happened to be added in:
use arcature::i18n::{Catalog, LocaleId};
let catalog = Catalog::parse(LocaleId::parse("en").unwrap(), "a = A").unwrap();
assert!(catalog.with_source("a = B").is_err());
Attributes keep the strings of one UI element together so a translator sees them as a unit:
use arcature::i18n::{Catalog, LocaleId, TranslationArgs};
let catalog = Catalog::parse(
LocaleId::parse("en").unwrap(),
"search = Search\n .placeholder = Search the archive",
)
.unwrap();
assert_eq!(catalog.message("search").unwrap(), "Search");
assert_eq!(
catalog
.attribute("search", "placeholder", &TranslationArgs::new())
.unwrap(),
"Search the archive"
);
Isolation marks
isolating is on by default, and it should stay on in anything a person reads.
Fluent wraps every placeable in U+2068 and U+2069, the Unicode isolation
marks, so an Arabic name interpolated into an English sentence does not drag
the punctuation around it to the other side of the line. They are invisible in
a browser and visible in a byte-for-byte assertion, which is the only reason
isolating(false) exists: tests, and non-display sinks such as a log line.
Most runnable examples in this chapter that compare a formatted string call
it. Three do not, because the strings they assert on carry no placeable and so
no isolating marks appear in them.
Arguments
TranslationArgs is a builder of named values:
use arcature::i18n::{Catalog, LocaleId, TranslationArgs};
let catalog = Catalog::parse(
LocaleId::parse("en").unwrap(),
"invoice = { $name } owes { $amount }",
)
.unwrap()
.isolating(false);
let args = TranslationArgs::new().with("name", "Ada").with("amount", 12.5);
assert_eq!(catalog.translate("invoice", &args).unwrap(), "Ada owes 12.5");
with takes anything that converts into an ArgValue, which has three
variants and these From impls and no others:
ArgValue | Built from | Notes |
|---|---|---|
Text(String) | String, &str | interpolated as-is; escaping is the view layer’s job |
Integer(i64) | i64, i32, u32, usize | usize saturates at i64::MAX rather than wrapping |
Number(f64) | f64 |
There is no u64, no f32, no u8/u16/i8/i16 and no bool. Convert at
the call site.
Integer and float are separate cases because CLDR treats them separately: in
several languages 1 and 1.0 fall into different plural categories, and
collapsing both into an f64 would quietly pick the wrong one.
Setting the same name twice keeps the later value. Arguments keep insertion
order, and TranslationArgs is a Vec behind the scenes rather than a map,
because a message has a handful of placeables and a linear scan over three
entries beats hashing three keys.
TranslationArgs is deliberately not fluent_bundle::FluentArgs. FluentArgs
in a public signature would make fluent-bundle’s version part of Arcature’s
public API, and a 0.16 to 0.17 bump upstream — routine for a crate at
0.x — would become a breaking change here.
Registering locales
Catalogs is the registry, and it is also the whitelist: it is the only answer
in the framework to “is this a locale this application has?”.
use arcature::i18n::{Catalog, Catalogs, LocaleId};
let en = LocaleId::parse("en").unwrap();
let fr = LocaleId::parse("fr").unwrap();
let catalogs = Catalogs::new(
Catalog::parse(en.clone(), "greeting = Hello\nfarewell = Goodbye").unwrap(),
)
.with(Catalog::parse(fr.clone(), "greeting = Bonjour").unwrap());
assert_eq!(catalogs.default_locale(), &en);
assert_eq!(catalogs.message(&fr, "greeting").unwrap(), "Bonjour");
assert!(!catalogs.contains(&LocaleId::parse("de").unwrap()));
| Method | Behaviour |
|---|---|
Catalogs::new(catalog) | start the registry from the catalog that is also its default |
catalogs.with(catalog) | register another locale; the same locale twice keeps the later catalog |
catalogs.default_locale() | the &LocaleId used when nothing better is registered |
catalogs.default_catalog() | the default locale’s catalog, which always exists |
catalogs.contains(&locale) | the whitelist test |
catalogs.catalog(&locale) | Option<&Catalog> |
catalogs.locales() | every registered locale, in canonical-tag order |
catalogs.message(&locale, key) | format a no-argument message, with fallback |
catalogs.translate(&locale, key, &args) | format a message, with fallback |
new takes a Catalog and not a LocaleId on purpose. A registry whose
default locale has no catalog is a configuration that fails at the first
request in the worst language, and this signature makes it unspellable.
Nothing removes a catalog. The set is fixed once the registry is built, from values the application’s own code supplied.
Catalogs is Clone and the catalogs sit behind an Arc, so a copy per
request costs a refcount bump. Catalog itself is not Clone; build it once
and hand it to the registry.
Message fallback
There are two levels of fallback and they are separate on purpose.
Locale fallback happens before a catalog is chosen and belongs to negotiation: an unregistered locale becomes the default one.
Message fallback happens inside Catalogs::translate: a key the chosen
catalog does not have is looked up in the default catalog before the call
fails.
use arcature::i18n::{Catalog, Catalogs, LocaleId};
let fr = LocaleId::parse("fr").unwrap();
let catalogs = Catalogs::new(
Catalog::parse(
LocaleId::parse("en").unwrap(),
"greeting = Hello\nfarewell = Goodbye",
)
.unwrap(),
)
.with(Catalog::parse(fr.clone(), "greeting = Bonjour").unwrap());
// `farewell` was never translated. The page renders in English rather than
// failing.
assert_eq!(catalogs.message(&fr, "farewell").unwrap(), "Goodbye");
That is what makes a partially translated locale usable: a new string ships in
en, and a fr page shows the English sentence rather than a 500 or an
empty span. It is per key, so it cannot hide a missing catalog — a locale with
no catalog at all was never selectable.
Two things the fallback deliberately does not do:
- A formatting failure is not retried against the default catalog. The message was found and the caller’s arguments did not fit it; the same arguments will not fit the English one either.
Catalogshas noattributemethod. Attributes are reachable only throughCatalog::attribute, which is one catalog, so an attribute a locale has not translated isI18nError::Missingrather than the default catalog’s text. See What this does not do.
A key that is missing everywhere reports the locale that was asked for, not the default one, so the error names the language the reader was in.
Locale identifiers
LocaleId is a validated, canonical BCP-47 language identifier, and it is the
only locale type the API accepts.
use arcature::i18n::LocaleId;
// Canonical casing is applied on the way in.
let locale = LocaleId::parse("en-us").unwrap();
assert_eq!(locale.as_str(), "en-US");
assert_eq!(locale, LocaleId::parse("EN-US").unwrap());
assert_eq!(locale.language(), "en");
assert!(LocaleId::parse("../../etc/passwd").is_err());
parse is the only constructor and it is fallible. Validation runs in two
passes: a cheap allocation-free shape check, then the real parser on a string
already known to be short and alphanumeric.
| Rule | Value |
|---|---|
| maximum length | 35 bytes, checked before parsing |
| subtag length | 1–8 bytes |
| byte set | ASCII alphanumerics, - as the separator only |
| subtag order | language, script, region, variants |
| casing | canonicalized, so zh-hant-hk and ZH-HANT-HK are one locale |
Everything else is refused: .., /, \, a NUL byte, a newline, a space, an
underscore, an empty subtag, a percent-encoded sequence, a bidi override
character, a 4 KB string. LocaleId::parse is the one place that check has to
be written, which is the point of the newtype — a String carries no history,
so the check would otherwise have to be repeated at every use and the one call
site that forgot would be the bug.
LocaleId implements Display, AsRef<str>, FromStr, Serialize, Clone,
Ord and Hash. It does not implement Deserialize: a struct with a
LocaleId field cannot #[derive(Deserialize)]. Take a String and call
parse on it.
Negotiating a request locale
LocaleNegotiator turns what a request proposed into a registered locale. The
whole design is one sentence: a request proposes locales, Catalogs decides
which of them exist, and anything unproposed, unparseable or unregistered
becomes the default.
use arcature::i18n::{Catalog, Catalogs, LocaleId, LocaleNegotiator, LocaleSource};
let catalogs = Catalogs::new(
Catalog::parse(LocaleId::parse("en").unwrap(), "hi = Hello").unwrap(),
)
.with(Catalog::parse(LocaleId::parse("fr").unwrap(), "hi = Bonjour").unwrap());
let negotiator = LocaleNegotiator::new(catalogs)
.query_parameter("lang")
.session_key("locale");
// A browser configured for French, with no explicit choice on record.
let locale = negotiator.resolve(None, None, Some("fr-CA,fr;q=0.9,en;q=0.4"));
assert_eq!(locale.id().as_str(), "fr");
assert_eq!(locale.source(), LocaleSource::Header);
// An explicit `?lang=` beats both the session and the header.
let locale = negotiator.resolve(Some("en"), Some("fr"), Some("fr"));
assert_eq!(locale.id().as_str(), "en");
assert_eq!(locale.source(), LocaleSource::Url);
// A hostile tag is not a candidate.
let locale = negotiator.resolve(Some("../../etc/passwd"), None, None);
assert_eq!(locale.source(), LocaleSource::Default);
Precedence
LocaleSource, most specific first:
| Source | Origin | Default state |
|---|---|---|
LocaleSource::Url | the query parameter named by .query_parameter(..) | off |
LocaleSource::Session | the session key named by .session_key(..) | off |
LocaleSource::Header | the request’s Accept-Language | always read |
LocaleSource::Default | nothing the request offered was registered | — |
Both overrides start off, and an override that was never configured is not an
override: an application that has not opted into ?lang= cannot have a user’s
locale changed by a link somebody emailed them. A URL parameter puts the locale
into every link a page emits and into every log line; a session key is a write
to storage the application owns. Neither should appear because a framework
assumed a name for it.
LocaleSource is #[non_exhaustive]. Match with a catch-all arm.
Two properties of the overrides worth knowing before you turn them on:
- The query value is taken verbatim, without percent-decoding. A
well-formed locale tag is
[A-Za-z0-9-]and needs none, so a percent-encoded tag fails validation and falls through to the next source. That is one decoder fewer between a request and a lookup. A repeated?lang=resolves to the last occurrence, matching whatserde_urlencodeddoes, so a parameter smuggled in ahead of the real one does not take precedence over it. - The session is read and never written. Persisting a choice is a decision
with a cookie and a lifetime attached, and it belongs to the handler that
offers the language switcher. Reading it needs the
authfeature, which is what bringstower-sessions; withoutauththe key is accepted and never consulted, so enablingauthlater does not change a call site. The value goes throughLocaleId::parselike everything else — “we wrote it, so it is fine” is how a validated field stops being validated.
Matching a proposed tag
Every proposed tag, from any source, goes through exactly two steps:
LocaleId::parse, which refuses anything that is not a canonical BCP-47 identifier of at most 35 bytes;- a lookup in
Catalogs, which is an in-memoryBTreeMapwhose keys came from the application’s own source at startup.
A tag that fails either is discarded and the next candidate is tried. On an
exact miss there is one further step: the first registered locale whose
language subtag equals the candidate’s language subtag. That matching runs on
an already-validated identifier, never on a prefix of the raw string — a
prefix match on raw bytes would make en/../../etc match en.
The consequence is a fallback in both directions:
| Registered | Requested | Selected |
|---|---|---|
en, fr, pt-BR | fr-CA | fr — a region falls back to its language |
en, fr, pt-BR | pt | pt-BR — a language falls back to a registered region |
en, fr, pt-BR | de-DE | en — the default |
en, fr, pt-BR | fr-, frx, fr.. | en — not a prefix match |
Candidates are tried in order, and each candidate is resolved exact-first then
by language. There is no pass that prefers an exact match somewhere later in
the list over a language match earlier in it: with fr and pt-BR registered,
Accept-Language: pt-PT, fr selects pt-BR, because pt-PT resolves by
language before fr is ever reached. When several registered locales share a
language subtag the first in canonical-tag order wins, so pt with both
pt-BR and pt-PT registered selects pt-BR.
Accept-Language
The header is parsed into weighted candidates, best first.
| Rule | Behaviour |
|---|---|
absent q | 1.0, per RFC 9110 |
q=0 | “not this one” — the entry is dropped, not ranked last |
malformed q | treated as absent rather than as zero |
q outside 0.0..=1.0 | treated as absent |
| equal weights | stable, so the client’s order is kept |
* | dropped; “anything” is what falling through to the default already does |
parameters after ; that are not q | discarded |
| whitespace and casing | tolerated |
| bytes read | the first 512; the rest of the header is ignored |
| entries read | the first 16; the rest are ignored |
The two bounds are why Accept-Language: en;q=0.1, repeated ten thousand times
followed by fr selects en: the fr at the far end is never seen. A real
browser sends well under 100 bytes and at most a dozen entries, so the bound
costs nothing real and removes the reason to walk a megabyte of padding.
Weights are compared as thousandths (q=0.8 is 800), so the sort is over
integers rather than over a partial order on f32.
A hostile entry does not take the rest of the header down with it:
../../etc/passwd;q=1.0,\0;q=0.95,fr;q=0.9 selects fr.
Installing the layer
LocaleLayer negotiates once per request, puts the Locale in the request’s
extensions, and annotates the response.
use arcature::i18n::{LocaleLayer, LocaleNegotiator};
Application::new()
.routes(routes())
.layer(LocaleLayer::new(
LocaleNegotiator::new(catalogs).query_parameter("lang"),
))
.build()
On the way out it sets two headers:
| Header | Behaviour |
|---|---|
Content-Language | the selected tag — only if the handler did not already set one |
Vary | Accept-Language appended to whatever was there, never duplicated; a Vary: * is left alone |
The Vary is not decoration. Without it a shared cache stores one
representation per URL and serves the French page to the next English reader,
which is a correctness bug at best and a privacy one as soon as a page contains
anything about the person who requested it. An existing
Vary: accept-language in any casing is recognised and left as it is.
An Accept-Language header whose bytes are not visible ASCII is treated as
absent rather than as an error: HeaderValue::to_str refuses it, and the
request is served in the default locale with a 200.
Where you install the layer decides what sees the locale. Layers added with
Application::layer(..) are stage 21 of the request pipeline — the innermost
stage, inside the session (stage 16) and inside Inertia (stage 18);
src/application/pipeline.rs has the full table. That is the right side of the
session, so the session override works. It is the wrong side of Inertia for one
path only:
| Path | Sees the locale from a user .layer() |
|---|---|
the Locale extractor | yes — an extractor runs after every layer on the route |
the Inertia extractor and inertia.render(..) | yes, for the same reason |
a handler returning Page<T>, rendered by InertiaLayer after the fact | no |
InertiaLayer reads the locale out of the extensions when it runs, which is
before a user layer. A deferred Page<T> render therefore loses its locale
prop and nothing else. To fix it, install LocaleLayer on a Router outside
InertiaLayer — with Router::layer the last layer applied is the outermost,
so LocaleLayer is the later call:
let app: Router = Router::new()
.route("/users", get(index))
.layer(InertiaLayer::new(config))
.layer(LocaleLayer::new(negotiator));
Reaching the locale from a handler
Locale is an extractor. It reads what the layer put in the extensions; it
does not negotiate:
use arcature::i18n::Locale;
use arcature::prelude::*;
async fn greet(locale: Locale) -> Result<Response> {
Ok(text(StatusCode::OK, locale.message("greeting")?))
}
| Method | Returns |
|---|---|
locale.id() | &LocaleId, the active tag |
locale.source() | LocaleSource — worth reading in a language switcher |
locale.is_default() | whether the locale was fallen back to rather than asked for |
locale.catalogs() | &Catalogs, every locale the application registered |
locale.catalog() | &Catalog for this locale; always present |
locale.message(key) | Result<String, I18nError> |
locale.translate(key, &args) | Result<String, I18nError> |
Cloning is cheap: the tag is an Arc<str> and the catalogs are behind an
Arc.
The extractor deliberately does not negotiate a locale of its own. Doing that
would need a Catalogs, which only the layer has, and it would make a route
that forgot the layer answer in a language nobody negotiated instead of saying
the wiring is missing. On a route with no LocaleLayer the extractor produces
I18nError::NotNegotiated, which becomes a 500. What the client sees is
covered under Error handling; the short version is that it
does not name the layer.
There is no OptionalFromRequestParts impl, so Option<Locale> is not an
extractor. A route either has the layer or does not.
From a view
With views on, translation happens in the template: give the template struct
a Locale field and call it.
#[derive(Template)]
#[template(
source = "<h1>{{ locale.message(\"hi\").unwrap_or_default() }}</h1>",
ext = "html",
askama = arcature::askama
)]
struct Greeting {
locale: arcature::i18n::Locale,
}
let response = view(Greeting { locale: locale.clone() })
.in_locale(&locale)
.into_response();
Two things to notice.
There is no {{ t("key") }} filter and there will not be one. Adding one
would mean a lookup the compiler cannot check, which is the opposite of the
reason the view layer compiles its templates at all. The cost is visible in the
example: a template cannot use ?, so a failed lookup needs
unwrap_or_default() or a field the handler resolved before rendering.
in_locale is a separate call, and Content-Language is absent without
it. The framework does not infer the header in either direction. A compiled
template carries no language — askama resolved it to write! calls — and the
locale LocaleLayer negotiated is what the request asked for, which is not
the same claim as what the bytes in this response are actually in. A handler
that renders a French template says so; one that renders a template it did not
translate says nothing, which is better than an untrue header.
LocaleLayer will not fill the gap either: it sets Content-Language only
when the response does not already carry one, and a View that never called
in_locale carries none, so the layer’s value is used. The two agree exactly
when the template really was rendered in the negotiated locale, which is the
claim in_locale exists to let a handler make explicitly.
From Inertia props
With inertia on, the renderer publishes the negotiated locale as a locale
prop:
{
"id": "fr",
"source": "header",
"available": ["en", "fr"]
}
source is "url", "session", "header" or "default". available is
every registered locale in canonical-tag order, which is the list a language
switcher needs. It is an object rather than a bare string so it can grow
without changing a shape a client already destructures.
The Inertia extractor exposes the same value to a handler that wants to
translate something itself: inertia.locale() returns
Option<&arcature::i18n::Locale>.
Three rules, and each is a rule about not acting:
| Situation | Behaviour |
|---|---|
the application already shares a locale prop | its prop wins, untouched |
a partial reload that did not name locale in X-Inertia-Partial-Data | no locale prop |
| a partial reload that did name it | the prop is sent |
no LocaleLayer, or the layer ran too late | no locale prop, and the page renders as it did before |
Overwriting an application’s own locale prop would break a working page on a
feature flag; adding an unrequested prop to a partial response is exactly the
payload growth partial reloads exist to avoid.
The prop is inserted after prop resolution and is not a field of any #[page]
struct, so it is outside the Client Exposure Firewall’s schema and arc typegen does not know about it. A generated page type will not have a
locale field. Declare it in the TypeScript by hand, or add your own
locale prop to the page struct, which then wins under the first rule above.
Security
Every claim here is a property of src/i18n/, checked against the source
rather than assumed from a type’s name.
There is no filesystem access
src/i18n/ performs no filesystem access at all. A search of the module for
std::fs, std::io, tokio::fs, File, Path, PathBuf, read_to_string
and Command returns one line, and it is a comment discussing the absence. The
whole module is args.rs, catalog.rs, error.rs, locale.rs,
negotiate.rs and mod.rs, and none of them opens anything.
Catalogs are values the application constructs and hands over. The registry is
an in-memory BTreeMap built at startup. Lookup is a map lookup against
Catalogs::contains, a whitelist whose entries came from the application’s own
source.
This matters because the classic way to lose is to turn a locale tag into
locales/{tag}.ftl and open it, at which point ../../etc/passwd and
..\..\..\windows\win.ini are one request away. There is no filesystem path
for a hostile tag to traverse because there is no filesystem access to
traverse it with. The property is structural, not defended.
A hostile or unregistered locale selects the default
That is the belt; LocaleId is the braces. It is the only locale type the API
accepts, its only constructor validates, and a request’s raw string cannot be
passed where a locale is expected without going through it.
Each of these is something a request can carry, and none of them parses, so
none of them is ever a candidate — from ?lang=, from the session, or from
Accept-Language:
../../etc/passwd ..\..\..\windows\win.ini /etc/passwd
C:\Windows\System32 en/../../etc/passwd %2e%2e%2f%2e%2e%2f
....//....//etc/passwd en\0 \0/etc/passwd
fr\nSet-Cookie: stolen fr\r\nX-Injected: 1 $(cat /etc/passwd)
`id` {{7*7}} <script>alert(1)</script>
en\u{202e} \u{feff}en "e" x 64000
In every case the outcome is the application’s default locale with
LocaleSource::Default — a value that was never derived from the input at
all. A well-formed but unregistered tag such as de-DE takes the same path.
src/i18n/negotiate.rs pins this in a test that walks the list above through
all three sources.
One entry deserves a note, because it looks like an exception and is not.
Accept-Language: en; rm -rf / selects en, with LocaleSource::Header. That
is not a hostile tag being accepted: ; starts a parameter list, so the
language range is en and the rest is a parameter that is not a q and is
discarded, which is what RFC 9110 says the field means. The en still goes
through LocaleId::parse and the whitelist like any other candidate, and
nothing after the ; survives into the result. The same bytes offered as a
whole tag through ?lang= are refused.
The Fluent parser runs over developer-authored catalogs
The view layer chose askama specifically so that no template parser runs inside the request path. This module adds a runtime parser. The tension is real and the answer is that the two parsers eat different food.
A catalog is developer-authored and lives in the repository. The .ftl
text passed to Catalog::parse is a file a translator wrote and a reviewer
merged. It does not arrive over the network and it is not selected by anything
a request controls. In the intended use it is include_str!, so the bytes are
in the binary and the parse is a startup cost.
A request supplies arguments, not messages. Values from a request reach
Fluent as ArgValues — a string, an integer, a float — and Fluent
interpolates them. It does not evaluate them: there is no path by which a
$name of { $other } becomes a placeable, because the message’s pattern was
fixed when the catalog was parsed and an argument is substituted into that
pattern rather than re-parsed with it. Fluent has no property lookup on host
objects, no filesystem access and no eval, and a catalog here can invoke no
functions at all: NUMBER and DATETIME exist in Fluent but require
FluentBundle::add_builtins, which Catalog::parse never calls. The machinery a template-injection
payload needs is not there to reach.
That leaves one rule this module holds itself to, and it is the thing to check
in review: never call Catalog::parse on bytes that came from a request. A
feature that let an administrator upload a .ftl file, or that read a catalog
out of a database row, would put attacker-influenced text into the parser and
would need its own analysis. Nothing here does that, and nothing here offers a
way to.
Errors quote the catalog, never the request
Two rules shape what I18nError is allowed to carry.
A rejected locale tag is never quoted back. LocaleRejection says what was
wrong and never what was sent, in three coarse variants — Empty, TooLong,
NotWellFormed. The string that failed validation is the one place in this
subsystem where a request’s bytes arrive unexamined, and writing it into an
error’s Display puts it one tracing::warn! away from a log line. A log line
is a text format with no escaping: a tag containing \n writes a second entry,
and a tag containing a terminal escape sequence is read by whoever cats the
file. The three variants are enough to debug a developer’s own typo.
A message key is not a secret, and a translated string may be. A key is a
constant in the application’s source, so naming one in an error is safe and
useful. The formatted value is not: it can carry whatever the caller
interpolated. I18nError::Format therefore reports Fluent’s diagnostics and
never the partially formatted output — Fluent’s recovery for an unresolved
placeable is to emit the placeable’s own source text, so what it hands back on
the error path is a string with { $count } in it, and a caller holding a
String will put it on a page.
Error handling
I18nError has five variants:
| Variant | Cause |
|---|---|
InvalidLocale(LocaleRejection) | a string was refused as a locale tag; the string is not carried |
Parse { locale, errors } | an .ftl source did not parse, or its messages collided with ones already in the catalog |
Missing { locale, key } | neither the locale’s catalog nor the default one has the key |
Format { locale, key, errors } | the message exists but could not be formatted |
NotNegotiated | a handler asked for Locale on a route without LocaleLayer |
Parse is a developer error by definition: the source is a file in the
repository, so a catalog that fails here fails on the first request after a
deploy, for everyone, identically. NotNegotiated is wiring, not input: it is
the same for every request that reaches that route and it is fixed in one line
of the router.
From<I18nError> for arcature::Error produces Error::Other("translation failed"), which answers status 500, code internal_error, for all five
variants. The key, the locale and Fluent’s diagnostics are dropped, because
Error’s IntoResponse writes its Display text into the detail field of
the problem document outside production — so anything left in it is one
APP_ENV away from the wire, and a message key is a fragment of the
application’s source tree.
The detail goes to tracing::error! instead. tracing arrives with
observe, and i18n does not imply it. In a build with i18n and without
observe the diagnostics are discarded with nothing recorded anywhere: the
client gets its uninformative 500 and the operator gets silence. This is the
same trap Views documents, and the same advice
applies — if you enable i18n, enable observe.
Concretely, a missing LocaleLayer on a route produces a 500 whose body says
translation failed and mentions neither the layer nor negotiation. There is a
test asserting exactly that. Without observe, nothing anywhere says what went
wrong.
unsafe in the dependency tree
arcature is #![forbid(unsafe_code)]. Its dependencies are not, and two
facts about this feature belong in the open.
fluent-bundle pulls in self_cell, which contains unsafe. self_cell
is how FluentResource holds a String of .ftl source together with an AST
that borrows from it — a self-referential struct, which safe Rust cannot
express, so the crate builds one with a small amount of unsafe and a
well-known soundness argument. It is not incidental: it is the reason parsing a
catalog does not copy every string out of the source. Enabling i18n accepts
that.
The rest of the subtree is pure Rust with no C, no network and no filesystem access:
| Crate | Role |
|---|---|
fluent-syntax | the .ftl parser |
fluent-langneg | language negotiation primitives |
intl-memoizer | caches per-locale formatters |
intl_pluralrules | the CLDR plural-category tables |
unic-langid, unic-langid-impl | the BCP-47 identifier type |
type-map, rustc-hash, smallvec | containers |
self_cell | contains unsafe |
The cargo geiger baseline does not change, and that is not a claim that
nothing was added. baselines/unsafe-baseline.<host-target>.txt is recorded by just geiger, which runs cargo geiger --all-targets over the default feature
set. i18n is not in default, so self_cell is outside the graph the
baseline measures and the file is byte-identical. A reader who expected the
number to move should know why it did not, rather than conclude the dependency
is free: an application that turns i18n on takes on self_cell’s unsafe,
and that is not visible in the recorded numbers.
What this does not do
Collected, because a chapter that lists only what works is useless to somebody deciding whether to depend on this.
- No catalog loading of any kind. No directory scan, no
locales/convention, no reload.Catalog::parsetakes a&strand the application decides where it came from. This is the security property, not an omission to be fixed. - No hot reload. A catalog compiled in with
include_str!changes when the crate is rebuilt.arc devwatches.rs,Cargo.toml,Cargo.lockand.env*, so saving an.ftlfile triggers nothing — but theinclude_str!makes rustc track it, socargo buildor touching any.rspicks it up. - No fallback for attributes.
Catalogshasmessageandtranslateand noattribute. An attribute is only reachable throughCatalog::attribute, on one catalog, so an untranslated.placeholderisI18nError::Missingrather than the default catalog’s text. Message-level fallback does not extend to it. - No session write. The negotiator reads the session key and never sets it. A language switcher that should persist a choice writes the session itself.
- No cookie source.
Accept-Language, a query parameter and a session entry are the three sources. Alocalecookie is not one of them; read it in a handler and write the session, or put the tag in the query. - No
Option<Locale>extractor, and no negotiation inside the extractor. A route without the layer answers500. - No prelude entry. Import from
arcature::i18n::. - No
DeserializeforLocaleId. Take aStringandparseit. - No
Content-Languagefrom a view unless you call.in_locale(..), and no inference of a rendered page’s language from the negotiated one. - No
{{ t("key") }}template filter, by the same argument that made the view layer compile its templates. - No
localefield in generated TypeScript. The Inertia prop is added after prop resolution and is not part of any page contract. - No number or date formatting API of its own.
NUMBERandDATETIMEinside a Fluent message are the whole surface; there is nolocale.format_currency(..). - No diagnostics at all without
observe. Every failure becomes the same generic500, and the reason is dropped rather than logged. LocaleNegotiator::resolvetruncatesaccept_languageat 512 bytes on a byte boundary. ThroughLocaleLayerthe value has already passedHeaderValue::to_str, which admits only visible ASCII, so the cut is always a character boundary. Callingresolvedirectly with a longer non-ASCII string can land the cut mid-character and panic. The layer cannot reach it; a test harness callingresolveby hand can.
Database
One PostgreSQL pool, two first-class paths. SeaORM and SQLx share the same
PgPool through SqlxPostgresConnector::from_sqlx_postgres_pool. There is no
second pool, no global registry, and no thread-local.
The Db handle is Clone + Send + Sync + 'static, so it lives in Axum state
like any other value.
Connecting
use arcature::database::{DatabaseConfig, Db, PoolConfig};
let config = DatabaseConfig::new(&std::env::var("DATABASE_URL")?)?
.pool(PoolConfig::new().max_connections(20))
.application_name("acme");
let db = Db::connect(config).await?;
Application::database(config) does this for you at startup and hands the
handle to the state closure as resources.db().
SessionConfig sets per-connection PostgreSQL timeouts —
statement_timeout, lock_timeout,
idle_in_transaction_session_timeout — applied when a connection is
established. SessionConfig::none() opts out.
db.orm() borrows the SeaORM DatabaseConnection; db.sqlx() borrows the
PgPool. db.ping() checks liveness; db.close() shuts the pool down.
Reaching the handle from a handler
Db is not an Axum extractor. It comes out of state:
use arcature::axum::extract::State;
pub async fn index(State(state): State<AppState>) -> Result<Response> {
let db = state.db.as_ref().ok_or_else(|| not_found("no database"))?;
let users = user::Entity::query(db).all().await?;
Ok(json(&users))
}
The generated application’s AppState holds db: Option<Db>, because a
subsystem that was never configured contributes None rather than a panic.
Models
A model is an ordinary SeaORM entity. SeaORM is re-exported as
arcature::database::sea_orm, so there is no second version to keep in step:
pub mod user {
use arcature::database::sea_orm::entity::prelude::*;
use arcature::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub email: String,
pub name: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
The struct must be named Model and must live in its own module: that is
SeaORM’s requirement, not Arcature’s. DeriveEntityModel generates Entity,
Column and PrimaryKey beside it.
The short path: #[model(table = "...")]
#[model] writes the module above for you. It expands to a private module
holding a struct named Model – which is the name SeaORM’s
DeriveEntityModel requires – and re-exports the family under predictable
names in the parent scope:
#[model(table = "users")]
pub struct User {
#[sea_orm(primary_key)]
pub id: i64,
pub email: String,
}
yields User, UserEntity, UserActiveModel, UserColumn, UserPrimaryKey
and UserRelation. arc make:model generates exactly this.
The generated Relation enum is empty and there is no syntax to fill it: the
enum lives inside the generated module, which an application cannot write
into. A model that needs relations is written as a plain SeaORM entity module
instead. #[model] is the short path, not the only one, and nothing else in
the database layer depends on it – the query facade is a blanket impl over
every SeaORM entity, so a hand-written entity gets it for free.
A model is a database row. It is not a #[resource] and not browser-safe by
virtue of deriving Serialize; converting it for the browser is an explicit
impl From<User> for UserResource. See Inertia.
Querying
Every SeaORM entity gains Entity::query(&db) through the blanket
QueryModel impl. Bring the trait into scope with
use arcature::database::QueryModel;:
let recent = user::Entity::query(&db)
.where_eq(user::Column::Active, true)
.where_not_null(user::Column::VerifiedAt)
.latest()
.limit(20)
.all()
.await?;
let one = user::Entity::query(&db)
.where_eq(user::Column::Email, "ada@example.com")
.one()
.await?;
The predicates are where_eq, where_ne, where_gt, where_lt,
where_in, where_null, where_not_null, and filter for a raw SeaORM
condition. Ordering is latest, latest_by, oldest, order_by_asc,
order_by_desc. limit and offset bound the window. The terminators are
all, one and count.
paginate(per_page) returns a Paginated; page(n) fetches a 1-indexed
page, and page_with_count(n) fetches the page and the grand total in two
statements.
Writing
The CRUD free functions take the ActiveModel:
use arcature::database::{delete, find_by_pk, insert, update};
let created = insert(&db, user::ActiveModel { .. }).await?;
let changed = update(&db, active).await?;
delete(&db, active).await?;
let found = find_by_pk::<user::Entity>(&db, 1).await?;
Transactions
Transaction is a namespace, not a value. Both paths commit on Ok and roll
back on Err:
use arcature::database::Transaction;
Transaction::orm(&db, |txn| Box::pin(async move {
// SeaORM calls against `txn`
Ok(())
})).await?;
Transaction::sqlx(&db, |txn| Box::pin(async move {
sqlx::query("update accounts set balance = balance - $1")
.bind(100)
.execute(&mut **txn)
.await?;
Ok(())
})).await?;
The closure returns a pinned boxed future because the borrow of the transaction has to outlive the call; that is the price of not owning the transaction type.
Route model binding
Bound<T> loads a model from a route parameter before the handler runs:
#[arcature::route_model(entity = link::Entity, key = "id", key_type = i64)]
pub struct Link(pub link::Model);
pub async fn show(link: Bound<Link>) -> Result<Response> {
let link = link.into_inner();
Ok(json(&link.0.id))
}
The extractor reads KEY_PARAM from the path, parses it (400 on failure),
calls RouteModel::load (500 on a database error), and returns a 404 problem
when the row is absent.
Binding does not imply authorization. Bound<T> proves the row exists; it
does not check that this user may see it. That check is a Policy, and it is
a separate, explicit step. The invariant is permanent and is restated in the
source of both bound.rs and route_model.rs.
Bound<T> obtains its Db through DbFromState<S>, an Arcature trait
rather than axum::extract::FromRef — FromRef and Db are both foreign
types, so the blanket impl would collide with the orphan rule. An application
writes one line:
impl DbFromState<AppState> for Db {
fn db_from_state(state: &AppState) -> Db {
state.db.clone().expect("database configured")
}
}
For a lookup that is not a primary-key find_by_id — a slug scoped to a
tenant, say — write the impl RouteModel by hand. The macro covers the
common case and stops there.
Migrations
Migrations are SeaORM migrations. The module wraps the four operations
against a MigratorTrait schema:
use arcature::database::migration;
migration::up::<Schema>(&db).await?;
migration::down::<Schema>(&db, 1).await?;
migration::fresh::<Schema>(&db).await?;
let status = migration::status::<Schema>(&db).await?;
From the CLI: arc migrate, arc db:fresh, arc db:reset, arc db:seed,
and arc make:migration to scaffold one.
Drivers
The database feature splits three ways — db-postgres, db-sqlite,
db-mysql — so a SQLite application does not compile the PostgreSQL wire
protocol. The connection type above is PostgreSQL-specific, and the job queue
requires PostgreSQL because it depends on FOR UPDATE SKIP LOCKED. See
Jobs.
No ORM rewrite
Arcature does not own, reimplement, or rename SeaORM’s query builder,
relation engine, or transaction system. Query is a thin facade over
Select; when it runs out, db.orm() and db.sqlx() are right there. The
cost is that two query vocabularies exist in one codebase, which is a
smaller cost than a half-built ORM.
Cache
One multiplexed Redis/Valkey connection per Cache. Not a pool — redis-rs’s
MultiplexedConnection is cheap to clone, thread-safe, and multiplexes
commands over a single socket, so a second pool would buy nothing.
Cache is a value with methods. There is no Cache::remember(&cache, ..)
static form; the handle comes from state and you call it directly.
Connecting
use std::time::Duration;
use arcature::cache::{Cache, CacheConfig, Namespace};
let config = CacheConfig::new(&std::env::var("REDIS_URL")?)?
.response_timeout(Duration::from_secs(2))
.namespace(Namespace::new("acme")?);
let cache = Cache::connect(config).await?;
Application::cache(config) does this at startup. cache.ping() checks
liveness; cache.close() shuts it down.
CacheConfig never logs the password or the full URL — its Debug impl is
written by hand to redact them.
max_payload_size(Some(bytes)) caps what a single value may store.
Namespaces
Namespace::new("acme") prefixes every key as acme:key. It rejects an
empty prefix, a prefix ending in :, and control characters.
Namespace::none() is the explicit opt-out. It is a distinct value rather
than None at the type level, so an unprefixed cache is a decision someone
made rather than a field nobody filled in.
Operations
| Method | Does |
|---|---|
get::<T>(key) | JSON value, None when absent |
set(key, &value) | store JSON, no expiry |
put(key, &value, ttl) | store JSON with a TTL |
get_bytes(key) / set_bytes(key, bytes) | raw bytes |
set_bytes_with_ttl(key, bytes, ttl) | raw bytes with a TTL |
forget(key) | delete; returns how many keys went |
exists(key) | presence |
incr(key, delta) / decr(key, delta) | atomic counters |
expire(key, ttl) / ttl(key) | expiry control |
set_if_absent(key, bytes) | atomic compare-and-set |
set_if_absent_with_ttl(key, bytes, ttl) | the same, with a TTL |
Cache-aside
use std::time::Duration;
let user = cache
.remember("user:42", Duration::from_secs(300), || async {
load_user(42).await
})
.await?;
remember reads the key, and on a miss runs the loader, stores the result
with the TTL, and returns it.
The loader’s error type must implement Into<CacheError>. That is the part
worth checking before you write the closure: a loader returning
sqlx::Error needs a conversion, or the closure needs to map it.
A miss is not an error. A backend failure is, and it does not run the
loader. Arcature does not decide fail-open semantics on your behalf: if you
want the loader to run when Redis is unreachable, handle CacheError::Backend
explicitly before calling remember. Silently degrading to the database under
load is how a cache outage becomes a database outage.
What this module does not own
No Redis protocol reimplementation, no cache server, no connection pool, no
TLS stack, no distributed-lock subsystem, no client-side caching layer. The
redis-rs crate is re-exported as arcature::cache::redis when you need it
directly.
Storage
Object and file storage over OpenDAL, behind a named-disk registry.
Storage is a value, not a namespace. Storage::disk("s3") is an instance
method on a handle you got from state — there is no static Storage::disk.
Configuring
Single backend:
use arcature::storage::{Storage, StorageConfig, S3Config};
let storage = Storage::connect(StorageConfig::fs("storage/app")?).await?;
Storage::connect registers the backend as a disk named "default".
Several disks:
let storage = Storage::builder()
.disk("local", StorageConfig::fs("storage/app")?)
.disk("s3", StorageConfig::s3(
S3Config::new("acme-uploads")?
.region("eu-west-1")
.access_key_id(std::env::var("AWS_ACCESS_KEY_ID")?)
.secret_access_key(std::env::var("AWS_SECRET_ACCESS_KEY")?),
))
.default_disk("local")
.connect()
.await?;
S3Config redacts the access key id and the secret in its Debug impl.
Application::storage(config) wires the single-backend path at startup.
Disks
| Call | Behaviour |
|---|---|
storage.disk("s3") | the named disk; panics if it was never registered |
storage.try_disk("s3") | Option<Disk> |
storage.default_disk() | the disk named by default_disk, or "default" |
storage.disk_names() | what is registered |
disk panics deliberately: a disk name is a deployment constant, and a typo
should stop the process at the first use rather than return an error every
handler forgets to check. try_disk is there when the name really is dynamic.
Disk is cheap to clone — the OpenDAL Operator inside it is Arc-backed.
Paths
Every data-path method takes a &StoragePath, not a &str. Constructing one
is where the validation happens:
use arcature::storage::StoragePath;
let path = StoragePath::new("avatars/1.png")?;
storage.disk("s3").put(&path, &bytes).await?;
Rejected: empty keys, absolute paths (/etc/passwd), any .. segment,
backslashes, ASCII control characters, and empty segments (a//b).
Allowed: trailing slashes, because they are meaningful as list prefixes; Unicode of all kinds; dots inside a segment.
The check runs before any storage work does, so a traversal attempt fails at the type boundary rather than at the backend.
Operations
All on Disk:
let disk = storage.disk("local");
disk.put(&path, &bytes).await?;
let bytes: Bytes = disk.get(&path).await?;
let present: bool = disk.exists(&path).await?;
let meta = disk.stat(&path).await?;
let entries = disk.list(&prefix).await?;
disk.copy(&from, &to).await?;
disk.rename(&from, &to).await?;
disk.delete(&path).await?;
For large objects, disk.reader(&path) and disk.writer(&path) return the
OpenDAL Reader and Writer and stream rather than buffering.
Disk::from_operator(operator) is the escape hatch when you want to
configure the OpenDAL operator yourself; disk.operator() borrows it back.
Public files
arc storage:link links storage/app/public into public/storage, the
Laravel convention, so files written to the local disk under public/ are
served as static assets.
What this module does not own
No object-storage protocol implementation, no S3 signing, no AWS credential
machinery, no multipart engine, no TLS. OpenDAL owns the protocol layer, the
certified rustls plus aws-lc-rs stack owns TLS, Tokio owns the runtime. The
crates are re-exported as arcature::storage::opendal and
arcature::storage::bytes.
Uploads
multipart/form-data request bodies: bounded, sanitized, held to their own
bytes, and written to a storage disk under a key derived from those bytes.
Off by default, and that is the security decision rather than a packaging one. An upload endpoint is the largest attacker-authored surface a web application has — the filename, the declared content type and the byte count all come from the client — and a build with no upload route has no business carrying a multipart parser for one.
Turning it on
uploads is not in default. It is in fullstack.
arcature = { version = "0.1", features = ["uploads"] }
| It pulls | Because |
|---|---|
axum/multipart | the parser (multer, via axum::extract::Multipart) |
validation | the extractor lives beside the other validated extractors and reports RFC 9457 problem details |
storage-fs | an upload is written to a storage disk, never to a path the request named |
dep:tokio | one thing only: tokio::time::timeout. A byte cap cannot express “the client stopped sending”, because a request that never finishes never exceeds anything |
unicode-normalization | an attacker-authored filename goes into NFC before the reserved-name and extension checks look at it |
sha2 | the content address |
infer | the magic-number table |
arc make:upload avatar generates a controller with the whole shape in it.
The UploadedFile extractor
use arcature::prelude::*;
use arcature::storage::StorageError;
use arcature::validation::upload::UploadedFile;
/// The disk sub-tree these uploads live under. A constant, and the
/// application's own string -- nothing off the request is ever a prefix.
const PREFIX: &str = "avatars";
pub async fn store(State(state): State<AppState>, upload: UploadedFile) -> Result<Response> {
let storage = state
.storage
.as_ref()
.ok_or_else(|| Error::Storage("no storage disk is configured".to_string()))?;
let address = upload.store_under(&storage.default_disk(), PREFIX).await?;
let key = address.path_under(PREFIX).map_err(StorageError::from)?;
Ok((
StatusCode::CREATED,
json(serde_json::json!({
"path": key.as_str(),
"bytes": address.byte_len(),
"filename": upload.filename().to_string(),
})),
)
.into_response())
}
That is the generated blueprint’s shape, not a standalone example: it needs an
AppState with a storage field and a route to hang off, so it will not
compile on its own.
Five things are true by the time the handler body starts, because extraction is what makes them true:
- Bounds. Total size, per-part size, part count and a read timeout, from
the
MultipartLimitsin the request extensions. - A whitelisted extension, from the
UploadPolicyin the request extensions. - A sanitized filename. The path, the controls, the bidi overrides and the Windows device names are gone, and the result is metadata.
- Bytes that match the extension.
shell.phprenamedavatar.pngis refused here, not discovered later. - A client-safe rejection, if any of the above failed.
UploadedFile implements FromRequest, not FromRequestParts. It consumes
the body, so it has to be the last handler argument.
| Method | Returns |
|---|---|
filename() | &SafeFilename — metadata: show it, send it in a Content-Disposition, never resolve it as a path |
content_type() | Option<SniffedType>, derived from the object, never from the part’s header |
bytes() | &Bytes |
byte_len() | usize |
into_bytes() | Bytes, consuming the wrapper |
store(&disk) | writes under ab/cd/<digest>.<ext> |
store_under(&disk, prefix) | writes under <prefix>/ab/cd/<digest>.<ext> |
Both store methods return a ContentAddress and fail with UploadError,
which has exactly two variants: UploadError::Storage (the backend failed —
a 5xx) and UploadError::Content (the bytes and the extension disagree — a
4xx). They are kept apart so that a disk that is down and a file that was
refused do not answer with the same status. From<UploadError> for Error
preserves the split, which is why the example uses ? and not a map_err.
store_under writes under the prefix, so address.path() — the address on
its own — will not find the object afterwards. address.path_under(PREFIX)
is the key that will.
What a refusal looks like
Every refusal is an RFC 9457 problem document shaped exactly like any other
field validation failure, reported under the fixed key file
(upload::UPLOAD_FIELD) and never under the part’s own name.
| Code | Status | When |
|---|---|---|
missing | 422 | the body ended without a part that carried a filename= the policy would accept |
extension | 422 | the filename could not be sanitized, or its extension is off the whitelist |
contents | 422 | the bytes and the extension disagree |
A body that is not multipart/form-data at all is a 415. A bound that was
crossed is a 413 or a 408 — see MultipartLimits.
The code and the message are both fixed strings chosen in the framework.
Nothing from the request reaches the response body: not the filename, not the
declared content type, and not the sniffed one — reporting the last of those
would let an uploader use the endpoint as a free file-type oracle.
It buffers, and that is the small-file path
An extractor runs to completion before the handler is called, so it has
nowhere to put the bytes except memory. That buffer is bounded by
MultipartLimits::field_bytes, which defaults to 8 MiB; lowering it on an
upload route is how an application keeps the buffer small.
For anything bigger than an avatar, do not use this extractor. Drive
BoundedMultipart in the handler and write each chunk through UploadWriter,
which never holds more than one chunk regardless of object size. The extractor
is the convenience; that is the load-bearing path.
It is not authorization
A validated upload is not an authorized upload, exactly as a validated request is not an authorized request. Whether this user may write to this disk under this prefix is a separate, explicit decision.
UploadPolicy and AllowedExtensions
UploadPolicy is a tower::Layer. Applying it to a route puts the policy in
that request’s extensions, where the extractor reads it back.
use arcature::storage::AllowedExtensions;
use arcature::validation::upload::UploadPolicy;
let router = axum::Router::new()
.route("/attachments", axum::routing::post(store))
.layer(UploadPolicy::new(AllowedExtensions::documents()).with_field("attachment"));
With no policy layer installed, the default is images and nothing else.
UploadPolicy::from_extensions never returns None; it falls back to
UploadPolicy::default(), which is UploadPolicy::new(AllowedExtensions::images()).
A route that forgot the layer fails closed, because “no policy configured”
must not mean “anything goes”. Widening the whitelist is the thing an
application has to do on purpose.
| Built-in | Extensions |
|---|---|
AllowedExtensions::images() | jpg, jpeg, png, gif, webp |
AllowedExtensions::documents() | pdf, txt, csv |
AllowedExtensions::new(["JPG", "pdf"])? | whatever is listed, lowercased; errors on an entry that is not a valid extension |
AllowedExtensions::default() | empty — and an empty whitelist stores nothing at all, which is the right behaviour for a misconfiguration |
svg is deliberately absent from images(). An SVG is an XML document that
may carry script, so serving one inline is a stored-XSS primitive; an
application that needs SVG adds it knowingly with .with("svg")?.
It is a whitelist and never a blacklist. A blacklist of dangerous extensions
is a list of the ones somebody thought of, and the interesting ones are always
the other ones — .phtml, .php7, .cgi, .jsp, .svgz, .htaccess.
The rest of the surface: contains(&extension), iter(), len(),
is_empty(), and with(extension) to add one.
An Extension is one to sixteen ASCII alphanumerics, lowercased on parse, and
nothing else. That is not tidiness. An extension is the string a web server,
an operating system and a browser each use to decide what a file is, and
allowing only [0-9a-z]{1,16} leaves them no encoding, no separator and no
homoglyph to disagree about.
UploadPolicy::with_field("attachment") requires the file to arrive in the
part of that name. Without it the first part carrying a filename= wins,
which is convenient and slightly sloppy: a form with two file inputs becomes
order-dependent.
Filenames are sanitized, and then they are metadata
SafeFilename::parse(filename, &allowed) is the one place a client-authored
filename= is disarmed. It handles six different attacks, and a filter that
knows about one of them is a filter that has not been written yet.
| Input | What it wants | What happens |
|---|---|---|
../../etc/passwd.png | escape the storage root | the directory part is discarded before anything else runs; the name is passwd.png |
| a filename with a NUL in it | truncate the name in a C API downstream | rejected: control characters are never repaired |
CON.png | open a Windows device instead of a file | rejected, with any extension and with trailing dots or spaces |
| a name with a right-to-left override in it | render so the extension reads .jpg | rejected: bidi and other invisible format controls are fatal |
shell.php.jpg | be served by a mis-configured AddHandler | the inner extension marker is replaced: shell_php.jpg |
| an ordinary Vietnamese filename with diacritics | nothing | accepted, NFC-normalized, otherwise unchanged |
That last row is why this exists at all. StoragePath::new validates and
rejects; it has no opinion about how to repair a name, so a filename with a
space or a diacritic in it fails a check that was never aimed at it, and a
sanitizer that rejects real names teaches applications to bypass the
sanitizer.
The reject-versus-repair split is deliberate. A character is repaired
(replaced with _) when a human plausibly typed it and it is only dangerous
to a downstream parser: . (every remaining one, after the extension is split
off), /, \, :, *, ?, ", <, >, |. A character is rejected
when its presence is itself the attack: the C0 and C1 controls, DEL, the bidi
overrides and isolates, the zero-width joiners, the word joiner, the
byte-order mark, the blank-rendering separators and the Unicode tag block.
Nobody names a holiday photo with a right-to-left override in it. The
variation selectors are deliberately not fatal — they occur beside emoji in
names people really have.
The whole name is fitted inside MAX_FILENAME_BYTES (255, the per-component
limit on ext4, XFS, APFS and NTFS alike) by truncating the stem on a character
boundary. The extension is never truncated: half an extension is a different
file type.
FilenameError is the coarse reason the parse failed — Empty, TooLong,
ControlChar, Traversal, MissingExtension, InvalidExtension,
ExtensionNotAllowed, EmptyStem, ReservedName. It is coarse on purpose:
the caller reports a fixed string to the client, never the offending input.
The name that comes out is still metadata. StoragePath::from_filename exists
for the case where a sanitized name really is the key, and it produces exactly
one path segment — but two users still collide on it, and one overwrites the
other. Where the key does not have to be human-readable, use the content
address and keep the filename for the download header.
MultipartLimits
A multipart body is a stream the client writes and the server reads until the client stops. Four separate things there are the client’s choice, and each is a separate way to exhaust a process.
| Setting | Default | Const | What it stops |
|---|---|---|---|
fields | 32 | DEFAULT_FIELDS | fifty thousand two-byte parts inside a 1 MiB body — the cost of a part is a header parse and an allocation, not its length |
field_bytes | 8 MiB | DEFAULT_FIELD_BYTES | one part that is the entire budget. Deliberately below the total |
total_bytes | 16 MiB | DEFAULT_TOTAL_BYTES | one request that fills the disk or the heap |
read_timeout | 30 s | DEFAULT_READ_TIMEOUT | a byte a minute, holding a task and a socket open |
These apply with no layer installed. MultipartLimits::from_extensions
never returns None; it falls back to MultipartLimits::new(), the values
above. There is no way to end up with no bound, and nothing here reads any
value as unlimited — with_fields(0) means the body may contain no parts at
all, and that is what it does.
Override them per route:
use std::time::Duration;
use arcature::http::multipart::MultipartLimits;
let router = axum::Router::new().route(
"/avatar",
axum::routing::post(store).layer(
MultipartLimits::new()
.with_total_bytes(2 * 1024 * 1024)
.with_field_bytes(2 * 1024 * 1024)
.with_fields(4)
.with_read_timeout(Duration::from_secs(10)),
),
);
Readers: total_bytes(), field_bytes(), fields(), read_timeout().
The part count is checked before the read, so the part past the cap is refused rather than parsed and then refused. Byte counts are checked as soon as the chunk that crosses a cap arrives, so nothing past a cap is ever handed to the caller.
MultipartError | Status |
|---|---|
TooManyFields { limit } | 413 |
FieldTooLarge { limit } | 413 |
BodyTooLarge { limit } | 413 |
ReadTimeout { after } | 408 |
Parse { source } | whatever axum maps the parser error to |
MultipartError::problem() builds the RFC 9457 document. Its detail is a
fixed per-category string; axum’s MultipartError::body_text is deliberately
not used, because it can carry the parser’s own message, which quotes header
bytes the client wrote.
The streaming path
BoundedMultipart::new(multipart, limits) wraps axum’s parser rather than
replacing it. Every read goes through the timeout, every part is counted
before it is handed out, and every chunk is added to both a per-part and a
whole-body total.
let mut multipart = BoundedMultipart::new(multipart, limits);
while let Some(mut field) = multipart.next_field().await? {
while let Some(chunk) = field.chunk().await? {
upload.write(chunk).await?;
}
}
Parts are handed out one at a time and borrow the parser. That is multer’s
requirement, not a choice made here: part n+1 cannot be read before part n
has been consumed.
BoundedField offers name(), file_name() (raw and unsanitized, exactly as
the client wrote it), declared_content_type(), byte_len(), chunk(),
bytes() (consuming, bounded by field_bytes, and meant for the small text
inputs beside the file) and text() (which returns Ok(None) rather than an
error when the part is not UTF-8, because that is the caller’s schema failing,
not the transfer). BoundedMultipart itself reports limits(),
fields_read() and bytes_read().
Content-addressed storage
The object key is SHA-256(bytes) plus a whitelisted extension, split into
two levels of fan-out:
ab/cd/abcd...9824.png
Path traversal is not one bug, it is a family of them, and the family is large
because the input is a string whose structure the attacker chose: ../,
..\, %2e%2e%2f, ....//, a NUL that truncates the name in the C library
underneath, an NTFS alternate data stream, a symlink the previous upload
planted. A sanitizer answers those one at a time and is only ever as complete
as the last person who thought about it. Content addressing answers all of
them at once, structurally: no byte of the request reaches the path. There
is nothing left for a traversal payload to be in.
Three things fall out of it. The same bytes uploaded twice land on the same key and occupy the storage once. Re-uploading is idempotent rather than a race between two writers for one name. And a filesystem disk gets 65,536 leaf directories instead of one directory with a million entries in it.
ContentAddress carries digest() (64 lowercase hex characters,
DIGEST_HEX_LEN), byte_len(), extension(), path() and
path_under(prefix). ContentAddress::of(bytes, extension) addresses a
buffer already in memory; ContentHasher (new, update, byte_len,
finish) does it a chunk at a time.
The original filename is not part of any of this. It is a label carried
alongside the object, rendered in a Content-Disposition header and shown in
a UI, and resolved by nothing. Keep both layers: the sanitizer makes the
metadata safe to display, and content addressing makes the path safe to
resolve. Neither substitutes for the other.
Staging, because the key is not known until the end
The digest exists only after the last byte has gone past, so bytes go to a
unique transient key under STAGING_PREFIX (_staging) first and are moved
onto the content-addressed key afterwards. The staging key is process id,
wall-clock nanoseconds and a process-local counter — never anything the
client sent.
let mut upload = disk.begin_upload_under("avatars").await?;
upload.write(chunk).await?;
let address = upload.finish_verified(Extension::parse("png")?).await?;
begin_upload() puts the object at the root; begin_upload_under(prefix)
validates the prefix before a byte is written, rather than after the whole
body has been streamed to a key that turns out to have nowhere to go.
UploadWriter::write sends the chunk to the backend and to the hasher and
then drops it. Nothing accumulates: an upload of any size costs one chunk of
memory, which is what makes the size caps a policy rather than the only thing
standing between a request and the heap. The one exception is the sniff
buffer, at most 512 bytes, readable as head().
finish(extension) closes the object — the close is what completes it on an
S3-compatible backend, and it happens before the move — then moves it onto
its content-addressed key. If something is already at the destination the
staging copy is deleted instead. That is not an optimization: the key is the
digest, so an object already there has exactly these bytes, and overwriting it
would be a no-op that can still fail (on Windows a rename over a file another
reader has open does).
finish_verified(extension) is the call an upload handler wants. It re-checks
the leading bytes against the extension and, on disagreement, removes the
staging object before returning the error, so a rejected upload costs no
disk.
abort() drops the staging object. Neither finish nor abort is
optional. Dropping an UploadWriter without calling one of them leaves the
staging object behind, and on an S3-compatible backend leaves the multipart
upload un-closed. There is no Drop that can fix that, because both fixes are
async.
It is not access control
A digest is unguessable in practice, but “unguessable URL” is not authorization — it leaks through referrers, logs and browser history like any other URL. Authorize the download.
The declared Content-Type is carried and never believed
A multipart part arrives with two claims about its type, and the client wrote
both of them. Content-Type: image/png is a string in a header the uploader
chose. filename="avatar.png" is a string in the same header, chosen by the
same uploader. Neither is evidence.
BoundedField::declared_content_type() hands the header value back, because
an application may want to log it or compare it. Nothing in Arcature decides
what a file is from it. The only statement about an upload that the client
did not author is the one made by its first few hundred bytes.
sniff(bytes) compares the leading SNIFF_BYTES (512) against a table of
magic numbers and returns Option<SniffedType>, which carries both the
canonical extension() and the mime(). verify(bytes, &extension) holds
the two to a symmetric agreement:
- An extension with a known signature —
png,jpg,pdf,docx,mp4, and the rest of the table — must sniff to that signature. Bytes that sniff to nothing are refused, not waved through: “unrecognized” is exactly what a PHP script renamed.jpglooks like. - An extension with no signature —
txt,csv, an application’s own — must sniff to nothing. If a.txtupload’s first bytes are a PE header or a zip local-file header, the extension and the content disagree just as loudly, in the other direction.
Both directions are refusals, so there is no “unknown” state an upload can
land in and be accepted by default. expected_signatures(&extension) exposes
the table entry, or None for a format with no magic number. The OOXML
entries accept plain zip beside their own type, because a .docx is a zip
archive and whether the inner content-type part is close enough to the front
of the stream to be seen inside 512 bytes depends on how the producing
application ordered it.
SniffError has two variants: Unrecognized { declared } and
Mismatch { declared, sniffed }.
A .txt or .csv upload therefore succeeds with content_type() of None.
That is correct, not a gap: there was no signature to find.
Serving a stored file back
Accepting an upload safely and serving it safely are two different jobs, and doing the first perfectly buys nothing if the second hands the file to a browser as a document. A stored file served inline is content the attacker wrote, on the application’s own origin, with the application’s cookies attached — stored XSS with extra steps.
use arcature::http::download::Attachment;
let label = SafeFilename::parse(&stored_name, &AllowedExtensions::documents())
.map_err(|_| arcature::bad_request("that filename is not storable"))?;
Ok(Attachment::from_disk(&disk, &key)
.await
.map_err(|_| arcature::not_found("no such file"))?
.with_filename(&label)
.into_response())
Every Attachment response carries all four of these, by construction:
| Header | Value | Why |
|---|---|---|
Content-Disposition | attachment, plus the filename if one was set | the browser saves the file instead of rendering it. Nothing is a document, so nothing has an origin, so nothing has script |
X-Content-Type-Options | nosniff | without it a browser is free to disagree with the declared type and render what it thinks it found |
Content-Security-Policy | default-src 'none'; sandbox (DOWNLOAD_CSP) | the belt to the disposition header’s braces, for the browser or plugin that renders it anyway |
Content-Type | the sniffed media type, or application/octet-stream (OCTET_STREAM) | never taken from the request |
Content-Length is set as well when the length is known.
A recognized-but-scriptable media type is downgraded to
application/octet-stream rather than forwarded. The list is text/html,
application/xhtml+xml, image/svg+xml, text/xml, application/xml. The
disposition header should already have made this moot, and nosniff and the
sandbox policy should have made it moot twice; this is the third lock, and it
is there because the cost of being wrong is stored XSS on the application’s
own origin.
Attachment::from_disk(&disk, &path) reads only the leading 512 bytes up
front, to decide the media type, and streams the remainder — so the response
costs one buffer rather than one object. It returns StorageError when the
object cannot be stat’d or read, including when it does not exist, which a
handler should translate into a 404 rather than passing through.
Attachment::from_bytes(bytes) serves something already in memory.
with_content_type(sniffed) overrides the type when it was sniffed at upload
time and stored beside the object; it takes a SniffedType and there is
deliberately no way to set an arbitrary string, so every media type this
response can carry is one some bytes were recognized as.
with_filename takes a SafeFilename rather than a &str on purpose — a
string parameter would be a header-injection hole waiting for the one caller
who forgot. An ASCII name goes out once, as filename="report.pdf". A name that is
not ASCII goes out twice per RFC 6266 — the plain form plus an RFC 5987
filename*=UTF-8'' — because a name with diacritics is not representable in
the first form and silently mangling it is worse than sending both. The
extended form is only worth sending when the plain one lost something.
Limits
axum’s own 2 MiB body cap bites first
axum wraps a request body in http_body_util::Limited at 2 MiB unless the
application raises DefaultBodyLimit. Arcature never touches
DefaultBodyLimit — grep the crate and the only mentions are in prose. So
on a default build the effective ceiling on an upload is 2 MiB, not the 16 MiB
DEFAULT_TOTAL_BYTES above, and a 5 MiB photograph is refused by axum before
MultipartLimits has seen a byte of it.
That failure arrives as MultipartError::Parse wrapping axum’s
StreamReadFailed, and takes the status axum gives it — which is also a
413. axum downcasts the inner error to http_body_util::LengthLimitError
and answers PAYLOAD_TOO_LARGE, so a client cannot tell which of the two caps
refused it from the status alone. Only the detail string differs. An
application that accepts files larger than 2 MiB has to raise the axum limit
itself.
Stage 12 of the request pipeline is a separate, third cap:
tower-http’s RequestBodyLimitLayer, applied only when the application
calls Application::body_limit(bytes). It defaults to absent in the
framework, and the generated application’s bootstrap/app.rs sets it to
2 MiB. Nothing in MultipartLimits can raise either outer wall, and nothing
needs to: a body over one of them is refused without being buffered.
MultipartLimits is the inner bound that knows the body has parts. Carrying
a total there as well is not redundant — an application that never configured
stage 12 still gets one, and one that did gets to make a single upload route
stricter than the rest of the application without loosening anything.
read_timeout is per read, not per request
The timeout wraps each individual read: each next_field() and each
chunk(). A large upload over a slow link is many reads that each return
promptly, and it is not what this refuses. What it refuses is the connection
that goes quiet with the request half-sent.
The consequence runs the other way too: a client that sends one byte just
inside the timeout, forever, is not stopped by the clock. It is stopped by
total_bytes, which is why both bounds exist. There is no whole-request
deadline here. Application::timeout(duration) at stage 13 of the pipeline is
the place for one.
No image decoding, deliberately
Nothing in the upload path decodes anything. No image is parsed, no dimensions are read, no pixel is produced, and there is no image crate in the dependency graph. The type check is a comparison of at most 512 leading bytes against a table of magic numbers, and that is all it is.
A decoder is an interpreter for attacker-controlled input, and decoders are the densest source of memory-safety CVEs in any web stack. Running one in the request path trades a type-confusion bug for a heap-corruption bug. A prefix comparison has no state for hostile bytes to drive.
So if an application needs an image’s dimensions, a thumbnail, a re-encode or a strip of EXIF, that work belongs in a queue worker with its own memory bound and its own timeout — see Jobs — and not in the handler. Put the bytes on a disk, put the key in the payload, and let something that is allowed to crash do the decoding.
What else it does not do
- It is not a virus scanner, and not a claim of safety. A file can be genuinely, verifiably a PNG and still be malicious. What sniffing answers is narrower and worth having on its own: the bytes and the extension agree.
- It does not clean up after a process that died mid-upload. Staging
objects live under one prefix (
_staging) precisely so an operator can see at a glance whether anything was left behind, but nothing sweeps it. - It does not delete stored objects, ever. Content addressing means two references can share one object, so nothing here can know when the last one went away. Reference counting and garbage collection are the application’s.
- It does not record the upload anywhere. No table, no row, no metadata store. The handler gets an address and a filename and decides what to persist.
- It does not rate-limit. Stage 15 of the pipeline does, and an upload route usually wants a tighter limit than the rest of the application.
Authentication
This module owns the integration seams, not your identity schema. There is no
framework User table, no roles table, no permissions system. You write the
user type; Arcature gives it sessions, hashing, extractors, and an
authorization seam.
The user contract
use arcature::AuthUser;
pub struct User {
pub id: uuid::Uuid,
pub email: String,
}
impl AuthUser for User {
type Id = uuid::Uuid;
const SESSION_KEY: &'static str = "user_id";
fn id(&self) -> &uuid::Uuid { &self.id }
}
Id is what goes in the session — Uuid, i64, String, anything
serializable. SESSION_KEY defaults to "user_id".
Then say how to load one back:
impl UserLoader<AppState> for User {
type Error = sea_orm::DbErr;
async fn load_user(id: &uuid::Uuid, state: &AppState) -> Result<Option<User>, DbErr> {
// query state.db
Ok(None)
}
}
Ok(None) means the session is stale and the extractor answers 401. Err
means the database failed, which is a different thing.
absolute_max_age() is the maximum session lifetime measured from the login
timestamp, regardless of activity. It defaults to 30 days. This is separate
from the session layer’s sliding inactivity timeout — one bounds how long a
session can live, the other how long it can idle.
Extractors
| Extractor | Behaviour |
|---|---|
Auth<U> | the signed-in user, or a 401 rejection |
OptionalAuth<U> | Option<U>, never rejects |
AuthManager<U> | login, logout, session rotation |
Session | the session store |
Flash | flash messages |
CsrfToken | the current CSRF token |
pub async fn dashboard(auth: Auth<User>) -> Result<Response> {
Ok(text(StatusCode::OK, format!("hello {}", auth.user().email)))
}
Logging in and out
pub async fn store(auth: AuthManager<User>, /* ... */) -> Result<Response> {
let user = /* look the user up and verify the password */;
auth.login(&user).await?;
Ok(redirect().to("/dashboard").into_response())
}
pub async fn destroy(auth: AuthManager<User>) -> Result<Response> {
auth.logout().await?;
Ok(redirect().to("/").into_response())
}
login returns a LoginBuilder; awaiting it does the work.
.remember(true) extends the session’s max age.
Awaiting the builder rotates the session ID by calling cycle_id before
binding the user. This is mandatory and not opt-in: the
anonymous-to-authenticated transition is exactly where a session-fixation
attack would persist, so the ID always changes. You do not need to call
regenerate() after login(). It is there for rotating outside login.
Awaiting also stamps the authentication time into the session, which is what
absolute_max_age() is measured against.
logout flushes the whole session rather than removing the user key.
Passwords
Argon2id, from the argon2 crate. No Arcature-written cryptography.
use arcature::auth::{PasswordConfig, PasswordHasher, PasswordHashString, PasswordSecret,
RehashOutcome, verify_password};
let hasher = PasswordHasher::new(PasswordConfig::default())?;
let stored = hasher.hash(b"correct horse battery staple")?;
let parsed = PasswordHashString::new(&row.password_hash)?;
verify_password(b"attempt", &parsed)?;
if matches!(hasher.needs_rehash(&parsed), RehashOutcome::Rehash) {
// parameters changed since this hash was written; rehash on next login
}
PasswordSecret wraps a plaintext password, PasswordHashString a
PHC-formatted stored hash. Both are secrecy-backed: Debug and Display
never expose the secret and the buffer zeroizes on drop. No plaintext
password, signing key, or token appears in logs, error output, or a Debug
line anywhere in the framework.
Sessions
Sessions are tower-sessions. Arcature owns the cookie attributes and the signed jar:
use std::time::Duration;
use arcature::auth::{SameSite, SessionConfig, SessionKey};
let key = SessionKey::generate()?; // or ::from_bytes(&secret)
let config = SessionConfig::new(key.as_bytes())?
.with_cookie_name("acme_session")
.with_same_site(SameSite::Lax)
.with_max_age(Duration::from_secs(60 * 60 * 2))
.with_absolute_max_age(Duration::from_secs(60 * 60 * 24 * 7));
let layer = config.into_layer(store)?;
SessionConfig::dev(key) relaxes Secure for plain HTTP in development.
arc key:generate produces a signing key.
The store is yours to choose: any tower_sessions::SessionStore. Behind
session-store-db, DbSessionStore keeps sessions in the application’s own
database, which is what stops a deploy from signing everybody out; the
scaffold wires it. Swap it for MemoryStore in a test, or for anything else
implementing the trait.
From a handler:
pub async fn handler(session: Session) -> Result<Response> {
session.put("last_seen", 1_700_000_000i64).await?;
let value: Option<i64> = session.get("last_seen").await?;
let taken: Option<i64> = session.forget("last_seen").await?;
session.regenerate().await?;
session.flush().await?;
Ok(no_content())
}
session.raw() borrows the underlying tower-sessions Session.
Flash writes one-shot messages read and cleared on the next request:
flash.success(..), .error(..), .warning(..), .info(..), and
flash.messages() to read them.
Sign-in flows
Everything above is a seam: hash a password, bind a user to a session,
authorize an action. A sign-in screen is those seams plus a handful of small
decisions where the obvious implementation is wrong in a way nothing tells
you about. auth::flows, behind auth-flows and off by default, owns that
handful and nothing else.
| Type | What the naive version leaks |
|---|---|
CredentialChecker | Skipping the Argon2 verification when the address is unknown turns response time into a working list of who has an account. |
EmailVerification | A link bound to the account rather than to the address verifies whichever address the account holds when it is clicked. |
LoginThrottle | Counting failures per account misses the actual attack, which is one guess each against ten thousand accounts; counting them per account only also hands anybody a way to lock anybody else out. |
PasswordConfirmation | A boolean in the session says a password was proved at some point, never says when, and is inherited by whoever holds the session next. |
PasswordResets (auth-reset) | Checking “is this token valid?” and then deleting it is two statements with a gap, and two requests carrying the same link both pass the check. |
RememberTokens (auth-remember) | A cookie that does not rotate cannot tell a returning user from a stolen one. |
Each type documents its own attack in full. This is the half of a sign-in form that is the same in every application, and the half where being wrong is silent — which together is the whole reason it is here rather than in yours.
Scaffolding the other half
The account table, the handlers, the routes and the HTML are the
application’s. arc make:auth user writes all but the last:
| File | Holds |
|---|---|
app/auth/user.rs | the account: the model, plus AuthUser and UserLoader |
app/auth/user_registration_controller.rs | sign-up |
app/auth/user_session_controller.rs | sign-in and sign-out |
app/auth/user_password_controller.rs | forgotten-password and reset |
app/auth/user_routes.rs | user_auth_routes() |
database/migrations/m<stamp>_create_users.rs | the table |
Every file is declared as it is written, so no pub mod line is left to add
by hand. Four notes are, and they are the whole of what is not automatic:
auth-flows and auth-reset are not in the feature list arc new writes;
the migration is not in Migrator::migrations(); the reset table is not in
that migration, because PasswordResets::new(pool).migrate() owns it; and
the route collection is not merged into bootstrap/app.rs.
Headless throughout — six files and no screens. Reach for arc make:page or
arc make:view for what the user actually looks at.
Authorization
Authorization is never automatic and never implied. Validation proves a
request is well-formed; Bound<T> proves a row exists; neither says the user
may act on it.
pub struct LinkPolicy;
impl arcature::Policy<Link> for LinkPolicy {
type User = User;
fn check(user: &User, action: &str, link: &Link) -> bool {
match action {
"view" => true,
"update" => user.id == link.user_id,
_ => false,
}
}
}
Call it through Auth::authorize:
pub async fn update(auth: Auth<User>, link: Bound<Link>) -> Result<Response> {
let link = link.into_inner();
auth.authorize::<Link, LinkPolicy>("update", &link)?;
Ok(no_content())
}
Both type parameters are required. authorize is generic over the model M
and the policy P, and Rust has no partial turbofish, so
authorize::<LinkPolicy>(..) does not compile — the model comes first. (The
doc comments on Auth::authorize and on the Policy trait show the
one-parameter form; they are wrong.)
false becomes AuthzError::Forbidden.
CSRF
CsrfLayer enforces a naive double-submit token. Not signed, not
session-bound: the server issues a random nonce in a cookie, the client echoes
it in a header, and the server compares the two. The strength is in the
cookie attributes, not in a signature.
What is exempt, by design:
- Safe methods:
GET,HEAD,OPTIONS,TRACE. These get a fresh cookie if the request did not carry one. - Bearer-token requests. An unsafe request carrying
Authorization: Bearer …is forwarded without the check and without a CSRF cookie. A bearer token is not sent automatically by the browser, so there is nothing to forge.
Unsafe non-bearer methods — POST, PUT, PATCH, DELETE — must present a
matching cookie and header, or the request is rejected with 403.
Three presets
| Preset | Cookie | Header | Secure | SameSite |
|---|---|---|---|---|
CsrfConfig::new() | __Host-csrf | x-csrf-token | yes | Strict |
CsrfConfig::dev() | arcature-csrf | x-csrf-token | no | Strict |
CsrfConfig::inertia() | XSRF-TOKEN | x-xsrf-token | yes | Lax |
new() is the strongest. The __Host- prefix mandates Secure, forbids
Domain, and pins the path to / (RFC 6265bis), so a sibling subdomain
cannot overwrite the cookie. SameSite=Strict keeps it off every cross-site
request. HttpOnly is false on purpose: JavaScript has to read the cookie to
put it in the header, and the header is the proof the page is same-origin.
Why an Inertia application uses inertia()
Inertia’s client is axios. Axios reads a cookie named XSRF-TOKEN and echoes
it in X-XSRF-TOKEN. Both are hard-coded and neither is configurable without
writing application JavaScript.
Against CsrfConfig::new(), an Inertia form is rejected with 403 until the
application ships a shim that reads the token and reconfigures axios. That
shim is exactly the framework-owned client package Arcature does not publish
(see ADR 0001), so the server moves to meet the client
instead.
Two attributes weaken, deliberately:
- No
__Host-prefix, because axios will not look for one. A sibling subdomain able to set cookies on the parent domain can then overwriteXSRF-TOKEN. That is a session-fixation-shaped attack on the nonce, not a way to read it, and it requires the attacker to already control a subdomain of your site. SameSite=Laxrather thanStrict. Strict withholds the cookie on any cross-site navigation — an OAuth callback, a link from an email — so the first page load after one arrives with no token at all. Lax sends it on top-level GET navigations, which is the case Strict breaks and not one CSRF exploits: a forged unsafe request is still cookie-less.
The full reasoning, including the cost, is ADR 0002.
Keeping new() and configuring axios yourself
If you would rather not weaken those two attributes, keep CsrfConfig::new()
and tell axios where to look. This is application JavaScript, in your own
codebase, not a framework package:
import axios from "axios";
axios.defaults.xsrfCookieName = "__Host-csrf";
axios.defaults.xsrfHeaderName = "X-CSRF-Token";
Both defaults are writable, so no interceptor is needed. The trade is one file you maintain against two cookie attributes you keep.
Overriding individual attributes
let config = CsrfConfig::new()
.with_cookie_name("__Host-app-csrf")
.with_header_name("x-app-csrf")
.with_same_site(SameSite::Lax)
.with_secure(true)?;
with_cookie_name auto-enables Secure when the name starts with __Host-,
and with_secure(false) returns an error if the cookie name carries that
prefix. The invalid combination is not representable.
What it does not defend against
Not XSS: same-origin script can read the cookie and send the header. Not anything the reverse proxy owns — TLS termination, rate limiting, request-size limits. It defends against forged cross-site unsafe requests from an authenticated browser, which is the attack it is named after.
What this module does not own
Your user model, roles, permissions, or account schema. Cryptography — Argon2,
HMAC, SHA-2 and TLS come from RustCrypto, cookie, and the certified rustls
plus aws-lc-rs path. The screens: auth::flows owns the decisions behind a
sign-in form and arc make:auth writes the handlers, but nothing in this
module renders a page.
Clients that have no cookie
Everything above assumes a browser holding a session. For a CLI, a CI job or
another service, the api-tokens feature issues an opaque bearer credential
instead: the plaintext is shown once, the database holds only a SHA-256
digest, and lookup is constant-time. Tokens carry abilities and an expiry.
It is deliberately independent of auth – an API with no passwords and no
sessions may still hand out a token, and should not be made to compile a
password hasher to do it. CSRF also steps aside for a request carrying
Authorization: Bearer, because a bearer request is not a browser-driven one.
API tokens
An opaque bearer credential for a client that has no cookie and no session —
a CLI, a CI job, a mobile app, another service. The client sends
Authorization: Bearer <token> on every request. The framework turns that
back into a record, or refuses the request.
Off by default:
arcature = { version = "0.1", features = ["api-tokens", "db-postgres"] }
api-tokens = ["database", "dep:sha2", "dep:subtle", "dep:zeroize"]. It is
not part of fullstack. It brings a table and a migration, and an
application that only serves a browser never needs one. No new crate enters
the dependency graph: sha2, subtle and zeroize are already pulled in by
session-store-db, crypt and signed-urls, and the randomness comes from
getrandom, which is unconditional.
The examples below need a live database, so they are marked ignore: they
are neither compiled nor run. no_run would compile them without running
them, which is the stronger marker, but these name an application’s own
account store and state type — neither of which exists in this crate — so
there is nothing here for a compiler to check them against.
The property the design exists for
A token is two halves: a public 16-byte id and a secret 32-byte half. The row holds the id in the clear — it is a lookup key, not a credential — and the SHA-256 of the secret. The secret itself is never written anywhere.
From src/tokens/migrations/postgres/0001_api_tokens.sql:
CREATE TABLE IF NOT EXISTS arcature_api_tokens (
id BYTEA PRIMARY KEY,
secret_digest BYTEA NOT NULL,
tokenable_id TEXT NOT NULL,
name TEXT NOT NULL,
abilities JSONB NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
There is a secret_digest column and no column that could hold the secret.
Disclosure of the table is therefore not disclosure of credentials: a stolen
backup, a read replica, or a reporting account with SELECT yields 32 bytes
of digest per token and no way to authenticate as anybody. A unit test in
src/tokens/migrate.rs asserts that every bundled migration contains
secret_digest and contains neither secret_plaintext nor token TEXT, so a
future migration that adds a plaintext column has to delete that test first.
An integration test reads the raw columns back and asserts the stored bytes
equal digest_of(secret) and not the secret.
Issuing
use arcature::tokens::{Abilities, ApiTokens, NewApiToken};
use std::time::Duration;
let tokens = ApiTokens::new(pool.clone());
tokens.migrate().await?;
let issued = tokens
.issue(
&NewApiToken::expiring_in("user:42", "CI deploy key", Duration::from_secs(3600))
.abilities(Abilities::of(["deploy:write"])),
)
.await?;
// Show this once. There is no second chance.
println!("{}", issued.plaintext().expose());
ApiTokens::new takes the application’s own pool (arcature::tokens::TokenPool,
which is arcature::database::Pool). There is no connect_lazy twin as there
is on DbSessionStore: a token store is used from handlers, by which time the
pool is in hand, and a second pool would be a second slice of the database’s
connection budget for no reason.
issue returns an IssuedApiToken: token() is the stored record,
plaintext() is the credential, into_parts() splits them. The plaintext
exists exactly once, in memory, in that return value. Nothing — not this
module, not a SELECT, not a backup — can produce it again. Losing it means
issuing another.
PlaintextToken is built to make an accidental second copy hard:
| Property | Why |
|---|---|
no Clone | a credential that is trivially copied is a credential with an unknown number of copies |
Debug prints PlaintextToken([redacted]) | the common way a secret reaches a log file is a struct that derived Debug three types up |
| zeroized on drop | the plaintext does not linger in freed heap for a core dump or a later allocation to find |
expose(), not as_str() | every call site should read as a decision |
The zeroize is best-effort, not a guarantee. Anything the caller copies the
string into — a response body, a format argument, a String of its own — is
outside the type’s reach.
The plaintext is arcpat_ followed by 32 hex characters of id, _, and 64
hex characters of secret: 104 characters in total. TOKEN_PREFIX is a public
const so a secret scanner — a pre-commit hook, a CI step, a log pipeline —
can recognise an Arcature token in a paste or a diff from one literal. The
prefix is not a security control; it is what makes one possible.
Failures from issue:
| Error | Cause |
|---|---|
ApiTokenError::Entropy | the OS randomness source was unavailable. No fallback is attempted: a token minted from a clock or a counter is not a secret |
ApiTokenError::IdCollision { attempts: 8 } | eight random 128-bit ids were all already taken, which in practice means the random source is broken. Reported rather than retried forever, because a loop would hide it |
ApiTokenError::Database | the insert failed |
The insert is ON CONFLICT (id) DO NOTHING on PostgreSQL, INSERT IGNORE on
MySQL, INSERT OR IGNORE on SQLite. A clash arrives as zero rows affected
rather than as an error, which is what lets issue draw another id instead of
parsing a driver-specific constraint name out of an error string.
The migration and the table
tokens.migrate() creates arcature_api_tokens and its indexes. It is
idempotent and safe to run from every replica at once. Call it at startup; a
store whose table is missing fails on the first request instead, which is the
same outage discovered by a user.
The migration is embedded per dialect and applied under an
arcature_api_tokens_schema_migrations history table. Statements are split on
a line reading --;; and executed one at a time, because MySQL rejects
multiple statements in a single prepared query unless the connection opted in.
| Dialect | Migration lock | Notes |
|---|---|---|
| PostgreSQL | pg_advisory_lock(71420003) | a key of its own; tests/advisory_locks.rs is the registry and fails if two subsystems claim the same number |
| MySQL 8 | GET_LOCK('arcature_api_tokens_migrate', 10) | a lock name of its own, for the same reason |
| SQLite | none | it serialises writers itself, and every statement in the migration is IF NOT EXISTS or INSERT OR IGNORE |
The lock is taken on one acquired connection for the whole run, not one per
statement, because pg_advisory_lock and GET_LOCK are held by the session.
The unlock is best-effort: if it fails the session is already broken, and
reporting that instead of the migration’s own error would hide the reason the
caller needs. On MySQL the wait is bounded at ten seconds and the result of
GET_LOCK is not inspected, so a migrator that waited out the timeout
proceeds anyway — which converges rather than corrupts, because every
statement in the file is idempotent.
Storage differs where the dialects differ:
| Column | PostgreSQL | MySQL 8 | SQLite |
|---|---|---|---|
id | BYTEA | BINARY(16) | BLOB |
secret_digest | BYTEA | BINARY(32) | BLOB |
tokenable_id, name | TEXT | VARCHAR(191) | TEXT |
abilities | JSONB | JSON | TEXT |
expires_at, created_at | TIMESTAMPTZ | DATETIME(6) in UTC | INTEGER epoch milliseconds |
sqlx::types::Json covers all three abilities columns, so the store has one
code path. SQLite has no timestamp type: text timestamps compare correctly
only while every writer agrees on the format down to the digit, and integers
always do. The cost is that SQLite drops sub-millisecond precision on a round
trip. MySQL compares against UTC_TIMESTAMP(6) rather than NOW(), so a
connection set to a different session time zone cannot move an expiry.
Two indexes: tokenable_id for listing and mass revocation, expires_at for
the sweep.
The extractor
ApiAuth reads the header, splits the credential, hashes the secret, and
compares it against the stored digest. The route body runs only if a live
token matched. The store reaches the extractor through an axum Extension, so
an application installs it once on the router:
use arcature::axum::{Extension, Router, http::StatusCode, routing::get};
use arcature::tokens::{ApiAuth, ApiTokens};
async fn deploy(ApiAuth(token): ApiAuth) -> Result<String, StatusCode> {
// Authentication says who; the ability says what.
if !token.can("deploy:write") {
return Err(StatusCode::FORBIDDEN);
}
Ok(format!("deploying for {}", token.tokenable_id()))
}
fn routes(tokens: ApiTokens) -> Router {
Router::new()
.route("/deploy", get(deploy))
.layer(Extension(tokens))
}
ApiAuth is a tuple struct over ApiToken, so destructuring it in the
argument list is the usual spelling. ApiAuth::token() and ApiAuth::can()
are there when it is bound whole.
What the extractor answers:
| Condition | Response |
|---|---|
no Authorization header | 401, WWW-Authenticate: Bearer, body Authentication required |
a scheme other than bearer (matched case-insensitively) | the same 401 |
Bearer with nothing after it | the same 401 |
| a header value that is not UTF-8 | the same 401 |
| a credential that is not the shape this module mints | the same 401, with no query made |
| unknown id, wrong secret, or expired token | the same 401 |
the store was never installed as an Extension | 500, body API tokens are not configured |
| the database refused the query | 503 |
Every authentication failure is the same response with the same body. A client that can tell them apart is being told about tokens it does not hold.
The two non-401 rows are deliberate. A missing Extension is a wiring
mistake, and answering 401 would send a correct client away to mint a token
that would fail in exactly the same way. A database that is down is the
server’s problem, and 401 would tell an honest client its credential was
bad.
401 and not 403 for a missing token: 401 means “authenticate and try
again” and obliges the response to name a scheme, which this one does. 403
is the answer a route gives after ApiToken::can returns false, as the
example above does.
Why SHA-256 and not Argon2
This looks like the password path and is not, and the difference decides the algorithm.
A password hash is slow on purpose because a password is low entropy. Users
pick from a distribution an attacker can enumerate — a wordlist, a leaked
corpus, a few billion candidates — so the only defence a stolen hash has is
that each guess costs real time and memory. Argon2 buys that time. It is the
right call in auth::password, and it stays the right call there.
A token secret is 32 bytes straight from the OS CSPRNG: 256 bits of uniform randomness, with no distribution to guess from. Enumerating it is not expensive, it is impossible. Multiplying an impossible search by Argon2’s cost factor leaves it impossible. The slow hash buys nothing, because the entropy already did the work a slow hash exists to do.
The cost, meanwhile, is real and lands on every request. A token is presented on each API call, so verification is on the hot path in a way a login never is. Argon2’s default parameters are tens of milliseconds and tens of megabytes, tuned so that an attacker’s GPU farm is slow. Put that in front of every request and the tuning applies to the server: a client with a valid token and a loop becomes a memory-hard workload generator. Rate limiting does not save it, because the work happens before the request is known to be abusive. Choosing Argon2 here would not harden the token; it would hand anyone holding one a denial-of-service primitive against the application.
So: a single SHA-256, with no length-extension risk in this construction (the
input is a fixed 32 bytes and the digest is never a prefix of a longer
authenticated message). The reasoning generalises — hash slowly what humans
chose, hash quickly what the CSPRNG chose — and it is why DbSessionStore
stores a SHA-256 of a session id as well. The full argument lives at the
hashing site, digest_of in src/tokens/store.rs.
The comparison is constant-time
From ApiTokens::authenticate in src/tokens/store.rs:
let stored: Vec<u8> = row.try_get(0)?;
let matches: bool = presented_digest.ct_eq(stored.as_slice()).into();
subtle::ConstantTimeEq reads every byte every time. A == would return at
the first differing byte. A few hundred nanoseconds, averaged over enough
requests, is enough to recover a digest one byte at a time — thirty-two
rounds of two hundred and fifty-six guesses, instead of a search of 2^256.
Two more details of that path. The presented secret is hashed before the
query, not after, so an unknown id and a known id with a wrong secret follow
the same code and differ by one constant-time comparison. And the digest is
selected by exactly one statement: FIND and AUTHENTICATE are identical
except that the second also selects secret_digest, which makes every other
read structurally incapable of loading a digest into memory. An integration
test asserts that against the column names the server reports, not against the
statement text, because the statement text is what a mistake would be written
in.
What remains observable is whatever the database leaks by finding a row versus not finding one. That residue has the same shape as the account-enumeration oracle a login form must not have, and it is acceptable here for a reason worth stating: an email address is public and the password behind it is often guessable, so “this account exists” is a real step forward, whereas a token id is 128 random bits and the secret behind it is 256 more. Learning that some id exists costs the same 128-bit search either way.
authenticate returns Ok(None) for every authentication failure —
malformed, unknown id, wrong secret, expired — and an Err only when the
database fails or a row does not hold what the schema promises.
Abilities
A token carries a set of opaque strings the application chooses. Matching is exact.
use arcature::tokens::Abilities;
let scoped = Abilities::of(["posts:read", "posts:write"]);
assert!(scoped.contains("posts:read"));
assert!(!scoped.contains("billing:write"));
// The one wildcard, and the only one.
assert!(Abilities::all().contains("anything at all"));
// No prefix matching: `posts:*` grants `posts:*` and nothing else.
assert!(!Abilities::of(["posts:*"]).contains("posts:read"));
| Constructor | Result |
|---|---|
Abilities::none() | grants nothing. This is Default, and the default on a NewApiToken |
Abilities::of(iter) | grants exactly those strings |
Abilities::all() | the reserved "*" (Abilities::ALL), which matches every ability |
.with(ability) | one more, by value |
"posts:*" is a legal ability string and it grants the ability spelled
"posts:*". A wildcard grammar here would mean every application’s
authorization decisions depend on this module’s pattern matcher agreeing with
the application’s intuition, which is not a bet worth taking in an
authorization path.
The default is closed. A token minted without naming an ability can do
nothing, and ApiToken::can returns false for everything. The only permissive
setting is one somebody typed: Abilities::all().
Abilities are not checked by the extractor. ApiAuth proves the token is
live; the route calls can. There is no ability extractor, because the check
a route needs is a line of Rust and a second extractor would be a less
readable way to write it.
Expiry
Every token has a deadline. NewApiToken has no constructor that omits one,
and the column has no null state to hold one. A credential that outlives the
reason it was minted is the ordinary way a leak stays useful, so “forever” has
to be typed out as a date somebody chose.
use arcature::tokens::NewApiToken;
use chrono::{Duration, Utc};
// Spell the deadline...
let explicit = NewApiToken::new("user:42", "laptop", Utc::now() + Duration::days(30));
// ...or the time to live.
let ttl = NewApiToken::expiring_in("user:42", "CI", std::time::Duration::from_secs(3600));
A ttl too large for the calendar saturates at the furthest instant chrono
can represent rather than wrapping into the past, because wrapping would mint
a token that is already dead.
Every read carries expires_at > now(), evaluated by the database, in FIND,
AUTHENTICATE and LIST_FOR alike. Three consequences:
- An expired token stops working the instant it expires, whether or not any sweep has run.
- The clock that decides is the database server’s, not the reader’s. A token does not outlive its expiry by however far one web node’s clock is fast.
- An
ApiTokenthat came back from a query is live by construction.expires_at()on it is in the future as of the moment of the read.
Revocation and sweeping
let revoked: bool = tokens.revoke(id).await?; // one token
let count: u64 = tokens.revoke_all_for("user:42").await?; // sign out everywhere
let reclaimed: u64 = tokens.sweep_expired().await?; // disk, not security
Revocation is a DELETE, not a flag. A revoked row that is still in the table
is a row some future query can forget to filter; a row that is gone cannot
authenticate anybody by accident. revoke reports whether there was a token
to revoke; revoke_all_for reports how many went. The second is the “the
laptop was stolen” path, and the one to call when a password changes.
sweep_expired deletes rows whose expiry has passed and reports how many. It
reclaims disk. It is not what makes expiry correct — the predicate in every
read already does that — so a deployment that never calls it is secure and
merely wasteful. Nothing calls it for you; see the limits below.
Reading, for a token-management screen:
for token in tokens.list_for("user:42").await? {
println!("{} ({}) expires {}", token.name(), token.id(), token.expires_at());
}
list_for returns every live token for one subject, newest first, ties broken
by id (ORDER BY created_at DESC, id). find(id) reads one. Neither can
return a plaintext, and neither loads a digest. ApiTokenId is safe to show,
to log, and to accept from a revocation request — it travels in the header in
the clear next to the secret, and it is not a credential. It parses from and
prints as 32 lowercase hex characters, via ApiTokenId::from_hex and
to_hex.
Independent of auth, on purpose
api-tokens implies database and nothing else. It does not imply auth.
An API with no passwords and no sessions may still hand out a token. Making it
compile a password hasher and a session layer to get one would be a packaging
decision pretending to be a security one. database is required because a
revocable credential has to live somewhere revocable.
The two sides do not know about each other. tokenable_id is a String in
the application’s own spelling — a user id, a tenant id, a service name — so
this module never has an opinion about the shape of an application’s primary
key, and never joins to a users table.
CSRF steps aside for a bearer request
CsrfLayer exempts any unsafe request carrying an Authorization: Bearer
header: no double-submit check, and no CSRF cookie injected on the way out.
The decision is made by is_bearer_request in src/auth/csrf.rs, which takes
the bytes up to the first ASCII whitespace and compares them
case-insensitively against bearer. Basic is not exempt, and a request with
no Authorization header is not exempt.
The reason is that a bearer request is not browser-driven. Double-submit defends a cookie-authenticated browser against a forged cross-site request; a request that authenticates with a header the application handed to a CLI is not that request, and there is no cookie for a forged one to ride on.
Two notes about the shape of the exemption. It is keyed on the scheme alone,
so it is granted before any token is validated — the request is then still
rejected by ApiAuth unless it carries a live token, so the exemption skips
CSRF, not authentication. And a route protected only by a session cookie gains
nothing from a client that also sends a bearer header; a route that means to
accept tokens should read ApiAuth.
What this does not do
No OAuth. No authorization server, no grant types, no consent screen, no
client registration, no refresh endpoint, no discovery document, and no
scope parameter. Abilities are the application’s own strings and mean
whatever the application decides. The separate oauth feature is an OAuth 2
client — Authorization Code with PKCE against somebody else’s provider —
and shares no code with this module.
No refresh tokens, and no rotation. The store’s only writes are issue,
revoke, revoke_all_for and sweep_expired. There is no method that
modifies a row, so a token’s expiry cannot be extended and its abilities
cannot be edited. Both mean issuing a new token and revoking the old one.
No usage tracking. The table has no last_used_at and no counter, and
authenticate performs no write. A token screen cannot show “last used three
days ago”.
No caching, and no rate limiting. Every authenticated request is one
SELECT. Nothing counts or throttles failed presentations the way
auth::flows throttles failed logins. The 256-bit secret is the whole defence
against guessing, which is the same reason the digest is fast.
No layer, and no framework wiring. ApiAuth is a per-route extractor.
There is no ApiTokenLayer that protects a router subtree, and no
Application::tokens(..) helper of the kind Application::storage(config)
is. Install the store with Extension yourself.
No history. Revocation is a DELETE and every read filters on expiry, so
there is no way to list expired or revoked tokens, and no audit record of who
revoked what.
No scheduled sweep. sweep_expired runs when the application calls it.
Nothing in the framework calls it, and there is no arc subcommand for tokens
the way there is arc queue work. Wire it into a jobs schedule if
you want one.
No transaction variants. Unlike the job queue’s enqueue_tx and
migrate_tx, every method here runs on the pool. A token cannot be issued
inside a transaction you already hold.
No serde. ApiToken derives Clone and Debug only — not even
PartialEq, so two tokens cannot be compared with ==. ApiTokenId and
Abilities add comparison traits. None of the three derives Serialize. Rendering a token list into an Inertia
prop or a JSON body means a struct of the application’s own.
No relationship to a user row. tokenable_id has no foreign key and no
cascade in any dialect, so deleting a user does not delete their tokens. Call
revoke_all_for.
No limits on quantity, names, or length. A subject may hold any number of
tokens, two of them may share a name, and list_for returns all of them in
one Vec with no pagination. Nothing validates the length of tokenable_id
or name; on MySQL both columns are VARCHAR(191) and longer values do not
fit.
No transport check. Nothing here verifies that the request arrived over TLS. A bearer token is a password in a header, and the reverse-proxy front door owns TLS termination, as it does for the rest of the framework.
Encryption and signed URLs
Two things an application does with its own secret. Encrypter turns bytes
into an opaque token it can hand out and get back. UrlSigner mints a link
that proves the application issued it and, optionally, carries its own
deadline.
Both hang off one secret, APP_KEY. Neither is on by default.
Two features, not one
They are separate features because they cost different things. Signing needs a MAC; encrypting needs a cipher. An application that only hands out one-hour download links has no reason to pull an AEAD into its graph, and an application that only encrypts has no reason to carry a URL parser.
Neither crypt nor signed-urls is in default, and neither is in
fullstack. Turning one on is a decision written into the manifest: the
moment a build can produce ciphertext is the moment somebody owns a key
rotation story.
arcature = { version = "0.1", features = ["crypt", "signed-urls"] }
| Feature | Gives you | Pulls |
|---|---|---|
crypt | Encrypter, EncryptError, DecryptError | chacha20poly1305, secrecy, zeroize, hmac, sha2 |
signed-urls | UrlSigner, Clock, SystemClock, SignedUrlError | secrecy, zeroize, hmac, sha2, subtle, percent-encoding |
AppKey and AppKeyError are exported when either feature is on. The
module arcature::crypt does not exist when both are off; the whole surface
is also re-exported at the crate root (arcature::Encrypter,
arcature::UrlSigner).
Why each crate is there:
| Crate | Load it carries |
|---|---|
chacha20poly1305 | the AEAD behind Encrypter, in its X variant |
hmac + sha2 | subkey derivation from APP_KEY, and the MAC UrlSigner computes |
subtle | ConstantTimeEq on the presented signature — see below |
percent-encoding | escaping and unescaping query components |
secrecy + zeroize | key material that redacts in Debug and wipes on drop |
There is no dep:subtle under crypt. The only comparison that feature makes
is the AEAD’s own tag check, which chacha20poly1305 already does in constant
time.
auth-flows implies signed-urls, because an email-verification link is a
signed URL with an address binding on top. A build with auth-flows,
auth-reset or auth-remember already has UrlSigner.
The application key
APP_KEY is 64 bytes, written into .env as 128 lowercase hexadecimal
characters. It is the same material arcature::auth::SessionKey holds. There
is deliberately one secret per deployment: one thing to rotate, one thing to
store, one thing to keep out of a repository.
$ arc key:generate
APP_KEY written to .env
$ arc key:generate --show
APP_KEY=<128 lowercase hexadecimal characters>
| Behaviour | Detail |
|---|---|
| source | 64 bytes from the OS RNG, via SessionKey::generate |
| encoding | 128 lowercase hex characters, not base64 — hex has no alphabet variants for a .env parser to get wrong, and length alone says whether the value is intact |
| default action | rewrite the existing APP_KEY= line in .env, or append one |
| re-running | replaces rather than appends, so a .env never ends up with three keys and a loader picking one |
--show | prints and touches nothing, which is what a pipeline wants when the secret belongs in a secret store |
no .env | an error naming the path, suggesting --show |
| RNG failure | an error; no key is generated |
The command is gated on the auth feature, not on crypt or signed-urls,
because the key type and the certified RNG behind it live in the auth module.
auth is in default, so a generated application has the command. A build
that turns default features off and asks for crypt alone gets AppKey and
no way to mint one from the CLI — produce 64 bytes from the OS RNG elsewhere
and pass them to AppKey::from_bytes.
Reading the key
use arcature::crypt::AppKey;
let key = AppKey::from_hex(&arcature::config::env_required("APP_KEY")?)?;
AppKey::from_bytes(&[u8]) is the other constructor and takes exactly 64
bytes.
from_hex trims surrounding whitespace and accepts either case. The generator
writes lowercase, but a value that has been through a secret store and back
may not have stayed that way, and rejecting an unambiguous key on its
capitalisation would be a fault report with no fault behind it.
The hex decoder is written out rather than built on u8::from_str_radix,
which accepts a leading sign: with it, a 128-character string of +4 decodes
to 64 valid bytes. A decoder for a secret should accept exactly one spelling
of it, so +4, -4 and 4 are all refused, and a non-ASCII character is
refused rather than sliced mid-codepoint.
AppKeyError | Means |
|---|---|
Empty | nothing but whitespace |
NotHexadecimal | a character outside 0-9a-fA-F, or an odd number of digits |
WrongLength | decoded, but not to 64 bytes |
Every variant is about the value’s shape, never its content, so an error that
reaches a log carries no secret with it. Every Display ends with the same
instruction: run arc key:generate to write a valid one into .env.
Nothing uses those 64 bytes directly
APP_KEY is never handed to a cipher or a MAC. Each consumer gets its own
32-byte subkey:
subkey(label) = HMAC-SHA256(APP_KEY, "arcature/kdf/v1" || len(label) || label)
len(label) is a big-endian u64. It is there so no two labels can produce
the same input: without it, "ab" then "c" and "a" then "bc" are one
string, and a future label could collide with a present one.
| Label | Consumer |
|---|---|
encrypter | Encrypter |
url-signer | UrlSigner |
Sharing one key across two algorithms is how a weakness in either becomes a weakness in both, and how a chosen-ciphertext oracle in one becomes a forgery in the other. Recovering the signing key does not hand anybody the ability to decrypt.
The derivation is a single PRF call rather than a full HKDF because there is
nothing to extract from: APP_KEY is already 64 uniformly random bytes from
the OS RNG, which is exactly what HKDF’s expand step wants as input. It is
also exactly one HMAC block wide, so it is used as an HMAC key without being
pre-hashed.
AppKey::subkey is pub(crate). A caller who can name a label can make two
subsystems share a key, which is the mistake the derivation exists to
prevent. Labels are constants in the module.
The bytes live in a secrecy::SecretSlice, which zeroizes on drop, and there
is no accessor that hands the key out. Debug prints
AppKey(<redacted 64-byte key>).
Encrypter
For values an application has to hand out and get back: a token in a link, an opaque cursor, a payload in a queue an operator can read.
use arcature::crypt::{AppKey, Encrypter};
let key = AppKey::from_hex(&arcature::config::env_required("APP_KEY")?)?;
let encrypter = Encrypter::new(&key);
let token = encrypter.encrypt_string("order 4417")?;
assert!(token.starts_with("v1."));
assert_eq!(encrypter.decrypt_string(&token)?, "order 4417");
| Method | Signature |
|---|---|
Encrypter::new(&AppKey) | derives the encrypter subkey |
encrypt(&[u8]) | Result<String, EncryptError> |
encrypt_string(&str) | Result<String, EncryptError> |
decrypt(&str) | Result<Vec<u8>, DecryptError> |
decrypt_string(&str) | Result<String, DecryptError> |
new is cheap enough to call per request, though an application will normally
build one at startup and keep it in state. Debug prints
Encrypter(XChaCha20-Poly1305, <redacted key>).
Why XChaCha20-Poly1305
Every message gets a fresh 192-bit nonce from the OS RNG. That width is the whole reason for the X variant: at 192 bits a random draw per message is safe for any number of messages, so there is no counter for a caller to manage and no way for a caller to reuse one.
AES-GCM’s nonce is 96 bits. Random nonces there have a birthday bound a busy application can actually reach, and a repeat under GCM does not merely leak plaintext — it leaks the authentication subkey, so the attacker gains forgery as well. The alternative to a 192-bit nonce is asking every caller to own a counter that must never repeat across restarts, replicas and rollbacks.
The nonce comes from getrandom, which this crate depends on
unconditionally, and an RNG failure is EncryptError::Rng rather than a
fallback: a nonce from anything but the OS RNG is not a nonce.
Why not ring or aws-lc-rs
chacha20poly1305 is RustCrypto, pure Rust, compiled here with
default-features = false (alloc for the Vec output, zeroize to wipe
the key schedule on drop). ring and aws-lc-rs both carry C and assembly.
This is not a claim that no C runs in the process. A default build already
links aws-lc-rs: sqlx selects tls-rustls-aws-lc-rs and lettre selects
aws-lc-rs, both for TLS. The narrower decision is about which bytes reach
it. A token pulled out of a query string or a cookie is attacker-authored
input arriving at a parser, and this module’s threat model is that such bytes
are handled by pure Rust with unsafe_code = "forbid" above them.
The token format
v1.<base64url( nonce || ciphertext || tag )>
| Part | Size |
|---|---|
| version tag | the literal v1. |
| nonce | 24 bytes |
| tag | 16 bytes (Poly1305) |
| ciphertext | the plaintext’s length |
So a token is ceil((n + 40) * 4 / 3) + 3 characters for an n-byte
plaintext. The body is unpadded base64url, so a token is safe in a URL path, a
query value, a cookie and a JSON string with no further escaping.
Associated data is the fixed string arcature/crypt/v1. It is not secret and
it is not the message; it is a statement the tag covers. Binding the version
means a v1 token cannot be relabelled as a v2 one, so a future version
that weakens something cannot be reached by editing four characters of an
existing token.
Encryption is randomised. The same plaintext encrypted twice gives two different tokens, by design. An empty plaintext round-trips.
It fails closed
A token whose bytes have changed returns no plaintext at all. Which error
depends on where: the version tag is stripped before anything else, so
altering it gives DecryptError::UnknownVersion, and a body that is not
base64url gives Malformed. Everything the cipher actually sees — nonce,
ciphertext, tag — gives DecryptError::Authentication. There
is no partial result and no “decrypted but unverified” path, because a caller
holding attacker-chosen bytes that look like plaintext is the failure mode an
AEAD exists to prevent.
EncryptError | Means |
|---|---|
Rng | the OS RNG failed; nothing was encrypted |
Oversized | the plaintext exceeds what the cipher can address; not reachable on a 64-bit target |
DecryptError | Means |
|---|---|
UnknownVersion | no version tag this build reads — not an Arcature token, or minted by a newer release |
Malformed | not unpadded base64url, or shorter than a nonce plus a tag (40 bytes) |
Authentication | the tag did not match: altered, or encrypted under a different key |
NotUtf8 | authenticated, but the plaintext is not UTF-8. Only from decrypt_string, and not reachable by an attacker — it means encrypt was given bytes and decrypt_string was used to read them back |
The variants distinguish shapes of failure so an application can tell a stale link from an attack in its own logs. None of them is a near-miss: every one means no plaintext was produced.
Not a password store, not a database column
Passwords go through arcature::auth::PasswordHasher. Hashing is one-way and
encryption is not, so a stolen APP_KEY turns an encrypted password table
into a plaintext one.
Encrypting a column you then want to query is also a trap. Two encryptions of
one value differ, by design, so WHERE email = ? finds nothing.
UrlSigner
A signed URL is the answer to “let this one person fetch this one thing, without giving them an account and without leaving the door open”. The link is self-contained: nothing is written down when it is issued and nothing is looked up when it is presented, so it costs a MAC to make and a MAC to check.
use std::time::Duration;
use arcature::config::AppConfig;
use arcature::crypt::{AppKey, UrlSigner};
let key = AppKey::from_hex(&arcature::config::env_required("APP_KEY")?)?;
let config = AppConfig::new().url("https://example.com");
let signer = UrlSigner::new(&key, &config);
// Expires in an hour.
let url = signer.sign_temporary("/invoices/9", &[("as", "pdf")], Duration::from_secs(3600))?;
// https://example.com/invoices/9?as=pdf&expires=<unix seconds>&signature=v1.<base64url>
signer.verify(&url)?;
| Method | Behaviour |
|---|---|
UrlSigner::new(&AppKey, &AppConfig) | derives the url-signer subkey, roots links at config.base_url() |
.with_clock(Arc<dyn Clock>) | replaces the clock expiry is measured against |
.sign(path, params) | a link with no deadline |
.sign_temporary(path, params, valid_for) | a link that stops verifying valid_for from now |
.verify(url) | Result<(), SignedUrlError> |
params is &[(&str, &str)]. Debug prints the base URL and <redacted> in
place of the key.
Defaults
| Thing | Default |
|---|---|
| base URL | AppConfig::base_url(), which is http://localhost:3000 on a fresh AppConfig::new() and drops any trailing slash |
| clock | SystemClock, the wall clock |
deadline from sign | none at all — a URL signed with sign verifies forever |
deadline from sign_temporary | whatever you pass; nothing caps it |
| MAC | HMAC-SHA256, untruncated, all 32 bytes |
The base URL comes from APP_URL rather than from a request’s Host header
because a signed link is usually built with no request in scope — it goes in
an email, or in a job’s output — and behind a reverse proxy that header is
not authoritative anyway.
sign is there for a link whose validity is a property of the target rather
than of time: an unsubscribe link, say, which stops working when the
subscription does. Prefer sign_temporary whenever a deadline makes sense. A
permanent signed URL is a bearer token with no end date.
The expiry is inside the signature
sign_temporary writes the deadline into the expires query parameter as a
Unix timestamp in seconds, and the MAC covers it along with the path and every
other parameter. Moving the expiry forward changes the signed material, so the
edited link returns Mismatch. An expiry outside the signature would make the
whole feature decorative.
A URL is valid up to and including its expiry second, and invalid from the
next one. With a signer frozen at second 1000 and a 60-second window: 1059
verifies, 1060 verifies, 1061 is Expired.
Clock is a trait (fn now_unix(&self) -> u64, Send + Sync + 'static) for
one reason: a test for “the link stops working after an hour” written against
the real clock either sleeps for an hour or proves nothing.
use std::sync::Arc;
use std::time::Duration;
use arcature::crypt::{Clock, SignedUrlError, UrlSigner};
struct Frozen(u64);
impl Clock for Frozen {
fn now_unix(&self) -> u64 { self.0 }
}
let at = |second| UrlSigner::new(&key, &config).with_clock(Arc::new(Frozen(second)));
let url = at(100).sign_temporary("/download/42", &[], Duration::from_secs(30))?;
assert_eq!(at(130).verify(&url), Ok(()));
assert_eq!(at(131).verify(&url), Err(SignedUrlError::Expired));
SystemClock reads a pre-epoch wall clock as 0, and that fails open, not
closed. The expiry check is if self.clock.now_unix() > expires_at, so a
clock reporting 0 is never past any deadline and every expired link is
accepted. A machine whose clock has fallen behind the epoch honours links
forever rather than refusing them.
This is a real if unlikely failure mode — it needs a system clock set before
1970, which in practice means a dead RTC battery or a deliberately wound-back
container. It is written down rather than fixed because the fix is a decision
about what an application should do when it cannot tell the time, and that is
not the signer’s call to make. If it matters to you, supply your own
Clock
that refuses rather than returning a sentinel.
Constant-time comparison
verify compares the presented MAC with the expected one using
subtle::ConstantTimeEq, never ==:
if !bool::from(signature.as_slice().ct_eq(&expected)) {
return Err(SignedUrlError::Mismatch);
}
A byte-by-byte comparison that returns at the first difference is a timing
oracle. An attacker who can measure it recovers a valid signature one byte at
a time — roughly 8,000 guesses instead of 2^256. ConstantTimeEq reads every
byte every time, and it is a dependency rather than a five-line loop precisely
because its job is to be the thing the optimiser is not allowed to turn back
into an early return. There is no == on a signature anywhere in the crate.
The signature is checked before the expiry, so a tampered URL reports tampering whatever its deadline claims. Reading the expiry only after the MAC matched is what makes the parsed timestamp trustworthy.
What is signed, and how it is canonicalised
The MAC input is, in order: the domain separator arcature/signed-url/v1; the
path, length-prefixed; the parameter count as a big-endian u64; then each
parameter’s name and value, length-prefixed, sorted by name and then by value.
Length prefixes are load-bearing. Without them, a=bc and ab=c feed the MAC
one identical string, and an attacker who can move a character across the
boundary has a forgery for free.
Sorting is what lets a reordered query still verify. Parameters are compared after percent-decoding, so a link a mail client or a redirect has re-escaped still verifies; a link that has been edited does not.
On the way out, everything outside RFC 3986’s unreserved set
(A-Za-z0-9-._~) is percent-encoded — including &, =, +, %, #,
space and every non-ASCII byte — so a parameter value can never introduce a
parameter. A space becomes %20 and never +: + is an
application/x-www-form-urlencoded convention, not a URL one, and a verifier
that undid it would decode a literal + in a signed value into something that
was never signed.
A path is canonicalised to exactly one leading slash, so "reports/7" and
"/reports/7" sign the same thing.
What verify accepts and rejects
url may be absolute, as sign returns it, or the path-and-query a request
carries.
| Input | Result |
|---|---|
| the absolute URL as minted | verifies |
the same, stripped to /path?query | verifies |
the same with #anything appended | verifies — a fragment is never sent to a server, so it is not signed and not checked |
| the query reordered | verifies |
| any parameter edited | Mismatch |
https://example.com.evil/... | ForeignOrigin, decided before any MAC is computed |
a relative URL not starting with / | ForeignOrigin |
no signature parameter | MissingSignature |
two signature parameters | Malformed |
a signature with no v1. tag | UnknownSignatureVersion |
| a signature that is not base64url | Malformed |
| an unreadable percent-escape | Malformed |
| genuine, past its deadline | Expired |
genuine, no expires at all | verifies, at any time |
The origin is not in the MAC because it does not have to be: the URL must sit
under the configured APP_URL, which is checked first. The prefix check
carries an explicit guard — the remainder must be empty or start with / or
? — because without it https://example.com is a prefix of
https://example.com.evil/x. The comparison is a literal string prefix, so it
is sensitive to scheme, host case and port.
Two reserved names cannot be passed as parameters, because a caller that could set them could contradict the signer:
| Signing input | Result |
|---|---|
a parameter named signature or expires | ReservedParameter |
a path containing ? or # | QueryInPath — pass the query as params so it is canonicalised and signed rather than appended unsigned |
Mismatch and Expired are worth logging apart. The first is somebody editing
a link; the second is somebody using a link too late. An application that
treats them alike cannot tell an attack from a slow reader.
The base64url decoder is written in-crate
Both formats encode with an unpadded base64url (RFC 4648 section 5) written
inside the crate rather than pulled from a dependency. It is pub(crate):
there is one implementation, not two, because a second decoder is a second
place for a padding bug that makes two spellings of one token both valid.
Two properties are wanted that a general-purpose encoder does not promise.
The alphabet has to stay stable forever, because it is baked into every token the module has ever issued and a token outlives the release that minted it.
The decoder has to be strict. It rejects:
| Input | Why |
|---|---|
padding (Zg==) | the encoder never writes it |
any character outside A-Za-z0-9-_ | including the standard alphabet’s + and / |
a length congruent to 1 modulo 4 (Z, Zm9vY) | no byte string encodes to that length |
non-canonical trailing bits (Zh, Zm9) | the unused low bits of a final group are padding the encoder wrote as zero |
The result is that a byte string has exactly one spelling. Zg decodes to
0x66; Zh carries the same byte in its top eight bits and is refused. A lax
decoder gives an attacker a family of distinct strings that decode alike,
which is how a token revoked by string comparison comes back to life under
another name.
Sixty lines of table lookup is a smaller thing to own than a dependency whose
behaviour on malformed input is a version-to-version decision. The same
reasoning produced the hand-written hex in arc key:generate and in
AppKey::from_hex.
Everything carries a version
| Value | Version tag |
|---|---|
| an encrypted token | the leading v1. |
| a URL signature | v1. inside the signature parameter value, not a parameter of its own |
| the key schedule | arcature/kdf/v1, the derivation domain |
Replacing an algorithm later is therefore additive: the new reader keeps the old branch, tokens and links already in flight keep working, and nothing has to be re-issued during a deploy. A format with no version can only ever be changed by breaking every holder of an outstanding token at once.
The signature version rides inside the parameter value so a future format is a change to one string a verifier already parses, rather than a new parameter every old verifier ignores.
Changing the derivation domain would be a different matter: it changes every
subkey, and so invalidates every token and every signature in flight. A v2
there would be introduced alongside v1, not in place of it.
What is not here
No middleware and no extractor. verify is a call a handler makes; nothing in
the routing layer checks a signature for you, and there is no
SignedUrl-shaped extractor that rejects before your code runs.
No key store, no key registry, no key identifier in either format. No JWT, no PASETO, no public-key signature: both formats are this crate’s own and both are symmetric.
No storage of any kind. Signing writes no row and verification reads none.
Limits
Key rotation is the application’s problem
There is exactly one APP_KEY, one subkey per label, and no key identifier
anywhere in a token or a signature. A verifier tries one key, and that key is
whatever the process was started with.
What that means concretely, the day APP_KEY changes:
| Outstanding thing | Outcome after rotation |
|---|---|
| every encrypted token | DecryptError::Authentication — indistinguishable, by design, from a forgery |
| every signed URL | SignedUrlError::Mismatch |
| every signed session cookie | invalid, because SessionKey is the same material |
Nothing in the module reads a second key, so there is no overlap window to
configure. If you need one, it is yours to build: keep the old AppKey
alongside the new one, try the new one first, fall back to the old, and stop
falling back once the longest deadline you ever minted has passed. The module
gives you the pieces for that — AppKey::from_bytes, and an Encrypter or
UrlSigner per key — and none of the policy.
The corollary is that a deadline you mint is a commitment. A permanent signed
URL from sign outlives every rotation plan you have.
A signed link is replayable until it expires
A signed URL is a bearer token in a query string. Presenting it is the whole of proving you may have it, and presenting it twice works exactly as well as presenting it once. Nothing is recorded at signing time and nothing is consulted at verification time — that statelessness is what makes the link cost one MAC, and it is also what makes it replayable.
It will end up in browser history, in Referer headers, in access logs and in
whatever archived the email. Within its window, anybody who reads it in any of
those places can use it.
Two things follow.
Give a link the shortest lifetime the use allows, and do not sign an action a replay would make worse. A download link is a reasonable thing to sign; “close this account” is not.
If a link must be single-use, the application spends it. Record something when
the link is redeemed — a nonce parameter marked used, a row’s state moved on,
a version column bumped — and check that record after verify returns Ok.
verify answers “did we issue this, and is it still in date”. It cannot
answer “has this already been used”, because it never learns that anything
was.
For the email-verification case the framework does part of this for you: the
auth-flows link binds the address it was minted for, so a link is refused
once the account’s address changes. That is a binding, not a spend counter.
An encrypted token has no deadline and no context
Encrypter is not UrlSigner. A token has no expiry field, and nothing in
the module ever refuses one for being old. A token minted today decrypts in a
year under the same key. If a deadline matters, put a timestamp in the
plaintext and check it after decrypting.
The associated data is the constant arcature/crypt/v1. There is no parameter
for caller-supplied context, so a token minted at one call site decrypts
cleanly at another. If two purposes in one application must not accept each
other’s tokens, put the purpose in the plaintext and check it after
decrypting.
The path is signed as literal bytes
sign rejects ? and # in a path and otherwise passes it through
unchanged: the path is not percent-encoded on the way out, and verify does
not percent-decode it on the way in. Both sides MAC the same literal string,
with leading slashes collapsed to one.
So anything that rewrites the path in transit — a client that escapes a
space, a proxy that resolves a .. segment, a router that normalises a
trailing slash — produces Mismatch. That fails in the safe direction, but
it fails. Build signed paths out of characters that survive a round trip, and
put anything else in a parameter, where it is escaped and unescaped for you.
What this module does not own
No cipher implementation, no MAC implementation, no hash. chacha20poly1305
owns the AEAD, hmac and sha2 own the MAC, subtle owns the constant-time
comparison, percent-encoding owns the escaping, secrecy and zeroize own
the key handling. All of them are pure Rust with no C and no assembly, and the
crate compiles under unsafe_code = "forbid".
What the module owns is the composition: the key schedule, the two token formats, the canonical form the MAC is computed over, and the strict decoder that gives every token exactly one spelling.
OAuth
OAuth 2.0 Authorization Code with PKCE, against any provider. From a client’s point of view an authorization server is two URLs, and two URLs is all this module asks for.
It ends at the token response. Calling a userinfo endpoint, matching the result to a local account, starting a session — none of that is here, and the section at the bottom says why.
Turning it on
arcature = { version = "0.1", features = ["oauth"] }
oauth = ["dep:oauth2", "dep:url"]. It is in neither default nor
fullstack. It also needs nothing else: CI builds it with
--no-default-features --features oauth and runs both test binaries on that
build, so the feature is known to stand up without a database, a job runner or
a CLI underneath it.
oauth2 owns the protocol and vendors the HTTP client it drives, reachable as
arcature::oauth::oauth2::reqwest. There is deliberately no direct reqwest
in [dependencies]: adding one would put a second major version of the same
client in the dependency graph, and nothing in src/oauth/ would ever reach
it. The whole crate is re-exported as arcature::oauth::oauth2 so downstream
code targets the version Arcature pinned rather than resolving its own.
Examples below are marked ignore — neither compiled nor run. They name a
session, a callback route and an account store, the first behind the auth feature an oauth-only build never compiles, the last two absent from this
crate, so there is nothing here for a compiler to check them against.
Configuring a provider
An Endpoints is a pair of &'static str. The bundled providers are const
values of it, not variants of anything:
| Preset | Authorization endpoint | Token endpoint |
|---|---|---|
GITHUB | https://github.com/login/oauth/authorize | https://github.com/login/oauth/access_token |
GOOGLE | https://accounts.google.com/o/oauth2/v2/auth | https://oauth2.googleapis.com/token |
DISCORD | https://discord.com/oauth2/authorize | https://discord.com/api/oauth2/token |
use arcature::oauth::{Endpoints, OauthClient, GITHUB};
// A bundled provider.
let github = OauthClient::new(
GITHUB,
client_id,
Some(client_secret),
"https://app.example.com/auth/github/callback",
)?;
// A provider the framework has never heard of, configured identically.
const ACME_SSO: Endpoints = Endpoints {
authorization: "https://sso.acme.example/oauth/authorize",
token: "https://sso.acme.example/oauth/token",
};
let sso = OauthClient::new(ACME_SSO, client_id, Some(client_secret), redirect)?;
The rejected alternative was a Provider enum with a variant per vendor. It
reads better in a signature, and it makes adding a provider a framework
release: an in-house identity server could never be more than a second-class
Provider::Custom { .. } beside the real ones. A const pair costs nothing,
compares by value (Endpoints is Copy, PartialEq and Eq), and makes the
company SSO and GitHub the same kind of thing. tests/oauth.rs asserts that
by running the bundled presets and an invented one through identical
assertions.
Endpoints known only at run time — read from configuration, or discovered —
cannot be &'static str, so they take the other constructor:
let client = OauthClient::for_urls(
&config.authorization_endpoint,
&config.token_endpoint,
&config.client_id,
config.client_secret.clone(), // Option<String>
&config.redirect_uri,
)?;
client_secret is an Option. None is a public client — a native or
single-page app with no secret to keep, relying on PKCE alone — and the
secret is then omitted from the token request rather than sent empty, which
some providers reject outright. With a secret set, the client authenticates to
the token endpoint over HTTP Basic; the round trip pins the exact header,
Basic base64(client_id:client_secret).
An OauthClient owns its HTTP client and its endpoints and is not looked up
from anywhere. Hold it as application state.
Transport: https, and one exception
All three URLs are parsed and transport-checked when the client is built, in this order:
| Position | Role named in the error | Rejects |
|---|---|---|
| 1 | "authorization endpoint" | unparseable, or plaintext off loopback |
| 2 | "token endpoint" | same |
| 3 | "redirect URI" | same |
A URL that does not parse is OauthError::InvalidUrl { role }. One that
parses and fails the transport check is OauthError::InsecureTransport { role }. The first failure wins, so role names the first bad URL, not all
of them.
The rule itself, from require_transport_security in
src/oauth/provider.rs:
| URL | Verdict |
|---|---|
https:// anything | allowed |
http://localhost (ASCII case-insensitive) | allowed |
http://127.0.0.1, any IPv4 loopback | allowed |
http://[::1], any IPv6 loopback | allowed |
http:// any other host | refused |
http://localhost.evil.test | refused |
| any other scheme | refused |
So plaintext HTTP is permitted, and only when the host is loopback. That is the one case with no network to intercept, and it is the case every local development redirect URI needs.
There is no flag to widen it, and that absence is the decision. An application
that could switch the check off would eventually ship with it switched off,
and the switch would be found in a production config file six months later.
Development gets what it needs from the loopback exception and nothing more.
A host that merely mentions loopback is not loopback: localhost.evil.test
and 127.0.0.1.evil.test are both refused, and both are pinned by tests in
src/oauth/provider.rs and in tests/oauth.rs.
tests/oauth_round_trip.rs runs on the exception on purpose. Its mock
provider binds 127.0.0.1:0, so the suite needs no certificate and no
network, and behaves identically on a pull request from a fork.
The HTTP client built for the token exchange sets exactly one option:
redirect::Policy::none(). A token endpoint that answers 302 is a
server-side request forgery primitive, not a provider quirk to accommodate.
The authorization redirect
use arcature::oauth::OauthClient;
pub async fn start(session: Session, client: OauthClient) -> Result<Redirect> {
let start = client.authorize(&["read:user"])?;
session.put("oauth.state", start.state().as_str()).await?;
session.put("oauth.verifier", start.verifier().secret()).await?;
Ok(Redirect::to(start.url().as_str()))
}
authorize returns an Authorization holding three things: the URL, the
state, and the PKCE verifier. The browser is handed the URL. The other two
have to survive until the callback, which means the session or somewhere like
it — they are per-attempt values, not per-user ones, and a user with two tabs
open has two of each. into_parts() takes the three apart by value when
borrowing them is awkward.
Authorization’s Debug prints the URL up to the end of the path and then
?[redacted], because the state and the code challenge live in that query
string and a Debug output is exactly the thing that ends up in a log.
PKCE (S256), and why
The challenge is built by PkceCodeChallenge::new_random_sha256(). The method
is S256 and there is no way to ask for anything else.
The rejected alternative is RFC 7636’s other method, plain, where the
challenge is the verifier. It exists for clients that cannot compute a
SHA-256, which is no client this framework will ever run on, and it defends
against nothing: an attacker who can read the authorization request can read
the challenge, and under plain the challenge is the secret. Offering the
option would only create a way to configure the protection off.
What PKCE buys is the case where the authorization code is intercepted — a
malicious app registered on the same custom URI scheme, a code leaking through
a Referer header, a shared-machine browser history. The code alone is not
enough to redeem it: the token endpoint wants the verifier whose SHA-256 was
committed to at the start, and only the client that started the flow has it.
tests/oauth_round_trip.rs is what turns that from a claim into a test. The
mock provider recomputes the challenge from the verifier the token endpoint
was handed and refuses the exchange when the two disagree, which is what a
real authorization server does. The suite therefore proves three things a
“the string appears in the URL” test cannot:
- the
code_challenge_methodthe provider saw wasS256; - the challenge the provider saw is the base64url SHA-256 of the verifier the exchange later sent, and is not the plain verifier;
- a well-formed verifier from somebody else’s flow is refused, arriving as
OauthError::Provider { code: "invalid_grant" }.
The test writes out its own SHA-256 and base64url rather than pulling a crate.
sha2 belongs to the uploads feature and is not compiled by an oauth
build, and a test that shares an implementation with the code under test can
agree with its bugs. The test’s arithmetic is pinned against the published
FIPS 180-4 and RFC 7636 vectors.
The state parameter
| Property | Value |
|---|---|
| Source | getrandom::fill, the OS CSPRNG |
| Length | 32 bytes, STATE_BYTES in src/oauth/pkce.rs |
| Encoding | lowercase hex, so 64 characters, safe in a query string unescaped |
| On RNG failure | OauthError::Entropy, no fallback |
| Comparison | OauthState::verify -> constant_time_eq, same file |
OauthState::generate returns Err(OauthError::Entropy) if the OS randomness
source is unavailable. There is no fallback to a clock, a counter or a hash of
the request, because a predictable state is not a weaker state, it is no
state.
The comparison is constant time with respect to the contents of the two
values. constant_time_eq XOR-accumulates every byte and tests the
accumulator once at the end, and the accumulator goes through
std::hint::black_box before that test — without it a compiler is entitled
to notice that the accumulator can only grow and to break out of the loop
early, which is precisely the timing signal the function exists to remove.
Length is compared up front and does short-circuit; the length of a state is
visible in the query string already, so hiding it buys nothing.
The rejected alternative is ==, which returns at the first differing byte.
Correctness alone cannot tell the two apart — both give the same answer — so
tests/oauth.rs asserts the property that can: the answer is identical
wherever the difference sits, checked at every one of the 32 positions
including the first, which is the one a short-circuiting comparison exits on
immediately. A wall-clock measurement of the same property sits beside it
under #[ignore], because a shared or loaded CI machine makes any tolerance
wrong.
The state is checked before the code is redeemed. It is the first
statement in exchange, and a mismatch returns without touching the network.
Two tests pin the order rather than trusting it:
tests/oauth.rspoints a client at a token endpoint that is not listening and asserts the error isStateMismatchand notTransport. If the check ran second, the variant would be the other one.tests/oauth_round_trip.rsdrives a real callback carrying a second flow’s state, then asserts the provider’s ledger recordedtoken_calls == 0— a forged callback is refused before the code is spent, not after.
The order matters because an authorization code is one-time. A state check that ran after the exchange would let a CSRF callback burn a legitimate code, and would have handed the tokens over before anybody objected.
The callback and the exchange
use arcature::oauth::{OauthClient, OauthState, PkceVerifier};
pub async fn callback(
session: Session,
client: OauthClient,
Query(params): Query<CallbackParams>, // code: String, state: String
) -> Result<Response> {
let stored: String = session
.forget("oauth.state")
.await?
.ok_or_else(|| Error::forbidden("no OAuth flow in progress"))?;
let verifier: String = session
.forget("oauth.verifier")
.await?
.ok_or_else(|| Error::forbidden("no OAuth flow in progress"))?;
let tokens = client
.exchange(
&OauthState::from_stored(stored),
¶ms.state,
¶ms.code,
PkceVerifier::from_secret(verifier),
)
.await?;
// `tokens.access_token()` is a bearer credential. Send it; do not put it
// in a log line or an error message.
Ok(sign_in(profile_for(&tokens).await?).await?)
}
exchange takes the stored state by reference and the verifier by value. The
verifier is consumed, so the same one cannot be reused for a second exchange
by accident. Take both out of the session rather than reading them, which is
what forget does here: a flow finishes once, and leaving the values behind
leaves a live verifier sitting in the session for whatever arrives next.
What a successful exchange returns:
TokenSet accessor | Type | What the round trip observed |
|---|---|---|
access_token() | &str | the provider’s access_token member |
refresh_token() | Option<&str> | Some, and not equal to the access token |
token_type() | &str | "bearer" — the provider sent Bearer, and this path lowercases |
expires_in() | Option<Duration> | Some(3600s), from expires_in |
scopes() | &[String] | ["read:user"] after ["read:user", "profile"] was asked for |
That last row is the reason the accessor exists at all. Narrowing the granted scopes is the provider’s prerogative, so the answer has to be read out of the response rather than echoed back from the request.
TokenSet::new(access_token, token_type) builds one directly, for tests and
for applications that obtained tokens some other way and want the same
redaction. It stores what it is given and lowercases nothing.
Fetching user info
The module does not do this, and that is the deliberate half of the two-URL
model. An OAuth 2.0 authorization server is an authorization endpoint and a
token endpoint; a userinfo endpoint belongs to a resource server, and its
path, its JSON shape and its field names differ per provider — sub here,
id there, login versus username versus preferred_username. A framework
type that covered them would be a per-provider parser, which is the provider
enum this module already declined, wearing a different hat.
So the leg after exchange is an ordinary authenticated HTTP request, with
the access token as a bearer credential:
use arcature::oauth::oauth2::reqwest;
let profile: serde_json::Value = reqwest::Client::new()
.get("https://api.github.com/user")
.bearer_auth(tokens.access_token())
.send()
.await?
.json()
.await?;
arcature::oauth::oauth2::reqwest is the client oauth2 already vendors, so
reaching for it adds nothing to the dependency graph. An application that
already has an HTTP client should use that one instead.
The round trip makes this call for a reason beyond illustration. Everything
before it compares strings against strings, and an access token parsed out of
the refresh_token member is still a string that survives every assertion. A
resource server is the only thing that can tell the two apart, so the test
stands one up, presents tokens.access_token() to it, and asserts on the
provider’s side that the credential it received was Bearer <access token> —
a refresh token must never be the credential sent to a resource server.
Errors
OauthError is the one error type. Every variant is built from a fixed
&'static str or from a provider-supplied error code, never from a response
body:
| Variant | Carries | Raised by | Retry? |
|---|---|---|---|
InvalidUrl { role } | the role, a &'static str | construction | no, it is a config bug |
InsecureTransport { role } | the role | construction | no, same |
Entropy | nothing | authorize | no, not recoverable by retrying |
StateMismatch | nothing | exchange, before the network | no, start the flow again |
Transport | nothing | exchange; also a client that fails to build | yes, this is the retryable one |
Provider { code } | the provider’s error member | exchange | depends on the code |
MalformedResponse | nothing | exchange | no |
Provider { code } carries invalid_grant, invalid_client,
unsupported_grant_type and the rest of RFC 6749’s fixed vocabulary. It does
not carry the error_description beside it, which is free-form text the
provider wrote.
A token-endpoint response that does not parse becomes MalformedResponse,
and the body is dropped on the floor. Upstream, RequestTokenError::Parse
holds the raw bytes the provider sent; both it and RequestTokenError::Other
collapse to MalformedResponse with nothing attached. The reason is the case
that looks harmless: a malformed success response still contains an access
token, so a variant that carried the body for diagnostics would put
credentials into every log line that formatted the error.
The cost of that is real and worth stating. Debugging a provider that answers in a shape this implementation does not understand means reproducing the request, because the error will not tell you what it said.
The three failure modes of exchange stay distinguishable because an
application may retry one of them and must not retry the others, and
tests/oauth_round_trip.rs covers each: a replayed code arrives as
Provider { code: "invalid_grant" }, a token endpoint that is not listening
as Transport, and a callback from another flow as StateMismatch.
an_oauth_error_never_carries_a_response_body in tests/oauth.rs renders
the five runtime variants — StateMismatch, Entropy, Transport, MalformedResponse and Provider — and asserts none of them mentions access_token and none runs
past 200 characters.
What is never logged
| Type | Debug renders | Display |
|---|---|---|
PkceVerifier | PkceVerifier([redacted]) | none |
OauthState | OauthState([redacted]) | none |
TokenSet | TokenSet([redacted]) | none |
Authorization | the URL through the path, then ?[redacted], plus the two redacted fields | none |
OauthClient | OauthClient { .. } | none |
Endpoints | derived, in full — it holds two public URLs | none |
OauthError | derived | yes, and it carries no body |
None of the secret-bearing types implements Display, so none of them can
reach a log line through ordinary formatting. Reading a secret out means
calling secret(), as_str() or access_token() by name, which is the point
where a reviewer sees the decision. OauthClient’s Debug is hand-written
rather than derived because the client holds a ClientSecret, and oauth2’s
own redaction is not something this crate should rely on transitively.
Five tests in tests/oauth.rs pin this by formatting a real value and
asserting the secret is absent from the output.
Separately, under the observe feature, the JSON log layer drops the value of
any field whose name contains one of the fragments in
arcature::observe::redact::DENY_LIST — token, verifier, secret, auth,
credential and the rest — with - and . folded to _ first. That is a
second net under the first, not a replacement for it: it matches on field
names, so it catches a field called oauth.access-token and does not catch a
secret interpolated into a message string.
What this module does not do
No provider registry, and no discovery. There are three const
Endpoints and no way to look one up by name — no enum, no FromStr, no
table keyed by a string from a config file. There is also no OpenID Connect
discovery: nothing reads /.well-known/openid-configuration. Fetch it
yourself if you want it, and hand the two URLs to for_urls.
No token storage. The oauth feature brings no table, no migration and no
model, and the module never touches a session. authorize hands you the state
and the verifier, exchange hands you a TokenSet; where those live between
the two requests, and whether the access token is kept after the flow at all,
is the application’s decision. It is also why oauth needs no database.
No refresh loop, and no refresh method. exchange is the only thing on
OauthClient that talks to a token endpoint. There is no background task
watching expires_in, no interceptor that retries a 401 with a refreshed
credential, and no refresh(). TokenSet::refresh_token() hands you the
string; driving the refresh grant with it goes through the re-exported
oauth2, which is exactly what
a_refreshed_token_set_carries_the_new_access_token does. A refresh loop
needs somewhere to write the new token back to, and the paragraph above is the
reason there is no such place.
No OpenID Connect. No id_token on TokenSet, no JWT parsing, no
signature verification, no nonce. An id_token member in a token response is
ignored. Verifying one is a JWS implementation plus a key-set fetcher, and
neither belongs behind a feature whose stated job is two URLs.
No revocation and no introspection. RFC 7009 and RFC 7662 are two more
endpoints, and Endpoints holds two.
No routes, no extractor, no middleware. Nothing in src/oauth/ imports
axum. There is no callback handler to mount, no Application::oauth(..)
wiring, and no arc make: generator. The two handlers in this chapter are
what an application writes.
No timeout on the token exchange. The HTTP client is built with one option
set — the redirect policy — so the request inherits whatever the vendored
reqwest defaults to. An application that needs a bounded exchange should
wrap the exchange future in tokio::time::timeout.
Jobs
Durable background jobs on PostgreSQL. A FOR UPDATE SKIP LOCKED queue over
the application’s existing PgPool — no second connection pool, no separate
broker to operate.
The queue is at-least-once. A handler must tolerate running twice.
Declaring a job
use arcature::Job;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, arcature::Job)]
struct SendVerificationEmail {
user_id: u64,
}
The derive generates impl DxComponent (so NAME is
"SendVerificationEmail") and a JOB const describing the queue identity.
The defaults are the struct name in snake_case, version 1, three attempts:
assert_eq!(SendVerificationEmail::JOB.kind(), "send_verification_email");
assert_eq!(SendVerificationEmail::JOB.version(), 1);
assert_eq!(SendVerificationEmail::JOB.max_attempts(), 3);
Override them with the helper attribute:
#[derive(Debug, Clone, Serialize, Deserialize, arcature::Job)]
#[job(kind = "custom_kind", version = 2, attempts = 5)]
struct CleanupSessions {
user_id: u64,
}
version is part of the queue identity, not decoration. A handler registers
for a (kind, version) pair, so bumping the version lets a new payload shape
coexist with jobs already in the table rather than deserializing into the
wrong struct.
JobModel::new(kind, version, max_attempts) builds the identity by hand if
you would rather not derive it.
Enqueueing
use arcature::jobs::{JobRequest, Jobs};
let jobs = Jobs::new(pool.clone());
let request = JobRequest::new(
&SendVerificationEmail::JOB,
&SendVerificationEmail { user_id: 42 },
)?;
jobs.enqueue(&request).await?;
JobRequest builders: .delay(Duration), .run_at(DateTime<Utc>),
.max_attempts(n) to override the model’s default for this one job.
jobs.enqueue_tx(..) enqueues inside a transaction you already hold, and
enqueue_with(executor, ..) takes any SQLx executor. Enqueueing in the same
transaction as the state change is the only way to avoid the job that runs
before its row exists.
jobs.migrate() creates the queue tables; migrate_tx does it inside a
transaction you own.
Payloads are size-capped (DEFAULT_MAX_PAYLOAD_BYTES, overridable per model
with with_max_payload_bytes). A queue row is not a blob store; put the bytes
in Storage and the key in the payload.
Handlers
A handler is a closure registered against the job model. Registry::add
takes &mut self, so the registry is mutable while you build it:
use arcature::jobs::{JobError, Registry};
pub fn registry() -> Registry {
let mut registry = Registry::new();
registry
.add(&SendVerificationEmail::JOB, |job: SendVerificationEmail| async move {
send_email(job.user_id).await.map_err(JobError::retryable)
})
.expect("job kind is valid and registered once");
registry
}
Registering the same (kind, version) twice is an error, not a silent
overwrite.
The handler’s error type decides what happens next. JobError::Retryable
means retry per the backoff policy until max_attempts is exhausted;
JobError::Permanent means dead immediately. retryable(e) and
permanent(e) wrap any std::error::Error; retryable_msg(s) and
permanent_msg(s) take a string. Choosing between them is the handler’s job,
because the framework cannot tell a bad payload from a flaky network.
#[job_handler] validates that a handler function is pub async fn with a
return type and emits it unchanged. It generates no binding const and does
not register anything: a handler’s proc-macro cannot see the job’s kind and
version, since those come from #[derive(Job)] on the payload struct.
Registration stays explicit in application code.
Running a worker
use std::time::Duration;
use arcature::jobs::{RetryPolicy, Worker, WorkerConfig};
use tokio_util::sync::CancellationToken;
let worker = Worker::builder(pool.clone(), registry())
.worker_id("worker-1")
.config(WorkerConfig::default().concurrency(16))
.retry_policy(
RetryPolicy::exponential(Duration::from_secs(5), 2.0, Duration::from_secs(600))
.jitter(true),
)
.build();
worker.run(CancellationToken::new()).await?;
Worker::new(pool, registry) skips the builder when the defaults suffice.
run takes a CancellationToken and returns when it fires, so shutdown is
the caller’s to sequence.
WorkerConfig defaults: concurrency 8, poll interval 200ms, lease 300s, poll
batch 8, sweep every 30s in batches of 64, per-job timeout 60s, heartbeat
derived as lease / 3.
Or from the CLI: arc queue work, arc queue drain, arc queue stats.
Why claims are fenced
Every claim carries a per-claim UUID claim_token, and every completion
mutation fences on (id, status = 'running', claim_token).
The reason is the lease. A worker claims a job for a bounded time; if it dies or stalls past the lease, the sweep requeues the job and another worker picks it up. Without the token, the first worker waking up late would write its result over the second worker’s claim — two runs, one of them clobbering the other’s outcome. With it, the stale worker’s UPDATE matches zero rows and does nothing.
At-least-once still means at-least-once. The fence stops a stale worker committing a result, not a job body running twice.
Retries
RetryPolicy::exponential(base, multiplier, cap) computes
base * multiplier^(attempts - 1), capped. RetryPolicy::fixed(delay) for a
flat wait. .jitter(true) enables full jitter, which is what stops a batch of
simultaneous failures retrying in lockstep forever.
A job that exhausts max_attempts is dead. arcature::jobs::admin exposes
requeue_dead, cancel, and sweep_expired_leases for the operator paths.
Scheduling
use arcature::jobs::{ScheduleBinding, ScheduleCadence, Scheduler};
const NIGHTLY: ScheduleBinding = ScheduleBinding {
job: "cleanup_sessions",
version: 1,
cadence: ScheduleCadence::Daily { hour: 3, minute: 0 },
};
let scheduler = Scheduler::new().schedule(&NIGHTLY, move || {
let jobs = jobs.clone();
async move { /* enqueue */ Ok(()) }
});
scheduler.run(CancellationToken::new()).await?;
ScheduleCadence is an interval or a daily wall-clock time. The scheduler
enqueues; the worker runs. They are separate processes if you want them to
be. From the CLI: arc schedule.
Observability
Observer is the seam, defaulting to NoopObserver. Implement it and pass it
to WorkerBuilder::observer to see claims, completions and failures.
PostgreSQL only
The queue requires PostgreSQL. FOR UPDATE SKIP LOCKED is the whole design,
and SQLite and MySQL do not have a usable equivalent. An application on
db-sqlite gets the rest of the framework and no job queue.
Events
In-process typed event dispatch. Not a message bus, not durable, not cross-process: if the process dies mid-dispatch, the remaining listeners do not run. For work that must survive a restart, use Jobs — a listener that enqueues a job is the usual bridge.
Declaring an event
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, arcature::Event)]
pub struct UserRegistered {
pub user_id: u64,
pub email: String,
}
The derive generates impl DxComponent with NAME = "UserRegistered" and the
empty impl Event. Serialize and Deserialize are yours to add: dispatch
erases the type through serde_json::Value, serializing once and
deserializing per listener.
That is a deliberate choice over TypeId plus Any. The dispatch key is a
&'static str name, which means the same mechanism describes itself to
tooling and no downcast can silently miss. It costs one round-trip through
JSON per listener.
Listeners
use arcature::events::{DispatchError, Dispatcher};
let dispatcher = Dispatcher::new()
.register(|event: UserRegistered| async move {
println!("welcome {}", event.email);
Ok(())
});
register consumes and returns the dispatcher, so registration chains.
Several listeners may share an event type; they run in registration order.
#[listener(UserRegistered)] marks a free function as a listener and emits a
LISTENER_BINDING static beside it for inspection:
#[arcature::listener(UserRegistered)]
pub async fn send_welcome_email(
event: UserRegistered,
) -> Result<(), arcature::events::DispatchError> {
let _ = event;
Ok(())
}
assert_eq!(LISTENER_BINDING.event, "UserRegistered");
assert_eq!(LISTENER_BINDING.listener, "send_welcome_email");
The function is emitted unchanged and stays directly callable. The macro does
not register it — you still pass it to Dispatcher::register. The binding
const is metadata for the Unified Application Graph, not a registry that
wires itself.
Dispatching
dispatcher
.dispatch(&UserRegistered { user_id: 1, email: "a@b.com".into() })
.await?;
Listeners run sequentially in registration order. A listener failure does
not stop the others: the error is logged, every listener still runs, and
dispatch returns the first error afterwards. Dispatching an event with no
listeners is a no-op that returns Ok(()).
Sequential, not concurrent, and in-process, so a slow listener delays the request that dispatched the event. Listeners should be short; anything with latency belongs in a job.
Errors
DispatchError has three variants. Serialize(String) carries the serde
message. Listener(String) carries whatever the listener chose to expose.
Deserialize carries no message at all, deliberately: a serde
deserialization error can echo the payload it choked on, and an event payload
is exactly the kind of thing that should not end up in a log line.
Testing
Dispatcher::recording() records dispatched event names:
let dispatcher = Dispatcher::recording();
dispatcher.dispatch(&UserRegistered { user_id: 1, email: "a@b.com".into() }).await?;
assert!(dispatcher.was_dispatched("UserRegistered"));
dispatched_events() lists them and listener_count(name) reports how many
listeners an event has. In a non-recording dispatcher was_dispatched always
returns false, so recording is opt-in rather than something production pays
for.
SMTP over lettre. Arcature owns the ergonomics; lettre owns the protocol and the certified rustls plus aws-lc-rs stack owns TLS.
Mail is a value, not a namespace. There is no Mail::to("a@b.com") static
constructor — to is a method on a facade that already knows the mailer and
the From address.
The transport
use std::time::Duration;
use arcature::mail::{Mailer, SmtpConfig, SmtpCredentials, TlsMode};
let config = SmtpConfig::new("smtp.example.com")?
.port(587)
.tls_mode(TlsMode::StartTls)
.credentials(SmtpCredentials::new("user", "secret"))
.timeout(Duration::from_secs(10));
let mailer = Mailer::smtp(config)?;
SmtpConfig::from_url("smtps://user:pass@host:465") parses a connection URL
instead.
Mailer is Clone + Send + Sync + 'static. Application::mail(config)
builds one at startup.
Credentials never reach a log. SmtpCredentials has a Debug that prints
only the type name and deliberately has no Display; SmtpConfig
implements both by hand and never emits the password or the full URL.
Sending
Implement Mailable for the message type. The build method receives an
Email builder that already has From and To set:
use arcature::mail::{Email, EmailError, Mailable};
use arcature::mail::lettre::message::Message;
pub struct WelcomeEmail {
pub name: String,
}
impl Mailable for WelcomeEmail {
fn build(&self, email: Email) -> Result<Message, EmailError> {
email
.subject(format!("Welcome, {}", self.name))
.plain(format!("Welcome, {}", self.name))
}
}
Then send:
use arcature::mail::Mail;
let mail = Mail::from_str(mailer, "noreply@example.com")?;
mail.to(user.email).send(&WelcomeEmail { name: user.name }).await?;
Mail::new(mailer, mailbox) takes an already-parsed Mailbox if you have
one; parse_mailbox("Ada <ada@example.com>") produces one.
The message builder
Email::builder() starts a message. The chainers — from, reply_to, to,
cc, bcc, subject — return Self and do not fail. Only the body
terminators return a Result, because that is where the message is actually
assembled:
let message = Email::builder()
.from(parse_mailbox("noreply@example.com")?)
.to(parse_mailbox("ada@example.com")?)
.subject("Welcome")
.html("<h1>Welcome</h1>")?;
| Terminator | Produces |
|---|---|
plain(body) | text/plain |
html(body) | text/html |
alternative(plain, html) | multipart/alternative |
mixed(..) | multipart/mixed |
plain_with_attachments(body, attachments) | text plus attachments |
alternative_with_attachments(plain, html, attachments) | both, plus attachments |
EmailAttachment::new(..) builds an attachment. Its Debug redacts the body
bytes — an attachment in a log line is a data leak, not a diagnostic.
Email::from_builder(builder) and email.into_builder() cross to and from
lettre’s own MessageBuilder when the wrapper runs out.
Testing
Mailer::capture_ok() records every message in memory and always succeeds.
Mailer::capture_error() always fails the send, which is the one a retry path
needs. Neither opens a socket.
let mailer = Mailer::capture_ok();
let mail = Mail::from_str(mailer.clone(), "noreply@example.com")?;
mail.to("ada@example.com").send(&WelcomeEmail { name: "Ada".into() }).await?;
let sent = mailer.captured().await.expect("capture mailer");
assert_eq!(sent.len(), 1);
captured() returns None for an SMTP mailer. is_capture() and
is_smtp() report which kind you hold, so a production guard can refuse to
start with a capture transport.
What this module does not own
SMTP, TLS, or cryptography. The lettre crate is re-exported as
arcature::mail::lettre.
Notifications sit on top of this
Mail is one channel. The notifications feature adds the layer above it: one
event, told to one person, over whichever channels apply – mail plus an
in-app inbox (notifications-db), a live push over WebSocket/SSE
(notifications-broadcast), or mail handed to the job queue so the send does
not happen inside the request (notifications-queue).
Reach for it when the same event has to arrive more than one way, or when
“who gets told what” is a decision worth writing down once. A single
transactional mail is what this chapter already covers. arc make:notification <name> writes the starting point.
Notifications
One event, told to one person, over whichever channels apply: an email, a row in an in-app inbox, a live push to a socket that is open right now.
Not enabled by default. notifications is absent from the crate’s default
feature list, and so are the three that build on it.
Notifier is a value, not a namespace. Nothing in Application constructs
one — you build it at startup and put it in application state, the same way
you would a Mail or a Jobs.
What a notification is
A notification is a type that knows how to render itself for each channel. The
trait has one method per channel and every one of them defaults to None:
| Method | Returns | Channel |
|---|---|---|
to_mail(&self, recipient) | Option<MailContent> | Channel::Mail |
to_database(&self, recipient) | Option<DatabaseContent> | Channel::Database |
to_broadcast(&self, recipient) | Option<BroadcastContent> | Channel::Broadcast |
use arcature::notifications::{MailContent, Notification, Recipient};
struct InvoicePaid {
amount_cents: i64,
}
impl Notification for InvoicePaid {
fn to_mail(&self, recipient: &Recipient) -> Option<MailContent> {
// No address, no mail -- and no error, because this notification is
// genuinely not a mail notification for this person.
recipient.email_address()?;
Some(MailContent::new(
"Your invoice is paid",
format!("We received {}.{:02}.", self.amount_cents / 100, self.amount_cents % 100),
))
}
}
impl Notification for Silent {} compiles and reaches nobody. That is what
makes adding a channel later additive: a notification written today keeps
compiling when a fourth method appears, and does not use it.
There is no via
Laravel names the channels in via() and renders them in
toMail/toDatabase/toBroadcast. Two places, and nothing keeps them
agreeing: a channel in via() with no method behind it throws at runtime, and
a method via() forgot is never called.
Here the channel set is derived rather than declared. A notification reaches a
channel exactly when that channel’s method returns Some, so the list is
the methods. The per-recipient decision via($notifiable) exists to make is
still available — every method receives the Recipient — but it is made in
the same place that produces the content.
to_database and to_broadcast exist whatever features are on, and so do the
Channel::Database and Channel::Broadcast variants. Rendering costs nothing
but serde_json; it is delivering that needs a feature. A method compiled
out by a feature flag would be a notification that silently changes what it
does.
The three content types
MailContent::new(subject, text) takes the plain-text body as a mandatory
argument and MailContent::html(html) adds the HTML one. That order is
deliberate: an HTML-only email is unreadable in a text client, in a screen
reader that falls back, and in the preview line every mail app shows, and it
is one of the older signals a spam filter weighs. html_body() is None
until .html(..) is called, and the last call wins. The HTML is used
verbatim — nothing escapes what a caller interpolates into it.
DatabaseContent and BroadcastContent are the same pair of fields — a
kind string and a serde_json::Value payload — and deliberately two types.
An inbox row is read on purpose and can afford detail; a live push arrives
unasked, is usually a toast or a badge, and is often smaller. A notification
that wants them identical builds both from the same value, which is one line;
one that wants them different has nowhere to say so if they share a method.
Both have two constructors. new(kind, value) cannot fail, because
serde_json::json! produces a Value infallibly. serializing(kind, &T)
takes a Serialize value and hands back the serde_json::Error. The split
exists because to_database returns an Option and an Option has nowhere
to put an error: a constructor that serialised would turn a #[serde(..)]
mistake into a notification that never appears.
The kind is the application’s own name — "invoice.paid", "mention" —
and deliberately not a Rust type path. It is stored in a row and switched on
by a front end, so deriving it from a type name would make refactor: rename
a silent protocol change.
Recipients
use arcature::notifications::{Notifiable, Recipient};
struct User {
id: i64,
email: String,
}
impl Notifiable for User {
fn recipient(&self) -> Recipient {
Recipient::new(format!("user:{}", self.id)).email(&self.email)
}
}
A Recipient is a stable key plus whatever a channel needs to reach them. The
key is the same shape the rest of the framework uses for a subject — the
string an API token is issued to — so a notification, a token and an audit
line name the same person the same way. It should be a primary key rather than
an email address, because the inbox stores it alongside every delivered row.
A fresh Recipient has no email address; email_address() returns None
until .email(..) is called, and a second call replaces the first. A
recipient with no address is ordinary rather than broken — a notification that
only writes to an inbox needs no way to email anybody.
recipient() is called once per send, so it may allocate. It must not query a
database.
Recipient implements Notifiable for itself, so notifier.send(&recipient, ..) works without a wrapper type.
The four features
| Feature | Implies | What it adds |
|---|---|---|
notifications | mail | Notification, Recipient, Notifiable, Notifier, Delivery, Channel, NotificationError, the three content types, and the mail channel |
notifications-db | notifications, database | DatabaseNotifications, StoredNotification, NotificationId, NotificationPool, Notifier::with_database — plus one table and one migration |
notifications-broadcast | notifications, realtime | BroadcastChannels, PerRecipientChannels, BroadcastNotifications, Notifier::with_broadcast |
notifications-queue | notifications, jobs | Notifier::queue, NotificationQueue, QueuedMail, MAIL_JOB, register_mail_handler |
None of the four adds a crate to the dependency graph. notifications is
mail plus the unconditional thiserror; notifications-db rides the sqlx
that database already brings, with serde_json and getrandom
unconditional; notifications-broadcast is realtime, which is tokio,
futures and bytes — axum is unconditional and no feature turns it on;
notifications-queue is jobs, which is database plus tokio and
tokio-util, both of which the default feature set already brings.
Why four and not one
notifications implies mail rather than splitting a channel-less core into
its own feature. Mail is the channel a notification system is overwhelmingly
used for, and the alternative — a notifications that can deliver nothing
plus a notifications-mail on top — would be two features and two powerset
dimensions to spare a dependency the same application has almost certainly
already enabled.
The other three earn their separation because each costs something a mail-only application should not pay:
notifications-dbbrings a schema. A table and a migration are not a line inCargo.toml; they are a thing an operator has to run and a thing a backup has to hold. An application that only sends mail should not carry them.notifications-broadcastanswers a different question from the inbox. The inbox is what a recipient sees when they arrive; the broadcast is what they see without reloading. Wanting one is not wanting the other, so the cost of each is opt-in on its own.notifications-queuechanges where the work happens. It is the only one of the four that moves work rather than adding work: an application enabling it takes on running a worker process, and one that has no worker should not be offered a method that writes rows nobody drains.
Wiring the notifier
use arcature::jobs::Jobs;
use arcature::mail::Mail;
use arcature::notifications::{
BroadcastNotifications, DatabaseNotifications, NotificationQueue, Notifier,
PerRecipientChannels,
};
let channels = PerRecipientChannels::new(64).expect("capacity is non-zero");
let notifier = Notifier::new()
.with_mail(Mail::new(mailer, "noreply@example.com".parse()?))
.with_database(DatabaseNotifications::new(pool.clone()))
.with_broadcast(BroadcastNotifications::new(channels.clone()))
.with_queue(NotificationQueue::new(Jobs::new(pool.clone())));
That example needs all four features; each with_* past with_mail is gated
on its own. Notifier::new() (and Notifier::default()) has nothing
wired: every channel is absent until it is given a backing. has_mail(),
has_database(), has_broadcast() and has_queue() report what is there.
Notifier is cheap to clone. Its Debug prints one boolean per channel and
nothing from behind them — a Mailer holds SMTP credentials and a pool holds
a database URL, and a Debug that printed either would put it in the first
log line that formats application state.
Sending
let delivery = notifier.send(&user, &InvoicePaid { amount_cents: 1250 }).await?;
assert!(delivery.reached(Channel::Mail));
The order is part of the contract: inbox, then live push, then mail. The durable local record first, then the local push, then the one thing that leaves the process. The inbox cannot fail for a reason outside the application, so writing it first means an SMTP server that is down leaves the notification visible in the application rather than losing it along with the email. The reverse order would trade a recoverable failure for an unrecoverable one.
Delivery stops at the first failing channel.
Delivery is returned rather than discarded because “reached nobody” is a
real outcome and an invisible one:
| Call | Answers |
|---|---|
delivery.channels() | the channels that ran, in the order they were tried |
delivery.reached(channel) | whether that channel ran |
delivery.queued() | the channels handed to the queue instead of run |
delivery.is_queued(channel) | whether that channel was queued rather than run |
delivery.is_empty() | whether nothing ran and nothing was queued |
channels() and queued() never overlap. A job row is not a delivery, and
folding the two together would make reached(Channel::Mail) say yes to a row
in a table.
Channel::Broadcast appears in channels() only when at least one connection
actually received the push. Nobody connected is not a failure — it is the
ordinary state of a recipient who is not looking at the application — so it
is reported as the channel not being among the ones that ran.
Nothing is delivered quietly
Asking for a channel the notifier was never given returns
NotificationError::NotConfigured instead of skipping it. A forgotten
.with_mail(..) at startup fails on the first send rather than becoming
password-reset emails that never arrive.
| Variant | Raised when | Needs |
|---|---|---|
NotConfigured { channel } | the notification rendered content for a channel with no backing | — |
NoAddress { key } | mail content for a recipient with no email address | — |
Mail { source } | the transport refused the message or could not deliver it | — |
Database { source } | the database rejected a statement or was unreachable | notifications-db |
Decode(String) | a stored row did not hold what the schema promises | notifications-db |
Timestamp(String) | a stored epoch-millisecond value is not a representable time | notifications-db, SQLite only |
IdCollision { attempts } | eight random ids were all taken | notifications-db |
Entropy | the OS randomness source was unavailable | notifications-db |
Encode(String) | a broadcast payload could not be serialized | notifications-broadcast |
Queue { source } | the job row could not be written | notifications-queue |
QueueNotConfigured | Notifier::queue was called with no queue wired | notifications-queue |
NotConfigured is also what you get for Channel::Database or
Channel::Broadcast when the feature is off entirely, rather than a compile
error. The trait methods exist in every build, so the mistake surfaces on the
first send, naming the channel that has no backing.
QueueNotConfigured is feature-gated where NotConfigured is not, because a
louder signal exists there: Notifier::queue cannot be called without
notifications-queue, so the same mistake is already a compile error.
The mail channel
The mail channel is the one notifications itself brings. A MailContent
goes through the same Mail::to(..).send(..) path a hand-written Mailable
uses, so address parsing, the From header and the transport’s error mapping
stay in one place. See Mail for the transport.
Two failures are distinguished. A notification that returns None from
to_mail for a recipient with no address is not an error — it decided mail
does not apply. A notification that returns content anyway for a recipient
with no address is a contradiction, and raises NoAddress { key }.
The database channel: an in-app inbox
notifications-db adds a table. Enabling the feature is not enough; the
schema has to be created.
The table and its migration
DatabaseNotifications::migrate() creates arcature_notifications and its
two indexes. It is idempotent, records what it applied in
arcature_notifications_schema_migrations, and is safe to run from every
replica at once: PostgreSQL takes pg_advisory_lock(71420006), MySQL takes
GET_LOCK('arcature_notifications_migrate', 10), and SQLite takes no lock
because it serialises writers itself and every statement is IF NOT EXISTS.
Call it at startup, or run the bundled SQL alongside the application’s own
migrations.
One row per notification delivered to this channel:
| Column | PostgreSQL | SQLite | MySQL 8 |
|---|---|---|---|
id | BYTEA primary key | BLOB primary key | BINARY(16) primary key |
notifiable_key | TEXT NOT NULL | TEXT NOT NULL | VARCHAR(191) NOT NULL |
kind | TEXT NOT NULL | TEXT NOT NULL | VARCHAR(191) NOT NULL |
data | JSONB NOT NULL | TEXT NOT NULL | JSON NOT NULL |
read_at | TIMESTAMPTZ, nullable | INTEGER epoch ms, nullable | DATETIME(6) NULL |
created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | INTEGER NOT NULL, computed default | DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) |
Indexes: arcature_notifications_inbox_idx on (notifiable_key, created_at DESC) for the listing, and arcature_notifications_unread_idx on
(notifiable_key, read_at) for the badge.
Three things about that schema are decisions rather than accidents:
notifiable_keyis not a foreign key. A notification is a record of something that was said, and it should outlive a soft-delete or an account merge rather than vanish with it. The cost is that nothing cascades: an account deletion has to calldelete_all_foritself.read_atis nullable, and null is the whole meaning of unread. A boolean would answer “has this been read” and nothing else; a timestamp answers “when”, which is what an inbox grouping by day and a support engineer reading a complaint both need.- There is no expiry column. Unlike an API token, a notification is not a credential and nothing gets safer by dropping it on a schedule.
SQLite stores both timestamps as epoch milliseconds because it has no timestamp type: text timestamps compare correctly only while every writer agrees on the format down to the digit, and integers always do. Sub-millisecond precision is dropped there.
Reading and writing the inbox
let inbox = DatabaseNotifications::new(pool);
inbox.migrate().await?;
let row = inbox
.store("user:42", &DatabaseContent::new("invoice.paid", serde_json::json!({ "amount": 4200 })))
.await?;
assert_eq!(inbox.unread_count("user:42").await?, 1);
assert!(inbox.mark_read("user:42", row.id()).await?);
| Call | Returns |
|---|---|
store(key, &content) | the written StoredNotification |
inbox(key, limit) | that recipient’s notifications, newest first, at most limit |
unread(key, limit) | the unread ones only, same order and bound |
unread_count(key) | u64, a COUNT rather than the length of a listing |
mark_read(key, id) | bool — whether the statement changed a row |
mark_all_read(key) | u64 rows affected |
delete(key, id) | bool — whether it existed |
delete_all_for(key) | u64 rows affected |
prune_read_before(cutoff) | u64 rows affected, across all recipients |
pool() | the NotificationPool underneath |
NotificationPool is the application’s own pool — the same Pool the
database feature exposes. The inbox opens no connection of its own.
There is no unbounded listing. limit is mandatory on both readers, because
an inbox read is a page render and a method that could return every
notification a long-lived account ever received is a memory spike waiting for
the one account that has them.
unread_count is the badge. It is a COUNT because the number next to a bell
is asked for on far more page loads than the inbox is opened, and it should
not cost the rows.
mark_read returning false does not say which of three things happened: no
such notification, somebody else’s notification, or one already read. That is
deliberate — a handler that could distinguish “not yours” from “does not
exist” is an oracle for which ids exist, and none of the three calls for a
different response. A notification that was already read keeps its original
read time, because the statement carries read_at IS NULL.
StoredNotification exposes id(), notifiable_key(), kind(), data(),
read_at(), is_read() and created_at(). The payload is a
serde_json::Value rather than a typed struct: the rows one query returns
were written by different notifications with different shapes, and a list that
could hold only one shape would not be an inbox. Match on kind() first, then
deserialize.
The inbox cannot be read across recipients
Every method takes the recipient key, including the ones that already have an
id, and the key is in the WHERE clause rather than checked in Rust
afterwards. There is no statement in the store a handler can reach with an id
alone. Passing somebody else’s notification id returns false, not a
deletion.
This is the difference between an ownership check a handler can forget and one it cannot reach around. An inbox is exactly the endpoint that grows an insecure-direct-object-reference bug.
prune_read_before is the single exception, and it is scoped by read_at
instead: it can only reach rows a recipient has already seen.
Ids are random
A NotificationId is 16 bytes from the OS randomness source, with no
fallback — an id drawn from a clock is guessable, and Entropy is reported
rather than worked around. store draws a fresh id and retries on a clash up
to eight times before returning IdCollision; eight collisions on a 128-bit
id is not chance, it is a randomness source that is not random.
Random rather than sequential because the id appears in the URL a “mark as read” button posts to. Guessing one still gets nobody anywhere, since every statement is recipient-scoped too, but it makes the two defences independent rather than one defence written twice.
NotificationId::from_hex(text) parses the 32-character spelling that arrives
from a route parameter, returning None for anything that is not exactly 32
hex digits. to_hex() writes it back in lowercase; uppercase input parses to
the same id.
Retention
Nothing expires on its own. An unread notification is still worth reading a
month later, so how long an inbox keeps history is an application decision,
made by calling prune_read_before(cutoff) on whatever schedule suits.
That sweep only ever touches notifications that were read. An inbox that quietly empties itself of things nobody has seen is worse than one that grows.
The broadcast channel
notifications-broadcast pushes to whoever is connected right now, over the
realtime WebSocket and SSE machinery.
realtime offers one thing: a Broadcast, a bounded fanout where every
subscriber receives every message. That is right for “the build status
changed” and wrong for something addressed to a person — publishing
notifications onto one shared Broadcast would hand every connected user
every other user’s notifications.
So the broadcast channel is not a channel. It is a BroadcastChannels
resolver: given a recipient key, hand back the Broadcast that recipient’s
connections are subscribed to, or None.
pub trait BroadcastChannels: Send + Sync + fmt::Debug {
fn channel_for(&self, notifiable_key: &str) -> Option<Broadcast>;
}
Targeting is then which channel the bytes go into, not a filter applied afterwards. There is no code path that puts one recipient’s payload into another’s channel, so there is no rule for a handler to remember.
If you write your own resolver — grouping by tenant, team or document — the contract is that everything subscribed to the returned channel is entitled to see this recipient’s notifications. A resolver that maps two people onto one channel to save an allocation has turned a targeted notification into a leak, and nothing downstream can detect it.
PerRecipientChannels
The built-in resolver: one Broadcast per recipient key, created when the
first connection subscribes.
let channels = PerRecipientChannels::new(64).expect("capacity is non-zero");
// A websocket handler subscribes the connection it has accepted.
let subscription = channels.subscribe("user:1");
assert_eq!(channels.connections("user:1"), 1);
new(capacity) returns Option<Self> and gives None for a capacity of
zero — a channel that can hold nothing drops every message. There is no
default capacity; the argument is mandatory. It is per recipient, and it
bounds how far one connection may fall behind before it starts missing
messages. It does not need to be large: a notification the recipient missed is
still in the inbox if notifications-db is on, and a connection thousands of
notifications behind has a problem a bigger buffer postpones rather than
solves.
Dropping the Subscription releases the connection. The map entry is
reclaimed lazily, on a later call to subscribe, which keeps the drop path
free of a lock. connections(key), len() and is_empty() report the shape;
len() counts entries including unswept ones, so it is a metric rather than a
count of who is online.
channel_for deliberately does not create. A resolver that created a channel
per push would grow the map once per notification sent to someone offline —
which is most of them — and none of those channels would have a subscriber to
sweep it away.
Debug on PerRecipientChannels prints the capacity and the entry count,
never the keys: the keys are recipient identifiers, and printing them would
put a list of everyone currently online into a log line.
What reaches the browser
BroadcastNotifications::push(key, &content) publishes the JSON object
{"kind": <kind>, "data": <data>} and returns how many connections received
it. A subscriber gets those bytes verbatim. Nothing filters them on the way
out, so a field the recipient should not learn does not belong in data even
if the page would not display it.
Ok(0) means the recipient has no live connection. The only error is
Encode, for a payload that could not be serialized. A recipient with no
channel, a channel with no subscribers, and a channel whose last subscriber
dropped between the lookup and the send all report zero.
The inbox and the push are complements
The push is what a recipient sees without reloading; the inbox is what they see when they arrive. A recipient who was offline missed the push and lost nothing, provided the inbox was written too.
An application enabling notifications-broadcast alone is choosing
best-effort delivery.
Queueing the mail channel
Notifier::send talks to the SMTP server while the request is still open.
Notifier::queue, behind notifications-queue, writes a job row instead and
lets a worker do the talking.
let delivery = notifier.queue(&user, &InvoicePaid { amount_cents: 1250 }).await?;
assert!(delivery.is_queued(Channel::Mail));
assert!(!delivery.reached(Channel::Mail));
Wiring a queue changes nothing about send, which still sends inline. The two
are separate methods so that a handler asking to defer is saying so, rather
than finding out from whether startup happened to call with_queue.
Only mail is queued
The inbox row and the live push still run inline, in the same order send
runs them. Both for reasons about correctness rather than taste:
- The inbox is a write to the same database the job row goes into. Deferring it would buy nothing and cost the guarantee that matters: a recipient who opens the application immediately after the event would find an empty inbox, because the row they are looking for is sitting in a queue behind it.
- The live push reaches the connections held by this process. A worker is a different process and holds none of them, so queueing a push is not deferring it — it is dropping it.
A queued send is an inline send with one thing moved: the part that leaves the machine.
What the latency claim is
The request stops waiting on SMTP. A connection, a TLS handshake, and a server
that may itself be waiting on a DNS lookup become one INSERT into a table
the request is already connected to.
The variation goes with it. How long an SMTP conversation takes depends on the address at the other end — whether the domain resolves, whether the server greylists, whether the recipient exists — and a handler that answers a registration form at a speed that depends on those things is telling anyone with a stopwatch which addresses are already taken. The enqueue costs the same for an address that will bounce as for one that will not.
It does not make the handler constant-time. The inbox write and the live push still happen inline, and password hashing — the usual reason a registration handler is timed — is somewhere else entirely. This removes one oracle, not the category.
An email can arrive twice
Jobs is at-least-once. A worker that hands a message to the SMTP server and then dies before marking the row complete leaves a job another worker will claim, and the message is sent again.
That is not a bug that can be fixed here. Handing bytes to a remote server and recording that you did are two operations in two systems, and no amount of care makes them one. Anything whose second delivery is harmful — a one-time code consumed on send, an email that charges a card — should not rely on the send being the only record that it happened.
The job, and the worker that runs it
MAIL_JOB is the shared identity: kind "arcature.notifications.mail",
version 1, three attempts. It is public because the two halves live in
different processes — the web process enqueues against it, the worker
registers a handler for it — and a disagreement of one character would leave
jobs sitting in the table with nobody to run them. The kind is namespaced
under arcature. because the table is shared; an application’s own job called
mail would otherwise collide.
// In the worker process.
use arcature::jobs::Registry;
use arcature::notifications::register_mail_handler;
let mut registry = Registry::new();
register_mail_handler(&mut registry, mail)?;
The Mail given to the worker need not be the one the web process was built
with, and usually is not — once its mail goes through the queue, the web
process may have no SMTP credentials at all. Registering twice returns
RegisterError::AlreadyRegistered, because two registrations mean two
transports for one job and which one wins is an accident of call order.
Retry classification is the one decision the handler makes. A message the transport could not build — a malformed address, a body that is not valid MIME — is permanent; nothing about waiting fixes an address that will not parse. Everything else is retryable, because SMTP reply codes are advisory and a 5xx from a misconfigured relay is not the recipient’s fault. Retrying a genuinely permanent failure costs two extra attempts; treating a temporary one as permanent costs the email.
What is stored in the row
QueuedMail holds the rendered email — to, subject, text and an
optional html — not the notification.
Laravel serializes the notification object and re-renders it in the worker. That needs every notification to be serializable, plus a registry mapping a stored type name back to a Rust type, and it means the content is produced by whatever version of the code the worker is running, which during a deploy is not the version that decided to send it.
Here the render happens in the request, where it is a few string allocations,
and what is stored is the result. Nothing new is required of Notification, a
notification holding borrowed data queues as well as any other, and the email
that arrives says what the code that sent it meant.
The cost is that the payload carries the body rather than a reference to it,
so a large email is a large row. MAIL_JOB inherits the queue’s default
payload limit, and an oversized payload is refused at enqueue rather than
truncated.
QueuedMail::request() hands back the JobRequest, which is what an
application needs to enqueue the mail in the same transaction as whatever
caused the notification, via Jobs::enqueue_tx. Enqueueing outside that
transaction is a job that runs for a change that then rolled back.
NoAddress is checked at queue time rather than in the worker. An address
that does not exist is not going to appear by the time the job runs, and
failing now puts the error in the request that caused it instead of in a dead
job row. QueueNotConfigured is likewise loud rather than falling back to an
inline send — the fallback would take exactly the latency the caller asked to
avoid, and only under load, which is when it is least affordable and hardest
to see.
arc make:notification
arc make:notification InvoicePaid
Writes app/notifications/invoice_paid.rs and registers the module in the
sibling mod.rs.
The generated file implements all three channels, because every method on
Notification defaults to None, which makes a channel nobody considered
indistinguishable from a channel that was considered and declined. Deleting
the two that do not apply is how the decision gets recorded.
The kind string is a named constant — const KIND: &str = "invoice.paid",
the file stem with underscores turned into dots — with the reason beside it.
It is not derived from the Rust type, so renaming the type stays free and
changing the protocol stays a migration.
It is one of the few make: kinds whose output does not compile in a
fresh application. arc new does not enable notifications, and the
generator does not edit Cargo.toml: a generator that reaches into the
manifest is a generator that can break a build it was never pointed at. The
artifact’s notes name the feature instead, and add that to_database and
to_broadcast render whatever the features are but need notifications-db
and notifications-broadcast to deliver.
Limits
What needs a migration. Only notifications-db. It adds
arcature_notifications and arcature_notifications_schema_migrations.
Enabling the feature does not create them — call
DatabaseNotifications::migrate() at startup or run the bundled SQL yourself.
An inbox whose table is missing fails on the first notification, which is the
same outage discovered later. Nothing else here touches the schema: the mail
channel has no storage, the broadcast channel has none, and
notifications-queue writes into the tables Jobs already owns
through jobs.migrate().
What needs a worker process. Only notifications-queue. Notifier::queue
writes a row and returns; unless something runs a Worker whose registry had
register_mail_handler called on it, the rows accumulate and none of the
emails are sent. Nothing warns.
arc queue work is not that something, and reaching for it is the
mistake this paragraph exists to prevent. It builds a worker with an empty
registry — it sweeps expired leases, marks jobs it has no handler for as
dead, and prints a note saying so. Pointed at a queue of notification mail it
will discard the rows rather than send them. Real dispatch is the
application’s own in-process worker, through ApplicationBuilder::jobs.
Registering the handler is a second, separate step: enabling the feature and
running a worker are not enough on their own, because a worker with no
registration for arcature.notifications.mail leaves the rows exactly where
they are — or, with arc queue work, does something worse than leave them.
The broadcast is per process, and there is no switch. Broadcast wraps a
tokio::sync::broadcast, a channel between tasks inside one process. A push
from instance A reaches only the connections held by instance A. Nothing
errors and nothing warns — subscribers on instance B never see it. This bites
notifications harder than the rest of realtime, because a notification is
exactly the kind of thing an application sends from a background worker, and a
worker holds none of the web process’s sockets: a push from a queue worker
reaches nobody at all. Until a cross-process bridge exists, an application
running more than one instance should treat the push as an optimisation over
the inbox and enable notifications-db alongside it. The same limit, and the
three honest ways to live with it, are set out in
Deployment.
A failed send does not report what already succeeded. Delivery stops at
the first failing channel and the whole call returns Err, so a mail
transport failure after the inbox row was written gives you
NotificationError::Mail and no Delivery. The row is still there. The
channel order exists so that this is the recoverable direction, but the caller
cannot learn from the error which earlier channels ran.
No deduplication and no delivery log. Sending the same notification twice
writes two inbox rows and sends two emails. Delivery is returned to the
caller and stored nowhere.
No preference storage. There is no opt-out table and no per-channel
subscription model. The mechanism is returning None from a channel method
for that recipient; where the preference is kept is the application’s
decision.
No templating. MailContent takes strings. The HTML body is used
verbatim, and nothing escapes what a caller interpolates into it — an email
body is a good place to land a phishing link. Render it through a template
engine that escapes.
Three channels, no extension point. Mail, database and broadcast are the
methods on the trait; Channel is #[non_exhaustive] and a fourth would be
added here rather than by an application. There is no SMS, push-notification
or chat channel.
Nothing wires it for you. There is no Application::notifications. Build
the Notifier and put it in state.
Realtime
WebSocket and Server-Sent Events over axum. One bounded
tokio::sync::broadcast channel fans a payload out to every connection the
process is holding.
The wrappers are thin on purpose. There is no Arcature realtime protocol, no
X-Arcature-* headers, and no client package to install: a browser talks to
this with WebSocket and EventSource, the two APIs it already has. The
rejected alternative is the Pusher/Echo shape — a framed protocol carrying
subscribe and unsubscribe messages, plus a JavaScript client to speak it. That
buys channel multiplexing over a single socket, and it costs a second wire
format to version, a package to publish and keep in step with the server, and
a debugging story where the network tab shows you frames to decode instead of
events to read. axum::extract::ws and axum::response::sse stay first-class
escape hatches; when the wrapper is the wrong shape, drop to the thing it
wraps.
Realtime is app-owned. The framework mounts no route, registers no service and
holds no global. You build a Broadcast, a Registry and a ShutdownConfig
once, put them in your state, and clone WebSocketEndpoint / SseEndpoint
into handlers.
Turning it on
realtime is in default, so cargo add arcature already has it. The
manifest entry is the whole of what it pulls:
realtime = ["dep:tokio", "dep:futures", "dep:bytes"]
Three optional dependencies, and on a default build all three are already
present — tokio from macros, bytes and futures from storage-fs. So
on the default feature set realtime adds no crate to the graph, only edges.
On a default-features = false build that names realtime, it adds those
three.
What it does not pull is the WebSocket implementation. The axum dependency
enables ws unconditionally, so tungstenite compiles into every build of this
crate whether realtime is on or off. Turning the feature off removes
Arcature’s wrappers, not the protocol underneath them.
arcature::realtime is the module. Broadcast, SseEndpoint and
WebSocketEndpoint are also re-exported at the crate root and from the
prelude.
Broadcast and publishing
use arcature::realtime::{Broadcast, ChannelPayload};
// Capacity is a required argument. There is no default and no `Default` impl.
let broadcast = Broadcast::new(256).expect("capacity is non-zero");
let payload = ChannelPayload::from_bytes(serde_json::to_vec(&update)?);
let delivered: usize = broadcast.publish(payload)?;
Broadcast::new returns Option<Self> and gives None for a capacity of
zero. A channel that can retain nothing drops every message, so handing one
back as if it had worked would be worse than saying no. It never panics.
ChannelPayload is opaque bytes behind an Arc<[u8]>. The channel stores a
value once and clones it per receiver, so the Arc makes that clone a
refcount bump rather than a copy of the payload. There is no JSON helper and
no envelope: serialize it yourself, and the bytes on the wire are the bytes
you published.
publish has one result that surprises people:
| Situation | publish returns |
|---|---|
n subscribers | Ok(n) |
| no subscribers | Err(ChannelError::Closed) |
all Broadcast handles dropped | Err(ChannelError::Closed) |
| buffer full | Ok(n) — tokio overwrites the oldest retained message |
An empty channel and a dead channel are the same error, because
tokio::sync::broadcast::Sender::send reports failure when the receiver count
is zero and publish maps every send failure to Closed. Treat Err(Closed)
as “nobody was listening”, not as “something broke”. tests/realtime.rs pins
this.
Broadcast::capacity() returns the number you passed. Tokio rounds the ring
buffer up to the next power of two, and lag is measured against the
rounded size, so a Broadcast::new(100) reports 100 and starts lagging
receivers at 128.
ChannelError has a third variant, Full. Nothing in this crate constructs
it. Both endpoints match on it, and neither arm can be reached.
broadcast.subscribe() hands back a Subscription whose recv() yields the
next payload. Subscription also exposes its pub rx, the raw
broadcast::Receiver, so it composes with tokio::select!.
Broadcast::subscriber_count() is a separate atomic maintained by
subscribe() and Subscription::drop, not tokio’s own receiver count — a
receiver you conjure by calling rx.resubscribe() yourself is invisible to
it.
Subscribing over SSE
use arcature::realtime::{OriginPolicy, Registry, SseEndpoint, SseLimits,
ShutdownConfig, VerifiedOrigin};
let sse = SseEndpoint::new(
broadcast.clone(),
OriginPolicy::allow_exact(VerifiedOrigin::from_trusted("https://app.example.com")),
registry.clone(),
SseLimits::conservative(),
shutdown.clone(),
);
// In a handler:
sse.clone().handle(headers, channel_id).await
Admission is origin, then the connection limit. There is no authorizer on this
path: SseEndpoint::new takes one Broadcast and every admitted request
subscribes to it. The channel_id argument to handle is accepted and
ignored. If SSE needs per-channel authorization, it belongs in a layer above
the handler — an SSE request is a plain GET, so ordinary middleware works on
it in a way it cannot for an upgrade.
The first event on the stream is a retry: preamble carrying
SseLimits::retry_ms, which tells EventSource how long to wait before
reconnecting. After that, each published payload becomes one data: event.
The payload is converted with str::from_utf8(..).unwrap_or(""). A payload
that is not valid UTF-8 does not error, is not logged, and is not dropped: it
becomes an event with an empty data field, which browsers ignore. Publish text
to a channel with SSE subscribers on it.
Keep-alive is a :keep-alive comment every keep_alive_interval. It exists so
proxies and load balancers with an idle-read timeout do not cut a quiet stream.
Subscribing over WebSocket
use arcature::realtime::{Authorizer, Broadcast, WebSocketEndpoint, WsLimits};
#[derive(Clone)]
struct DocumentAccess { /* ... */ }
impl Authorizer for DocumentAccess {
fn authorize(
&self,
headers: &HeaderMap,
channel_id: &str,
) -> impl Future<Output = Option<Broadcast>> + Send {
let this = self.clone();
let channel_id = channel_id.to_owned();
async move { this.channel_if_permitted(&channel_id).await }
}
}
let ws = WebSocketEndpoint::new(
DocumentAccess::new(state),
origin_policy,
registry.clone(),
WsLimits::conservative(),
shutdown.clone(),
);
Authorizer returns Option<Broadcast>, not bool. That is the design
decision in this module. Authorizing a connection and choosing its channel are
the same call, so there is no code path where a request names a channel and
gets subscribed to it without something having handed that Broadcast back. A
channel name never implicitly authorizes, because a name is not what selects
the channel. The rejected alternative — a predicate plus a name-to-channel
lookup — puts the two halves in separate places, and then the lookup has to be
trusted to have been guarded.
AllowAll implements the trait by admitting everything to one fixed channel.
It is there for tests.
Admission is origin, then the authorizer, then the connection limit, and all three run before the upgrade. A refused request gets a clean HTTP status on an unupgraded connection rather than a socket that opens and then closes.
Once upgraded, the loop:
- sends each published payload as a binary frame, not text
- answers nothing the client sends.
Closeends the loop andPongrefreshes the liveness clock. Every other inbound frame is matched and discarded. The server parses no client payload, so there is no request surface here. - pings every
heartbeat_intervaland closes when the last pong is older thanheartbeat_timeout
The pong check is elapsed > heartbeat_timeout, evaluated only when the
heartbeat fires, and the clock starts at connection open rather than at the
first pong. With the defaults that means a client that never pongs is closed at
the 60-second tick: at 20s and 40s the elapsed time is not yet greater than
40s, so both of those send another ping.
Note the asymmetry with SSE. The same publish call reaches a WebSocket client
as opaque binary and an SSE client as a UTF-8 data: line. A payload that is
valid UTF-8 arrives intact on both.
The connection registry and its cap
Registry is an Arc-backed counter plus a drain signal. It is the only thing
that knows how many realtime connections a process is holding, and it is shared
between the WebSocket and SSE endpoints.
The cap is not stored on the Registry. It is passed to Registry::acquire by
the caller, and both endpoints pass ShutdownConfig::max_connections(). So the
number lives on the shutdown config, and two endpoints built with two different
ShutdownConfigs enforce two different numbers against one shared count.
acquire refuses when current >= max — at the cap, not past it. A cap of 100
admits 100 connections. It returns a ConnectionGuard, and dropping the guard
decrements the count and wakes any drain waiter.
Both transports hold their guard for the whole connection.
run_connection takes the guard as an argument and holds it for the life of
the socket. SseEndpoint::handle carries its guard in the stream’s unfold
state, so it lives exactly as long as the stream and drops when the client
disconnects or the drain ends it. The cap therefore bounds concurrent
connections rather than concurrent admissions, on both paths.
This was not always true of SSE. The line that should have held the guard was
let _ = guard;, which drops it immediately — _ is not a binding — under a
comment claiming the opposite, so the cap gated the instant of admission and
nothing after it. It is fixed, and
a_second_sse_stream_is_refused_while_the_first_is_still_held in
tests/realtime.rs fails against the old code rather than merely passing
against the new.
What a client sees when it lags
A subscriber that falls further behind than the (rounded-up) channel capacity
has messages overwritten under it. Tokio reports this once, as
RecvError::Lagged(n), and then resumes the receiver at the oldest message
still retained — the connection is not closed and the subscription is not
broken.
What reaches the client:
| Transport | On lag | Missed-message count |
|---|---|---|
| SSE | a :lagged comment line | not sent |
| WebSocket | nothing at all — the loop continues | not sent |
Subscription::recv maps RecvError::Lagged(n) onto a unit
ChannelError::Lagged, so the count of dropped messages is discarded before
either endpoint could report it. An SSE comment is invisible to EventSource:
no listener fires for it, and a browser client cannot observe the lag through
that API. A WebSocket client gets no signal whatsoever.
The practical consequence is that a lagging client silently has a hole in its
stream. If the messages matter, they need a durable source the client can
reconcile against — the pattern notifications-broadcast uses, where the live
push is an optimisation over an inbox row that is still there.
Graceful drain
use std::time::Duration;
use arcature::realtime::{self, ShutdownConfig};
let shutdown = ShutdownConfig::new(1_000); // max_connections
// On shutdown:
realtime::drain(®istry, &shutdown, Duration::from_secs(10)).await?;
realtime::drain is two steps: shutdown.begin_drain(), then
registry.drain(bound). begin_drain is idempotent — it flips an AtomicBool
with swap and only notifies waiters on the transition, so calling it twice
from two signal paths is safe. ShutdownConfig carries both the connection cap
and the drain flag; it is Arc-backed and cheap to clone into every endpoint.
registry.drain(bound) returns Ok(()) immediately if the live count is
already zero. Otherwise it waits for zero within bound and then re-reads the
count, returning Err(RealtimeError::Shutdown { remaining }) if any connections
are still live. The waiter is woken by each guard drop and also re-checks every
100ms, so a missed notification costs latency rather than a hang.
How each transport responds:
| Transport | Response to begin_drain |
|---|---|
| WebSocket | drain_notified() is a select! arm, so the loop sends a Close frame and exits at its first pass |
| SSE | the flag is read once per event, before the wait for the next payload |
A new connection during a drain is not refused. Neither handle checks
is_draining() during admission, so an arriving WebSocket is upgraded and then
immediately closed, and an arriving SSE request gets its retry: preamble
followed by end-of-stream — which, with the default 3-second retry hint, means
EventSource reconnects into the same outcome three seconds later. Stop
accepting realtime requests at the router or the load balancer if that
reconnect loop matters.
Defaults
Everything the two conservative() constructors set, and the two values that
have no default at all.
| Setting | Default | Source |
|---|---|---|
| Channel capacity | none — required argument to Broadcast::new; None for 0 | Broadcast::new |
| Connection limit | none — required argument to ShutdownConfig::new | ShutdownConfig::new |
| Origin policy | DenyAll (the Default impl) | OriginPolicy |
| SSE retry hint | 3000 ms | SseLimits::conservative() |
| SSE keep-alive interval | 15 s, sent as a :keep-alive comment | SseLimits::conservative() |
| WS max message size | 65536 bytes (64 KiB) | WsLimits::conservative() |
| WS max frame size | 65536 bytes (64 KiB) | WsLimits::conservative() |
| WS heartbeat interval | 20 s | WsLimits::conservative() |
| WS pong timeout | 40 s (strict >, checked on heartbeat ticks) | WsLimits::conservative() |
SseLimits, WsLimits and ShutdownConfig have no Default impl. Ask for
conservative() or fill in the fields; the numbers above are not applied to
anything you did not construct. Registry::default() exists and is an empty
registry — it carries no cap of its own.
Statuses a refused connection gets, from admission_status:
| Refusal | Status |
|---|---|
| Origin denied | 403 Forbidden |
Authorizer returned None (WebSocket only) | 403 Forbidden |
| At or above the connection limit | 503 Service Unavailable |
The mapping is an explicit function rather than an IntoResponse impl, so a
refusal returns a status and no body. A denied origin and a failed authorization
are deliberately the same status: the difference between “you are not from here”
and “you may not have this channel” is information the caller does not need.
OriginPolicy defaults to DenyAll, and a policy with an allow-list denies a
request that carries no Origin header at all.
VerifiedOrigin::from_header rejects a non-ASCII value; from_trusted
validates nothing, which is the point of the name. Matching is exact string
equality — an origin is public, so constant-time comparison is not wanted here.
Limits
Fan-out reaches one process, and there is no switch. Broadcast wraps a
tokio::sync::broadcast channel, which is a channel between tasks inside one
process. A message published on instance A reaches only the WebSocket and SSE
subscribers connected to instance A. Nothing errors and nothing warns:
subscribers on instance B never see it, because the channel delivered correctly
to everyone it can see and it cannot see the other process. With two instances
and clients spread evenly, roughly half of every broadcast is missing from a
given client’s view. This is the one limit here with no configuration switch —
sessions share through session-store-db and rate limiting shares through
RateLimit::redis, and realtime has no equivalent. Deployment
lists the three honest ways to live with it: run one instance, pin realtime
upgrades to one instance, or write the bridge by hand. A Redis pub/sub bridge is
the obvious general answer and is deliberately not written, because its delivery
semantics, ordering and back-pressure would have to be decided rather than
inherited.
Publishing from a job worker reaches nobody. It is the same limit with a sharper edge: a worker is a different process, and it holds none of the web process’s sockets.
An idle SSE stream does not notice a drain. The is_draining() check runs
once per event, immediately before the wait for the next payload. A stream
parked in that wait when the drain begins stays parked. It ends after delivering
one more payload, or when the last Broadcast handle drops and the channel
closes. The keep-alive tick does not help: axum emits the keep-alive comment
when the inner stream is pending and does not re-enter it, so from the client’s
side the connection keeps looking healthy while the server is trying to shut
down. Because the stream holds its connection guard for its whole life, the registry
does count it, so a parked stream does not vanish from the drain — it stalls
it. realtime::drain waits out its full bound and then returns
Err(RealtimeError::Shutdown { remaining }) naming the streams still open,
rather than returning Ok(()) while they linger.
The WebSocket path copies each payload per subscriber. The Arc<[u8]> keeps
the channel’s own fan-out cheap, but the send does
Bytes::from(payload.as_bytes().to_vec()), which allocates and copies once per
connection per message.
Three public variants are unreachable from inside this module.
ChannelError::Full, RealtimeError::Protocol { hint } (and every
ProtocolHint) and RealtimeError::Channel(_) are declared, matched or mapped
to a status, and never constructed by any code in the crate. They are usable by
an application that constructs them; do not write a handler that waits for the
framework to hand one over.
What this module does not do
No message history and no replay. A subscriber receives what is published after
it subscribes, and a lagging subscriber’s missed messages are gone. If a client
needs to catch up after a reconnect, that comes from your database, not from
here — there is no Last-Event-ID handling and no id: field on the events.
No presence, no rooms, no channel registry. There is no list of who is
connected, no join or leave notification, and no server-side directory mapping
names to channels: a Broadcast is a value your application holds, and the
Authorizer is the only thing that turns a name into one.
No client-to-server messages. The WebSocket loop discards every inbound frame
except Close and Pong. This is one-directional fan-out, and a client that
needs to tell the server something should post to a route.
No routes and no automatic wiring. The framework mounts nothing at /ws or
/events, registers no service, and reads no configuration. Every value in this
chapter is one your application constructs and stores.
No cross-process bridge, as above. No ordering guarantee across channels —
ordering holds within one Broadcast, which is all a single ring buffer can
promise. No backpressure on the publisher: publish never blocks and never
fails because a subscriber is slow; the slow subscriber lags instead.
API
RFC 9457 problem details for errors, and an OpenAPI 3.1 document derived from the application graph. Two subjects behind two features, sharing one rule: both are generated from what the code already declares, never written a second time by hand.
Turning it on
arcature::api compiles unconditionally. Problem, ProblemBuilder,
ProblemKind and PROBLEM_JSON exist in a build with no features at all.
The reason is the validation subsystem: it answers a failed #[validate]
with a Problem, and a feature gate under Problem would make validation
depend on api.
| Feature | In default | What it adds |
|---|---|---|
| none | — | arcature::api — Problem, ProblemBuilder, ProblemKind, PROBLEM_JSON |
api | yes | Problem and ProblemKind in the prelude, http::json(), Bound<T> (with dx + database), TestResponse::assert_problem (with test-kit). Pulls http and validator. |
api-docs | no | api + uag, which is what compiles arcature::uag::codegen::openapi |
api-docs is in neither default nor fullstack. An API description is a
map of the attack surface, so it is named explicitly or not at all.
A problem document
use arcature::{Problem, ProblemKind};
Problem::of(ProblemKind::NotFound)
.with_detail("user 42 does not exist")
.with_instance("/users/42")
{
"type": "urn:arcature:problem:not-found",
"title": "Resource not found",
"status": 404,
"detail": "user 42 does not exist",
"instance": "/users/42"
}
The IntoResponse impl sets the status from the problem, Content-Type: application/problem+json, and Content-Length.
| Member | Comes from | Omitted when |
|---|---|---|
type | kind.type_uri(), or the URI given to custom | never |
title | kind.title(), or the status reason phrase for custom | never |
status | kind.status(), or the status given to custom | never |
detail | with_detail(..) / .detail(..) | not set |
instance | with_instance(..) / .instance(..) | not set |
Three constructors:
| Constructor | For |
|---|---|
Problem::of(kind) | one of the distinguished categories |
Problem::builder(kind) | the same, chained, finished with .build() |
Problem::custom(type_uri, status) | a category outside the list |
Problem::custom takes the title from the status reason phrase —
StatusCode::PAYMENT_REQUIRED gives "Payment Required" — falling back to
"Request error" for a status with no canonical reason. Pass "about:blank"
as the type when the status is the whole story.
This is not a { "success": false, "message": "..." } envelope, and it is not
mandatory. A handler returning any IntoResponse is free to ignore Problem
entirely.
Extensions
Anything beyond the five standard members is an extension member, serialized
flat alongside them. with_extension(key, value) and .extension(key, value)
add one; with_extensions(&value) and .extensions(&value) add every
top-level pair of a value that serializes to a JSON object.
Four things are dropped in silence:
| Dropped | Why |
|---|---|
a key equal to type, title, status, detail or instance | an extension must never be able to rewrite a standard member. A key that could set status to 200 on a 500 is the attack. |
a value that serializes to JSON null | absent and null say the same thing |
| a value whose serialization fails | a response is not the place to discover it |
a with_extensions argument that is not a JSON object | there are no top-level pairs to take |
Extensions live in a BTreeMap<String, Value>: one entry per key, last write
wins.
The detail member must be short and client-safe. Nothing in Problem::of,
Problem::custom or the IntoResponse impl adds server-side context, so what
leaves is what you put in. Extension members are entirely the application’s
responsibility.
If serializing the whole document fails, the body falls back to a fixed
urn:arcature:problem:internal string rather than panicking. The status line
is still the problem’s own status; only the body is replaced.
Problem derives Debug and Clone, and implements Serialize by hand. It
is not PartialEq and not Deserialize. ProblemKind derives Debug,
Clone, Copy, PartialEq and Eq.
The kinds
| Variant | Status | title | type |
|---|---|---|---|
BadRequest | 400 | Bad request | urn:arcature:problem:bad-request |
MalformedJson | 400 | Malformed JSON request body | urn:arcature:problem:malformed-json |
Authentication | 401 | Authentication required | urn:arcature:problem:authentication |
Authorization | 403 | Access denied | urn:arcature:problem:authorization |
NotFound | 404 | Resource not found | urn:arcature:problem:not-found |
MethodNotAllowed | 405 | Method not allowed | urn:arcature:problem:method-not-allowed |
Timeout | 408 | Request timed out | urn:arcature:problem:timeout |
Conflict | 409 | Request conflicts with current state | urn:arcature:problem:conflict |
PayloadTooLarge | 413 | Request body too large | urn:arcature:problem:payload-too-large |
UnsupportedMediaType | 415 | Unsupported media type | urn:arcature:problem:unsupported-media-type |
Validation | 422 | Validation failed | urn:arcature:problem:validation |
RateLimit | 429 | Rate limit exceeded | urn:arcature:problem:rate-limit |
Internal | 500 | Internal server error | urn:arcature:problem:internal |
Unavailable | 503 | Service unavailable | urn:arcature:problem:unavailable |
ProblemKind::ALL is the same fourteen as a &'static [ProblemKind], kept by
hand so that adding a variant without adding it there fails a test rather than
quietly narrowing what the tests check.
The type values are URNs, not URLs. The rejected alternative was an
https:// URI under a docs domain, which reads better and promises a page
that has to stay alive at that exact address for as long as any client is
running. RFC 9457 permits a type that does not dereference, and a client is
required to treat an unknown one as about:blank, so the URN costs nothing
and commits to nothing.
The list is closed. An application-specific category is Problem::custom,
not a new variant.
Turning a bare status into a kind
ProblemKind::for_status(status) -> Option<ProblemKind> is what gives a
status a body when whatever produced it did not.
| Status | Result |
|---|---|
any status in the table above, except 400 | that row’s variant |
400 | BadRequest, never MalformedJson |
anything else — 402, 418, 502, 504 | None |
The mapping is partial on purpose. 400 resolves to the generic kind because
a bare 400 arriving from a layer is not evidence about JSON, and a status
with no distinguished kind gets a generic document rather than being pushed
into a category it does not belong to.
How a framework error becomes a problem response
There are two paths through the framework, and they are not the same code.
Errors a layer produced: ErrorMapping
Most error responses in a Rust web stack come from something other than the
application. Axum answers an unmatched path with a bare 404; tower-http
answers an oversized body with a bare 413 and an expired deadline with a
bare 408. Bare is literal: status line, no Content-Type, no body. A
fetch() caller gets "" to parse.
ErrorMapping is stage 11 of the pipeline. It is not
installed by default — the slot is None until .error_mapping(..) is
called — and the application arc new generates calls it:
use arcature::http::ErrorMapping;
Application::<AppState>::new()
.catch_panic()
.error_mapping(ErrorMapping::new())
It sits inside the panic catcher and outside the body limit, the timeout, the session, CSRF and the router, so it sees the responses it exists to dress and a mapped response is still compressed, still carries the security headers, and is still logged under its real status.
Precedence, in order:
- A custom mapper from
ErrorMapping::with(..), if it returnsSome. - Redaction, if the response is a
text/plain5xx and redaction is on. - A problem body, if the response has no
Content-Typeat all. - Otherwise the response is passed through untouched.
Anything that is not a 4xx or a 5xx is returned untouched before any of that runs.
A replacement keeps every header the original carried except Content-Type
and Content-Length. That matters more than it looks: a 405 carries
Allow, a 429 carries Retry-After, a 401 carries WWW-Authenticate.
Those are the parts a client acts on, and dropping them to deliver a nicer
body would be a bad trade.
ErrorMapping::with(..) is handed the status and the headers of the
request, never the response body. Reading the body would mean buffering
every error response, and a mapper that needs it is a handler. The request
headers are what content negotiation actually wants — Accept,
X-Requested-With, X-Inertia — so a mapper can answer HTML to a browser
and a problem document to everything else.
Errors a handler returned: Error
A controller returns Result<Response>, whose error type is
arcature::Error. Its own IntoResponse does not build a Problem.
| Variant | Status | code |
|---|---|---|
NotFound | 404 | not_found |
BadRequest | 400 | bad_request |
Unauthorized | 401 | unauthorized |
Forbidden | 403 | forbidden |
Validation | 422 | validation_failed |
Redirect | 400 | invalid_redirect |
Io | 500 | io_error |
Database | 500 | database_error |
Cache | 500 | cache_error |
Storage | 500 | storage_error |
Mail | 500 | mail_error |
Job | 500 | job_error |
Serialization | 500 | serialization_error |
Config | 500 | config_error |
Other | 500 | internal_error |
The body is application/json — not application/problem+json — with
type set to urn:arcature:problem: plus the code above. Those codes carry
underscores, so Error::NotFound produces urn:arcature:problem:not_found
while ProblemKind::NotFound produces urn:arcature:problem:not-found. Two
different strings for the same idea.
Its redaction is keyed on the APP_ENV environment variable, read at response
time: production or prod (case-insensitive) emits type, title and
status and stops; anything else, including an unset variable, adds
detail from the error’s Display, which for Error::Database is the
underlying driver message. Because the body is application/json,
ErrorMapping passes it through unchanged.
Redaction
ErrorMapping::new() sets redaction to !cfg!(debug_assertions).
| Build | redacts() |
|---|---|
cargo build, cargo test — debug-assertions on | false |
cargo build --release — debug-assertions off by default | true |
ErrorMapping::redact_errors(bool) overrides it in either direction. true
in a development build is how a test asserts that nothing leaks.
Keying on debug_assertions rather than on an environment variable is the
decision. The rejected alternative reads APP_ENV, which means a production
binary can be talked into leaking by whoever can set a variable on the host,
with no redeploy and no diff. A compile-time key is decided by the build that
produced the artifact.
What the stage does to a 4xx or 5xx:
| Response leaving the stage | Result |
|---|---|
no Content-Type at all | replaced with a problem document |
405, 408 or 413 with text/plain | replaced, whether redaction is on or off |
any other 5xx with text/plain | replaced when redacts() is true |
a 4xx with text/plain | untouched |
any status with text/html, application/json, application/problem+json, or anything else | untouched |
405, 408 and 413 are the three statuses that, inside this pipeline, come
from a layer rather than a handler, so a text/plain body on one of them is a
library’s string — length limit exceeded — and not a message anyone wrote
for this application’s clients. A handler that returns one of those itself has
its body replaced too. That is a smaller loss than leaving an API client with
an unparseable sentence.
A text/plain 4xx is left alone because it is a message written for the
client, and deleting it would delete the explanation.
The narrowness is deliberate: a 5xx carrying HTML or JSON is a body somebody chose, and only the shape nothing chooses on purpose gets replaced. The cost of that choice is stated under what this deliberately does not do.
Panics are separate. .catch_panic() (stage 10, also opt-in) answers with
Problem::of(ProblemKind::Internal) and discards the payload entirely — no
detail, in any profile. A panic message is written for a developer reading a
backtrace and routinely contains a path, a SQL fragment, or the value that
caused it. The operator still gets all of it from tower-http’s log.
Building an API resource
use arcature::resource;
#[resource]
pub struct LinkResource {
pub id: String,
pub url: String,
pub title: Option<String>,
}
#[resource] takes no arguments, requires named fields, and emits three
things:
- the struct unchanged, with a
#[derive(Serialize)]added; impl inertia::ClientData, whoseexposure_schema()is built from the named fields — the explicit browser-exposure opt-in;impl ResourceMetadata, the same fields as a&'static [FieldShape], whichroutes!resolves when a route declaresquery: T.
It generates no PAGE_CONTRACT. A resource is a value nested inside page
props, not a page. It needs macros, dx and inertia — ClientData lives
in the Inertia module.
A SeaORM entity is not a resource. Convert explicitly with impl From<Link> for LinkResource. The reason is that Serialize is not a safety boundary: a
field whose type is not a recognised primitive maps to
PropsSchema::nested::<T>, which requires T: ClientData, so an internal
domain model nested inside a resource fails to compile. Deriving exposure from
Serialize would make every model that can be logged also a model that can be
served.
Returning one:
| Return type | Response | Feature |
|---|---|---|
Json<T> | T as JSON, application/json, Content-Length set | dx |
Empty | 204, empty body | dx |
Problem | the document, application/problem+json | none |
json(value) | the same as Json, as a free function | api or inertia |
Declaring the shape at the route:
| Key | Means | Constraint |
|---|---|---|
action: T | the request body type; resolves T: RequestMetadata, which #[request] emits | a non-safe method. A GET is a compile error. |
query: T or query: Vec<T> | the response type; resolves the element’s ResourceMetadata | GET only. A POST is a compile error. |
query_string: T | the typed query string of a query route | requires query: on the same route |
action: and query: on one route is a compile error: a route mutates or it
reads.
routes! {
pub api {
state: AppState;
get "/links" => LinksController::index { name: links.index, query: Vec<LinkResource> }
get "/links/{link}" => LinksController::show { name: links.show, query: LinkResource }
post "/links" => LinksController::store { name: links.store, action: StoreLinkRequest }
}
}
Bound<T> loads a model from the database by a route parameter and answers
with a problem when it cannot:
use arcature::{Bound, Json, Result};
async fn show(link: Bound<Link>) -> Result<Json<LinkResource>> {
let link = link.into_inner();
// authorize here -- binding did not
Ok(Json(LinkResource::from(link)))
}
| Failure | Kind | Status |
|---|---|---|
| the request has no path parameters | BadRequest | 400 |
no parameter named T::KEY_PARAM | BadRequest | 400 |
the value will not parse as T::Key | BadRequest | 400 |
T::load returned an error | Internal | 500 |
T::load returned None | NotFound | 404 |
Binding is not authorization. Bound<T> proves the row exists; whether this
caller may see it is a policy check the handler still owes. That invariant is
permanent — the alternative, an extractor that also authorizes, would make
every route’s access rule invisible at the route.
Bound<T> needs dx + database + api together. It reads the database
handle through DbFromState, not axum::extract::FromRef, to avoid
orphan-rule conflicts in application state types.
The OpenAPI document
api-docs turns on uag, and the document is generated from the UAG — the
same deterministic artifact behind arc routes and arc typegen.
There is no utoipa and no annotation on the handler. Everything in the
document already exists in the route descriptor: routes! baked the request
and response field shapes in, and #[validate(...)] rules travel with the
fields. The rejected alternative is attributes above each handler, which is a
second source of truth and is wrong the first time someone renames a field in
one place.
use arcature::uag::build;
use arcature::uag::codegen::openapi::{self, OpenApiOptions};
let artifact = build(&app::graph(), &app::page_contracts());
let document = openapi::generate_json(&artifact, &OpenApiOptions {
title: "Acme API".to_owned(),
version: "2026.8".to_owned(),
description: None,
})?;
generate returns a serde_json::Value; generate_json returns pretty JSON.
OpenApiOptions::default() is title "Arcature application", version
"0.0.0", no description. There is no generated_at and no timestamp
anywhere: a timestamp would make every regeneration a diff, which is the one
thing a derived artifact exists to avoid.
Top level:
| Key | Contents |
|---|---|
openapi | the const "3.1.0" |
info | title, version, and description when set |
paths | one item per path, keyed by lowercase method |
components.schemas | one entry per named action: and query: type; absent when there are none |
Per operation:
| Key | Source | Absent when |
|---|---|---|
operationId | the route’s name: verbatim; otherwise the lowercase method followed by the path with every non-alphanumeric replaced by _ | never |
tags | a single tag, the module name | the route has no module name |
parameters | one in: path per path parameter, plus one in: query per query_string: field | there are none |
requestBody | application/json, required: true, the action: schema | there is no action: |
responses | below | see below |
| The route declares | responses |
|---|---|
query: T | 200, application/json, $ref to T |
query: Vec<T> | 200, application/json, an array of $ref to T |
page: / pages: and no query: | 200, text/html, no schema |
| neither | the key is omitted entirely |
An omitted responses is valid OpenAPI 3.1 and is the honest statement.
Claiming a 200 for a handler that redirects would make the document worse
than silence.
Axum and OpenAPI already agree on {name}, so only the wildcard marker is
rewritten: /files/{*rest} becomes /files/{rest}.
Rust types reach JSON Schema through one mapping, shared with the TypeScript emitters:
| Rust | JSON Schema |
|---|---|
String, str, char | {"type": "string"} |
| any integer or float | {"type": "number"} |
bool | {"type": "boolean"} |
Vec<T> | {"type": "array", "items": T} |
Option<T> | {"anyOf": [T, {"type": "null"}]}, and the field is left out of required |
| anything else | {} |
References and lifetimes are stripped and a path is reduced to its last
segment, so &'a std::string::String and String map the same. Integer width
is not carried, because JSON has one number type and pretending a u64
survives JavaScript intact would be a claim the generated types cannot back
up. An unrecognised type becomes the empty schema, which accepts anything —
the honest statement about a type the mapping does not model.
Option<T> becomes anyOf rather than an omitted key because serde writes an
absent Option as null. Requiredness is the separate fact recorded in the
object’s required list.
Validation rules become constraints only where the translation is exact:
| Rule | Becomes |
|---|---|
email | "format": "email" |
url | "format": "uri" |
length(min, max) on a string | minLength / maxLength |
length(min, max) on a Vec | minItems / maxItems |
length(equal = n) | both bounds set to n |
range(min, max) | minimum / maximum |
anything else, regex(...) and custom(...) included | nothing |
A non-numeric argument is skipped rather than coerced. regex(...) names a
Rust const, not a pattern the document could carry, and a constraint stated
wrong is worse than one left out, because a generated client enforces it.
Constraints land on the non-null branch: Option<String> with
length(max = 5) is anyOf: [{string, maxLength 5}, {null}], not a
maxLength on the union.
What this deliberately does not do
Nothing serves the document. The api-docs comment in Cargo.toml names
/_arcature/openapi.json and /_arcature/docs. Neither route exists. No
source file in the crate is compiled under cfg(feature = "api-docs"), so the
feature’s entire effect today is to enable api and uag. Producing the
document means calling openapi::generate_json yourself, from a binary or a
test you write.
No arc command emits it. arc typegen writes four files to
resources/js/generated/ — routes.ts, pages.d.ts, forms.ts,
index.ts — and the OpenAPI document is not one of them.
The document describes success only. The generator emits exactly one
response, a 200, and only for a route that declares query: or a page. Not
one of the problem documents in this chapter appears in it: not the 422 from
a validated extractor, not the 404 from Bound<T>, not the 429 from the
rate limiter. A client generated from it has no error types.
No security, no servers, no securitySchemes. A route’s policy: and
policies: are in the artifact and the generator does not read them. The
document does not say which routes need authentication or what they need.
No summaries, descriptions or examples per operation. Rust doc comments are not in the route descriptor, so there is nothing to copy across.
Path parameters are always strings. {"type": "string"} for every one,
whatever the handler parses it into. The descriptor carries the name, not the
type.
A request body is always application/json. A route whose action: type
arrives as a form submission is still described as JSON.
Redaction does not cover a JSON body. Only text/plain on a 5xx is
replaced. A 500 whose body is application/json or
application/problem+json is passed through in every profile. Bound<T>
produces one of those: its database-error branch is a ProblemKind::Internal
problem whose detail is "database error: " followed by the driver’s
message. ErrorMapping::with(..) runs ahead of the redaction check and is
the place to catch it.
Error is not Problem. A handler error takes the second path described
above: application/json, an underscored type URI, and redaction keyed on
APP_ENV at response time rather than on the build. An unset APP_ENV is the
non-production branch, so it includes detail. TestResponse::assert_problem
fails on such a response twice over, on the content type and on the type
URI, which is the fastest way to notice which path a route is on. The two
errors shapes differ too: validation_problem writes an object keyed by
field name holding [{ "code", "message"? }], while Error::Validation
writes an array of { "field", "message" } — and writes no errors member
at all on the production branch.
The type URIs do not resolve. They are URNs. There is no page behind
urn:arcature:problem:not-found and none is planned.
ErrorMapping is not on unless asked for. The pipeline slot is None
until .error_mapping(..) is called. arc new calls it; an application that
assembles its own builder and does not gets bodiless 404s and no redaction
at all.
No content negotiation, and no mapper shipped. Everything is a problem
document whatever the request’s Accept header, so a browser hitting an
unmatched path receives JSON. ErrorMapping::with(..) exists precisely to fix
that, and receives the request headers for that purpose, but the framework
ships no HTML error page to install.
No response envelope. Json<T> writes T and nothing around it — no
data key, no meta, no links, no sparse-fieldset or filtering vocabulary.
paginate(per_page).page(n) hands back rows and page_with_count hands back
rows and a total; shaping those into a response is the resource’s job, because
an envelope the framework picked would be one every client then has to unwrap.
Problem cannot be parsed back. It implements Serialize and not
Deserialize. A test or a Rust client reads a problem response as
serde_json::Value, or through assert_problem.
Observability
Request ids, newline-delimited JSON logs, an access log, a Prometheus text
endpoint, W3C trace context, and OTLP export. All of it sits on tracing,
which the framework re-exports as arcature::observe::tracing so downstream
code targets the pinned version through Arcature.
Nothing here installs itself. There is no global recorder, no global tracer
provider, and no subscriber the crate registers on import — a JsonLog sink,
a Metrics registry and a Telemetry pipeline are values the application
holds and clones, and the subscriber is installed by a call the application
makes from main. The rejected alternative is the usual one: a library that
grabs the global subscriber when it is linked. It costs the binary the ability
to choose, and it costs a test the ability to capture only its own output.
Turning it on
# Logs, request ids, the access log, the Prometheus registry, trace context.
arcature = { version = "0.1", features = ["observe"] }
# The same, plus OTLP span export. `otel` implies `observe`.
arcature = { version = "0.1", features = ["otel"] }
| Feature | Gives you | Pulls |
|---|---|---|
observe | install_logging, JsonLog, RequestId + RequestIdLayer, AccessLogLayer, Metrics + MetricsLayer, TraceContext + TraceContextLayer, redact | tracing, tracing-subscriber, uuid |
otel | Telemetry, TelemetryBuilder, the observe::otel module | observe, opentelemetry, opentelemetry_sdk, opentelemetry-otlp, tracing-opentelemetry |
| Where | observe | otel |
|---|---|---|
framework default | on | off |
framework fullstack | on | off |
| generated application | on | off, and arc new scaffolds nothing for it |
otel = ["observe", ...], so it is an addition and never an alternative. It
is an operator opt-in: four crates and the gRPC stack under them enter the
graph, and most applications never enable it.
otel adds the OTLP span exporter and nothing else. The Prometheus
endpoint belongs to observe — observe::metrics is not gated on otel,
as the comment above the feature in Cargo.toml also says — so a default
build already has it.
tracing-subscriber is pinned with registry and fmt and nothing else.
ansi is off, so colour is unavailable rather than disabled; env-filter is
off, so the filter is Targets rather than EnvFilter. json is off too,
which is why the JSON formatter in this module is written by hand. That last
one is not a saving, it is the design: the layer owns the serialisation, so
redaction runs on every field with no way for a caller to opt
out of it.
Installing the subscriber
// The first line of the generated application's `run`, before anything that
// might have something to say.
arcature::observe::install_logging(LOG_FILTER).map_err(std::io::Error::other)?;
install_logging(default_filter) -> Result<(), ObserveError>. Call it once,
from main, before anything that might log. tracing events go nowhere
at all until a subscriber exists: without this call the access log runs on
every request and emits into the void, and a job that fails does so without a
line anywhere. Nothing errors — the process is quiet, which is the worst way
for logging to be broken, because it looks like nothing is happening.
The format is chosen by build profile, not by an environment variable, on the grounds that the shape of a log line is a property of the build:
| Build | Layer | Redaction |
|---|---|---|
debug (cfg!(debug_assertions)) | tracing_subscriber::fmt, no ANSI, target shown | none |
| release | JsonLog on StderrSink | every field |
Both write to standard error, so a log line never interleaves with what the process writes to standard output.
The redaction column is not a typo, and it is the single most misread thing in
this module. install_logging installs JsonLog in release builds only; a
debug build gets the fmt layer, which prints every field it is given,
verbatim, including one named password. An application that wants redaction
while developing composes its own subscriber with JsonLog in it (see
the sinks) rather than calling install_logging.
default_filter is used when RUST_LOG is unset. The variable name is in
FILTER_ENV, and it is RUST_LOG and not ARCATURE_LOG because every Rust
operator already knows the name.
RUST_LOG | default_filter | Result |
|---|---|---|
| unset, or blank | parses | the default |
| set, parses | — | the variable |
| set, does not parse | parses | warning on stderr, then the default |
| unset | does not parse | warning on stderr, then info |
A typo in a log filter must not stop a process from booting, and an application whose own hard-coded default does not parse has a bug that must not be silent either — hence a warning in both directions and a running process in both directions.
Targets rather than EnvFilter is a deliberate trade. EnvFilter brings a
regex engine along for span-field matching that a web application almost never
uses; Targets reads the target=level syntax people actually write —
info, info,sqlx=warn, my_app=debug — and costs no additional
dependency.
install_logging returns Err(ObserveError::Logging) if a global subscriber
is already installed. That is a real error rather than a no-op: the second
caller’s configuration is being discarded, and the honest response is to say
so.
The generated application calls it on the first line of run, with a filter
chosen the same way:
| Build | LOG_FILTER |
|---|---|
| debug | info,<app>=debug,arcature=debug |
| release | info |
What a log line looks like
One JSON object per line, no pretty printing, no trailing state. Every mainstream shipper reads that without configuration.
{"fields":{"client_ip":"203.0.113.9","duration_ms":7,"method":"GET","path":"/dashboard","request_id":"6f1e6f8c-6e5e-4a2b-9a24-6f3a2f0f1c77","status":200},"level":"INFO","message":"GET /dashboard 200 7ms","target":"arcature::observe::access_log","timestamp":"2026-08-23T10:15:04.220Z"}
| Key | Present | Value |
|---|---|---|
timestamp | always | RFC 3339 UTC, millisecond precision, always Z |
level | always | TRACE / DEBUG / INFO / WARN / ERROR |
target | always | the emitting module path |
message | when the event recorded one | the formatted message, lifted out of the fields |
spans | when the event is inside an entered span | span names, outermost first |
fields | when there is at least one | the event’s fields, plus inherited span fields |
Keys are serialised from a BTreeMap, so they come out in alphabetical order
— both at the top level and inside fields. Nothing depends on that; it is
what a reader will see.
message is only another field to tracing. Lifting it to a top-level key is
what makes the line readable to a human and indexable by everything else.
Span fields are folded into each event inside the span, with a nested span’s
field beating its parent’s and the event’s own field beating both. The
timestamp is written by hand — Howard Hinnant’s civil_from_days plus a
format string — because chrono belongs to the database feature and a
logging layer must not drag a database dependency behind it.
JsonLog::without_span_fields() drops the inherited fields and logs only what
the event itself recorded. Smaller lines, at the cost of losing whatever the
enclosing span was carrying.
Sinks
LogSink is one method, write_line(&self, line: &str), and the line arrives
without its terminator. A trait rather than std::io::Write because a sink is
shared across threads and must serialise whole lines: a writer that
interleaved two events would produce unparseable output.
| Sink | Use |
|---|---|
StderrSink | standard error, one writeln! under the lock |
CaptureSink | keeps lines in memory; cloning shares the buffer |
CaptureSink is how a test asserts on its own output without touching a
global:
use arcature::observe::{CaptureSink, JsonLog};
use tracing_subscriber::layer::SubscriberExt as _;
let sink = CaptureSink::new();
let subscriber = tracing_subscriber::registry().with(JsonLog::new(sink.clone()));
tracing::subscriber::with_default(subscriber, || {
tracing::info!(user = "ada", password = "hunter2", "signed in");
});
assert!(sink.transcript().contains("[redacted]"));
lines() returns the vector, transcript() joins it. A poisoned mutex is
recovered from rather than propagated in both sinks: a panic in one log call
must not silence every later one.
The request id
RequestId is a validated, low-cardinality identifier. RequestIdLayer
resolves it once per request and puts it in request extensions, and every
response carries it back as x-request-id — the wire-compatible name, with
no X-Arcature-* prefix, so a reverse proxy and a client library both already
understand it.
| Question | Answer |
|---|---|
| Where from | the inbound x-request-id header, if it parses; otherwise a fresh UUID v4 |
| Charset | ASCII alphanumerics plus - _ . : @ + / = |
| Maximum | MAX_REQUEST_ID_BYTES, 128 bytes |
| Rejected input | empty, oversized, or a disallowed byte |
| On rejection | a fresh id is generated — RequestId::from_header never errors |
| Response | x-request-id, on every response the layer sees |
Reusing the upstream value is what makes a trace survive the hop from a
reverse proxy. Validating it is what stops the same header from becoming an
injection point into every log index downstream: RequestId::parse_str
enforces the allow-list, and hostile input is replaced rather than reported,
because failing a request over a malformed correlation id would be a denial of
service with no upside.
RequestIdError is Empty, TooLarge { size, limit } or InvalidChar, and
only the parsing entry points return it.
How it reaches a log line
AccessLogService reads the id out of request extensions and records it as
the request_id field on the access line it writes. That is the path, and it
is one hop: the id reaches the access line because the access line writes it,
not because anything is inherited.
Two consequences worth being exact about:
- The order matters.
RequestIdLayermust run outsideAccessLogLayeror the id is not in extensions when the access line is written. The pipeline does this by construction — request id is stage 8 and the access log is stage 9 — and switching the request id off while leaving the log on produces lines with an empty id rather than an error. - A handler’s own
tracing::info!does carry the request id.AccessLogServiceattaches itsarcature.requestspan to the inner call withInstrument, so the span is entered exactly while that future is polled and a handler’s own events inheritrequest_id,method,pathandclient_ip.Instrumentrather than aspan.enter()guard: in async code a guard stays entered across every await point, including the ones where the task is parked and another request is running on the thread, which would attribute other requests’ lines to this one.tests/observe_request_span.rspins both halves — the access line carrying the id, and a handler event inheriting it.
Both layers are off unless asked for:
Application::new()
.request_id()
.access_log()
The access log
One tracing event per request, at INFO, from
arcature::observe::access_log. The message is "{method} {path} {status} {duration}ms" and the structured fields are:
| Field | Type | Value |
|---|---|---|
method | string | the request method |
path | string | uri.path() — the path only |
status | number | the response status as u16 |
duration_ms | number | whole milliseconds, truncated |
request_id | string | the resolved id, or "" if RequestIdLayer did not run |
client_ip | string | the resolved ClientIp, or "" if nothing resolved one |
That is the whole line. No request body, no response body, no headers, and no query string.
The query string is the interesting omission. It is the part of a URL that
ends up in every proxy log on the way, and applications put credentials in it
— an OAuth code, a PKCE code_verifier, a password-reset token.
AccessLogService records uri.path() and tests/observe_redaction.rs sends
a request whose query string carries a PKCE verifier and asserts it appears in
none of the outputs, so uri cannot quietly replace uri.path() one refactor
later.
An empty string rather than an absent field is deliberate for both optional values: a reader can tell “not known here” from “this field was never part of this log”.
The client address is a field and never the message. An IP address is personal
data in most of the places this will run, and redaction decides per field name
— so an address interpolated into the human-readable message would be past
the only checkpoint there is. It is written as client_ip and passed through
redact::apply("client_ip", ..) on the way, using the same string for both,
so adding an address term to the deny-list would withhold it everywhere rather
than everywhere-except-the-message. A unit test asserts the name written and
the name asked about are the same string.
The layer sits outside the panic catcher, the body limit and the timeout, so a
500, a 413 and a 408 are all logged.
Metrics
Metrics is a registry the application holds and clones. Two Metrics values
are two independent registries, which is what lets a test assert on exactly
its own counters; cloning shares one, so the handle given to a middleware and
the handle given to the /metrics route are the same set of series. There is
no global recorder and no macro that reaches for one.
| Call | Does |
|---|---|
Metrics::new() | empty registry, DEFAULT_BUCKETS |
Metrics::with_buckets(&[..]) | same, with your bounds — sorted on the way in, non-finite values dropped |
describe_counter/gauge/histogram(name, help) | attach # TYPE and # HELP for a name |
increment(name, labels, by) | add to a counter series |
set(name, labels, value) | set a gauge series |
observe(name, labels, value) | record one histogram observation |
counter_value(name, labels) | Option<u64>, for assertions |
render() | the whole registry as Prometheus text |
response() | the same, as an HTTP response with the content type |
Recording a series that was never described registers the name with the
implied kind and empty help, so a metric is never lost for want of a
description. Labels are a BTreeMap internally, so [("a","1"),("b","2")]
and [("b","2"),("a","1")] are one series and not two.
The exposition
Prometheus text format, version 0.0.4 — the one every scraper reads,
including OpenMetrics parsers, which accept it as a subset.
PROMETHEUS_CONTENT_TYPE is text/plain; version=0.0.4; charset=utf-8.
# HELP http_requests_total Total HTTP requests handled.
# TYPE http_requests_total counter
http_requests_total{method="GET",status="200"} 3
# HELP http_request_duration_seconds HTTP request duration in seconds.
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{method="GET",le="0.005"} 1
...
http_request_duration_seconds_bucket{method="GET",le="+Inf"} 3
http_request_duration_seconds_sum{method="GET"} 0.11
http_request_duration_seconds_count{method="GET"} 3
Series are grouped by name and emitted in name order, because the format
requires every sample of a name to sit under its one # TYPE line;
interleaving names is a parse error, not a style choice. # HELP is written
only when help text exists. Whole numbers render without a trailing .0, so a
bucket bound of 1.0 is le="1". Label values are escaped for backslash,
quote and newline.
tests/observe_prometheus.rs parses the rendered document with a
purpose-written validator rather than a contains assertion, and the
validator is itself tested against documents that break each rule, because a
contains assertion cannot see whether the document around the substring
parses — and a scrape a scraper rejects is silent.
The HTTP layer
MetricsLayer::new(metrics) records two series per request:
| Name | Type | Labels | Help |
|---|---|---|---|
http_requests_total | counter | method, status | Total HTTP requests handled. |
http_request_duration_seconds | histogram | method | HTTP request duration in seconds. |
MetricsLayer::labelled(metrics, "/users/{id}") adds a route label to both.
DEFAULT_BUCKETS, in seconds: 0.005, 0.01, 0.025, 0.05, 0.1,
0.25, 0.5, 1.0, 2.5, 5.0, 10.0 — the set the Prometheus client
libraries ship, bracketing the range a web request lives in from a cached 5 ms
answer to a 10 s timeout.
There is deliberately no path label taken from the request URI. A URI is
unbounded input and an unbounded label value is how a scrape target runs out
of memory. The rejected alternative — label with uri.path() and hope —
costs the process its memory the first time a crawler walks a parameterised
route. An application that wants a route dimension installs the layer per
route or per group and names the template itself, and route is a
&'static str precisely so it cannot be a value derived from a request.
Wiring it
The builder installs the layer but never the registry and never the route.
ApplicationBuilder::metrics(registry) puts MetricsLayer at stage 9, which
is where you want it — see What this module does not do
for why stage 21 is not. Nothing in the
pipeline constructs a Metrics, and there is no
/metrics route unless the application adds one.
Hand-wiring the layer instead puts it among the user layers at 21:
use arcature::observe::{Metrics, MetricsLayer};
let metrics = Metrics::new();
let metrics_for_layer = metrics.clone(); // a clone shares the registry
let router = axum::Router::new()
.route(
"/metrics",
axum::routing::get(move || {
let metrics = metrics.clone();
async move { metrics.response() }
}),
)
.layer(MetricsLayer::new(metrics_for_layer));
An ordinary route, so it inherits whatever the application puts in front of it. A metrics endpoint usually wants an allow-list or basic auth, and that belongs to the application: the framework has no way to know which of the two is right, and shipping either one as a default would be wrong for half the deployments and invisible to the other half.
Constants live at arcature::observe::metrics::{DEFAULT_BUCKETS, HTTP_REQUESTS_TOTAL, HTTP_REQUEST_DURATION, PROMETHEUS_CONTENT_TYPE}. Only
Metrics, MetricsLayer and MetricsService are re-exported at
arcature::observe.
Trace context
TraceContextLayer reads W3C Trace Context off the inbound request, puts a
TraceContext in extensions, and opens a span carrying the ids so log lines
can be joined to the trace.
Inbound headers are untrusted. A malformed traceparent is discarded and a
fresh root started rather than propagated, because half a trace id is worse
than none: it silently corrupts every trace it joins.
| Rule | traceparent |
|---|---|
| Length | exactly 55 bytes, dashes at 2, 35 and 52 |
| Version | 00 only; anything else, including the reserved ff, is rejected |
| Hex case | lowercase only — accepting both would make two spellings of one id |
| Trace id | 16 bytes, all-zero rejected |
| Parent id | 8 bytes, all-zero rejected |
| Flags | one octet; bit 0 is FLAG_SAMPLED |
| Rule | tracestate |
|---|---|
| Length | 512 bytes maximum |
| Members | MAX_TRACESTATE_MEMBERS, 32 |
| Bytes | printable ASCII 0x20–0x7e only |
Without a valid traceparent | dropped — it describes a trace this request is not part of |
| On rejection | dropped; the trace itself still propagates |
The member cap and the byte range are not pedantry: tracestate is
attacker-controlled text that would otherwise be copied onto every outbound
request the service makes. It is stored as the original text rather than a
parsed member list, because the only operations performed on it are “carry it
forward” and “prepend our own member”, and re-serialising a parsed form risks
normalising away something a downstream vendor depends on.
The layer records three fields on an arcature.request span, which it does
enter:
| Field | Meaning |
|---|---|
trace_id | 32 lowercase hex characters |
parent_span_id | 16 lowercase hex characters |
continued_trace | true if an upstream trace was joined, false if this is a root |
continued_trace earns its place: a service at the edge starts roots and one
behind a gateway should not, and the difference is otherwise invisible.
It enters that span with span.enter() and holds the guard across the inner
call, rather than attaching it to the future with Instrument. The guard is
an entry on a thread-local stack and a future that yields does not unwind it,
so which of a handler’s own events end up inheriting these fields depends on
how the runtime scheduled the task. Correlate on lines that record an id
themselves; treat span-field inheritance as a convenience, not a contract.
Nothing is written back onto the response. traceparent is a request header,
and echoing it would tell a client the internal trace ids for no benefit.
For a downstream call, context.outbound_headers() returns a HeaderMap with
a traceparent whose span id is a fresh child — which is what makes the next
hop a child of this one rather than a sibling of the caller — plus the
tracestate if one survived validation.
Root ids come from getrandom when the oauth feature has pulled it in, and
otherwise from a SplitMix64 mix of the clock, a per-process counter and a
stack address. Trace ids need to be unique, not unpredictable — they carry no
authority and grant no access — so the fallback is adequate, and this module
refuses to require a crypto dependency for a correlation identifier.
OTLP export
With otel, Telemetry is an OTLP-over-gRPC span pipeline the application
holds:
use std::time::Duration;
use arcature::observe::{JsonLog, StderrSink, Telemetry};
use tracing_subscriber::layer::SubscriberExt as _;
use tracing_subscriber::util::SubscriberInitExt as _;
let telemetry = Telemetry::builder("checkout")
.endpoint("http://collector:4317")
.timeout(Duration::from_secs(5))
.build()?;
tracing_subscriber::registry()
.with(JsonLog::new(StderrSink))
.with(telemetry.tracing_layer())
.init();
// ... run the application ...
telemetry.shutdown()?;
| Point | Behaviour |
|---|---|
| Service name | required by Telemetry::builder, never defaulted |
| Transport | gRPC over tonic; the grpc-tonic feature is the only one enabled |
| Signal | traces only — opentelemetry and the SDK are pinned with trace and nothing else |
| Resource attributes | service.name, and that is all this crate sets |
| Runtime | a Tokio runtime must already be running when build() is called |
| Shutdown | Telemetry::shutdown flushes; dropping the value without it loses whatever is buffered |
| Sampling | none configured here, and nothing in this crate consults the sampled flag |
A deployment reporting as unknown_service is a deployment nobody can find,
so the name is a parameter rather than a default, and
tests/observe_otlp.rs asserts it actually travels as the service.name
resource attribute.
endpoint is worth reading carefully. When it is not called, build() does
not call with_endpoint at all, so opentelemetry-otlp’s own default applies
— which is the layer that reads OTEL_EXPORTER_OTLP_ENDPOINT.
observe::otel::DEFAULT_ENDPOINT is published as http://127.0.0.1:4317,
the address a local collector listens on out of the box, but nothing in
build() passes it: it is documentation of the address, not a value this
builder sends.
The pipeline must be built inside the async entry point and not in a static
initialiser, because the batch processor spawns a background task.
shutdown is fallible because a collector that is already gone cannot accept
the final batch, and that is worth reporting rather than swallowing. A
collector that never answers does not panic the application — there is a test
that binds a port, drops the listener, and exports at the dead address.
tests/observe_otlp.rs runs a real TraceService gRPC server on a loopback
port and decodes the protobuf the exporter wrote, so the assertions are
against bytes that crossed a socket: a span arrives, a parent and child share
one trace id, a child names its parent’s span id, a three-level nesting
arrives as a chain rather than a fan, and a span field arrives as an
attribute.
The two id spaces meet, under otel
TraceContextLayer and the OTLP exporter both deal in trace ids, and joining
them is the point of a distributed trace.
When otel is on and the request carried a usable traceparent, the layer
calls set_parent with a remote SpanContext built from the parsed ids, so
the span it exports is part of the caller’s trace rather than the root of
a new one. The tracestate travels with it when the exporter accepts it. The
traceparent is also still recorded as the trace_id and parent_span_id
attributes, which is what correlates a log line.
This did not always hold, and the failure was invisible from the log side.
The layer parsed the header and opened a span carrying the ids as fields,
and never called set_parent — so tracing-opentelemetry minted a fresh
trace id for the exported span. A request arriving with a traceparent
started a new trace at this service, and the two halves never met in the
backend, while every log line looked correct.
an_incoming_traceparent_becomes_the_exported_trace_id in
tests/observe_otlp.rs reads the id off the span the collector holds rather
than off a log field, because reading the field would pass against the bug.
Two details worth knowing. The parent is only set when the trace was
continued: inventing a remote parent for a root would have a backend draw
an edge to a span that never existed. And set_parent is called before the
span is entered, because it refuses with AlreadyStarted afterwards and
would be a silent no-op.
Without otel, none of this compiles in, and the layer is what it always was
— correlation in your logs against the ids your upstream used.
Redaction
Logging is the most common way a secret escapes a process, because a log line
is written once and then copied everywhere — to a file, to a shipper, to a
third-party index, into a support ticket. So the defence is a property of the
writer, not a rule the caller remembers: JsonLog asks
redact::is_sensitive about every field name it is about to serialise, and a
field that matches is written as REDACTED, the fixed marker "[redacted]".
A marker rather than an omission, so a reader can tell “this field was
withheld” from “this field was never recorded”.
Two mechanisms are at work and they are not the same size. The first is that
the framework’s own layers record structured fields and never format a secret
into a message string: the access log records method, path, status and
duration, and the metric labels are a method, a status and a &'static str.
That mechanism holds everywhere. The second is the deny-list, which covers a
field an application adds — and it is much narrower than it first looks.
Which sinks it reaches
The deny-list is consulted in exactly two files: json_log.rs, which asks
is_sensitive about every field of every event and every span, and
access_log.rs, which calls redact::apply on the one client_ip value.
metrics.rs, otel.rs and trace_context.rs never call either.
| Channel | Redacted | Note |
|---|---|---|
| JSON log, event field | yes | the name is matched, the value is dropped |
| JSON log, span field | yes | redacted when the span is created, so the folded copy is already safe |
| JSON log, message string | no | the deny-list catches the field, not the sentence |
Debug-build console (fmt) | no | install_logging only installs JsonLog in release builds |
| Metric label value | no | escaped for the exposition format, not redacted |
| Exported OTLP span attribute | no | tracing_opentelemetry has its own visitor |
The last two are pinned by tests that assert the leak, named so nobody mistakes them for tests of a working defence:
a_secret_recorded_as_a_span_field_reaches_the_collector_in_full— the same field the JSON layer wrote as[redacted]leaves the process in plaintext, over the wire, to a collector.a_secret_recorded_as_a_metric_label_is_rendered_in_full— a series labelled with a session id publishes it on/metrics.
Both assert today’s behaviour so that closing either gap is a visible change rather than a silent one. If either starts failing because the value is now redacted, the defence has been extended and the test is to be deleted.
Until then: treat a metric label and a span attribute as unredacted channels.
Keep the value out of them, or record it as a type whose own Debug renders
redacted — the JSON visitor’s fallback formats through Debug, so a secret
newtype protects itself in every channel that formats one. A label value is
also a series dimension, so a secret used as one is usually an
unbounded-cardinality bug as well.
How a name is matched
pub fn is_sensitive(field: &str) -> bool
Three steps, in this order:
- Every
-and every.becomes_. - Every other character is ASCII-lowercased.
- The result is tested for containment of any needle in
DENY_LIST.
Substring rather than exact name, on purpose: password, user_password and
db.password are all the same mistake, and a deny-list that only catches the
spelling someone thought of is not a deny-list. False positives cost a
debugging session; false negatives cost a credential.
Separator folding, also on purpose: an HTTP header is x-api-key, an
OpenTelemetry attribute is http.request.header.authorization, a struct field
is api_key. One secret, three spellings, and a needle written with _ is a
substring of only the third. Every needle uses _ and none contains - or
., so folding can only ever match more.
DENY_LIST is public, sorted and lowercase — a unit test asserts the last
two — so an application can check its own field names against it:
access_token | api_key | apikey | auth |
bearer | bind | body | cache_value |
card | cookie | credential | csrf |
cvv | id_token | otp | passphrase |
passwd | password | payload | pin_code |
private_key | pwd | refresh_token | secret |
session_id | signature | sql_args | token |
verifier |
Because the test is containment, ordinary names collide with short needles and are redacted:
| Field name | Redacted by |
|---|---|
author, authority | auth |
wildcard, discard, cardinality | card |
binding | bind |
body_bytes | body |
token_count | token |
That is the trade the module chose, stated so it is not a surprise at three in
the morning when a field reads [redacted] and nothing is wrong.
What is not folded: any separator other than - and .. A space, a
slash, a colon and a camelCase word boundary all survive step 1, so a
multi-word needle cannot match across them. Non-ASCII case is not folded
either — a field name is not expected to contain non-ASCII, and folding
Unicode would widen the surface without widening the protection.
redact::apply(field, value) is the borrowing form: it returns REDACTED or
the value unchanged, so nothing is copied for a field that is allowed through.
is_sensitive and REDACTED are re-exported at arcature::observe;
DENY_LIST and apply live at arcature::observe::redact.
The disclosed gap: camelCase multi-word names
tests/observe_redaction.rs pins it by name:
the_deny_list_is_written_in_snake_case_and_therefore_misses_camel_case_spellings
The list is written in snake_case, and camelCase has no separator to fold.
privateKey lowercases to privatekey; the needle is private_key; there is
no match. The test walks the spellings side by side:
| Spelling | Result | Why |
|---|---|---|
x-api-key, X-Api-Key | redacted | - folds to _, matching api_key |
http.request.header.authorization | redacted | . folds, and auth matches anyway |
apiKey | redacted | apikey is on the list as its own needle |
accessToken | redacted | the single-word needle token matches |
privateKey | not redacted | privatekey does not contain private_key |
sessionId | not redacted | sessionid does not contain session_id |
The exposure is narrower than the headline. Only multi-word needles are
reachable this way, single-word needles catch most camelCase spellings
anyway, and Rust field names are snake_case — so what is left is an
application that records a JSON body’s keys under the names the client chose.
If that is your application, check the names against DENY_LIST in a test of
your own, or normalise them before recording.
As with the leak tests: if privateKey starts coming out redacted, the
matcher has been widened and that test should be narrowed to whatever spelling
is still missed, or deleted.
A list of names cannot see values
The other limit is structural, and it also has a test:
a_secret_under_a_field_name_nobody_denied_is_logged_in_full. A field called
note carries a password in one handler and a postcode in the next. No list
of names can catch that.
Nor can any formatter undo a secret a caller has already interpolated into a
message string. tracing::info!("signing in {password}") is past every
checkpoint there is by the time the layer sees it. Record fields, not
sentences.
The defence for both is the same one the framework applies to itself: its own layers never record a field whose contents they have not chosen. The list of what that means in practice, from the module’s own documentation —
- Request and response bodies. Method, path, status and duration; never the payload.
- SQL bind values. A query’s text may be recorded; the parameters bound into it may not, because that is where the row data lives.
- Cache values. Keys are loggable and are logged; values are not.
- Credentials of every kind — passwords, password hashes, API keys,
bearer tokens, OAuth access and refresh tokens, PKCE verifiers, CSRF state,
session identifiers, cookies, and
Authorizationheaders. - Email bodies and recipients’ message content. A send is recorded as an event with a message id; the letter is not.
- Job payloads. A job is recorded by name, queue and attempt count.
— holds for the framework’s own layers in every channel.
tests/observe_redaction.rs drives one request carrying a password, a bearer
token, a session cookie and a PKCE verifier through the whole stack — request
ids, access logging, metrics, trace context — with a log sink, a metrics
registry and a live OTLP collector capturing at once, then searches the log
transcript, the metrics exposition, the exported span attributes and the
exported resource attributes for each secret’s value. It also asserts that
each channel captured something, because a harness that silently captured
nothing would pass every absence assertion in the file.
Stable span names
Seven &'static str constants at arcature::observe, with
is_stable(name) and ALL to iterate them:
| Constant | Value | Opened by the framework |
|---|---|---|
REQUEST | arcature.request | yes — AccessLogService and TraceContextService |
DB_QUERY | arcature.db.query | no |
CACHE_GET | arcature.cache.get | no |
JOB_HANDLE | arcature.job.handle | no |
PAGE_RENDER | arcature.page.render | no |
EVENT_LISTENER | arcature.event.listener | no |
SCHEDULE_TICK | arcature.schedule.tick | no |
Six of the seven are reserved names rather than spans anything currently
emits: no code in the crate opens them today. They are here so that
instrumentation added later, in the framework or in an application, agrees on
one spelling instead of inventing a second. arcature.request is opened
twice when both layers are installed — once by each — so a spans array can
carry the name more than once.
What this module does not do
Install anything on its own. No global subscriber, no global recorder, no
global tracer provider. install_logging is a call the binary makes.
Add a /metrics route, or protect one. The registry and the layer are
values; routing and access control are the application’s.
Use .metrics(..), not .layer(MetricsLayer::new(..)). Both compile.
Only one of them counts a refused request.
A user .layer() lands at stage 21 — inside the body limit (12), the
timeout (13), maintenance (14) and the rate limiter (15). A request refused
with a 413, a 408, a 503 or a 429 never reaches stage 21, so a counter
installed there does not see it. The request total then quietly means
“requests that got through admission”, the access log at stage 9 disagrees
with it, and the gap is widest under exactly the load an incident is about.
ApplicationBuilder::metrics(registry) installs the same layer at stage 9,
beside the access log, where it sees what the access log sees.
let metrics = Metrics::new();
let app = Application::<AppState>::new()
.routes(routes())
.rate_limit(RateLimit::per_minute(60))
.metrics(metrics.clone()) // stage 9: counts the 429s too
.build();
tests/observe_metrics_stage.rs pins the difference: the same request through
the same limit is counted at stage 9 and missed at stage 21, and both
placements agree on a request that is served. The second of those asserts the
gap on purpose, so that anyone who later moves user layers outside the
admission stages learns it from a failing test rather than from a graph.
Use .trace_context() for the same reason. It installs
TraceContextLayer at stage 8, beside the request id and outside the
admission stages, so the access line for a 429 carries the caller’s trace
id. Through .layer(..) it would land at 21 and a refused request would
produce log lines with no trace on them at all — and a refused request is one
somebody is very likely to go looking for.
tests/observe_trace_stage.rs pins that pair the same way the metrics tests
do, including the negative: a user layer is asserted to miss the refusal, so
moving user layers later fails a test rather than quietly costing traces.
Redact in debug builds. The fmt layer prints fields verbatim.
Redact metric labels or OTLP span attributes. See the table above.
Sample. TelemetryBuilder sets no sampler and reads no sampling
environment variable, so whatever the SDK’s own default does is what happens.
TraceParent carries and preserves the sampled flag, but nothing in this
crate consults it to decide whether to record or export.
Export metrics or logs over OTLP. Traces only. opentelemetry and the SDK
are pinned with the trace feature and nothing else; metrics leave as
Prometheus text or not at all.
Register a propagator. The inbound join is done directly with
set_parent rather than through a global TextMapPropagator, and outbound
headers come from TraceContext::outbound_headers. Nothing installs a global
propagator, so a third-party client that expects to find one injects nothing.
Write to a file, rotate, or ship. Both formats go to standard error. The process manager owns the file, and every mainstream one already does this better than a library could.
Throttle or deduplicate log lines. A chatty target is what the filter is for.
Provide error tracking. No Sentry, no crash reporter, no aggregation. An
ERROR line with structured fields is what the module produces; turning that
into an incident is a shipper’s job.
Testing
Arcature applications are tested the way Tower services are tested: build the router, drive it in-process, assert on the response. There is no socket, no port to allocate, and no teardown race.
Driving the router
Application::into_router hands back the composed Router. From there
tower::ServiceExt::oneshot sends a single request through the whole
router-level pipeline and returns the response.
use arcature::axum::Router;
use arcature::axum::body::Body;
use arcature::axum::http::{Request, Response};
use tower::ServiceExt as _;
async fn send(router: Router, request: Request<Body>) -> Response<Body> {
router.oneshot(request).await.expect("infallible")
}
fn get(uri: &str) -> Request<Body> {
Request::builder()
.uri(uri)
.body(Body::empty())
.expect("request")
}
tower is not re-exported by Arcature, so a test crate using oneshot
needs tower = { version = "0.5", features = ["util"] } under
[dev-dependencies]. axum is re-exported, as arcature::axum.
This is the pattern the framework’s own tests/application.rs uses. It
exercises stages 3 through 20 of the pipeline:
everything the builder composes onto the router.
It does not exercise stages 1 and 2 – the dev proxy and the pre-routing
proxy. Those are composed around the router as a service in run_with_state
and serve, because they rewrite the URI before route selection. A test that
needs them has to build the service, not the router.
Asserting layer order
Layer order is a contract, so the framework asserts it rather than only documenting it. The technique is a marker layer that appends its own name to a response header; the resulting header value spells out the order the layers actually ran in. If a future edit reorders the stack, the assertion fails with a diff a reader can act on, instead of a subtly different security posture nobody notices.
If your application depends on where its own .layer() calls sit relative to
the framework’s, the same technique works: user layers are stage 18, inside
everything the builder installs and outside the router.
Route tables
A route table is data, so it can be asserted without a request at all. The generated application ships exactly this as its smoke test:
use my_app::routes;
#[test]
fn home_route_is_registered() {
let routes = routes::routes();
assert_eq!(routes.url_for("home", &[]).unwrap(), "/");
}
url_for returns Err(Error::NotFound(..)) for a name that is not in the
table, so a renamed route fails the test rather than silently producing a
broken link at runtime.
Events
Dispatcher::recording() builds a dispatcher that remembers the names of the
events it dispatched. was_dispatched(name) and dispatched_events() read
that record back. Both return false / an empty vector on a dispatcher built
with Dispatcher::new() – recording is opt-in and costs nothing in
production.
Assert on the event, not on the listener’s side effect, when what you care about is that the event fired. Assert on the listener when what you care about is what it did.
Mailer::capture_ok() accepts every message and keeps it; Mailer::capture_error()
rejects every message. Both are constructors, so a test builds one directly
instead of pointing SMTP at a local catcher.
use arcature::mail::Mailer;
let mailer = Mailer::capture_ok();
// ... run the code under test ...
let sent = mailer.captured().await.expect("capturing mailer");
assert_eq!(sent.len(), 1);
captured() returns Option<Vec<(Envelope, String)>> – None when the
mailer is not a capturing one, so a test that accidentally runs against real
SMTP fails on the expect rather than passing vacuously. The String is the
serialised message; the Envelope carries the actual sender and recipients,
which is what you want to assert on, since the envelope and the To: header
can legitimately differ.
capture_error() is the one that finds the bugs: it proves the calling code
handles a send failure instead of unwrapping it.
Jobs
A job handler is an ordinary async function over a deserialised payload. Test
it by calling it. That covers the interesting part – the business logic and
the JobError::Retryable / JobError::Permanent decision – without a
database.
Testing the queue itself needs PostgreSQL, because the claim protocol is
FOR UPDATE SKIP LOCKED and there is nothing to emulate it with. The
framework’s own suite takes a DATABASE_URL for exactly this reason; see
Deployment for the CI service
definition.
Validation
Validated<T> and its siblings are extractors, so they are tested through a
request. A 422 with an errors extension on the problem document is the
success case for a validation test – assert on the field names in that
extension, not on the message strings, which are not a stable interface.
Databases in tests
There is no per-test transaction rollback helper and no test database provisioner. A test that needs a database connects to one named by an environment variable and is responsible for its own cleanup. This is less convenient than the Laravel equivalent and is a known gap.
The test-kit feature
into_router plus oneshot covers the router-level pipeline and needs no
framework support at all, which is why the chapter leads with it. When a test
needs the whole application – subsystem startup, state construction, the lot
– arcature::test_kit boots one in process and drives it as a
tower::Service, so there is no socket, no port and no teardown race.
Enable it under [dev-dependencies]:
[dev-dependencies]
arcature = { version = "0.1", features = ["test-kit"] }
The feature belongs there and nowhere else: shipping a test harness inside a production binary is the mistake the feature split exists to prevent.
What it holds: TestApp (the in-process driver) and TestServer (a real
socket, for the few things a tower::Service call cannot exercise, such as a
WebSocket upgrade); TestRequest and TestResponse with the assertions;
session seeding behind the auth feature, so acting_as has something to act
as; a two-condition database gate with transaction-per-test and
assert_database_has behind the database feature; and recorder fakes for
events, jobs and mail, wired into the seams those subsystems already expose
rather than being parallel copies of them. #[arcature::test(app = ...)]
binds a fresh TestApp to the test function’s parameter.
It registers nothing globally – no inventory, no thread-local, no ambient application. A test names the thing it is testing and holds it in a value.
Deployment
An Arcature application is one binary that listens on one TCP port. There is no separate Node process, no PHP-FPM pool, no asset server. What you deploy is the binary, the built frontend assets, and whatever the application reads out of the environment.
The pipeline
Every request travels through a fixed, ordered stack. The order is a contract
written down in src/application/pipeline.rs and asserted by the test suite;
it does not depend on the order the builder methods were called in.
Outermost first:
| # | Stage | Note |
|---|---|---|
| 1 | DevProxy | Forwards Vite requests in development. Pass-through unless arc dev set an IPC endpoint. |
| 2 | Proxy | Pre-routing URI rewriting. |
| 3 | Health | Merged beside the router, not layered over it. |
| 4 | UagEndpoint | Merged beside the router too. Debug builds only, after an explicit .uag_endpoint(..). |
| 5 | Compression | Sees the final body, whoever produced it. |
| 6 | SecurityHeaders | Outside the body limit and timeout, so a 413 and a 408 carry them too. Mints the per-request CSP nonce on the way down. |
| 7 | CORS | Answers a preflight without waking anything below. |
| 8 | RequestId, TraceContext | Every response carries x-request-id. Outside the admission stages, so a refused request still resolves its trace. |
| 9 | AccessLog, Metrics | Outside the panic catcher, the body limit, the timeout and the rate limiter, so a 500, a 413, a 408 and a 429 are logged and counted. |
| 10 | CatchPanic | A panic becomes a 500, not a dropped connection. |
| 11 | ErrorMapping | RFC 9457 bodies for bodiless errors; release redaction. |
| 12 | BodyLimit | Rejects an oversized upload before buffering it. |
| 13 | Timeout | A slow handler cannot hold a connection open. |
| 14 | Maintenance | Outside session and CSRF. |
| 15 | RateLimit | Inside maintenance, outside the session. |
| 16 | Session | Loaded before CSRF needs the token. |
| 17 | CSRF | An unsafe request is rejected before it can act. |
| 18 | Inertia | Innermost framework layer, so a CSRF rejection is not dressed up as a page. |
| 19 | PageContracts | Data, not behaviour. |
| 20 | RedirectMapper | Finishes redirect().route(..) and .with(..) against the route table and the session. |
| 21 | user .layer()s | Applied in call order, innermost. |
| 22 | Router | Route matching and the handler. |
| 23 | StaticFiles | The router’s fallback. |
Stages 5 through 21 are off unless asked for, RedirectMapper excepted –
it is installed by default, because a redirect().route(..) that silently
400s is not a default anybody wants. An application that calls nothing but
.routes() gets a bare router plus the health endpoints. That is deliberate:
each entry in the list above is a decision someone made, which is what makes
the list readable.
The reasoning behind each position is in ADR 0004 and in the module documentation itself.
Health, liveness and readiness
Three endpoints, mounted under /up by default (.health_prefix("/healthz")
moves them, .health(false) removes them):
| Path | Question | Answered from |
|---|---|---|
GET /up/live | Is this process alive? | The lifecycle alone. Never touches a database, cache, or the network. |
GET /up/ready | Should this process receive traffic? | The lifecycle and every started subsystem’s probe. |
GET /up | The same, as JSON for a human. | Both. |
Wiring a restart policy to readiness is the classic outage: a database blip
restarts the pod, and the restart does not bring the database back. Point
liveness probes at /up/live and load-balancer health checks at /up/ready.
These three bypass maintenance mode and Inertia, and always answer
application/json with Cache-Control: no-store. A cached readiness answer
is a wrong answer.
Readiness is false before startup finishes: the health handle holds the
subsystem set in a OnceLock that startup fills in, so a process that has not
booted reports that it has not booted.
Startup and shutdown order
run_with_state starts subsystems in a fixed order and tears them down in
reverse:
start: database -> jobs -> cache -> storage -> mail
serve: mark ready, accept connections
drain: begin_drain (readiness turns 503, requests in flight continue)
stop: mail -> storage -> cache -> jobs -> database
The drain step is why the readiness endpoint exists in the form it does.
begin_drain runs before the listener stops, so /up/ready answers 503
while in-flight requests are still being served. That window is exactly what a
load balancer needs to take the instance out of rotation without dropping
anything.
SIGTERM and Ctrl-C both trigger graceful shutdown on Unix; on Windows only
Ctrl-C is wired.
serve(listener) is the escape hatch: it takes an already-bound listener and
skips ordered startup entirely. Health endpoints still work, but they report
on an empty resource set, because on that path there are no subsystems.
Running more than one instance
Most of the framework is indifferent to how many processes you run. Three subsystems are not, and the difference between them matters more than the list suggests: two have a cross-instance mode you switch on, and one does not.
Sessions are shared, if you configure a store that shares them. The
session-store-db feature puts sessions in the same database the
application already uses, so a request may land on any instance and a
deploy does not log everyone out. MemoryStore does neither. This is a
configuration choice with a correct answer, not a limit.
Rate limiting is per-process until you point it at Redis. The default
backend is an in-process HashMap of token buckets, so with n instances
behind a load balancer a client gets roughly n times the nominal quota.
RateLimit::redis(cache) (needs the cache feature) moves the buckets to
Redis/Valkey and the quota becomes global. Decide this deliberately:
OnBackendError controls what happens when Redis is unreachable, and it
defaults to Refuse — the limiter fails closed rather than silently
becoming no limiter at all.
A per-hour quota keyed by address is the one combination that costs
throughput. The in-memory backend sweeps its bucket table past 8192
entries and drops every bucket that has refilled to capacity, which is what
keeps one-bucket-per-IP from growing without bound. It only works while
buckets refill faster than new addresses arrive. Under a per-hour quota a
bucket stays ineligible for six minutes, so the sweep drops nothing, the
table keeps growing, and every subsequent request rescans it while holding a
blocking mutex. Measured at 128 connections: a fresh key on every request
costs nothing under a per-second quota and 5.6x throughput under a
per-hour one — 6786 requests a second against 1201. That run is recorded in
baselines/load-baseline.x86_64-unknown-linux-gnu.txt, and tests/load_profile.rs
reproduces it, one variable per row. The memory reading agrees independently:
it is the only one of the four runs whose resident set moved, +4.2% against
+0.1% to +0.3% for the other three, which is the bucket table growing.
That combination is exactly the shape of a login or password-reset throttle,
so it is worth choosing on purpose. RateLimit::redis(cache) avoids it
entirely — there is no client-side map to scan, only a per-key expiry the
server honours. Failing that, prefer the faster-refilling spelling of the
same rate: per_minute(600) and per_hour(10) allow nearly the same
traffic over an hour, but the first refills a spent bucket in a tenth of a
second and only the second accumulates.
Realtime fan-out is per-process, and there is no switch. Broadcast
wraps a tokio::sync::broadcast channel, which is a channel between tasks
inside one process. A message published on instance A reaches only the
WebSocket and SSE subscribers connected to instance A. Nothing errors and
nothing warns: subscribers on instance B simply never see it, which is why
this is worth stating plainly rather than leaving to be discovered. With
two instances and clients spread evenly, roughly half of each broadcast is
lost from any given client’s point of view.
Until a cross-instance bridge exists, there are three honest ways to live with this:
- Run one instance. Vertical scale goes a long way, and this is the only option that needs no extra reasoning.
- Pin realtime connections to one instance. A load balancer routing WebSocket and SSE upgrades to a single backend keeps fan-out correct while ordinary HTTP scales out. Whether that instance’s failure is acceptable is an availability question, not a correctness one.
- Publish from a shared source. If every message originates from a job worker or an external system, have each instance subscribe to that source and re-publish locally. This is the bridge, written by hand.
A Redis pub/sub bridge is the obvious general answer and redis is already
in the tree behind the cache feature, but it is not written: it would
need delivery semantics, ordering and back-pressure decided on purpose
rather than inherited, and no traffic has yet asked the question.
Maintenance mode
Maintenance is an Arc-backed handle, not a global and not a file on disk.
Flip it from an admin route, a signal handler, or a test. Everything except
the health endpoints and any path passed to Maintenance::allow gets a 503
with a Retry-After header and an RFC 9457 body — so a browser, a fetch,
and a CLI client all get an answer they can act on.
Because nothing looks the handle up in a registry, an application that does not keep the handle cannot engage maintenance mode. That is the intended trade: no ambient switch that some other part of the process can flip.
Errors in release
ErrorMapping::new().redact_errors(true) replaces text/plain 5xx bodies
with a generic problem document. The layer sits at stage 10, inside the panic
catcher and outside everything that runs application code, so it catches both
handler errors and the bodiless 404, 405, 408 and 413 that axum and
tower-http emit on their own.
Redaction is a builder flag, not an automatic consequence of a release build. Set it explicitly.
Security headers
SecurityHeaders::new() sets X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, and Referrer-Policy: strict-origin-when-cross-origin.
.with_hsts() adds Strict-Transport-Security: max-age=31536000; includeSubDomains,
and .with_csp(policy) sets a Content-Security-Policy from a string you
supply.
Add HSTS only once TLS is actually terminated in front of the process and you are prepared for the one-year commitment, subdomains included.
CSP nonces
.with_csp_nonce(template) is the other way to set the policy. It takes a
template containing {nonce} and substitutes a fresh 144-bit random value on
every request:
SecurityHeaders::new()
.with_hsts()
.with_csp_nonce("default-src 'self'; script-src 'self' 'nonce-{nonce}'")?
A template with no {nonce} in it is refused at construction rather than
quietly sent without one, and .with_csp(..) and .with_csp_nonce(..)
replace each other — the last one called wins.
The nonce goes into the request extensions before the request reaches
anything else, and the framework stamps it onto every element it emits
itself: the Inertia data-page payload script, the module script and
stylesheet links resolved from the Vite manifest, and the Vite HMR client in
development. Read it in a handler by extracting CspNonce (or
Option<CspNonce>), and in a hand-written root document by calling
body.nonce_attribute().
What the framework cannot stamp is anything the application writes itself: an
inline <script> in your own root document, an analytics snippet, a
third-party widget that injects scripts. Those either carry the nonce or stop
running.
Three details worth getting right before you turn this on:
- A nonce constrains only the directive that carries it.
script-src 'nonce-X'says nothing aboutstyle-srcorframe-src. - A CSP Level 2 or later browser ignores
'unsafe-inline'in a directive that also carries a nonce, which is whyscript-src 'nonce-X' 'unsafe-inline'is the documented fallback for old browsers rather than a contradiction. But'unsafe-inline'in a directive with no nonce in it –style-src, usually — is not ignored by anything. - Without
'strict-dynamic'a nonce does not propagate to scripts that a nonce’d script goes on to insert, so a code-split bundle needs'self'(or'strict-dynamic') inscript-srcalongside the nonce.
Do not let a shared cache store nonce’d HTML. The document and the header are
cached together so they stay consistent, but every visitor then gets a nonce
that every other visitor already knows, which is the one property it had.
Arcature sets no Cache-Control on the initial document; excluding it is the
CDN configuration’s job.
Ports and the environment
The listen port is resolved at startup in this order, highest first:
ARCATURE_BACKEND_PORTPORTAPP_PORT- whatever
.config(..)or.port(..)last set, defaulting to3000
The first that parses as a u16 wins; one that is present but malformed –
PORT= in a compose file, say — is skipped rather than fatal, so an empty
variable does not stop the process booting.
ARCATURE_BACKEND_PORT is first, ahead of the platform’s PORT, because
arc dev sets it and its supervisor owns the process’s only TCP listener. If
PORT outranked it, a stale PORT in a developer’s .env would aim the
child at the address the supervisor already holds, and the one-port topology
would fail with a message about the port being in use.
AppConfig::from_env() reads APP_NAME, APP_URL, APP_ENV and APP_PORT.
Hand the result to .config(..) and port becomes the port the server binds.
name and url appear on the startup line — the one record a booting
application emits unprompted — so a process that believes it is reachable at
an address nobody expected says so immediately rather than three days later in
a broken emailed link. url is otherwise spent through
AppConfig::absolute_url(path), which roots a path at APP_URL with the
trailing slash normalised away; that is the accessor to reach for whenever a
link has to be built with no request in scope, which is every link that
matters — password resets, redirect_uri, anything signed. path is joined
and never substituted, so passing something that looks like a URL of its own
produces a path segment under the configured host rather than a link to
another one.
env is carried, readable back through Application::config(), and
deliberately barred from gating behaviour. Every protection that could plausibly key off an
environment — the security headers, HSTS, release redaction of error
messages, the UAG endpoint — keys off cfg!(debug_assertions) instead, so it
is decided when the binary is built. An APP_ENV that could switch them off
would let anyone who can set an environment variable downgrade a production
binary without redeploying it.
ARCATURE_VITE_IPC is set by arc dev and consulted by both the dev proxy
and the asset resolver. In production it is unset, and both subsystems fall
back to hashed build output. See
ADR 0003.
Building for release
cargo build --release
Feature selection is how you control what ends up in the binary. The default
feature set is batteries-included and compiles the generated application with
no extra flags; fullstack adds the operator-adjacent extras (storage-s3,
dev-proxy, uag). Operator opt-ins — otel, api-docs, oauth — stay
off unless you name them; api-docs in particular publishes a map of your
attack surface.
Database drivers are separate features — db-postgres, db-sqlite,
db-mysql — and exactly one belongs in a build. Enabling database alone
gives a build that cannot connect to anything, which is deliberate: it is the
only way one crate serves all three without a SQLite user compiling the
Postgres protocol.
#![forbid(unsafe_code)] applies to the whole crate.
Continuous integration
CI runs on both the MSRV (1.97.1) and stable, with RUSTFLAGS: -D warnings
and a postgres:17 service on
postgres://postgres:postgres@localhost:5432/arcature_test. The gates, in
order:
cargo fmt --all — --check
cargo clippy --all-targets
cargo build
cargo test
cargo build --no-default-features
cargo build --features fullstack
cargo hack check --each-feature --no-dev-deps
cargo hack build --feature-powerset --skip database --keep-going
The feature-matrix jobs are there because feature gating is a compile-surface
decision: a feature that only builds when another one happens to be on is a
bug, and cargo hack is the only thing that finds it. A separate job runs
cargo publish --dry-run --no-verify.
The justfile at the repository root wraps these as just check, just fmt,
just lint, just test, just features and just docs.
Releasing
Tagging a version triggers .github/workflows/release.yml, which publishes
arcature-macros first and then arcature, and builds the arc binary for
Linux, macOS and Windows.
There is no npm step, and there will not be one. See ADR 0001.
Upgrading
Upgrading from 0.1.1 to 0.1.2
Nothing to do. 0.1.2 removes nothing, changes no public signature, and adds
no feature flag, so cargo update -p arcature takes it.
Read on only if you serve Server-Sent Events, or if something parses your logs.
Two behaviours change, and neither is a compiler error
An SSE connection now counts against the connection limit for as long as it
is open. It used to count for the instant it was admitted and no longer:
SseEndpoint::handle acquired a ConnectionGuard and then wrote
let _ = guard;, which drops it on that line — _ is not a binding — under a
comment claiming it was held for the life of the stream. The cap therefore
bounded concurrent admissions rather than concurrent streams, and an
application could hold any number of SSE connections open against a
ShutdownConfig::new(1).
If you sized max_connections while that was true, you sized it against a
limit that was not being enforced. The n + 1th concurrent SSE stream now
gets the 503 the configuration always promised. Raise the number if the old
behaviour was what your capacity planning assumed.
realtime::drain is affected in the same direction: an open SSE stream is now
visible to it, so a drain that used to return Ok(()) while streams lingered
will now wait for them and, if they outlast its bound, return
Err(RealtimeError::Shutdown { remaining }).
Handler log lines gain three fields. AccessLogService built its request
span and never entered it, so a tracing::info! inside a handler inherited
nothing. It now attaches the span to the inner call, and handler events carry
request_id, method, path and client_ip. Anything parsing your logs
against an exact key set will see the new keys.
Two new builder methods, both opt-in
ApplicationBuilder::metrics(registry) and
ApplicationBuilder::trace_context() install the metrics and trace-context
layers at stages 9 and 8. Both were previously reachable only through
.layer(..), which lands at stage 21 — inside the body limit, the timeout,
maintenance and the rate limiter — so a counter there missed every 413,
408, 503 and 429, and a trace context there left those requests with no
trace at all.
If you install either by hand today, switch:
- .layer(MetricsLayer::new(metrics.clone()))
- .layer(TraceContextLayer)
+ .metrics(metrics.clone())
+ .trace_context()
Your request total will go up, because it starts including the refusals it was always supposed to count.
Upgrading from 0.1.0 to 0.1.1
Nothing to do. 0.1.1 removes nothing and changes no public signature, so if
your manifest asks for 0.1 you already accept it:
[dependencies]
arcature = { version = "0.1", features = ["fullstack"] }
cargo update -p arcature takes it. Read the rest of this section only if you
want the new subsystems, or if you render pages through a PageContract, or
if you rate-limit by IP.
Two behaviours change
Both are listed in the changelog, and both are the kind of change that does not appear in a compiler error.
A page rendered through a PageContract now titles itself. Where the
<title> used to be the application title on every route, a page reached
through render_page now derives one from its contract name. This only
reaches applications using a #[page] contract and one of the stock root
documents; a hand-written Fn(ScriptBody) -> String root document ignores the
head, as it always has, and Inertia::render is unaffected. If you were
relying on one title everywhere, set the head explicitly with
Inertia::with_head.
The IP rate limiter now keys on the caller. KeySource::Ip previously had
no client address to read and put every request into a single shared bucket,
which meant a limiter configured per-IP was in practice a global one. It now
resolves a real address. Expect the limiter to start doing what its
configuration always said: if a limit was set per-IP and tuned against the
global behaviour, the effective ceiling is now that limit times your caller
count, so re-check the number. Addresses from X-Forwarded-For are trusted
only from peers you list with ApplicationBuilder::trusted_proxies, and the
default list is empty — behind a reverse proxy, the address is your proxy’s
until you say otherwise.
Fourteen new feature flags
Every one is off by default, and nothing was removed, so an existing build compiles unchanged and links not a byte more. This list is the release: the features are the release, and the two behaviour changes above are the only things that happen without asking.
Sign-in and credentials
| Feature | What it adds | Why it is opt-in |
|---|---|---|
auth-flows | auth::flows — the decisions between auth’s seams and a login form that are wrong in ways nothing reports. An unknown address costs the same time as a wrong password; a failed attempt is throttled by address and caller | An application with no sign-in screen has no use for it, and it is the half of authentication where a plausible implementation leaks who has an account |
auth-reset | One-time password-reset links: mailed once, redeemed once, stored as a SHA-256 digest, and issuing a new one invalidates the previous mail | Brings a table and a migration. An application whose accounts are provisioned by an administrator has no use for it |
auth-remember | Rotating remember-me tokens, with the theft detection that makes a weeks-long credential defensible | Brings a table and a migration. “Stay signed in” is a product decision |
api-tokens | Hashed personal access tokens — an opaque bearer credential for a CLI, a CI job, another service. The database holds only a SHA-256 digest | Brings a table and a migration, and is independent of auth: an API with no passwords may still hand out a token |
Cryptography
| Feature | What it adds | Why it is opt-in |
|---|---|---|
crypt | crypt::Encrypter: XChaCha20-Poly1305 over a versioned, self-describing token that refuses to return a single byte of altered ciphertext | The moment a build can produce ciphertext, somebody owns a key-rotation story |
signed-urls | crypt::UrlSigner: a link carrying its own proof of origin and its own deadline, refused if edited by a byte | Separate from crypt because signing needs a MAC and encrypting needs a cipher. A one-hour download link should not pull in an AEAD |
Request and response
| Feature | What it adds | Why it is opt-in |
|---|---|---|
uploads | multipart/form-data bodies, filename sanitizing, content-addressed object names, bounded readers, magic-byte content sniffing, attachment downloads | The filename, the declared content type and the byte count all come from the client. A build with no upload route has no business carrying a multipart parser |
views | Compiled HTML views through Askama, plus mail bodies rendered from the same templates | Askama compiles templates to Rust at build time, so there is no expression evaluator in the request path and server-side template injection is structurally absent rather than defended against. The trade is that editing a template means rebuilding |
i18n | Fluent translation catalogs, locale negotiation against a whitelist, and the active locale exposed to views and Inertia props | An application shipping one language should not carry a message parser and a plural-rule table to say so |
Persistence
| Feature | What it adds | Why it is opt-in |
|---|---|---|
session-store-db | A sqlx-backed SessionStore, so sessions survive a restart instead of logging every user out on deploy | Brings a table and a migration |
Notifications
| Feature | What it adds | Why it is opt-in |
|---|---|---|
notifications | One event, told to one person, over whichever channels apply. Implies mail, which is the channel it is overwhelmingly used for | A channel-less core would be a subsystem that can deliver nothing |
notifications-db | The in-app inbox: one row per delivered notification | Brings a table and a migration. An application that only sends mail should not carry them |
notifications-broadcast | A live push to whoever is connected now, over the realtime machinery | The inbox answers “what did I miss”; the broadcast answers “what just happened”. Wanting one is not wanting the other |
notifications-queue | Hands the mail channel to the job queue instead of the request | The only one of the four that changes where the work happens. It takes on running a worker, and an application without one should not be offered a method that writes rows nobody drains |
Five of these bring a table: auth-reset, auth-remember, api-tokens,
notifications-db and session-store-db. Each has its own idempotent
migration, applied the way you apply the job migrations. A project generated
by arc new on this version wires arcature_sessions into --migrate
already; the rest are yours to apply, because only you know the order they
belong in.
One thing auth-reset does not do, because it would be easy to assume it
does: spending a reset link does not sign the account’s other sessions out.
Sessions are keyed by session id and are not indexed by user, so there is no
portable statement that deletes every session belonging to one subject. The
mechanism that would hold — a credential stamp checked when a session loads
– is a separate piece this release does not ship. If your threat model is
“the attacker already has a session and the user is resetting to evict them”,
that is yours to build on top.
None of the five new database features shares a table with any other, and no
two claim the same advisory lock — tests/advisory_locks.rs fails if that
stops being true, so two migrators can run concurrently without one waiting on
a lock the other holds under a different name.
Following main instead
main moves ahead of the release and breaks without notice. To follow it,
depend by git reference and pin a revision — a branch reference will move
under you.
[dependencies]
arcature = { git = "https://github.com/ArcatureLabs/Arcature", rev = "...", features = ["fullstack"] }
The version scheme: semantic versioning
Arcature is versioned MAJOR.MINOR.PATCH, starting at 0.1.0.
| Field | Increments when |
|---|---|
MAJOR | Something breaks: a removed API, a changed signature, a changed default behaviour. Stays 0 until the API is frozen. |
MINOR | A compatible addition — and, while MAJOR is 0, a breaking change too. |
PATCH | Something is fixed compatibly. |
The current version is 0.1.2, readable at runtime as
arcature::FRAMEWORK_VERSION.
The row that matters is the middle one. Cargo treats the leftmost non-zero
field as the major, so under 0.x the minor is the breaking field, and the
usual requirement already accounts for it:
arcature = "0.1"
resolves 0.1.1 and 0.1.9 but refuses 0.2.0. An exact pin buys nothing
here, and costs you the patches. Raise the minor deliberately, with the
changelog open.
What 0.x is telling you is that the public API is not frozen: any minor
release before 1.0 may remove or reshape something. The parts most likely
to move are listed further down this page. Once 1.0 is tagged, the breaking
field moves back to the major and arcature = "1" becomes the safe
requirement.
Where breaking changes are recorded
CHANGELOG.md
in Keep a Changelog format. Every breaking release gets a ### Removed or
### Changed entry naming the API and the replacement. If a change requires
work in an application, the entry says what the work is.
The macro crate moves with the framework
arcature-macros is versioned in lockstep with arcature and is not a
separate upgrade decision. arcature depends on an exact version of it, and
the release workflow publishes arcature-macros first for that reason. Do not
depend on arcature-macros directly.
What is likely to break
These are the parts most likely to move before 1.0, so that nobody builds
on them by accident:
AppConfigcarriesAPP_NAME,APP_URL,APP_ENVandAPP_PORT.APP_PORT,APP_URLandAPP_NAMEare read by the framework as of0.1.1;APP_ENVis deliberately read by nothing, because a protection an operator can switch off with an environment variable is not one.arcature::test_kit,uagandoauthare the youngest subsystems and the least exercised by real applications, so their surface is the most likely to move.
Anything still unbuilt is marked “Not yet implemented” in the chapter that would otherwise document it. Nothing in this guide shows an example that does not compile today.
Upgrading the toolchain
rust-toolchain.toml pins stable with rustfmt and clippy. The MSRV is
1.97.1 and CI builds on both it and current stable, so an MSRV bump is a
visible change to that file and to the CI matrix, not a silent consequence of
using a new language feature. Arcature uses edition 2024.
Decisions
Some of Arcature’s shapes are surprising enough to be worth a written record.
Each one states the decision, the context that forced it, and the cost paid.
They live in
docs/decisions/
in the repository.
- Arcature publishes no npm package.
Applications use the official
@inertiajs/*adapters. Everything the Rust side hands the browser travels as generated.tsfiles in the application’s own tree, not through a framework runtime. - The CSRF cookie is
XSRF-TOKEN, not__Host-csrf. axios hard-codes those names, so the server moves to meet the client rather than making every application write a shim. - Exactly one TCP port, in development as well as production.
Vite runs in
middlewareModeover IPC and binds no port of its own. - Layer order is a written contract. The pipeline composes in a fixed, documented order, not in builder call order.
- There is no hidden registry.
No
inventory, nolinkme, noTypeIdmap, no thread-locals. All metadata is&'staticconst data emitted by macros and named by code you wrote. - The generated TypeScript stays derived.
resources/js/generated/is written byarc typegen, never committed, and never imported by the scaffold itself — so the type-saferoute()helper is opt-in rather than on by default.
The project
The rest of this guide is about the framework. This page is about the project around it: where to ask a question, how a change gets in, who decides, and what someone who finds a vulnerability is promised in return.
The five documents live in
.github/
in the repository, one of the three directories GitHub looks in for them.
- Support. Four doors – bug report, feature request, discussion, private security report – and which one you want depends on what you have. Reading this first is faster than waiting for someone to move your issue.
- Contributing. The build, test and lint gates, what a change is expected to look like, and the shape of commits and releases. Arcature is one crate with an opinion; this says which contributions sharpen it and which do not.
- Security policy. Private reporting through the repository’s Security tab, which versions get fixes, what is in scope, and the response targets a reporter can hold the project to.
- Governance. Arcature has one maintainer, and this says what follows from that – including what happens to the project if that person stops. A bus factor stated plainly is easier to depend on than an org chart nobody staffs.
- Code of conduct. The Contributor Covenant, and the mailbox that enforces it.