Skip to content

Rust off Rails 0.3.0

Choose a tag to compare

@danielcj danielcj released this 13 Oct 01:22
· 151 commits to main since this release

What's new

  • Open source (MIT OR Apache-2.0), with a one-line install (curl -fsSL https://rustoffrails.org/install.sh | sh) and the offrails command (new, doctor, version, update, uninstall).
  • Security headers in one middleware (kernel/security.rs), a __Host- session cookie in production, a STRIDE section in every feature spec, and CI that runs cargo-deny, gitleaks and a ZAP scan.
  • API-only apps and a public API: FRONTEND_MODE: api and an API: none | rest | graphql | both header line; REST under /api/v1 with bearer tokens and OpenAPI, or GraphQL.
  • Svelte as a UI library, chosen with a new UI_LIBRARY header line.
  • Realtime channels: one WebSocket per tab, rooms with presence, an opt-in Postgres cluster layer, and faster signals (NOTIFY on Postgres: 238 ms to 2.1 ms).
  • compose.yaml runs the whole app, and every app with pages gets favicons, social cards, robots.txt and a sitemap from day one.

Breaking changes

  • FRONTEND_MODE: react is renamed spa, and the app's AGENTS.md header gains UI_LIBRARY and API lines.
  • The production session cookie is renamed __Host-session_token: deploying logs everyone out once.
  • The frontend type-check is pnpm --dir web typecheck; web/package.json needs a typecheck script.
  • Every mounted route must have a ROUTES row (a new test fails otherwise).

Upgrading

Each subsection's App migration list below. The procedure is in upgrade.

Full list of changes

The open-source release. Rust off Rails is public at https://github.com/rust-off-rails/rustoffrails and installs with one line: curl -fsSL https://rustoffrails.org/install.sh | sh.

One-line install and the offrails command

  • install.sh (POSIX sh): shows its plan first, --dry-run, --yes; Rust via rustup (no sudo); sea-orm-cli, cargo-nextest and cargo-watch as prebuilt binaries via cargo-binstall; a SHA-256-verified framework release in ~/.offrails/<version> with a current symlink; installs the Claude Code plugin when claude is present; prints the AGENTS.md prompt for Codex and Cursor. Idempotent, no telemetry. See INSTALL.md.
  • offrails: new <name> "<pitch>", doctor, version, update, uninstall.
  • New apps also get .cursor/rules/offrails.mdc and .github/copilot-instructions.md, pointing to AGENTS.md.

App migration: optional. Add the two agent pointer files above.

Open source

  • Dual licensed MIT OR Apache-2.0; SECURITY.md (GitHub private vulnerability reporting), CONTRIBUTING.md (contributions are intent plus evidence), Code of Conduct, issue forms ("Agent got stuck" first), and CI: links, hook tests, plugin validation, gitleaks, installer tests, OpenSSF Scorecard.
  • Brand: the \R/ derailing mark and the hopping "off" wordmark, drawn as code (brand/).
  • Doctrine §0, "All code is written by AI", is now the founding rule.
  • Plugin: /offrails:security skill; hooks for security tripwires, banned crates in Cargo.toml, append-only committed migrations, raw .begin(), and no pkill; content-fingerprint drift detection; /offrails:upgrade reconciles instead of re-creating.

Security as the gold standard: Non-negotiables, the header matrix, automated enforcement

Spec: guides/security.md (Non-negotiables, the header matrix, Automated enforcement, Gotchas), plus blueprints/feature-spec.md (a ## Security STRIDE section), workflow.md, guides/test-standards.md §3, guides/testing.md CI, generators/new.md, stack.md, structure.md, guides/routing.md and guides/auth.md. Plugin: the /offrails:security skill and security tripwires in the hooks. Verified on the reference app (branch security): 207 tests on SQLite and Postgres 18; cargo-deny advisories/bans/licenses/sources ok; gitleaks clean over the history; ZAP baseline 0 FAIL, 0 WARN, 61 PASS; checked in Chrome.

  • kernel/security.rs, one middleware outside the panic and timeout layers:
    • the full header matrix, adding object-src 'none', COOP and CORP to what was there;
    • upgrade-insecure-requests and HSTS only with an https APP_URL;
    • no-store on every authenticated response;
    • production cookies forced to Secure; HttpOnly; SameSite=Lax.
  • Session cookie: __Host-session_token in production, and production ignores the plain name.
  • CI:
    • a security job (cargo-deny + gitleaks over the full history);
    • the Docker job boots the production image and runs the ZAP baseline;
    • Dependabot.
  • Findings fixed:
    • the flash cookie had no Secure in production;
    • authenticated pages were cacheable;
    • panic 500s and timeout 408s had no security headers.

App migration (reconcile, don't re-create):

  1. Header middleware. Detect: src/kernel/security.rs.
    • Missing: generate it per guides/security.md#the-header-matrix. In routes.rs, replace the per-header SetResponseHeaderLayers and the CSP const with one .layer(from_fn_with_state(security::Hardening::new(&config), security::middleware)) placed after (outside) CatchPanicLayer.
    • If a route sets its own CSP (the mailbox viewer), keep it. The middleware only fills in missing headers.
  2. Session cookie. Detect PROD_COOKIE in kernel/session.rs. Missing: add name(secure) and make read(headers, secure) accept only name(secure). Update cookie/clear_cookie, the load_session caller and any WebSocket upgrade that reads the cookie. Tests use ksession::name(config.is_production()). Deploying logs everyone out once, because the cookie name changes. Say so to the human.
  3. tests/app/headers_test.rs. Add it (one row per response class the app has), plus TestApp::request_head. Production rows use an https:// APP_URL. Make it fail first if the middleware is off.
  4. Scanner configs. Add deny.toml (copy the reference in security.md; keep any [bans] the app already has), with publish = false in both crates. Add .gitleaks.toml, .zap/rules.tsv (+ .zap/.gitignore) and .github/dependabot.yml. Run each scanner locally. Fix findings, or triage them with a reason in the config.
  5. CI. Add the security job and the Docker job's boot + ZAP steps from guides/testing.md#ci. Lint with actionlint.
  6. Feature specs. Add a ## Security section to every product/features/*.md (blueprints/feature-spec.md). Each threat gets a control and a test. A threat without a test is a finding.

API-only apps, the API axis (REST + OpenAPI, GraphQL), Docker compose, day-one meta and favicons

Spec: guides/api.md and guides/graphql.md (new), tools/brand/render.sh (new), plus interview.md, blueprints/AGENTS.md, generators/new.md, generators/scaffold.md, guides/views.md, guides/deployment.md, guides/auth.md, guides/controllers.md, guides/config.md, guides/test-standards.md, stack.md, structure.md, cli.md, RAILS_MAP.md. Verified with the offrails-api-demo app (FRONTEND_MODE: api, API: both): 151 tests green on SQLite and Postgres 18, clippy clean, GraphiQL and the Scalar reference checked in Chrome, docker compose up --build from scratch plus the mail and worker profiles, and direct TLS (h2 via ALPN).

  • FRONTEND_MODE: api (rails new --api): no UI, JSON errors everywhere except /dev and /admin, GET / as a JSON index, /robots.txt disallowing all.
  • API: none | rest | graphql | both, a separate header line, chosen in the interview. REST lives under /api/v1:
    • bearer API tokens (api_tokens, SHA-256 digests, scopes, expiry);
    • ApiJson (JSON error shape for bad bodies) and {"data": …} envelopes;
    • an OpenAPI document via utoipa, committed as docs/openapi.json with a drift test, plus Scalar at /dev/api-docs.
  • GraphQL lives at POST /graphql: async-graphql, DataLoaders, depth (≥ 13, or introspection breaks) and complexity limits, payload UserErrors, docs/schema.graphql with a drift test, GraphiQL at /dev/graphiql.
  • CORS comes from APP_CORS_ORIGINS: exact origins, also trusted by CSRF, no credentials. Per-token rate limits.
  • compose.yaml runs the whole app: the production image plus Postgres with healthchecks, and profiles worker and mail. New app health subcommand for container healthchecks.
  • Day-one brand and meta for every mode with pages:
    • tools/brand/render.sh writes favicon.svg, favicon.ico, apple-touch-icon.png, icon-512.png and og.png;
    • Ctx carries the title, description and canonical URL;
    • the layout has Open Graph and Twitter tags;
    • /robots.txt and /sitemap.xml;
    • smoke tests assert all of it.
  • Web server: documented as axum on hyper (HTTP/1.1 + h2c); optional direct TLS via APP_TLS_CERT/APP_TLS_KEY (axum-server + rustls).
  • Fixed: serde_html_form is a kernel dependency (the jobs dashboard's filter links). new adds it again, which corrects the earlier doc-fix pass.

App migration (reconcile, don't re-create):

  1. Header. If AGENTS.md has no API: line, add API: none under ISLANDS:. An app that already serves a public API (tokens, /api/v1) records rest, and follows guides/api.md for anything it lacks.
  2. compose.yaml. Detect: grep -q '^ app:' compose.yaml.
    • If it has only postgres/db, replace it with the shape in guides/deployment.md#docker-compose-the-whole-app-locally. Keep the existing Postgres major and volume name (renaming the volume loses local data; say so).
    • Add app health to src/main.rs if missing.
    • Verify with docker compose up --build, then down -v.
  3. Brand and meta (modes with pages).
    • Detect: static/og.png, static/apple-touch-icon.png, and og:image in the layout or shell.
    • Missing: run bash .offrails/tools/brand/render.sh "<APP_NAME>" "<accent from the layout>" "<pitch from product/README.md>" static, add the <head> tags and the Ctx meta fields from guides/views.md#meta-favicon-and-social-cards-every-app-with-pages, route /favicon.ico, /robots.txt, /sitemap.xml, and add the smoke-test assertions.
    • An existing hand-made favicon or OG image wins. Don't overwrite it; only fill in what's missing.
  4. serde_html_form. Ensure it's in Cargo.toml (cargo add serde_html_form). It's harmless if already present.
  5. Adding API: rest | graphql to an existing app is a feature, never part of an upgrade. Spec it, then follow guides/api.md / guides/graphql.md and generators/scaffold.md step 9b per resource.

Svelte as a UI library; the react mode is renamed spa

Spec: guides/frontend-svelte.md (new); stack.md, interview.md, blueprints/AGENTS.md, structure.md, generators/new.md and generators/scaffold.md updated. Verified in Chrome:

  • Inertia + Svelte 5 on the reference app: every protocol check, 409 redeploy reload, HMR, Playwright, 75 Rust tests on SQLite and Postgres;

  • a SvelteKit SPA served by Rust under a strict hashed CSP;

  • a Svelte island.

  • The app header gains UI_LIBRARY: react | svelte | none. FRONTEND_MODE values are now htmx | inertia | spa | api (api is specified by the API-only work).

  • FRONTEND_MODE: react is now spa. Same architecture; the name no longer implies the library.

  • The DoD's frontend type-check is pnpm --dir web typecheck. React keeps "typecheck": "tsc -b"; Svelte uses svelte-check --fail-on-warnings.

App migration (existing apps; reconcile, don't regenerate):

  1. Detect FRONTEND_MODE: in AGENTS.md.
    • If it says react, change it to spa and add UI_LIBRARY: react under it.
    • If it says inertia, or ISLANDS: yes, add UI_LIBRARY: react.
    • Otherwise add UI_LIBRARY: none.
      No code changes. Log the header change in docs/decisions.md.
  2. If web/package.json has no typecheck script, add "typecheck": "tsc -b". CI and the DoD now call pnpm --dir web typecheck; update .github/workflows/ci.yml if it runs exec tsc -b directly.
  3. Switching an existing app to Svelte is a human decision, never part of an upgrade. If asked, follow guides/frontend-svelte.md: the Rust side changes only VITE_ENTRY and drops the React refresh preamble route.

Realtime: channels, presence, the hub, the cluster layer

Spec: guides/channels.md (new), plus changes to guides/realtime.md. Verified in the reference app (book page: "reading now" avatars and a live discussion), with 21 tests on SQLite and Postgres 18 including a 3-node failover, a Playwright smoke, and a manual server-restart check in Chrome.

  • Signals got fast. pubsub::broadcast now wakes the poller: NOTIFY at commit on Postgres (238 ms → 2.1 ms) and an in-process Notify on SQLite (→ 0.24 ms). The 250ms poll remains as a backstop. New PubSub::start_with(poll_every).
  • Channels:
    • GET /socket: one WebSocket per tab, Phoenix v2 wire format, topics joined with authorization;
    • kernel/hub.rs: a room actor per topic, supervised; serialize-once fan-out; slow consumers dropped; presence by person;
    • kernel/channels.rs: Origin check, auth at upgrade, frame and rate limits;
    • static/channels.js, and /dev/sockets.
  • Cluster (opt-in, Postgres): kernel/cluster.rs, with cluster_nodes heartbeats, a NOTIFY bus, rendezvous ownership, forwarded calls, presence cleanup and failover. Settings: APP_CLUSTER, APP_NODE.
  • Fixed: the reference app's ROUTES table had no rows for books, reviews, blobs or streams, so the access matrix never tested them. New test every_mounted_route_is_in_the_routes_table parses src/routes.rs and fails on any mounted path missing from ROUTES.

App migration (an agent upgrading an existing app; reconcile, don't re-create):

  1. Every app: route table drift.
    • Detect: any .route("…") path in src/routes.rs without a ROUTES row.
    • Converge: add the rows with their real Access (check each handler's extractor). Then add the every_mounted_route_is_in_the_routes_table test from guides/test-standards.md §3 (tests/app/security_test.rs), and run the security suite, which may now fail on routes it never covered. Fix those routes, not the test.
  2. Apps with SSE channels: faster signals.
    • Detect: kernel/pubsub.rs without pg_notify / WAKE.
    • Converge: add the NOTIFY (Postgres) and WAKE (SQLite) calls in broadcast, start_with, and the dedicated listener task, per realtime.md's kernel spec.
    • Tests: the wake-up test (start_with(60s)) and the rollback test.
    • No schema change.
  3. Apps that need rooms or presence: only when a spec asks for them. Run generators/channel.md → room recipe.
    • Detect: an existing hand-rolled axum::extract::ws handler.
    • Converge: move its topics into Room impls behind /socket. Keep the old route as long as clients depend on it, and log the deviation.