50a0d6792a
Build Kvitt images / build (push) Failing after 1m20s
Part A (D-132): KVITT_WEB_DIR (default /app) served under /app/ by a small handler: /app redirects to /app/, app routes fall back to index.html, a missing assets/* file is 404, assets cached immutable, index.html no-store, the app's own CSP plus nosniff, DENY and no-referrer. Unsafe paths never reach the file system. /app paths count against the unauthenticated per-IP limit. Without the directory every /app path stays 404 not_found. server/Dockerfile gains a Node 24 LTS stage (pinned by digest) building web/, passed through the Rust stage so npm and rustc never run at once on the Pi 3; the build context is the repository root with an allowlist .dockerignore (server/.dockerignore removed); workflow, build-images.sh and infra/dev/build.sh follow. Part B (D-133): GET /analytics/trends (E-34) and GET /analytics/forecast (E-35), authenticated, read-only, SQL month buckets; rules (half-up average, trailing 3-month moving average, percent against the rounded average, template occurrence per month) in kvitt-domain::analytics. OpenAPI, examples, contracts-check and tests updated (35 operations). Cargo.lock: rustls-webpki 0.103.16 and webpki-root-certs 1.0.10 (RUSTSEC-2026-0382, -0383) so that cargo audit passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
177 lines
13 KiB
Markdown
177 lines
13 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 (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` | none: required | Base URL in invite links and the landing page, `https://<KVITT_PUBLIC_HOST>` on the Pi (set by compose). Every command refuses to start without it. |
|
|
| `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` | `none` | IP literal or host name allowed to set `X-Forwarded-For` (on the Pi `HA_HOST`, NPM's LAN address); `none` or a name that does not resolve means no proxy. |
|
|
| `KVITT_WEB_DIR` | `/app` | The built web client, served under `/app/` (D-132): `index.html` for app routes, `assets/*` cached for a year, the app's own CSP. Missing directory: every `/app` path is 404 `not_found` (as with `cargo run`, unless you point it at `web/dist`). |
|
|
| `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 | What it does | Exit codes |
|
|
|---|---|---|
|
|
| `serve` | 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` | Migrations only. | 0 |
|
|
| `healthcheck` | `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` | 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>` | Prints a `device` invite for the user like `bootstrap`. | 0 ok, 3 unknown user |
|
|
| `invite-partner` | What E-12 does for the admin on the server host: cancels a pending `partner` invite, prints a new code, a link naming the admin (`?from=`, D-087) and the expiry; `household full` or `no admin, run bootstrap first` on stderr otherwise. | 0 ok, 2 household full, 3 no admin |
|
|
| `rotate-epoch` | 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`, with its handler in `src/api/` (E-31 and E-32 in
|
|
`src/api/sync.rs`, the 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).
|
|
* `api::rows`: every stored row read into its wire form, and the expense and settlement inserts shared by sync push,
|
|
recurring generation and the importer (`rows::Origin` carries the server-owned columns); `api::validate` for the
|
|
input rules of SPEC §9.6.
|
|
* `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 (SPEC §6.3, D-015)
|
|
|
|
* **Rate limit:** `src/api/rate_limit.rs` returns the layer that `router.rs` attaches (SPEC §8.6, AC-2.12; per-IP
|
|
limits need `ConnectInfo<SocketAddr>` from `serve`).
|
|
* **Recurring generation:** `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:** `src/tasks/mod.rs` has `pub fn start(state: AppState) -> AppState`,
|
|
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`):**
|
|
|
|
```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` (the diff is `audit::diff::changes`).
|
|
* **Push (`src/push/mod.rs`):** `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 the generation function and the 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:** `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 audit && cargo test`.
|
|
|
|
`cargo audit` (cargo-audit 0.22.2, `cargo install cargo-audit --locked`) checks `Cargo.lock` against the RustSec
|
|
advisory database. It needs the network: each run fetches the database into `~/.cargo/advisory-db` (and the
|
|
crates.io index). Run the gate online at least once per machine so the database exists; offline,
|
|
`cargo audit --no-fetch` uses the copy already there. A fix is a semver-compatible bump
|
|
(`cargo update -p <crate>`); an advisory without a fix goes in `server/.cargo/audit.toml` under
|
|
`[advisories] ignore` with a dated comment and is an owner decision.
|
|
|
|
The pre-commit hook (`tools/git-hooks/pre-commit`) runs `cargo audit --no-fetch` only when `server/Cargo.lock` or a
|
|
`Cargo.toml` under `server/` is staged, so ordinary commits stay fast. When `cargo-audit` is not installed or the
|
|
database was never fetched it prints a message and skips the check; it never fails a commit for a missing tool.
|
|
|
|
* `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/`: E-2 to E-30, E-33 to E-35 (analytics, D-133), generation, rate limiting,
|
|
pictures, CLI, the web client under `/app/` (D-132).
|
|
* `crates/kvitt-server/tests/sync_api/`: 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/`: 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/`: 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`: 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.
|
|
|
|
Every `kvitt-server` test binary includes the shared harness `tests/common/mod.rs` through `#[path]` (not a test
|
|
target itself); the sync, push and scheduler binaries also include `tests/common/sync_fixtures.rs`. No
|
|
`unwrap`/`expect` in non-test code (a crate-level `deny` enforces it under clippy).
|
|
|
|
## Docker image
|
|
|
|
```sh
|
|
# from the repository root: the build context is the root (D-132), filtered by the root .dockerignore
|
|
docker build --platform linux/arm64 -f server/Dockerfile -t kvitt-server:dev .
|
|
```
|
|
|
|
Build on the Mac, never on the Pi (`infra/dev/build.sh` does this for the dev stack). Stage 1 is
|
|
`node:24.22.1-bookworm-slim` (pinned by digest, on the build platform), running `npm ci && npm run build` in `web/`;
|
|
stage 2 is `rust:<pinned version>-bookworm`, building both binaries in release mode with `--locked` (it copies the
|
|
web build through, so the two never run at the same time); stage 3 is `gcr.io/distroless/cc-debian12:nonroot`
|
|
pinned by digest, binaries in `/usr/local/bin/`, the web client in `/app`, 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.
|