Files
kvitt/server
jacobh2 06f48ef9e9 M2a: server workspace, kvitt-domain, skeleton routes, OpenAPI match, Dockerfile
Cargo workspace (kvitt-domain, kvitt-server, kvitt-import), Rust 1.99.1 pinned.
kvitt-domain passes every Rust vector and adds next_occurrence_date (D-015).
kvitt-server: config, SQLite pool (WAL, §8.9 pragmas), migration 0001 identical to
the contract schema, error type for all 25 codes, auth extractor and protocol
layer, all 33 routes (E-1/E-2 implemented, the rest 501), utoipa document equal
to contracts/openapi.yaml, seams for M3/M4/M9, serve/migrate/healthcheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:30:20 +02:00
..

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 Splitwise importer (SPEC §10.8, milestone M9). In M2a it prints its usage and exits 2.

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 https://icehack.asuscomm.com:8443 Base URL in invite links and the landing page.
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 caddy Host allowed to set X-Forwarded-For; a name that does not resolve means no proxy.
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 State in M2a Exit codes
serve Works: migrations, tasks::start, HTTP server; SIGTERM drains in-flight requests for up to 10 s. 0 on clean shutdown
migrate Works: migrations only. 0
healthcheck Works: 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 Prints not implemented in M2a (M3). 2
pair-device --user <user_id> Prints not implemented in M2a (M3). 2
rotate-epoch Prints not implemented in M2a (M4). 2

HTTP API

Every route of SPEC §7 is registered in src/router.rs. GET /health (E-1) and GET /setup/status (E-2) are implemented; every other handler returns 501 not_implemented until M3 (E-3 to E-30, E-33) and M4 (E-31, E-32) fill in the body in their own src/api/*.rs file. 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 / AdminDevice extractors (SPEC §8.2, §8.4); put them first in the handler's arguments.
  • IdPath for a UUID path segment (not a UUID: 404 not_found), ApiJson<T> for JSON bodies (400 invalid_request, 413 payload_too_large), JsonBody<T> for JSON responses with charset=utf-8.
  • AppError with every code of SPEC §14.3.2 and its status; ? on sqlx::Error gives unavailable for a busy database and internal (logged with a request id) otherwise.
  • db::begin_immediate for every write transaction and db::next_server_version inside it (SPEC §14.1).
  • AppState.clock (time::Clock; tests use FixedClock), time::format_ts for 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 for M3, M4 and M9 (SPEC §6.3, D-015)

  • Rate limit (M3): src/api/rate_limit.rs returns the layer that router.rs attaches on the line marked // M3: the rate limiter …. In M2a it is the identity layer.

  • Background tasks (M4): src/tasks/mod.rs has pub async fn start(state: AppState) -> Result<(), AppError>, called once by serve after migrations. In M2a it starts nothing.

  • Audit (src/audit/mod.rs, M4 implements, M3 and M9 call):

    pub trait AuditWriter: Send + Sync + 'static {
        fn write<'a>(&'a self, conn: &'a mut SqliteConnection, event: AuditEvent)
            -> BoxFuture<'a, Result<Uuid, AppError>>;
    }
    

    AuditEvent carries entity type and id, action, source, actor user and device, client_at, server_at, and the wire object before (None for create) and after the change as JSON; the writer derives changes per SPEC §14.4.7 and inserts the row with a server_version from the caller's BEGIN IMMEDIATE transaction (conn). AppState.audit holds NoopAuditWriter until M4 replaces it.

  • Push (src/push/mod.rs, M4 implements, M3 calls): PushQueue::enqueue(&self, message: PushMessage) with PushMessage { 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.push holds NoopPushQueue.

  • Recurring dates (kvitt-domain, for M3's generation function and M4's 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> and next_occurrence_date(&RecurrenceSchedule) -> Result<Option<String>, RecurrenceError>.

  • Importer (M9): kvitt-import already depends on kvitt-domain and kvitt-server (the library).

Tests and the gate

From server/: cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test.

  • crates/kvitt-domain/tests/vectors.rs: every case of every file in contracts/test-vectors/ (the two Kotlin-only analytics files are parsed and skipped).
  • crates/kvitt-server/tests/openapi_contract.rs: the generated document equals contracts/openapi.yaml after 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 (501), E-1, E-2, protocol and token rules, error shapes against contracts/examples/errors/, wire types against every example in contracts/examples/.

M3 and M4 put their integration tests in tests/core_*.rs, tests/sync_*.rs, tests/scheduler_*.rs, tests/push_*.rs. No unwrap/expect in non-test code (a crate-level deny enforces it under clippy).

Docker image

docker build --platform linux/arm64 -f server/Dockerfile -t kvitt-server:dev server/

Build on the Mac, never on the Pi (infra/dev/build.sh does this for the dev stack). Stage 1 is rust:<pinned version>-bookworm, building both binaries in release mode with --locked; stage 2 is gcr.io/distroless/cc-debian12:nonroot pinned by digest, binaries in /usr/local/bin/, 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.