Repository navigation
Rust off Rails 0.3.0
What's new
- Open source (MIT OR Apache-2.0), with a one-line install (
curl -fsSL https://rustoffrails.org/install.sh | sh) and theoffrailscommand (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: apiand anAPI: none | rest | graphql | bothheader line; REST under/api/v1with bearer tokens and OpenAPI, or GraphQL. - Svelte as a UI library, chosen with a new
UI_LIBRARYheader line. - Realtime channels: one WebSocket per tab, rooms with presence, an opt-in Postgres cluster layer, and faster signals (
NOTIFYon Postgres: 238 ms to 2.1 ms). compose.yamlruns the whole app, and every app with pages gets favicons, social cards,robots.txtand a sitemap from day one.
Breaking changes
FRONTEND_MODE: reactis renamedspa, and the app'sAGENTS.mdheader gainsUI_LIBRARYandAPIlines.- 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.jsonneeds atypecheckscript. - Every mounted route must have a
ROUTESrow (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 acurrentsymlink; installs the Claude Code plugin whenclaudeis 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.mdcand.github/copilot-instructions.md, pointing toAGENTS.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:securityskill; hooks for security tripwires, banned crates inCargo.toml, append-only committed migrations, raw.begin(), and nopkill; content-fingerprint drift detection;/offrails:upgradereconciles 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-requestsand HSTS only with an httpsAPP_URL;no-storeon every authenticated response;- production cookies forced to
Secure; HttpOnly; SameSite=Lax.
- the full header matrix, adding
- Session cookie:
__Host-session_tokenin production, and production ignores the plain name. - CI:
- a
securityjob (cargo-deny + gitleaks over the full history); - the Docker job boots the production image and runs the ZAP baseline;
- Dependabot.
- a
- Findings fixed:
- the flash cookie had no
Securein production; - authenticated pages were cacheable;
- panic 500s and timeout 408s had no security headers.
- the flash cookie had no
App migration (reconcile, don't re-create):
- Header middleware. Detect:
src/kernel/security.rs.- Missing: generate it per
guides/security.md#the-header-matrix. Inroutes.rs, replace the per-headerSetResponseHeaderLayers and theCSPconst 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.
- Missing: generate it per
- Session cookie. Detect
PROD_COOKIEinkernel/session.rs. Missing: addname(secure)and makeread(headers, secure)accept onlyname(secure). Updatecookie/clear_cookie, theload_sessioncaller and any WebSocket upgrade that reads the cookie. Tests useksession::name(config.is_production()). Deploying logs everyone out once, because the cookie name changes. Say so to the human. tests/app/headers_test.rs. Add it (one row per response class the app has), plusTestApp::request_head. Production rows use anhttps://APP_URL. Make it fail first if the middleware is off.- Scanner configs. Add
deny.toml(copy the reference insecurity.md; keep any[bans]the app already has), withpublish = falsein 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. - CI. Add the
securityjob and the Docker job's boot + ZAP steps fromguides/testing.md#ci. Lint with actionlint. - Feature specs. Add a
## Securitysection to everyproduct/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/devand/admin,GET /as a JSON index,/robots.txtdisallowing 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.jsonwith a drift test, plus Scalar at/dev/api-docs.
- bearer API tokens (
- GraphQL lives at
POST /graphql: async-graphql, DataLoaders, depth (≥ 13, or introspection breaks) and complexity limits, payloadUserErrors,docs/schema.graphqlwith 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.yamlruns the whole app: the production image plus Postgres with healthchecks, and profilesworkerandmail. Newapp healthsubcommand for container healthchecks.- Day-one brand and meta for every mode with pages:
tools/brand/render.shwritesfavicon.svg,favicon.ico,apple-touch-icon.png,icon-512.pngandog.png;Ctxcarries the title, description and canonical URL;- the layout has Open Graph and Twitter tags;
/robots.txtand/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_formis a kernel dependency (the jobs dashboard's filter links).newadds it again, which corrects the earlier doc-fix pass.
App migration (reconcile, don't re-create):
- Header. If
AGENTS.mdhas noAPI:line, addAPI: noneunderISLANDS:. An app that already serves a public API (tokens,/api/v1) recordsrest, and followsguides/api.mdfor anything it lacks. - compose.yaml. Detect:
grep -q '^ app:' compose.yaml.- If it has only
postgres/db, replace it with the shape inguides/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 healthtosrc/main.rsif missing. - Verify with
docker compose up --build, thendown -v.
- If it has only
- Brand and meta (modes with pages).
- Detect:
static/og.png,static/apple-touch-icon.png, andog:imagein 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 theCtxmeta fields fromguides/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.
- Detect:
- serde_html_form. Ensure it's in
Cargo.toml(cargo add serde_html_form). It's harmless if already present. - Adding
API: rest | graphqlto an existing app is a feature, never part of an upgrade. Spec it, then followguides/api.md/guides/graphql.mdandgenerators/scaffold.mdstep 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_MODEvalues are nowhtmx | inertia | spa | api(apiis specified by the API-only work). -
FRONTEND_MODE: reactis nowspa. 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 usessvelte-check --fail-on-warnings.
App migration (existing apps; reconcile, don't regenerate):
- Detect
FRONTEND_MODE:inAGENTS.md.- If it says
react, change it tospaand addUI_LIBRARY: reactunder it. - If it says
inertia, orISLANDS: yes, addUI_LIBRARY: react. - Otherwise add
UI_LIBRARY: none.
No code changes. Log the header change indocs/decisions.md.
- If it says
- If
web/package.jsonhas notypecheckscript, add"typecheck": "tsc -b". CI and the DoD now callpnpm --dir web typecheck; update.github/workflows/ci.ymlif it runsexec tsc -bdirectly. - 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 onlyVITE_ENTRYand 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::broadcastnow wakes the poller:NOTIFYat commit on Postgres (238 ms → 2.1 ms) and an in-processNotifyon SQLite (→ 0.24 ms). The 250ms poll remains as a backstop. NewPubSub::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, withcluster_nodesheartbeats, aNOTIFYbus, rendezvous ownership, forwarded calls, presence cleanup and failover. Settings:APP_CLUSTER,APP_NODE. - Fixed: the reference app's
ROUTEStable had no rows for books, reviews, blobs or streams, so the access matrix never tested them. New testevery_mounted_route_is_in_the_routes_tableparsessrc/routes.rsand fails on any mounted path missing fromROUTES.
App migration (an agent upgrading an existing app; reconcile, don't re-create):
- Every app: route table drift.
- Detect: any
.route("…")path insrc/routes.rswithout aROUTESrow. - Converge: add the rows with their real
Access(check each handler's extractor). Then add theevery_mounted_route_is_in_the_routes_tabletest fromguides/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.
- Detect: any
- Apps with SSE channels: faster signals.
- Detect:
kernel/pubsub.rswithoutpg_notify/WAKE. - Converge: add the
NOTIFY(Postgres) andWAKE(SQLite) calls inbroadcast,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.
- Detect:
- 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::wshandler. - Converge: move its topics into
Roomimpls behind/socket. Keep the old route as long as clients depend on it, and log the deviation.
- Detect: an existing hand-rolled