Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
936f670
build: add csrf and embed feature flags
adiologydev May 25, 2026
ea699a7
feat(csrf): signed double-submit token core
adiologydev May 25, 2026
7738b2c
refactor(csrf): use subtle for constant-time compare; harden token tests
adiologydev May 25, 2026
0d41510
feat(csrf): standalone CsrfLayer tower middleware
adiologydev May 25, 2026
0279710
fix(csrf): don't rotate a valid token cookie on 419
adiologydev May 25, 2026
ae417eb
test(csrf): cover cookie seeding + PUT; drop per-request alloc in pat…
adiologydev May 25, 2026
f512e0c
feat(embed): EmbeddedAssets service for single-binary deploys
adiologydev May 25, 2026
6d805ab
fix(embed): Allow header on 405, avoid copying borrowed asset bytes
adiologydev May 25, 2026
22c1365
docs: document csrf and embed features
adiologydev May 25, 2026
79dfda9
example: wire CsrfLayer into axum-react-todo
adiologydev May 25, 2026
29e9787
docs(changelog): add changelog with csrf + embed entries
adiologydev May 25, 2026
03ebd1f
docs: use .parse() in embed sample; test OPTIONS bypasses CSRF
adiologydev May 25, 2026
67ce8f8
fix(csrf,embed): address PR review findings
adiologydev May 26, 2026
0c005af
chore: release 0.1.2 (csrf + embed features)
adiologydev May 26, 2026
69090c6
fix(example): add default-run and migrate route to axum 0.8 path syntax
adiologydev May 26, 2026
55718af
fix(bindings): parse axum 0.8 path params ({id}/{*rest})
adiologydev May 26, 2026
1d8903d
style: apply rustfmt (fixes CI fmt --check)
adiologydev May 26, 2026
9f4c555
fix(example): wait for SSR sidecar before starting backend in just dev
adiologydev May 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Changelog

All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.1.2] - 2026-05-26

### Added

- `csrf` feature: `CsrfLayer`, a standalone tower layer providing
Inertia/axios-compatible CSRF protection via stateless HMAC-signed
double-submit tokens, plus the framework-agnostic `CsrfTokens` core.
- `embed` feature: `EmbeddedAssets`, an axum service for serving build assets
embedded in the binary (rust-embed / `include_dir` / map) — the single-binary
deploy counterpart to `ServeDir`.

[Unreleased]: https://github.com/Climactic/Veer/compare/v0.1.2...HEAD
[0.1.2]: https://github.com/Climactic/Veer/compare/v0.1.1...v0.1.2
6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "veer"
version = "0.1.1"
version = "0.1.2"
edition = "2021"
rust-version = "1.88"
license = "MIT OR Apache-2.0"
Expand All @@ -20,6 +20,8 @@ tower-sessions = ["dep:tower-sessions"]
multipart = ["axum", "axum?/multipart", "dep:base64", "dep:bytes"]
validator = ["dep:validator"]
garde = ["dep:garde"]
csrf = ["axum", "dep:cookie", "dep:hmac", "dep:sha2", "dep:base64", "dep:getrandom", "dep:subtle"]
embed = ["axum"]
ts = ["dep:ts-rs", "dep:inventory"]

[dependencies]
Expand All @@ -46,6 +48,8 @@ cookie = { version = "0.18", optional = true }
hmac = { version = "0.13", optional = true }
sha2 = { version = "0.11", optional = true }
base64 = { version = "0.22", optional = true }
getrandom = { version = "0.3", optional = true }
subtle = { version = "2", optional = true }

tower-sessions = { version = "0.15", optional = true }

Expand Down
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,85 @@ For SSR in production, build the sidecar with `vite build --ssr frontend/ssr.tsx

</details>

<details>
<summary><b>CSRF protection (Inertia/axios)</b></summary>

The Inertia client uses axios, which reads an `XSRF-TOKEN` cookie and echoes it
back in an `X-XSRF-TOKEN` header on every mutating request — no frontend code
needed. `CsrfLayer` is the server side of that convention: it issues the cookie
and verifies the header using a stateless, HMAC-signed double-submit token (no
server-side session required).

Enable the `csrf` feature and stack the layer next to `InertiaLayer`:

```toml
veer = { version = "0.1", features = ["csrf"] }
```

```rust,ignore
use veer::{CsrfLayer, InertiaLayer};

let app = router()
.with_state(state)
.layer(InertiaLayer::new(cfg))
.layer(CsrfLayer::new(secret)); // 32-byte secret; outermost layer
```

On a token mismatch the layer short-circuits with `419` (the Laravel/Inertia
convention for an expired token) before the handler runs. Safe methods
(GET/HEAD/OPTIONS/TRACE) are never checked. For endpoints that can't carry the
header (third-party webhooks), exclude them:

```rust,ignore
CsrfLayer::new(secret).exclude("/webhooks")
```

The cookie is JS-readable by design (so axios can echo it); it is `Secure` +
`SameSite=Lax` by default — call `.secure(false)` for local HTTP dev.

</details>

<details>
<summary><b>Embedded assets (single-binary deploy)</b></summary>

For a single self-contained binary, embed the built frontend instead of serving
it from disk. The manifest embeds via `include_str!`; `EmbeddedAssets` serves the
bytes. Enable the `embed` feature:

```toml
veer = { version = "0.1", features = ["embed"] }
rust-embed = "8"
```

```rust,ignore
use rust_embed::RustEmbed;
use veer::{EmbeddedAssets, ViteManifest, ViteRootView};

#[derive(RustEmbed)]
#[folder = "dist/"]
struct Assets;

let manifest: ViteManifest = include_str!("../dist/.vite/manifest.json").parse()?;
let version = manifest.hash();

let cfg = InertiaConfig::new()
.version(move || version.clone().into())
.root_view(ViteRootView::production().entry("frontend/app.tsx").manifest(manifest));

let app = router()
.with_state(state)
.layer(InertiaLayer::new(cfg))
// Replaces `.nest_service("/build", ServeDir::new("dist"))`:
.nest_service("/build", EmbeddedAssets::new(|p| Assets::get(p).map(|f| f.data)));
```

`EmbeddedAssets` takes any `Fn(&str) -> Option<Cow<'static, [u8]>>`, so it works
with `rust-embed`, `include_dir`, or a plain map — Veer depends on none of them.
It sets `Content-Type` from the file extension and serves content-hashed assets
with `Cache-Control: public, max-age=31536000, immutable`.

</details>

<details>
<summary><b>End-to-end TypeScript bindings (Wayfinder-style)</b></summary>

Expand Down Expand Up @@ -483,6 +562,8 @@ Flash is stored under a single key (`_veer_flash` by default; override with `Tow
| `tower-sessions` | off | Flash store backed by [`tower-sessions`](https://crates.io/crates/tower-sessions) |
| `validator` | off | `IntoErrorBag` impl for `validator::ValidationErrors` |
| `garde` | off | `IntoErrorBag` impl for `garde::Report` |
| `csrf` | off | Inertia/axios-compatible CSRF protection (`CsrfLayer`) |
| `embed` | off | Embedded-asset serving for single-binary deploys (`EmbeddedAssets`) |
| `ts` | off | End-to-end TypeScript bindings codegen (`ts-rs` + `inventory`) |

Disabling a feature drops its transitive deps entirely.
Expand Down
5 changes: 4 additions & 1 deletion examples/axum-react-todo/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ name = "axum-react-todo"
version = "0.0.1"
edition = "2021"
publish = false
# Two binaries live here (the server + the `gen-bindings` codegen tool), so
# `cargo run` / `just dev` need a default; `cargo run --bin gen-bindings` still works.
default-run = "axum-react-todo"

[dependencies]
veer = { path = "../..", features = ["axum", "cookie-session", "validator", "ssr", "ts"] }
veer = { path = "../..", features = ["axum", "cookie-session", "csrf", "validator", "ssr", "ts"] }
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
Expand Down
8 changes: 8 additions & 0 deletions examples/axum-react-todo/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ dev:
bun dev &
if [ "${SSR:-}" = "1" ]; then
bun run ssr:dev &
# The backend runs with ssr_required(true), so a request that arrives
# before the sidecar is ready fails with "ssr transport: error sending
# request". Wait for the sidecar's /health to respond before starting it.
echo "waiting for SSR sidecar on :13714..."
for _ in {1..100}; do
if curl -sf -o /dev/null http://127.0.0.1:13714/health 2>/dev/null; then echo "SSR sidecar ready"; break; fi
sleep 0.1
done
fi
SSR="${SSR:-}" cargo run &
wait
Expand Down
2 changes: 1 addition & 1 deletion examples/axum-react-todo/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ pub fn router() -> veer::Router<TodoStore> {
.named_route(GET, "todos.index", "/todos", todos_index)
.named_route(POST, "todos.store", "/todos", todos_create)
.named_route(GET, "todos.create", "/todos/new", todos_new)
.named_route(DELETE, "todos.destroy", "/todos/:id", todos_delete)
.named_route(DELETE, "todos.destroy", "/todos/{id}", todos_delete)
}

async fn home(inertia: Inertia) -> impl axum::response::IntoResponse {
Expand Down
10 changes: 7 additions & 3 deletions examples/axum-react-todo/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use axum_react_todo::{router, todos::TodoStore};
use std::net::SocketAddr;
use veer::{
session::cookie::CookieSessionStore, ssr::http::HttpSsrClient, InertiaConfig, InertiaLayer,
ViteRootView,
session::cookie::CookieSessionStore, ssr::http::HttpSsrClient, CsrfLayer, InertiaConfig,
InertiaLayer, ViteRootView,
};

#[tokio::main]
Expand Down Expand Up @@ -44,10 +44,14 @@ async fn main() {
cfg = cfg.csr_only(true);
}

// CSRF protection (demo secret — load from config/env in production).
// Stacked outside InertiaLayer so it verifies before the handler runs and
// issues the XSRF-TOKEN cookie the Inertia/axios client echoes back.
let app = router()
.build()
.with_state(store)
.layer(InertiaLayer::new(cfg));
.layer(InertiaLayer::new(cfg))
.layer(CsrfLayer::new(b"01234567890123456789012345678901".to_vec()).secure(false));

let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
println!(
Expand Down
Loading
Loading