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

155 lines
11 KiB
Markdown

# 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 <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):**
```rust
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
```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:<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.