# 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 (M9, 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 ```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` depends on `kvitt-domain` and `kvitt-server` (the library); it reuses the server's validation, row helpers and `SqlAuditWriter`, 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 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. * `crates/kvitt-import/tests/import.rs` (M9): 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 against `data/splitwise_categories.csv`; synthetic fixtures in `crates/kvitt-import/tests/fixtures/` only. 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.