Files
kvitt/server/README.md
T
jacobh2 86e08a12cf SPEC v0.7: M9 importer decisions (D-044 to D-046), category table, HANDOFF rewritten
D-044 adds nine Swedish Splitwise category names to the SPEC §10.8 table and
splitwise_categories.csv. D-045 records the reconciliation against the totals
rows; D-046 the importer CLI semantics and exit codes. M9 marked landed at
ab298ab, the M11 rehearsal recorded, server/README.md and prompts/README.md
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-18 07:51:04 +02:00

11 KiB

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

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 (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:<port of KVITT_BIND>/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 <user_id> 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<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 … (SPEC §8.6, AC-2.12; per-IP limits need ConnectInfo<SocketAddr> 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<AppState, AppError>, 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):

    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 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<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::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<Option<Period>, RecurrenceError> and next_occurrence_date(&RecurrenceSchedule) -> Result<Option<String>, 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

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.