06f48ef9e9
Cargo workspace (kvitt-domain, kvitt-server, kvitt-import), Rust 1.99.1 pinned. kvitt-domain passes every Rust vector and adds next_occurrence_date (D-015). kvitt-server: config, SQLite pool (WAL, §8.9 pragmas), migration 0001 identical to the contract schema, error type for all 25 codes, auth extractor and protocol layer, all 33 routes (E-1/E-2 implemented, the rest 501), utoipa document equal to contracts/openapi.yaml, seams for M3/M4/M9, serve/migrate/healthcheck. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
135 lines
8.1 KiB
Markdown
135 lines
8.1 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` | 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 <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` | Prints `not implemented in M2a` (M3). | 2 |
|
|
| `pair-device --user <user_id>` | Prints `not implemented in M2a` (M3). | 2 |
|
|
| `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; every other handler returns 501 `not_implemented` until M3 (E-3 to E-30, E-33) and M4 (E-31, E-32)
|
|
fill in the body in their own `src/api/*.rs` file. 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 …`. In M2a it is the identity layer.
|
|
* **Background tasks (M4):** `src/tasks/mod.rs` has `pub async fn start(state: AppState) -> Result<(), AppError>`,
|
|
called once by `serve` after migrations. In M2a it starts nothing.
|
|
* **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 `NoopAuditWriter` until M4 replaces it.
|
|
* **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.push` holds `NoopPushQueue`.
|
|
* **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` 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 (501), E-1, E-2,
|
|
protocol and token rules, error shapes against `contracts/examples/errors/`, wire types against every example in
|
|
`contracts/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
|
|
|
|
```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.
|