Imported from ZetaTwo/dotfiles (
.claude/skills/fullstack-webapp/SKILL.md). Install upstream withnpx skills add ZetaTwo/dotfiles --skill fullstack-webapp. Copyright stays with the author.
Full-stack web app (Rust/axum + Vue/TS + Postgres)
The user's house style for web apps, distilled from a working project (canst). Follow it by default; deviate only when a project needs it, and then record the deviation (see Keeping this skill current at the bottom).
If invoked with the argument sync, skip straight to Sync mode below.
Stack
Versions are what canst used; bump to latest stable when starting fresh.
| Layer | Choice |
|---|---|
| HTTP | axum 0.8, tokio (full), tower, tower-cookies + cookie |
| DB | Postgres 16, diesel 2 (postgres, uuid, chrono, serde_json) + diesel-async (postgres, deadpool), diesel_migrations, diesel-derive-enum |
| Auth | rusty_paseto (v4.local), argon2 (argon2id), base64 |
| API docs | axum-openapi3 #[endpoint] + utoipa (spec generated from code at GET /api/openapi.json) |
| Errors/config/logging | thiserror, serde + toml, clap (derive), tracing + tracing-subscriber (env-filter) |
| Outbound HTTP | reqwest (one shared Client in AppState) |
| Backend tests | testcontainers + testcontainers-modules (postgres), wiremock, tower (util, for oneshot), cargo-llvm-cov |
| Frontend | Vue 3 <script setup lang="ts">, vue-router, pinia (setup stores), Vite, vue-tsc, pnpm (never npm) |
| Frontend tests | Vitest + @vue/test-utils (jsdom); @playwright/test with system Chrome, pg for DB fixtures |
Repo layout
backend/
Cargo.toml diesel.toml .gitignore(target/)
config/local.example.toml # committed; local.toml is gitignored
migrations/ # diesel CLI timestamped dirs, up.sql + down.sql
src/
main.rs # clap --config, tracing init, prints StartupError via Display
lib.rs # AppState, FromRef impls, build_router(state) — shared by main and tests
config.rs # Config (serde from TOML)
startup.rs # StartupError (never becomes an HTTP response)
error.rs # AppError -> JSON {error, code}
authz.rs # Capability, Read/Edit/Delete, Authorized<T, C>, authorize_* fns
api_types.rs # path-param newtypes for the OpenAPI macro (UuidParam)
auth/{mod,paseto,password,session}.rs
db/{mod,schema}.rs # pool, DbConn extractor, MIGRATIONS; schema.rs is generated
models/<resource>.rs # Queryable/Selectable row, Insertable New*, request/response DTOs
routes/<resource>.rs # one router() per resource
tests/
common/mod.rs # TestApp harness (mod.rs so cargo doesn't treat it as a test binary)
api.rs # integration suite
frontend/
index.html (data-theme on <html>) vite.config.ts package.json playwright.config.ts
e2e/ # Playwright: run.sh, fixtures.ts, stub-server.mjs, *.spec.ts
src/
main.ts App.vue style.css
api/{client,types}.ts
stores/<resource>.ts
router/index.ts
views/<Name>View.vue
components/
theme/theme-base.css theme/theme-<name>.css
**/*.test.ts # Vitest unit/component tests, next to the code
.gitignore # /target, /backend/config/local.toml, /frontend/node_modules, /frontend/dist
CLAUDE.md # includes "## Deviations from fullstack-webapp"
Principles
Config & secrets
- Secrets live only in a gitignored TOML file (
backend/config/local.toml) whose path is passed as--config <path>. No env vars for config or secrets;RUST_LOGis the only exception. Commitlocal.example.tomlwith placeholders and the command that generates each secret (openssl rand -base64 32). - Every external HTTP endpoint the app calls is a
Configfield with a#[serde(default = ...)]pointing at the real URL. That field is how tests swap in a wiremock server. auto_migrate: bool(default false) is a dev-only shortcut that runs embedded migrations at startup.log_format(textby default) can be set tojsonwhen the deploy environment's log pipeline wants JSON lines (.json().flatten_event(true), solevel/messageare top level). Only set it if that environment actually parses JSON.
Operational hooks (every app has these, wherever it ends up running)
<app>-backend --config <path> migrateapplies the embedded migrations and exits. The default (no subcommand) runs the server. Migrations run as their own step before a new version starts, never implicitly at server startup outside dev.GET /api/healthzround-trips the database (SELECT 1viaDbConn) and returns 200, or 503 when the pool can't get a connection. It's a plain.route(), not an#[endpoint].
Dev setup
- Dev uses a Postgres installed locally, not Docker. Docker/Podman is needed only for
cargo test(testcontainers). - The app assumes a single origin, so cookie auth needs no CORS: the SPA and
/apiare served from the same host. In dev, Vite on :5173 proxies/apito axum on :3000. axum serves only the API and never static files; whatever hosts the app routes/apito it.
Out of scope: deployment (containers, hosting, proxies, deploy pipelines, backups) is not part of this skill. Running the test suites in CI is in scope. If the user has an infrastructure repo, follow its documentation for how apps get deployed.
Backend structure
- Each handler declares its full absolute
/api/...path in#[endpoint], and sub-routers are combined with.merge(), not.nest(). Nesting would double the prefix and break the OpenAPI spec. - Request guards are axum extractors:
DbConn(pool exhaustion → 503),Session(401 if missing/invalid),OptionalSession(infallible). A handler's signature states what it needs. - The backend is the real gate. Per-object checks use
Authorized<T, C>, which can only be built by anauthorize_*fn, so any function takingAuthorized<T, Edit>is statically known to have run the check. List endpoints authorize through theirWHEREclause. All-admin route groups use oneroute_layer(middleware::from_fn_with_state(state, require_admin)). - Handlers have the shape
async fn x(State, Path?, Session?, DbConn, Json?) -> Result<Json<T>, AppError>. AppErrormaps to(status, Json({error, code})). 4xx variants return their hand-written Display text. 5xx variants return a fixed generic message and log the fullsource()chain throughtracing::error!.StartupErroris a separate enum, andmainprints it with Display.- Map known DB constraint violations (e.g.
UniqueViolationon<table>_<col>_key) to specificConflictmessages, and fall back to a generic message for unknown ones. - Risky or unfinished features go behind a Cargo feature that is off by default, gating both the
modand the.merge()with#[cfg(feature = "...")].
Data modeling
- Use UUID PKs (
gen_random_uuid()),TIMESTAMPTZ NOT NULL DEFAULT now(), and an index for each query shape you actually run. - For case-insensitive text (usernames, emails), use an ICU nondeterministic collation
(
case_insensitive), not CITEXT. - Fixed value sets use a PG enum +
DbEnum+#[serde(rename_all = "lowercase")]. Sets that users/admins may extend get a lookup table with an FK, so adding a value needs no migration. - Derive state instead of storing it. Don't add a status column that could contradict other
columns (e.g. published =
scheduled_date <= today). Encode allowed row shapes asCHECKconstraints. - Prefer simple SQL that is correct by construction (
ORDER BY random(),EXISTSanti-joins) over app-layer logic, and note any perf caveat you are accepting.
Auth
- PASETO v4.local tokens in HttpOnly+Secure cookies: a 15-min access token (
path=/, SameSite=Lax) and a 30-day refresh token (path=/api/auth/refresh, SameSite=Strict), rotated on refresh.users.token_generationis embedded in both tokens;logout-allincrements it, which revokes every outstanding refresh token. - Passwords use argon2id with default params. There is no admin bootstrap route: promote the first admin with SQL.
API contract
- The OpenAPI spec is generated from the code. The TS client is hand-written:
api/types.tsmirrors the Rust DTOs by convention, with a header comment saying so. Check it against/api/openapi.jsonwhen it drifts. - Never serialize DB row structs that hold secrets. Use a response DTO with
From<Row>(e.g.User→UserResponse).
Frontend
api/client.tsis a thinfetchwrapper withcredentials: 'include'. It throwsApiError(status, code)and treats 204 asundefined.- Pinia setup stores (
defineStore('x', () => {...})) exposeloading/notFound-style refs. Refetch on view entry whenever server data is non-deterministic (e.g. randomized). - The router lazy-loads views and uses
meta.requiresAuth.beforeEachbootstraps the auth store once viaGET /api/auth/me. Guards are UX only. - Theming:
theme-base.cssdeclares the custom-property contract with no values, and eachtheme-<name>.csssupplies values under:root[data-theme='<name>'], including aprefers-color-scheme: darkblock. Components use onlyvar(--...)and never branch on the theme. - When the UI varies by a discriminator (e.g. platform/kind), one component switches on it and the variant components stay unaware of each other.
Testing
- Integration tests run against a real Postgres via testcontainers, never mocked Diesel. They use
the same
build_router, driven bytower::ServiceExt::oneshot, with no socket. - Outbound HTTP is pointed at
wiremockviaTestApp::spawn_with_config. Tests must never hit the real internet. - Unit tests for pure logic go in
#[cfg(test)] mod testsnext to the code: token round-trips and rejection, password hash/verify, error-body shape, URL parsing. - Measure coverage with
cargo llvm-cov. Test each Cargo feature both enabled and disabled. - Frontend unit/component tests (Vitest,
pnpm test) sit next to the code as*.test.ts. They cover the API client's session logic (401 → refresh → replay) against a stubbedfetch, stores with theapimodule mocked, and components through@vue/test-utils. - Browser tests (Playwright,
pnpm test:e2e) drive real Chrome against the production build, the real backend binary and a real Postgres, one clean database per test. They are hermetic: a local stub server stands in for third parties the backend calls (via the sameConfigendpoint fields),context.route()stubs widget scripts, and every other outbound request is blocked. - Run all three suites in CI before anything ships.
Reference files — read before writing the matching code
| Writing… | Read |
|---|---|
Cargo.toml, main.rs (incl. migrate), lib.rs (incl. healthz), config.rs, startup.rs |
reference/backend-core.md |
| error.rs, db/mod.rs, migrations, diesel.toml | reference/backend-errors-db.md |
| auth/* , auth routes, cookies | reference/backend-auth.md |
| authz.rs, admin gating | reference/backend-authz.md |
a resource's model + routes, feature flags, UuidParam |
reference/backend-routes.md |
| tests/common/mod.rs, tests/api.rs, coverage | reference/backend-testing.md |
| anything in frontend/ | reference/frontend.md |
| frontend unit/component tests, Playwright browser tests | reference/frontend-testing.md |
The snippets are real, working code with the domain types renamed. Adapt them; don't paste placeholder names into a real project.
New-project checklist
cargo new backend --lib(addsrc/main.rs) and set up the deps frombackend-core.md.cargo install diesel_cli --no-default-features --features postgresif missing.- Write
config/local.example.toml,.gitignoreentries,config.rs,startup.rs,error.rs,db/mod.rs,lib.rs, andmain.rs. createuser <app> --pwprompt && createdb <app> --owner=<app>. Copy the example config tolocal.tomland fill it in.diesel setup, then add migrations: the collation, thenusers(withis_admin,token_generation). Rundiesel migration run, which writessrc/db/schema.rs.- Write the auth module and auth routes (register/login/refresh/logout/logout-all/me), plus
authz.rs. - Write
tests/common/mod.rsand a firsttests/api.rscovering an empty-DB smoke test,/api/healthz, the OpenAPI document being well formed, and the auth flow. Check thatcargo run -- --config config/local.toml migrateworks. - Scaffold the frontend:
pnpm create vite@latest frontend -- --template vue-ts, thenpnpm add vue-router pinia. Add the Vite proxy,api/client.ts,api/types.ts, the auth store, the router, and the theme files. Then set up both test layers fromreference/frontend-testing.md: Vitest with the client's refresh tests, and Playwright withe2e/run.sh, the fixtures, a stub server for each third party, and a first auth/session spec. - Add resources one at a time with the checklist below, pairing each backend resource with its frontend view.
- README: prerequisites, DB setup, run, test (incl.
DOCKER_HOSTfor rootless podman), coverage, and feature flags. - Create a project
CLAUDE.mdcontaining an empty## Deviations from fullstack-webappsection.
Add-a-resource checklist
diesel migration generate create_<things>: writeup.sql(CHECKs, indexes) anddown.sql, then rundiesel migration run.models/<thing>.rs: a row struct (Queryable, Selectable,check_for_backend(Pg)),New<Thing>(Insertable), request DTOs (Deserialize, ToSchema), and enums (DbEnum).- If rows are loaded by id and need owner checks, add
authorize_<thing>_{read,edit,delete}inauthz.rs. routes/<thing>.rs:router()with.add(handler()), using#[endpoint]with an absolute path. Add.merge(routes::<thing>::router())inbuild_router.- Integration tests in
tests/api.rscovering the happy path, anonymous 401, cross-user 403, admin override, and 404. - Frontend: add types to
api/types.ts, then the store, the view, the route, and the nav link. - Frontend tests: store and component tests for the new logic, and a Playwright spec for the resource's UI flows. Cover the happy path, its server-side errors as shown in the UI, and visibility/authorization across two users.
Keeping this skill current
Rule (always on). When working in a project built with this skill, a decision may contradict or extend a convention above, for example a different library, a new cross-cutting pattern, or a changed default. When that happens:
- Append one line to the project's
CLAUDE.mdunder## Deviations from fullstack-webapp(create the section if missing). Format:- YYYY-MM-DD — <decision> — <why> — <skill section it affects>. - Ask the user whether this should become the new default in the skill. Edit files under
~/.claude/skills/fullstack-webapp/only after an explicit yes. If they say yes, apply the edit and append(merged into skill YYYY-MM-DD)to the entry. Project-specific choices stay as deviations only.
Sync mode (/fullstack-webapp sync).
- Read the current project's
CLAUDE.md## Deviations from fullstack-webappsection. If it is missing or every entry is already merged or rejected, say so and stop. - For each open entry, find the affected section in SKILL.md and/or
reference/*.md. If that project's code shows how the decision was implemented, read it so any reference snippet you write is real code. - Present the proposed edits one entry at a time (show the diff) and ask: merge, reject, or skip.
- Apply the approved edits. Annotate each entry with
(merged into skill YYYY-MM-DD)or(rejected for skill YYYY-MM-DD), and leave skipped entries untouched. - Keep SKILL.md concise. Detail belongs in
reference/. When a new default replaces an old one, delete the old text rather than keeping both.
~/.claude is versioned in the user's dotfiles repo, so the skill needs no separate history.
