671c00d0d8
Build Kvitt images / build (push) Failing after 8m17s
Contract (SPEC §14.8): migration 0004_card_purchases.sql (server schema 4, byte-identical contract and server copies), E-36 to E-44 in contracts/openapi.yaml with the four new error codes and a 502 response, examples for every card body and error code, the match_card_purchase vectors (17 cases) with their Python reference in tools/contracts-check.py (157 checks), web/src/api/schema.d.ts regenerated; web/src/lib/vectors.test.ts lists match_card_purchase as not ported until K3 ports it (orchestrator's allowance). Server: src/cards/ with the provider registry (CP-1), the CardSource trait and its Enable Banking adapter (JWT RS256 kid = application id, /aspsps, /auth, /sessions, transactions with PSU headers for user-present fetches, CP-14 error mapping, CP-7 mapping), the windowed fetch (30-day windows newest first, split at the 100-row cap down to a day, de-duplication by provider_ref, continuation keys, WRONG_TRANSACTIONS_PERIOD ends the walk), CP-8 matching with the secondary rule, the background poller (06:45, 11:45, 16:45, 21:45, 4 per 24 hours, 2-hour skip, no backlog), the bilingual callback page (E-39), E-36 to E-38 and E-40 to E-44, link clearing in sync push step 7, attempt pruning at 04:00, the card_fetch log line with aggregates only, KVITT_EB_* configuration. Tests against a local stub (tests/cards_api/, 34 tests); the real API is never called. Infra: Pi compose mounts secrets/cards read-only and passes the two application ids; .env.example keys; dev stack stages infra/secrets/cards/; NPM block with the /cards/callback gate line and unlogged location (kvitt.conf and README step 4); README "Card purchases". Pre-commit hook bypassed (--no-verify, orchestrator's decision): the Android fast tests fail in :domain VectorFilesTest (and the full check in :data ExampleRoundTripTest) because they pin the contract's vector and example sets; matchCardPurchase and the card DTOs are K2's by design (contract-first landing; K2 lands next). Server, contracts-check and web gates are green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
196 lines
15 KiB
Markdown
196 lines
15 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`). |
|
|
| `KVITT_EB_APP_ID` | empty | Enable Banking application id for card purchases (SPEC §10.4, D-175): the admin's, and the member's unless the next is set. Empty, or its key missing or unusable: card features off for that user (one warning at start). |
|
|
| `KVITT_EB_KEY_FILE` | `/secrets/cards/enable-banking.pem` | The PEM private key of `KVITT_EB_APP_ID`; never logged, never in `data/`. |
|
|
| `KVITT_EB_MEMBER_APP_ID` | empty | The member's own application (CP-13 fallback). |
|
|
| `KVITT_EB_MEMBER_KEY_FILE` | `/secrets/cards/enable-banking-member.pem` | Its key. |
|
|
| `KVITT_EB_API_URL` | `https://api.enablebanking.com` | Card API base URL; the tests point it at a local stub. |
|
|
| `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`, 0002 to 0004 to
|
|
their copies in `contracts/schema/migrations/`; schema version 4).
|
|
|
|
## 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`.
|
|
|
|
Card purchases (SPEC §9.12, E-36 to E-44) live in `src/cards/`: the registry (`providers.rs`), the `CardSource`
|
|
trait with Kvitt's own records (`mod.rs`) and its one adapter `enable_banking/` (the only code naming the vendor or
|
|
reading its JSON: JWT, `/aspsps`, `/auth`, `/sessions`, transactions, the CP-7 mapping), the windowed fetch and CP-8
|
|
matching (`fetch.rs`), the background poller (`poller.rs`, started by `tasks::start`), the callback page
|
|
(`callback.rs`, E-39) and the other endpoints (`api.rs`). Card rows take `card_version` from `card_sequence`, never
|
|
`server_version`, and are not audited or pushed; `cards::unlink_expense` is called by sync push step 7. `AppState.cards`
|
|
is disabled until `tasks::start` installs the configured sources.
|
|
|
|
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`), the `sync_mutations` and `card_auth_attempts` pruning (daily 04:00) and the card poller
|
|
(06:45, 11:45, 16:45, 21:45, SPEC §9.12 CP-4). 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 Kotlin-only
|
|
files, and `match_card_purchase.json` of the phone and the web, 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-server/tests/cards_api/`: card purchases against a local Enable Banking stub (RSA key generated per
|
|
test): the connect flow and the callback page, the windowed fetch (splits, truncation, de-duplication, continuation,
|
|
end of history), the mapping and the fields never stored, CP-8 matching and deletion, CP-14 errors, the poller with
|
|
a scripted clock, E-36 to E-44 with their authorization, link clearing on expense delete, E-39's rate limit, logs.
|
|
* `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.
|