# 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 ```sh 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 ` (SPEC §10.4, exhaustive): | Command | State in M2a | Exit codes | |---|---|---| | `serve` | Works: 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` | Works: migrations only. | 0 | | `healthcheck` | Works: `GET http://127.0.0.1:/health`, used by the compose healthcheck (no curl in the image). | 0 if 200, else 1 | | `bootstrap` | Works (M3): 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 ` | Works (M3): prints a `device` invite for the user like `bootstrap`. | 0 ok, 3 unknown user | | `rotate-epoch` | Works (M4): 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`. `GET /health` (E-1) and `GET /setup/status` (E-2) are implemented by M2a, E-3 to E-30 and E-33 by M3, E-31 and E-32 by M4 (`src/api/sync.rs`, 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` / `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` for JSON bodies (400 `invalid_request`, 413 `payload_too_large`), `JsonBody` 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 …` (SPEC §8.6, AC-2.12; per-IP limits need `ConnectInfo` from `serve`). * **Recurring generation (M3, for M4's scheduler):** `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 (M4):** `src/tasks/mod.rs` has `pub async fn start(state: AppState) -> Result`, called once by `serve` after 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, through `generate_all_due`) and the `sync_mutations` pruning (daily 04:00). The scheduler loop (`tasks/scheduler.rs`) reads the injectable `AppState.clock` and re-checks it at least every 30 s, so clock jumps are handled. * **Audit (`src/audit/mod.rs`, M4 implements, M3 and M9 call):** ```rust pub trait AuditWriter: Send + Sync + 'static { fn write<'a>(&'a self, conn: &'a mut SqliteConnection, event: AuditEvent) -> BoxFuture<'a, Result>; } ``` `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 `SqlAuditWriter` (M4; the diff is `audit::diff::changes`). * **Push (`src/push/mod.rs`, M4 implements, M3 calls):** `PushQueue::enqueue(&self, message: PushMessage)` with `PushMessage { push_type: PushType, entity_id: Option, actor_user_id: Uuid, count: u32 }` and the eight types of SPEC §14.6. It never blocks or fails; call it after commit. `AppState::new` holds `NoopPushQueue` (push disabled); `tasks::start` replaces it with `PushHub` (bounded queue of 100) drained by `push::Sender` (FCM HTTP v1, OAuth2 service-account JWT, retries 1 s / 5 s / 25 s) when `KVITT_FCM_CREDENTIALS` exists. * **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, RecurrenceError>` and `next_occurrence_date(&RecurrenceSchedule) -> Result, 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 and implemented, E-1, E-2, protocol and token rules, error shapes against `contracts/examples/errors/`, wire types against every example in `contracts/examples/`. * `crates/kvitt-server/tests/core_api/` (M3): E-2 to E-30, E-33, generation, rate limiting, pictures, CLI. * `crates/kvitt-server/tests/sync_api/` (M4): 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, `ConnectInfo` rate-limit buckets over real TCP. * `crates/kvitt-server/tests/push_fcm/` (M4): payloads against `contracts/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/` (M4): 06:00 and 04:00 across both DST changes, run at start, clock jumps, catch-up through `generate_all_due`, paused and deleted templates skipped, 90-day pruning. The M4 binaries reuse M3's harness (`tests/core_api/common.rs`) and `tests/sync_api/m4.rs` through `#[path]`. No `unwrap`/`expect` in non-test code (a crate-level `deny` enforces it under clippy). ## Docker image ```sh 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:-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.