Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8.6 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 |
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 |
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 |
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 by M2a, E-3 to E-30 and E-33 by M3; E-31 and E-32 return 501 not_implemented until M4 fills in
src/api/sync.rs. 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).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 for M3, M4 and M9 (SPEC §6.3, D-015)
-
Rate limit (M3):
src/api/rate_limit.rsreturns the layer thatrouter.rsattaches on the line marked// M3: the rate limiter …(SPEC §8.6, AC-2.12; per-IP limits needConnectInfo<SocketAddr>fromserve). -
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.rshaspub async fn start(state: AppState) -> Result<(), AppError>, called once byserveafter 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>>; }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.auditholdsNoopAuditWriteruntil M4 replaces it. -
Push (
src/push/mod.rs, M4 implements, M3 calls):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.pushholdsNoopPushQueue. -
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>andnext_occurrence_date(&RecurrenceSchedule) -> Result<Option<String>, RecurrenceError>. -
Importer (M9):
kvitt-importalready depends onkvitt-domainandkvitt-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 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 (501), E-1, E-2, protocol and token rules, error shapes againstcontracts/examples/errors/, wire types against every example incontracts/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.