Part A (D-132): KVITT_WEB_DIR (default /app) served under /app/ by a small handler: /app redirects to /app/, app routes fall back to index.html, a missing assets/* file is 404, assets cached immutable, index.html no-store, the app's own CSP plus nosniff, DENY and no-referrer. Unsafe paths never reach the file system. /app paths count against the unauthenticated per-IP limit. Without the directory every /app path stays 404 not_found. server/Dockerfile gains a Node 24 LTS stage (pinned by digest) building web/, passed through the Rust stage so npm and rustc never run at once on the Pi 3; the build context is the repository root with an allowlist .dockerignore (server/.dockerignore removed); workflow, build-images.sh and infra/dev/build.sh follow. Part B (D-133): GET /analytics/trends (E-34) and GET /analytics/forecast (E-35), authenticated, read-only, SQL month buckets; rules (half-up average, trailing 3-month moving average, percent against the rounded average, template occurrence per month) in kvitt-domain::analytics. OpenAPI, examples, contracts-check and tests updated (35 operations). Cargo.lock: rustls-webpki 0.103.16 and webpki-root-certs 1.0.10 (RUSTSEC-2026-0382, -0383) so that cargo audit passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
Kvitt server
The Rust backend of Kvitt (SPEC §6.3): one Cargo workspace with three crates.
| Crate | Kind | Contents |
|---|---|---|
crates/kvitt-domain |
library, no I/O | Split, balance, allocation and settlement rules (SPEC §9.1, §9.3, §9.4, §14.5) and next_occurrence_date with the day clamping of §9.5. Passes every vector in contracts/test-vectors/. |
crates/kvitt-server |
binary kvitt-server plus lib.rs |
Config, SQLite pool and migrations, HTTP API, auth, CLI. |
crates/kvitt-import |
binary kvitt-import plus lib.rs |
Splitwise importer, implemented per SPEC §10.8 (D-044 to D-046): one CSV export per run into one account, in one transaction that commits only when every row is valid and the account balance reconciles with the file's totals rows. Exit codes: 0 ok, 1 database or file error, 2 usage, 3 cannot import, 4 reconciliation mismatch. Category table in crates/kvitt-import/data/splitwise_categories.csv. |
The Rust toolchain is pinned in rust-toolchain.toml (rustup installs it on first use); Dockerfile uses the same
version.
Run locally
cd server
KVITT_DATA_DIR=./data KVITT_PUBLIC_URL=http://localhost:8080 cargo run -p kvitt-server -- serve
# or, from server/crates/kvitt-server, where the package is implied:
KVITT_DATA_DIR=../../data cargo run -- serve
curl -s http://localhost:8080/health
cargo run -- serve without -p works from crates/kvitt-server/; at the workspace root Cargo needs
-p kvitt-server because the workspace has two binaries.
Configuration is environment variables only (SPEC §10.4, exhaustive):
| Variable | Default | Meaning |
|---|---|---|
KVITT_DATA_DIR |
/data |
Holds kvitt.sqlite (+ -wal, -shm), pictures/, apk/. Created if missing. |
KVITT_BIND |
0.0.0.0:8080 |
Listen address. |
KVITT_PUBLIC_URL |
none: required | Base URL in invite links and the landing page, https://<KVITT_PUBLIC_HOST> on the Pi (set by compose). Every command refuses to start without it. |
KVITT_FCM_CREDENTIALS |
/secrets/fcm-service-account.json |
FCM key; missing file = push disabled (warning at start). |
KVITT_FCM_PROJECT_ID |
kvitt-1337 |
FCM project. |
KVITT_TRUSTED_PROXY |
none |
IP literal or host name allowed to set X-Forwarded-For (on the Pi HA_HOST, NPM's LAN address); none or a name that does not resolve means no proxy. |
KVITT_WEB_DIR |
/app |
The built web client, served under /app/ (D-132): index.html for app routes, assets/* cached for a year, the app's own CSP. Missing directory: every /app path is 404 not_found (as with cargo run, unless you point it at web/dist). |
RUST_LOG |
info |
tracing filter; logs are JSON lines on stdout. |
The default data directory /data is the container volume. When running outside Docker, set KVITT_DATA_DIR
(for example ./data, which server/.gitignore keeps out of git); the
database is then $KVITT_DATA_DIR/kvitt.sqlite. Every connection uses WAL, foreign_keys = ON,
synchronous = NORMAL, busy_timeout = 5000 (SPEC §8.9). serve and migrate run the embedded migrations
(crates/kvitt-server/migrations/, 0001_init.sql byte-identical to contracts/schema/server-v1.sql).
Subcommands
kvitt-server <command> (SPEC §10.4, exhaustive):
| Command | What it does | Exit codes |
|---|---|---|
serve |
Migrations, tasks::start (push sender, scheduler, pruning), HTTP server with the client address in ConnectInfo (rate limiter); SIGTERM drains in-flight requests for up to 10 s. |
0 on clean shutdown |
migrate |
Migrations only. | 0 |
healthcheck |
GET http://127.0.0.1:<port of KVITT_BIND>/health, used by the compose healthcheck (no curl in the image). |
0 if 200, else 1 |
bootstrap |
Cancels a pending admin_setup invite, prints a new code, link and expiry; already initialized on stderr when users exist. |
0 ok, 2 already initialized |
pair-device --user <user_id> |
Prints a device invite for the user like bootstrap. |
0 ok, 3 unknown user |
invite-partner |
What E-12 does for the admin on the server host: cancels a pending partner invite, prints a new code, a link naming the admin (?from=, D-087) and the expiry; household full or no admin, run bootstrap first on stderr otherwise. |
0 ok, 2 household full, 3 no admin |
rotate-epoch |
Sets meta.sync_epoch to a new random value and prints it (SPEC §10.9 restore step 5). |
0 ok, 1 database error |
HTTP API
Every route of SPEC §7 is registered in src/router.rs, with its handler in src/api/ (E-31 and E-32 in
src/api/sync.rs, the protocol in src/sync/).
GET /i/ with an empty code answers the landing page's HTML 404. The rules of SPEC §14.3.1 are layers in router.rs:
X-Kvitt-Protocol: 1 on authenticated routes (426 otherwise, checked before the token), X-Kvitt-Server-Version
on every response, body limits 1 MiB / 5 MiB for PUT /me/picture, JSON 404 for unknown paths.
Building blocks for handler authors (src/api/mod.rs, src/auth.rs, src/error.rs):
AuthDevice/AdminDeviceextractors (SPEC §8.2, §8.4); put them first in the handler's arguments.IdPathfor a UUID path segment (not a UUID: 404not_found),ApiJson<T>for JSON bodies (400invalid_request, 413payload_too_large),JsonBody<T>for JSON responses withcharset=utf-8.AppErrorwith every code of SPEC §14.3.2 and its status;?onsqlx::Errorgivesunavailablefor a busy database andinternal(logged with a request id) otherwise.db::begin_immediatefor every write transaction anddb::next_server_versioninside it (SPEC §14.1).api::rows: every stored row read into its wire form, and the expense and settlement inserts shared by sync push, recurring generation and the importer (rows::Origincarries the server-owned columns);api::validatefor the input rules of SPEC §9.6.AppState.clock(time::Clock; tests useFixedClock),time::format_tsfor the fixed RFC 3339 form,time::STOCKHOLM.
The OpenAPI document is generated with utoipa from the #[utoipa::path] annotations and the wire types in
src/wire/; src/openapi.rs lists the handlers and applies the document-wide rules. A handler change that alters
the wire contract makes tests/openapi_contract.rs fail against contracts/openapi.yaml.
Seams (SPEC §6.3, D-015)
-
Rate limit:
src/api/rate_limit.rsreturns the layer thatrouter.rsattaches (SPEC §8.6, AC-2.12; per-IP limits needConnectInfo<SocketAddr>fromserve). -
Recurring generation:
api::recurring::generate_due_periods(&AppState, template_id)generates the due periods of one template (SPEC §9.5);api::recurring::generate_all_due(&AppState)runs it for every non-deleted, non-paused template. -
Background tasks:
src/tasks/mod.rshaspub fn start(state: AppState) -> AppState, called once byserveafter migrations; it installs the push queue in the returned state (the router is built from it) and spawns the FCM sender, the recurring scheduler (at start and daily 06:00 Europe/Stockholm, throughgenerate_all_due) and thesync_mutationspruning (daily 04:00). The scheduler loop (tasks/scheduler.rs) reads the injectableAppState.clockand re-checks it at least every 30 s, so clock jumps are handled. -
Audit (
src/audit/mod.rs):pub trait AuditWriter: Send + Sync + 'static { fn write<'a>(&'a self, conn: &'a mut SqliteConnection, event: AuditEvent) -> BoxFuture<'a, Result<Uuid, AppError>>; }AuditEventcarries entity type and id, action, source, actor user and device,client_at,server_at, and the wire object before (Nonefor create) and after the change as JSON; the writer deriveschangesper SPEC §14.4.7 and inserts the row with aserver_versionfrom the caller'sBEGIN IMMEDIATEtransaction (conn).AppState.auditholdsSqlAuditWriter(the diff isaudit::diff::changes). -
Push (
src/push/mod.rs):PushQueue::enqueue(&self, message: PushMessage)withPushMessage { push_type: PushType, entity_id: Option<Uuid>, actor_user_id: Uuid, count: u32 }and the eight types of SPEC §14.6. It never blocks or fails; call it after commit.AppState::newholdsNoopPushQueue(push disabled);tasks::startreplaces it withPushHub(bounded queue of 100) drained bypush::Sender(FCM HTTP v1, OAuth2 service-account JWT, retries 1 s / 5 s / 25 s) whenKVITT_FCM_CREDENTIALSexists. -
Recurring dates (
kvitt-domain, for the generation function and the scheduler):Period(YYYY-MM,FromStr/Display,next(),days(),clamped_date(day_of_month)),next_candidate_period(start_period, end_period, last_generated_period) -> Result<Option<Period>, RecurrenceError>andnext_occurrence_date(&RecurrenceSchedule) -> Result<Option<String>, RecurrenceError>. -
Importer:
kvitt-importdepends onkvitt-domainandkvitt-server(the library); it reuses the server's validation, row helpers andSqlAuditWriter, and never opens a database it would have to create or migrate (SPEC §10.8).
Tests and the gate
From server/: cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo audit && cargo test.
cargo audit (cargo-audit 0.22.2, cargo install cargo-audit --locked) checks Cargo.lock against the RustSec
advisory database. It needs the network: each run fetches the database into ~/.cargo/advisory-db (and the
crates.io index). Run the gate online at least once per machine so the database exists; offline,
cargo audit --no-fetch uses the copy already there. A fix is a semver-compatible bump
(cargo update -p <crate>); an advisory without a fix goes in server/.cargo/audit.toml under
[advisories] ignore with a dated comment and is an owner decision.
The pre-commit hook (tools/git-hooks/pre-commit) runs cargo audit --no-fetch only when server/Cargo.lock or a
Cargo.toml under server/ is staged, so ordinary commits stay fast. When cargo-audit is not installed or the
database was never fetched it prints a message and skips the check; it never fails a commit for a missing tool.
-
crates/kvitt-domain/tests/vectors.rs: every case of every file incontracts/test-vectors/(the two Kotlin-only analytics files are parsed and skipped). -
crates/kvitt-server/tests/openapi_contract.rs: the generated document equalscontracts/openapi.yamlafter the normalisations documented at the top of the file, plus self-tests that the comparison catches changes. -
crates/kvitt-server/tests/skeleton/: migration identity and pragmas, every route registered and implemented, E-1, E-2, protocol and token rules, error shapes againstcontracts/examples/errors/, wire types against every example incontracts/examples/. -
crates/kvitt-server/tests/core_api/: E-2 to E-30, E-33 to E-35 (analytics, D-133), generation, rate limiting, pictures, CLI, the web client under/app/(D-132). -
crates/kvitt-server/tests/sync_api/: every rule of SPEC §14.4 (pull, push steps 1 to 12, rejection reasons, §9.10 conflicts, 14-day offline scenario, the push examples), audit writer,rotate-epoch, end to end push → pull,ConnectInforate-limit buckets over real TCP. -
crates/kvitt-server/tests/push_fcm/: payloads againstcontracts/examples/push/, recipients, OAuth2 flow and token cache, retries, token clearing, queue overflow, push disabled, against a local stub of Google's endpoints (an RSA key is generated per test; none is committed). -
crates/kvitt-server/tests/scheduler_daily/: 06:00 and 04:00 across both DST changes, run at start, clock jumps, catch-up throughgenerate_all_due, paused and deleted templates skipped, 90-day pruning. -
crates/kvitt-import/tests/import.rs: both header variants, split and settlement rules,--fx, the import key and idempotent re-runs, reconciliation and its exit codes, dry run, the CLI, and the §10.8 category table againstdata/splitwise_categories.csv; synthetic fixtures incrates/kvitt-import/tests/fixtures/only.
Every kvitt-server test binary includes the shared harness tests/common/mod.rs through #[path] (not a test
target itself); the sync, push and scheduler binaries also include tests/common/sync_fixtures.rs. No
unwrap/expect in non-test code (a crate-level deny enforces it under clippy).
Docker image
# from the repository root: the build context is the root (D-132), filtered by the root .dockerignore
docker build --platform linux/arm64 -f server/Dockerfile -t kvitt-server:dev .
Build on the Mac, never on the Pi (infra/dev/build.sh does this for the dev stack). Stage 1 is
node:24.22.1-bookworm-slim (pinned by digest, on the build platform), running npm ci && npm run build in web/;
stage 2 is rust:<pinned version>-bookworm, building both binaries in release mode with --locked (it copies the
web build through, so the two never run at the same time); stage 3 is gcr.io/distroless/cc-debian12:nonroot
pinned by digest, binaries in /usr/local/bin/, the web client in /app, user 10001, entrypoint kvitt-server,
command serve. --platform linux/amd64 also works (emulated on the Mac). The container needs only
/data writable and runs with a read-only root filesystem.