From 06f48ef9e9901f840fe4edbea64d8b95469842fa Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sat, 17 Oct 2026 21:25:35 +0200 Subject: [PATCH] M2a: server workspace, kvitt-domain, skeleton routes, OpenAPI match, Dockerfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/.dockerignore | 2 + server/.gitignore | 3 + server/Cargo.lock | 2151 +++++++++++++++++ server/Cargo.toml | 12 + server/Dockerfile | 37 + server/README.md | 134 + server/crates/kvitt-domain/Cargo.toml | 16 + server/crates/kvitt-domain/src/allocation.rs | 173 ++ server/crates/kvitt-domain/src/balance.rs | 86 + server/crates/kvitt-domain/src/lib.rs | 39 + server/crates/kvitt-domain/src/recurring.rs | 305 +++ server/crates/kvitt-domain/src/settlement.rs | 108 + server/crates/kvitt-domain/src/split.rs | 181 ++ server/crates/kvitt-domain/tests/vectors.rs | 193 ++ server/crates/kvitt-import/Cargo.toml | 14 + server/crates/kvitt-import/src/main.rs | 20 + server/crates/kvitt-server/Cargo.toml | 39 + .../kvitt-server/migrations/0001_init.sql | 230 ++ .../crates/kvitt-server/src/api/accounts.rs | 103 + server/crates/kvitt-server/src/api/apk.rs | 42 + .../crates/kvitt-server/src/api/categories.rs | 103 + server/crates/kvitt-server/src/api/devices.rs | 58 + server/crates/kvitt-server/src/api/health.rs | 60 + server/crates/kvitt-server/src/api/invites.rs | 82 + server/crates/kvitt-server/src/api/landing.rs | 26 + server/crates/kvitt-server/src/api/me.rs | 39 + server/crates/kvitt-server/src/api/mod.rs | 116 + .../crates/kvitt-server/src/api/pictures.rs | 64 + .../crates/kvitt-server/src/api/proposal.rs | 28 + .../crates/kvitt-server/src/api/rate_limit.rs | 8 + .../crates/kvitt-server/src/api/recurring.rs | 74 + server/crates/kvitt-server/src/api/setup.rs | 30 + server/crates/kvitt-server/src/api/sync.rs | 49 + server/crates/kvitt-server/src/audit/mod.rs | 63 + server/crates/kvitt-server/src/auth.rs | 226 ++ .../crates/kvitt-server/src/cli/bootstrap.rs | 11 + .../kvitt-server/src/cli/healthcheck.rs | 59 + server/crates/kvitt-server/src/cli/mod.rs | 183 ++ .../kvitt-server/src/cli/pair_device.rs | 11 + .../kvitt-server/src/cli/rotate_epoch.rs | 11 + server/crates/kvitt-server/src/config.rs | 136 ++ server/crates/kvitt-server/src/db/mod.rs | 24 + server/crates/kvitt-server/src/db/pool.rs | 45 + server/crates/kvitt-server/src/db/sequence.rs | 24 + server/crates/kvitt-server/src/error.rs | 317 +++ server/crates/kvitt-server/src/lib.rs | 59 + server/crates/kvitt-server/src/main.rs | 50 + server/crates/kvitt-server/src/openapi.rs | 388 +++ server/crates/kvitt-server/src/push/mod.rs | 66 + server/crates/kvitt-server/src/router.rs | 155 ++ server/crates/kvitt-server/src/tasks/mod.rs | 11 + server/crates/kvitt-server/src/time.rs | 114 + .../crates/kvitt-server/src/wire/account.rs | 65 + server/crates/kvitt-server/src/wire/audit.rs | 104 + .../crates/kvitt-server/src/wire/category.rs | 79 + server/crates/kvitt-server/src/wire/device.rs | 40 + .../crates/kvitt-server/src/wire/expense.rs | 74 + server/crates/kvitt-server/src/wire/invite.rs | 80 + server/crates/kvitt-server/src/wire/mod.rs | 240 ++ .../crates/kvitt-server/src/wire/recurring.rs | 150 ++ .../kvitt-server/src/wire/settlement.rs | 73 + server/crates/kvitt-server/src/wire/sync.rs | 242 ++ server/crates/kvitt-server/src/wire/user.rs | 105 + .../kvitt-server/tests/openapi_contract.rs | 511 ++++ .../kvitt-server/tests/skeleton/errors.rs | 93 + .../kvitt-server/tests/skeleton/main.rs | 8 + .../kvitt-server/tests/skeleton/migration.rs | 96 + .../kvitt-server/tests/skeleton/routes.rs | 336 +++ .../kvitt-server/tests/skeleton/support.rs | 176 ++ .../tests/skeleton/wire_examples.rs | 133 + server/rust-toolchain.toml | 4 + 71 files changed, 9187 insertions(+) create mode 100644 server/.dockerignore create mode 100644 server/.gitignore create mode 100644 server/Cargo.lock create mode 100644 server/Cargo.toml create mode 100644 server/Dockerfile create mode 100644 server/README.md create mode 100644 server/crates/kvitt-domain/Cargo.toml create mode 100644 server/crates/kvitt-domain/src/allocation.rs create mode 100644 server/crates/kvitt-domain/src/balance.rs create mode 100644 server/crates/kvitt-domain/src/lib.rs create mode 100644 server/crates/kvitt-domain/src/recurring.rs create mode 100644 server/crates/kvitt-domain/src/settlement.rs create mode 100644 server/crates/kvitt-domain/src/split.rs create mode 100644 server/crates/kvitt-domain/tests/vectors.rs create mode 100644 server/crates/kvitt-import/Cargo.toml create mode 100644 server/crates/kvitt-import/src/main.rs create mode 100644 server/crates/kvitt-server/Cargo.toml create mode 100644 server/crates/kvitt-server/migrations/0001_init.sql create mode 100644 server/crates/kvitt-server/src/api/accounts.rs create mode 100644 server/crates/kvitt-server/src/api/apk.rs create mode 100644 server/crates/kvitt-server/src/api/categories.rs create mode 100644 server/crates/kvitt-server/src/api/devices.rs create mode 100644 server/crates/kvitt-server/src/api/health.rs create mode 100644 server/crates/kvitt-server/src/api/invites.rs create mode 100644 server/crates/kvitt-server/src/api/landing.rs create mode 100644 server/crates/kvitt-server/src/api/me.rs create mode 100644 server/crates/kvitt-server/src/api/mod.rs create mode 100644 server/crates/kvitt-server/src/api/pictures.rs create mode 100644 server/crates/kvitt-server/src/api/proposal.rs create mode 100644 server/crates/kvitt-server/src/api/rate_limit.rs create mode 100644 server/crates/kvitt-server/src/api/recurring.rs create mode 100644 server/crates/kvitt-server/src/api/setup.rs create mode 100644 server/crates/kvitt-server/src/api/sync.rs create mode 100644 server/crates/kvitt-server/src/audit/mod.rs create mode 100644 server/crates/kvitt-server/src/auth.rs create mode 100644 server/crates/kvitt-server/src/cli/bootstrap.rs create mode 100644 server/crates/kvitt-server/src/cli/healthcheck.rs create mode 100644 server/crates/kvitt-server/src/cli/mod.rs create mode 100644 server/crates/kvitt-server/src/cli/pair_device.rs create mode 100644 server/crates/kvitt-server/src/cli/rotate_epoch.rs create mode 100644 server/crates/kvitt-server/src/config.rs create mode 100644 server/crates/kvitt-server/src/db/mod.rs create mode 100644 server/crates/kvitt-server/src/db/pool.rs create mode 100644 server/crates/kvitt-server/src/db/sequence.rs create mode 100644 server/crates/kvitt-server/src/error.rs create mode 100644 server/crates/kvitt-server/src/lib.rs create mode 100644 server/crates/kvitt-server/src/main.rs create mode 100644 server/crates/kvitt-server/src/openapi.rs create mode 100644 server/crates/kvitt-server/src/push/mod.rs create mode 100644 server/crates/kvitt-server/src/router.rs create mode 100644 server/crates/kvitt-server/src/tasks/mod.rs create mode 100644 server/crates/kvitt-server/src/time.rs create mode 100644 server/crates/kvitt-server/src/wire/account.rs create mode 100644 server/crates/kvitt-server/src/wire/audit.rs create mode 100644 server/crates/kvitt-server/src/wire/category.rs create mode 100644 server/crates/kvitt-server/src/wire/device.rs create mode 100644 server/crates/kvitt-server/src/wire/expense.rs create mode 100644 server/crates/kvitt-server/src/wire/invite.rs create mode 100644 server/crates/kvitt-server/src/wire/mod.rs create mode 100644 server/crates/kvitt-server/src/wire/recurring.rs create mode 100644 server/crates/kvitt-server/src/wire/settlement.rs create mode 100644 server/crates/kvitt-server/src/wire/sync.rs create mode 100644 server/crates/kvitt-server/src/wire/user.rs create mode 100644 server/crates/kvitt-server/tests/openapi_contract.rs create mode 100644 server/crates/kvitt-server/tests/skeleton/errors.rs create mode 100644 server/crates/kvitt-server/tests/skeleton/main.rs create mode 100644 server/crates/kvitt-server/tests/skeleton/migration.rs create mode 100644 server/crates/kvitt-server/tests/skeleton/routes.rs create mode 100644 server/crates/kvitt-server/tests/skeleton/support.rs create mode 100644 server/crates/kvitt-server/tests/skeleton/wire_examples.rs create mode 100644 server/rust-toolchain.toml diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..f44b440 --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,2 @@ +target/ +**/*.sqlite* diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..2ec0db0 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,3 @@ +# Build output and a local data directory for `cargo run` (KVITT_DATA_DIR=./data); never committed. +/target/ +/data/ diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100644 index 0000000..6a75ce1 --- /dev/null +++ b/server/Cargo.lock @@ -0,0 +1,2151 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.6", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kvitt-domain" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "kvitt-import" +version = "0.1.0" +dependencies = [ + "kvitt-domain", + "kvitt-server", +] + +[[package]] +name = "kvitt-server" +version = "0.1.0" +dependencies = [ + "axum", + "chrono", + "chrono-tz", + "clap", + "http-body-util", + "kvitt-domain", + "serde", + "serde_json", + "serde_norway", + "sha2 0.11.1", + "sqlx", + "thiserror", + "tokio", + "tower", + "tracing", + "tracing-subscriber", + "utoipa", + "uuid", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_norway" +version = "0.9.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e408f29489b5fd500fab51ff1484fc859bb655f32c671f307dcd733b72e8168c" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml-norway", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64", + "bytes", + "cfg-if", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-sqlite", + "syn 2.0.119", + "thiserror", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags", + "byteorder", + "bytes", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1", + "sha2 0.11.1", + "sqlx-core", + "thiserror", + "tracing", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "rand", + "serde", + "serde_json", + "sha2 0.11.1", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unsafe-libyaml-norway" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39abd59bf32521c7f2301b52d05a6a2c975b6003521cbd0c6dc1582f0a22104" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "utoipa" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8765fe27aeff71012a3f90fa474cf1df863a7d0dd81ed25de4e63fff2544994f" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d935f1c83fdf8b88f09bbe050d0cea78ea4afee6d7b0317403228c1200b2c8ab" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "uuid", +] + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..d9698ef --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,12 @@ +[workspace] +resolver = "3" +members = ["crates/kvitt-domain", "crates/kvitt-server", "crates/kvitt-import"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.99" +publish = false + +[workspace.lints.rust] +unsafe_code = "forbid" diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..521d5e0 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,37 @@ +# Kvitt server image (SPEC §10.3). Build context: server/. Built on the owner's M4 Mac, never on the Pi: +# docker build --platform linux/arm64 -f server/Dockerfile -t kvitt-server:dev server/ +# linux/amd64 builds the same way (--platform linux/amd64; emulated on the Mac, slow). The Rust version is the one +# pinned in rust-toolchain.toml; the two always change together. + +# ---- stage 1: build -------------------------------------------------------------------------------------------- +FROM docker.io/library/rust:1.99.1-bookworm AS build +ARG TARGETARCH +# Use the image's toolchain as is; rust-toolchain.toml names the same version but would also pull clippy/rustfmt. +ENV RUSTUP_TOOLCHAIN=1.99.1 \ + CARGO_TERM_COLOR=never +WORKDIR /src +COPY . . +# Native build for the target platform: aarch64-unknown-linux-gnu on arm64 (generic Armv8-A, no +# target-cpu=native: the Pi 3 is a Cortex-A53), x86_64-unknown-linux-gnu on amd64. +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/src/target,id=kvitt-target-${TARGETARCH} \ + case "${TARGETARCH:-arm64}" in \ + arm64) triple=aarch64-unknown-linux-gnu ;; \ + amd64) triple=x86_64-unknown-linux-gnu ;; \ + *) echo "unsupported TARGETARCH ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && cargo build --release --locked --target "$triple" -p kvitt-server -p kvitt-import \ + && mkdir -p /out \ + && cp "target/$triple/release/kvitt-server" "target/$triple/release/kvitt-import" /out/ + +# ---- stage 2: runtime ------------------------------------------------------------------------------------------ +# glibc + libgcc, no shell, no package manager. Pinned by digest (multi-arch index of :nonroot). +FROM gcr.io/distroless/cc-debian12:nonroot@sha256:9dac0a79194e45a7da0158a9c6da57b217585af0786db3845d1f0ec1a0dd182f +COPY --from=build /out/kvitt-server /out/kvitt-import /usr/local/bin/ +# Runs as uid/gid 10001 with a read-only root filesystem; /data is the only writable volume and /secrets is +# mounted read-only (SPEC §8.9). Listens on 8080 inside the compose network. +USER 10001:10001 +ENV KVITT_DATA_DIR=/data +EXPOSE 8080 +ENTRYPOINT ["kvitt-server"] +CMD ["serve"] diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..8ea4efe --- /dev/null +++ b/server/README.md @@ -0,0 +1,134 @@ +# 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 ` (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:/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 ` | 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` for JSON bodies (400 + `invalid_request`, 413 `payload_too_large`), `JsonBody` 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>; + } + ``` + + `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, 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, RecurrenceError>` + and `next_occurrence_date(&RecurrenceSchedule) -> Result, 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:-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. diff --git a/server/crates/kvitt-domain/Cargo.toml b/server/crates/kvitt-domain/Cargo.toml new file mode 100644 index 0000000..39111db --- /dev/null +++ b/server/crates/kvitt-domain/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "kvitt-domain" +description = "Kvitt money, split, balance, allocation and settlement rules (SPEC §9, §14.5). No I/O." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +serde = { version = "1.0.229", features = ["derive"] } + +[dev-dependencies] +serde_json = "1.0.154" diff --git a/server/crates/kvitt-domain/src/allocation.rs b/server/crates/kvitt-domain/src/allocation.rs new file mode 100644 index 0000000..ff608c2 --- /dev/null +++ b/server/crates/kvitt-domain/src/allocation.rs @@ -0,0 +1,173 @@ +//! Settlement allocation proposal and over-settle warnings (SPEC §9.4, §14.5.2). + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::amount_in_range; + +/// A non-deleted account with the payer's current net in it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AccountPosition { + pub account_id: String, + /// Fixed RFC 3339 form, so string order is time order. + pub created_at: String, + pub archived: bool, + pub payer_net_ore: i64, +} + +/// Why no proposal can be made. Checked in declaration order. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AllocationError { + AmountOutOfRange, + NoAccounts, +} + +impl AllocationError { + /// The wire token of SPEC §14.5.3. + pub fn token(self) -> &'static str { + match self { + Self::AmountOutOfRange => "amount_out_of_range", + Self::NoAccounts => "no_accounts", + } + } +} + +impl std::fmt::Display for AllocationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.token()) + } +} + +impl std::error::Error for AllocationError {} + +/// One account's part of a settlement. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Allocation { + pub account_id: String, + pub amount_ore: i64, +} + +/// The output of [`propose_allocation`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AllocationProposal { + /// In the order produced by SPEC §9.4. + pub allocations: Vec, + /// The amount added in steps 3 or 4 of SPEC §9.4; 0 otherwise. + pub excess_ore: i64, + /// The account that received `excess_ore`; empty when `excess_ore` is 0. + pub over_settled_account_ids: Vec, +} + +/// Proposes how a settlement of `amount_ore` paid by the payer is spread over the accounts (SPEC §9.4). +/// +/// `accounts` are all non-deleted accounts with the payer's net per account. Owing accounts (net < 0) are paid +/// oldest first (`created_at`, then `account_id`); any rest goes to the last paid account. With no owing account the +/// whole amount goes to the oldest non-archived account, or the oldest account when all are archived. +pub fn propose_allocation( + amount_ore: i64, + accounts: &[AccountPosition], +) -> Result { + if !amount_in_range(amount_ore) { + return Err(AllocationError::AmountOutOfRange); + } + if accounts.is_empty() { + return Err(AllocationError::NoAccounts); + } + let mut owing: Vec<&AccountPosition> = + accounts.iter().filter(|a| a.payer_net_ore < 0).collect(); + owing.sort_by(|a, b| account_order(a, b)); + + let mut allocations: Vec = Vec::new(); + let mut remaining = amount_ore; + for account in &owing { + if remaining <= 0 { + break; + } + let amount = remaining.min(account.payer_net_ore.saturating_neg()); + allocations.push(Allocation { + account_id: account.account_id.clone(), + amount_ore: amount, + }); + remaining -= amount; + } + + if let Some(last) = allocations.last_mut() { + if remaining > 0 { + last.amount_ore += remaining; + return Ok(AllocationProposal { + over_settled_account_ids: vec![last.account_id.clone()], + allocations, + excess_ore: remaining, + }); + } + return Ok(AllocationProposal { + allocations, + excess_ore: 0, + over_settled_account_ids: Vec::new(), + }); + } + + // No owing account: the oldest non-archived account, or the oldest account if every account is archived. + let oldest = |archived_allowed: bool| { + accounts + .iter() + .filter(|a| archived_allowed || !a.archived) + .min_by(|a, b| account_order(a, b)) + }; + let target = oldest(false) + .or_else(|| oldest(true)) + .ok_or(AllocationError::NoAccounts)?; + Ok(AllocationProposal { + allocations: vec![Allocation { + account_id: target.account_id.clone(), + amount_ore, + }], + excess_ore: amount_ore, + over_settled_account_ids: vec![target.account_id.clone()], + }) +} + +/// Ids, in allocation order, whose allocation exceeds `max(0, −payer_net_ore)` (SPEC §9.4 over-settle warning). +/// An allocation for an account not in `accounts` counts as net 0. +pub fn over_settled_accounts( + allocations: &[Allocation], + accounts: &[AccountPosition], +) -> Vec { + let nets: BTreeMap<&str, i64> = accounts + .iter() + .map(|a| (a.account_id.as_str(), a.payer_net_ore)) + .collect(); + allocations + .iter() + .filter(|a| { + let net = nets.get(a.account_id.as_str()).copied().unwrap_or(0); + a.amount_ore > net.saturating_neg().max(0) + }) + .map(|a| a.account_id.clone()) + .collect() +} + +fn account_order(a: &AccountPosition, b: &AccountPosition) -> std::cmp::Ordering { + (a.created_at.as_str(), a.account_id.as_str()) + .cmp(&(b.created_at.as_str(), b.account_id.as_str())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serde_form_is_the_wire_token() { + for e in [ + AllocationError::AmountOutOfRange, + AllocationError::NoAccounts, + ] { + assert_eq!( + serde_json::to_value(e).unwrap(), + serde_json::json!(e.token()) + ); + } + } +} diff --git a/server/crates/kvitt-domain/src/balance.rs b/server/crates/kvitt-domain/src/balance.rs new file mode 100644 index 0000000..7a08a88 --- /dev/null +++ b/server/crates/kvitt-domain/src/balance.rs @@ -0,0 +1,86 @@ +//! Balances (SPEC §9.3, §14.5.2). + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// A non-deleted expense as far as balances are concerned. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BalanceExpense { + pub account_id: String, + pub amount_ore: i64, + pub payer_user_id: String, + /// user id → share in öre. + pub shares: BTreeMap, +} + +/// A non-deleted settlement as far as balances are concerned. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BalanceSettlement { + pub from_user_id: String, + pub to_user_id: String, + /// account id → öre. + pub allocations: BTreeMap, +} + +/// Net per account and user, and the global net per user. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct Balances { + /// account id → user id → net öre. + pub per_account: BTreeMap>, + /// user id → net öre over every account. + pub global: BTreeMap, +} + +/// Computes `net(u)` per account and globally (SPEC §9.3). Inputs hold only non-deleted rows. +/// +/// `per_account` has an entry for every id of `account_ids` (zeros included) and for any other account id the rows +/// reference; each inner map has every id of `user_ids`. Arithmetic saturates instead of overflowing; real data is +/// many orders of magnitude below `i64::MAX`. +pub fn compute_balances( + user_ids: &[String], + account_ids: &[String], + expenses: &[BalanceExpense], + settlements: &[BalanceSettlement], +) -> Balances { + let zeros: BTreeMap = user_ids.iter().map(|u| (u.clone(), 0)).collect(); + let mut per_account: BTreeMap> = BTreeMap::new(); + for account in account_ids { + per_account + .entry(account.clone()) + .or_insert_with(|| zeros.clone()); + } + for expense in expenses { + let nets = per_account + .entry(expense.account_id.clone()) + .or_insert_with(|| zeros.clone()); + add(nets, &expense.payer_user_id, expense.amount_ore); + for (user, share) in &expense.shares { + add(nets, user, share.saturating_neg()); + } + } + for settlement in settlements { + for (account, amount) in &settlement.allocations { + let nets = per_account + .entry(account.clone()) + .or_insert_with(|| zeros.clone()); + add(nets, &settlement.from_user_id, *amount); + add(nets, &settlement.to_user_id, amount.saturating_neg()); + } + } + let mut global = zeros; + for nets in per_account.values() { + for (user, net) in nets { + add(&mut global, user, *net); + } + } + Balances { + per_account, + global, + } +} + +fn add(nets: &mut BTreeMap, user: &str, delta: i64) { + let net = nets.entry(user.to_owned()).or_insert(0); + *net = net.saturating_add(delta); +} diff --git a/server/crates/kvitt-domain/src/lib.rs b/server/crates/kvitt-domain/src/lib.rs new file mode 100644 index 0000000..757b999 --- /dev/null +++ b/server/crates/kvitt-domain/src/lib.rs @@ -0,0 +1,39 @@ +//! Kvitt domain rules as pure functions (SPEC §9.1, §9.3, §9.4, §9.5 `next_occurrence_date`, §14.5). +//! +//! No I/O, no clock, no randomness, no locale. Ids are plain strings (the test vectors use short ids like `"a"`); +//! money is `i64` öre and basis points are `u32`. The Kotlin `:domain` module implements the same functions and both +//! must pass every vector in `contracts/test-vectors/` (`tests/vectors.rs`). + +// No unwrap/expect outside tests (CLAUDE.md server rule); integration tests are separate crates and may use them. +#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))] + +mod allocation; +mod balance; +mod recurring; +mod settlement; +mod split; + +pub use allocation::{ + AccountPosition, Allocation, AllocationError, AllocationProposal, over_settled_accounts, + propose_allocation, +}; +pub use balance::{BalanceExpense, BalanceSettlement, Balances, compute_balances}; +pub use recurring::{ + Period, RecurrenceError, RecurrenceSchedule, next_candidate_period, next_occurrence_date, +}; +pub use settlement::{SettlementError, validate_settlement}; +pub use split::{SplitError, SplitSpec, compute_split}; + +/// Smallest valid amount of an expense, settlement or template, in öre (SPEC §9.6). +pub const AMOUNT_MIN_ORE: i64 = 1; +/// Largest valid amount of an expense, settlement or template, in öre: 10 000 000,00 kr (SPEC §9.6). +pub const AMOUNT_MAX_ORE: i64 = 1_000_000_000; +/// 100 % in basis points. +pub const BP_TOTAL: u32 = 10_000; +/// Most allocations one settlement may have (SPEC §9.6). +pub const MAX_ALLOCATIONS: usize = 50; + +/// Whether `amount_ore` is within SPEC §9.6 (1 to 1 000 000 000). +pub fn amount_in_range(amount_ore: i64) -> bool { + (AMOUNT_MIN_ORE..=AMOUNT_MAX_ORE).contains(&amount_ore) +} diff --git a/server/crates/kvitt-domain/src/recurring.rs b/server/crates/kvitt-domain/src/recurring.rs new file mode 100644 index 0000000..8f242d2 --- /dev/null +++ b/server/crates/kvitt-domain/src/recurring.rs @@ -0,0 +1,305 @@ +//! Recurring periods and the `next_occurrence_date` of a template (SPEC §9.5). +//! +//! Pure calendar arithmetic on `YYYY-MM` periods and `YYYY-MM-DD` dates; no clock and no time zone. The occurrence +//! *instant* (the clamped date at 06:00 Europe/Stockholm) is the server's concern; this module only fixes which +//! period and which calendar date. + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +/// A month, `YYYY-MM`. Orders chronologically. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Period { + year: u16, + month: u8, +} + +/// Why a recurrence input is malformed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RecurrenceError { + /// A period that is not `YYYY-MM` with month 01 to 12 (years 0001 to 9999). + PeriodInvalid, + /// `day_of_month` outside 1 to 31 (SPEC §9.6). + DayOfMonthOutOfRange, +} + +impl fmt::Display for RecurrenceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::PeriodInvalid => "period_invalid", + Self::DayOfMonthOutOfRange => "day_of_month_out_of_range", + }) + } +} + +impl std::error::Error for RecurrenceError {} + +impl Period { + /// A period, if `month` is 1 to 12 and `year` 1 to 9999. + pub fn new(year: u16, month: u8) -> Option { + ((1..=9999).contains(&year) && (1..=12).contains(&month)).then_some(Self { year, month }) + } + + pub fn year(self) -> u16 { + self.year + } + + pub fn month(self) -> u8 { + self.month + } + + /// The following month, or `None` after 9999-12. + pub fn next(self) -> Option { + if self.month == 12 { + Self::new(self.year.checked_add(1)?, 1) + } else { + Self::new(self.year, self.month + 1) + } + } + + /// Number of days in this month (Gregorian leap years). + pub fn days(self) -> u8 { + match self.month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + _ if is_leap(self.year) => 29, + _ => 28, + } + } + + /// The occurrence date of `day_of_month` in this month, clamped to its last day (`31` in April is `30`), as + /// `YYYY-MM-DD`. + pub fn clamped_date(self, day_of_month: u8) -> Result { + if !(1..=31).contains(&day_of_month) { + return Err(RecurrenceError::DayOfMonthOutOfRange); + } + Ok(format!("{self}-{:02}", day_of_month.min(self.days()))) + } +} + +impl fmt::Display for Period { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:04}-{:02}", self.year, self.month) + } +} + +impl FromStr for Period { + type Err = RecurrenceError; + + fn from_str(s: &str) -> Result { + let bytes = s.as_bytes(); + let digits = |r: std::ops::Range| bytes[r].iter().all(u8::is_ascii_digit); + if bytes.len() != 7 || bytes[4] != b'-' || !digits(0..4) || !digits(5..7) { + return Err(RecurrenceError::PeriodInvalid); + } + let year: u16 = s[0..4] + .parse() + .map_err(|_| RecurrenceError::PeriodInvalid)?; + let month: u8 = s[5..7] + .parse() + .map_err(|_| RecurrenceError::PeriodInvalid)?; + Self::new(year, month).ok_or(RecurrenceError::PeriodInvalid) + } +} + +fn is_leap(year: u16) -> bool { + (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400) +} + +/// The template fields `next_occurrence_date` depends on. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecurrenceSchedule { + pub day_of_month: u8, + pub start_period: String, + pub end_period: Option, + pub last_generated_period: Option, + pub paused: bool, + pub deleted: bool, +} + +/// The first period that may still be generated: the month after `last_generated_period`, but not before +/// `start_period`; `None` when that is after `end_period` (SPEC §9.5). Paused and deleted are not considered here. +pub fn next_candidate_period( + start_period: &str, + end_period: Option<&str>, + last_generated_period: Option<&str>, +) -> Result, RecurrenceError> { + let start: Period = start_period.parse()?; + let end: Option = end_period.map(str::parse).transpose()?; + let after_last: Option = match last_generated_period { + Some(last) => match last.parse::()?.next() { + Some(p) => Some(p), + None => return Ok(None), + }, + None => None, + }; + let candidate = after_last.map_or(start, |p| p.max(start)); + Ok(match end { + Some(end) if candidate > end => None, + _ => Some(candidate), + }) +} + +/// `next_occurrence_date` of the RecurringTemplate wire form (SPEC §9.5): the clamped date of the first candidate +/// period after `last_generated_period` that is ≥ `start_period` and ≤ `end_period`; `None` when paused, deleted or +/// past `end_period`. +pub fn next_occurrence_date( + schedule: &RecurrenceSchedule, +) -> Result, RecurrenceError> { + if !(1..=31).contains(&schedule.day_of_month) { + return Err(RecurrenceError::DayOfMonthOutOfRange); + } + let candidate = next_candidate_period( + &schedule.start_period, + schedule.end_period.as_deref(), + schedule.last_generated_period.as_deref(), + )?; + if schedule.paused || schedule.deleted { + return Ok(None); + } + candidate + .map(|p| p.clamped_date(schedule.day_of_month)) + .transpose() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn schedule(day: u8, start: &str, end: Option<&str>, last: Option<&str>) -> RecurrenceSchedule { + RecurrenceSchedule { + day_of_month: day, + start_period: start.to_owned(), + end_period: end.map(str::to_owned), + last_generated_period: last.map(str::to_owned), + paused: false, + deleted: false, + } + } + + fn next(s: &RecurrenceSchedule) -> Option { + next_occurrence_date(s).unwrap() + } + + #[test] + fn never_generated_starts_at_start_period() { + assert_eq!( + next(&schedule(25, "2026-11", None, None)).as_deref(), + Some("2026-11-25") + ); + } + + #[test] + fn after_last_generated_period() { + assert_eq!( + next(&schedule(1, "2026-01", None, Some("2026-10"))).as_deref(), + Some("2026-11-01") + ); + } + + #[test] + fn year_boundary() { + assert_eq!( + next(&schedule(15, "2026-01", None, Some("2026-12"))).as_deref(), + Some("2027-01-15") + ); + } + + #[test] + fn start_period_moved_past_last_generated() { + // Changing start_period never regenerates past periods; a later start wins over last + 1. + assert_eq!( + next(&schedule(10, "2027-03", None, Some("2026-10"))).as_deref(), + Some("2027-03-10") + ); + } + + #[test] + fn clamped_to_month_end() { + assert_eq!( + next(&schedule(31, "2026-04", None, None)).as_deref(), + Some("2026-04-30") + ); + assert_eq!( + next(&schedule(31, "2027-02", None, None)).as_deref(), + Some("2027-02-28") + ); + assert_eq!( + next(&schedule(30, "2028-02", None, None)).as_deref(), + Some("2028-02-29") + ); + assert_eq!( + next(&schedule(29, "2100-02", None, None)).as_deref(), + Some("2100-02-28") + ); + assert_eq!( + next(&schedule(29, "2000-02", None, None)).as_deref(), + Some("2000-02-29") + ); + } + + #[test] + fn end_period_inclusive_and_past() { + assert_eq!( + next(&schedule(5, "2026-01", Some("2026-11"), Some("2026-10"))).as_deref(), + Some("2026-11-05") + ); + assert_eq!( + next(&schedule(5, "2026-01", Some("2026-11"), Some("2026-11"))), + None + ); + assert_eq!(next(&schedule(5, "2026-12", Some("2026-11"), None)), None); + } + + #[test] + fn paused_or_deleted_is_none() { + let mut s = schedule(5, "2026-01", None, None); + s.paused = true; + assert_eq!(next(&s), None); + s.paused = false; + s.deleted = true; + assert_eq!(next(&s), None); + } + + #[test] + fn malformed_input() { + let bad = |s: RecurrenceSchedule, e| assert_eq!(next_occurrence_date(&s), Err(e)); + bad( + schedule(0, "2026-01", None, None), + RecurrenceError::DayOfMonthOutOfRange, + ); + bad( + schedule(32, "2026-01", None, None), + RecurrenceError::DayOfMonthOutOfRange, + ); + bad( + schedule(1, "2026-13", None, None), + RecurrenceError::PeriodInvalid, + ); + bad( + schedule(1, "2026-1", None, None), + RecurrenceError::PeriodInvalid, + ); + bad( + schedule(1, "2026-01", Some("x"), None), + RecurrenceError::PeriodInvalid, + ); + bad( + schedule(1, "2026-01", None, Some("2026-00")), + RecurrenceError::PeriodInvalid, + ); + } + + #[test] + fn period_round_trip_and_order() { + let p: Period = "2026-10".parse().unwrap(); + assert_eq!(p.to_string(), "2026-10"); + assert!(p < "2026-11".parse().unwrap()); + assert!(p > "2025-12".parse().unwrap()); + assert_eq!(p.next().map(|n| n.to_string()).as_deref(), Some("2026-11")); + assert_eq!("9999-12".parse::().unwrap().next(), None); + } +} diff --git a/server/crates/kvitt-domain/src/settlement.rs b/server/crates/kvitt-domain/src/settlement.rs new file mode 100644 index 0000000..8908bbc --- /dev/null +++ b/server/crates/kvitt-domain/src/settlement.rs @@ -0,0 +1,108 @@ +//! Settlement validity (SPEC §9.4, §14.5.2). + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +use crate::{Allocation, MAX_ALLOCATIONS, amount_in_range}; + +/// Why a settlement is invalid. Checked in declaration order; the first failure is returned. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SettlementError { + AmountOutOfRange, + SameUser, + UserUnknown, + NoAllocations, + TooManyAllocations, + AllocationNotPositive, + DuplicateAccount, + AllocationSumMismatch, +} + +impl SettlementError { + /// The wire token of SPEC §14.5.3. + pub fn token(self) -> &'static str { + match self { + Self::AmountOutOfRange => "amount_out_of_range", + Self::SameUser => "same_user", + Self::UserUnknown => "user_unknown", + Self::NoAllocations => "no_allocations", + Self::TooManyAllocations => "too_many_allocations", + Self::AllocationNotPositive => "allocation_not_positive", + Self::DuplicateAccount => "duplicate_account", + Self::AllocationSumMismatch => "allocation_sum_mismatch", + } + } +} + +impl std::fmt::Display for SettlementError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.token()) + } +} + +impl std::error::Error for SettlementError {} + +/// Checks a settlement against SPEC §9.4. Reference existence of the accounts is not checked here. +pub fn validate_settlement( + amount_ore: i64, + from_user_id: &str, + to_user_id: &str, + user_ids: &[String], + allocations: &[Allocation], +) -> Result<(), SettlementError> { + if !amount_in_range(amount_ore) { + return Err(SettlementError::AmountOutOfRange); + } + if from_user_id == to_user_id { + return Err(SettlementError::SameUser); + } + let known = |u: &str| user_ids.iter().any(|id| id == u); + if !known(from_user_id) || !known(to_user_id) { + return Err(SettlementError::UserUnknown); + } + if allocations.is_empty() { + return Err(SettlementError::NoAllocations); + } + if allocations.len() > MAX_ALLOCATIONS { + return Err(SettlementError::TooManyAllocations); + } + if allocations.iter().any(|a| a.amount_ore < 1) { + return Err(SettlementError::AllocationNotPositive); + } + let distinct: BTreeSet<&str> = allocations.iter().map(|a| a.account_id.as_str()).collect(); + if distinct.len() != allocations.len() { + return Err(SettlementError::DuplicateAccount); + } + let sum: i128 = allocations.iter().map(|a| i128::from(a.amount_ore)).sum(); + if sum != i128::from(amount_ore) { + return Err(SettlementError::AllocationSumMismatch); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serde_form_is_the_wire_token() { + let all = [ + SettlementError::AmountOutOfRange, + SettlementError::SameUser, + SettlementError::UserUnknown, + SettlementError::NoAllocations, + SettlementError::TooManyAllocations, + SettlementError::AllocationNotPositive, + SettlementError::DuplicateAccount, + SettlementError::AllocationSumMismatch, + ]; + for e in all { + assert_eq!( + serde_json::to_value(e).unwrap(), + serde_json::json!(e.token()) + ); + } + } +} diff --git a/server/crates/kvitt-domain/src/split.rs b/server/crates/kvitt-domain/src/split.rs new file mode 100644 index 0000000..1151945 --- /dev/null +++ b/server/crates/kvitt-domain/src/split.rs @@ -0,0 +1,181 @@ +//! Splits (SPEC §9.1, §14.5.2). + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +use crate::{BP_TOTAL, amount_in_range}; + +/// How an expense is split between the two household users. +/// +/// Modes `default` and `ratio` are both [`SplitSpec::Ratio`]; `exact` is [`SplitSpec::Exact`]; `full` is +/// [`SplitSpec::Full`]. The serde form is the one of the test vectors (SPEC §14.5.5): +/// `{"mode":"ratio","bp":{…}}`, `{"mode":"exact","ore":{…}}`, `{"mode":"full","bearer_user_id":"…"}`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "mode", rename_all = "snake_case")] +pub enum SplitSpec { + Ratio { bp: BTreeMap }, + Exact { ore: BTreeMap }, + Full { bearer_user_id: String }, +} + +/// Why a split is invalid. Checked in declaration order; the first failure is returned (SPEC §14.5.2). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SplitError { + AmountOutOfRange, + UsersInvalid, + PayerUnknown, + BpOutOfRange, + // `rename_all = "snake_case"` alone would give `bp_sum_not10000`; the token of SPEC §14.5.3 is `bp_sum_not_10000`. + // TODO(spec): §14.5.1 shows only `rename_all`, which does not produce this token. + #[serde(rename = "bp_sum_not_10000")] + BpSumNot10000, + ExactNegative, + ExactSumMismatch, + BearerUnknown, + ShareUsersMismatch, +} + +impl SplitError { + /// The wire token of SPEC §14.5.3. + pub fn token(self) -> &'static str { + match self { + Self::AmountOutOfRange => "amount_out_of_range", + Self::UsersInvalid => "users_invalid", + Self::PayerUnknown => "payer_unknown", + Self::BpOutOfRange => "bp_out_of_range", + Self::BpSumNot10000 => "bp_sum_not_10000", + Self::ExactNegative => "exact_negative", + Self::ExactSumMismatch => "exact_sum_mismatch", + Self::BearerUnknown => "bearer_unknown", + Self::ShareUsersMismatch => "share_users_mismatch", + } + } +} + +impl std::fmt::Display for SplitError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.token()) + } +} + +impl std::error::Error for SplitError {} + +/// Computes each user's share in öre (SPEC §9.1). The result has both user ids. +/// +/// Errors are checked in [`SplitError`] order: amount, users, payer, then the mode's own checks +/// (bp range and sum for `Ratio`, negativity and sum for `Exact`, bearer for `Full`), then the share keys. +pub fn compute_split( + amount_ore: i64, + payer_user_id: &str, + user_ids: &[String], + spec: &SplitSpec, +) -> Result, SplitError> { + if !amount_in_range(amount_ore) { + return Err(SplitError::AmountOutOfRange); + } + let users = two_distinct(user_ids).ok_or(SplitError::UsersInvalid)?; + if !users.contains(&payer_user_id) { + return Err(SplitError::PayerUnknown); + } + let user_set: BTreeSet<&str> = users.iter().copied().collect(); + match spec { + SplitSpec::Ratio { bp } => { + if bp.values().any(|&v| v > BP_TOTAL) { + return Err(SplitError::BpOutOfRange); + } + let sum: u64 = bp.values().map(|&v| u64::from(v)).sum(); + if sum != u64::from(BP_TOTAL) { + return Err(SplitError::BpSumNot10000); + } + if !same_keys(bp.keys(), &user_set) { + return Err(SplitError::ShareUsersMismatch); + } + Ok(ratio_rule(amount_ore, payer_user_id, users, |u| { + bp.get(u).copied().unwrap_or(0) + })) + } + SplitSpec::Exact { ore } => { + if ore.values().any(|&v| v < 0) { + return Err(SplitError::ExactNegative); + } + let sum: i128 = ore.values().map(|&v| i128::from(v)).sum(); + if sum != i128::from(amount_ore) { + return Err(SplitError::ExactSumMismatch); + } + if !same_keys(ore.keys(), &user_set) { + return Err(SplitError::ShareUsersMismatch); + } + Ok(ore.clone()) + } + SplitSpec::Full { bearer_user_id } => { + if !user_set.contains(bearer_user_id.as_str()) { + return Err(SplitError::BearerUnknown); + } + // `Full` is `Ratio` with the bearer at 10000 bp (SPEC §14.5.2). + Ok(ratio_rule(amount_ore, payer_user_id, users, |u| { + if u == bearer_user_id { BP_TOTAL } else { 0 } + })) + } + } +} + +/// The two user ids, if `user_ids` has exactly two distinct entries. +fn two_distinct(user_ids: &[String]) -> Option<[&str; 2]> { + match user_ids { + [a, b] if a != b => Some([a.as_str(), b.as_str()]), + _ => None, + } +} + +fn same_keys<'a>(keys: impl Iterator, users: &BTreeSet<&str>) -> bool { + let keys: BTreeSet<&str> = keys.map(String::as_str).collect(); + keys == *users +} + +/// The ratio rule of SPEC §9.1: the non-payer's share is rounded down, the payer absorbs the remainder. +/// `amount_ore` ≤ 1 000 000 000 and bp ≤ 10000, so the product fits in `i64`. +fn ratio_rule( + amount_ore: i64, + payer: &str, + users: [&str; 2], + bp_of: impl Fn(&str) -> u32, +) -> BTreeMap { + let non_payer = if users[0] == payer { + users[1] + } else { + users[0] + }; + let share_non_payer = amount_ore * i64::from(bp_of(non_payer)) / i64::from(BP_TOTAL); + BTreeMap::from([ + (non_payer.to_owned(), share_non_payer), + (payer.to_owned(), amount_ore - share_non_payer), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serde_form_is_the_wire_token() { + let all = [ + SplitError::AmountOutOfRange, + SplitError::UsersInvalid, + SplitError::PayerUnknown, + SplitError::BpOutOfRange, + SplitError::BpSumNot10000, + SplitError::ExactNegative, + SplitError::ExactSumMismatch, + SplitError::BearerUnknown, + SplitError::ShareUsersMismatch, + ]; + for e in all { + assert_eq!( + serde_json::to_value(e).unwrap(), + serde_json::json!(e.token()) + ); + } + } +} diff --git a/server/crates/kvitt-domain/tests/vectors.rs b/server/crates/kvitt-domain/tests/vectors.rs new file mode 100644 index 0000000..f2fe38b --- /dev/null +++ b/server/crates/kvitt-domain/tests/vectors.rs @@ -0,0 +1,193 @@ +//! Runs every case of every file in `contracts/test-vectors/` (SPEC §14.5.5) against `kvitt-domain`. +//! +//! The directory is read at test time (path from `CARGO_MANIFEST_DIR`), so a new vector file is picked up without a +//! code change; a file for a function this crate does not know fails the test unless it is one of the Kotlin-only +//! analytics functions (SPEC §14.5.4). + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use kvitt_domain::{ + AccountPosition, Allocation, BalanceExpense, BalanceSettlement, SplitSpec, compute_balances, + compute_split, over_settled_accounts, propose_allocation, validate_settlement, +}; +use serde::Deserialize; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; + +/// Functions that exist only in Kotlin (SPEC §14.5.4); their files are parsed but not run here. +const KOTLIN_ONLY: &[&str] = &["spend_series", "resolve_range"]; +/// Every Rust function of SPEC §14.5.2; each must have a vector file. +const RUST_FUNCTIONS: &[&str] = &[ + "compute_split", + "compute_balances", + "propose_allocation", + "validate_settlement", + "over_settled_accounts", +]; + +#[derive(Deserialize)] +struct VectorFile { + format: u32, + function: String, + cases: Vec, +} + +#[derive(Deserialize)] +struct Case { + name: String, + input: Value, + expected: Value, +} + +#[derive(Deserialize)] +struct SplitInput { + amount_ore: i64, + payer_user_id: String, + user_ids: Vec, + spec: SplitSpec, +} + +#[derive(Deserialize)] +struct BalancesInput { + user_ids: Vec, + account_ids: Vec, + expenses: Vec, + settlements: Vec, +} + +#[derive(Deserialize)] +struct ProposalInput { + amount_ore: i64, + accounts: Vec, +} + +#[derive(Deserialize)] +struct SettlementInput { + amount_ore: i64, + from_user_id: String, + to_user_id: String, + user_ids: Vec, + allocations: Vec, +} + +#[derive(Deserialize)] +struct OverSettledInput { + allocations: Vec, + accounts: Vec, +} + +fn vectors_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../contracts/test-vectors") +} + +fn input(file: &str, case: &Case) -> T { + serde_json::from_value(case.input.clone()) + .unwrap_or_else(|e| panic!("{file} / {}: bad input: {e}", case.name)) +} + +fn outcome(result: Result) -> Value { + match result { + Ok(v) => json!({ "ok": v }), + Err(e) => json!({ "error": e }), + } +} + +fn run(function: &str, file: &str, case: &Case) -> Value { + match function { + "compute_split" => { + let i: SplitInput = input(file, case); + outcome(compute_split( + i.amount_ore, + &i.payer_user_id, + &i.user_ids, + &i.spec, + )) + } + "compute_balances" => { + let i: BalancesInput = input(file, case); + let b = compute_balances(&i.user_ids, &i.account_ids, &i.expenses, &i.settlements); + json!({ "ok": b }) + } + "propose_allocation" => { + let i: ProposalInput = input(file, case); + outcome(propose_allocation(i.amount_ore, &i.accounts)) + } + "validate_settlement" => { + let i: SettlementInput = input(file, case); + outcome(validate_settlement( + i.amount_ore, + &i.from_user_id, + &i.to_user_id, + &i.user_ids, + &i.allocations, + )) + } + "over_settled_accounts" => { + let i: OverSettledInput = input(file, case); + json!({ "ok": over_settled_accounts(&i.allocations, &i.accounts) }) + } + other => panic!("{file}: no Rust implementation for function {other}"), + } +} + +#[test] +fn every_vector_file_passes() { + let dir = vectors_dir(); + let mut entries: Vec = std::fs::read_dir(&dir) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display())) + .map(|entry| entry.expect("directory entry").path()) + .filter(|p| p.extension().is_some_and(|x| x == "json")) + .collect(); + entries.sort(); + assert!(!entries.is_empty(), "no vector files in {}", dir.display()); + + let mut seen: BTreeMap = BTreeMap::new(); + let mut failures: Vec = Vec::new(); + for path in &entries { + let file = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let text = std::fs::read_to_string(path).expect("read vector file"); + let vectors: VectorFile = serde_json::from_str(&text) + .unwrap_or_else(|e| panic!("{file}: not a vector file (SPEC §14.5.5): {e}")); + assert_eq!(vectors.format, 1, "{file}: unknown format"); + assert_eq!( + file, + format!("{}.json", vectors.function), + "{file}: file name must be .json" + ); + assert!(!vectors.cases.is_empty(), "{file}: no cases"); + if KOTLIN_ONLY.contains(&vectors.function.as_str()) { + seen.insert(vectors.function.clone(), 0); + continue; + } + for case in &vectors.cases { + let actual = run(&vectors.function, &file, case); + if actual != case.expected { + failures.push(format!( + "{file} / {}: expected {}, got {}", + case.name, case.expected, actual + )); + } + } + seen.insert(vectors.function.clone(), vectors.cases.len()); + } + + for function in RUST_FUNCTIONS { + assert!( + seen.get(*function).is_some_and(|n| *n > 0), + "no vector cases for {function}" + ); + } + for (function, cases) in &seen { + println!("{function}: {cases} cases run"); + } + assert!( + failures.is_empty(), + "{} failing cases:\n{}", + failures.len(), + failures.join("\n") + ); +} diff --git a/server/crates/kvitt-import/Cargo.toml b/server/crates/kvitt-import/Cargo.toml new file mode 100644 index 0000000..7a28f95 --- /dev/null +++ b/server/crates/kvitt-import/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "kvitt-import" +description = "Splitwise CSV importer for Kvitt (SPEC §10.8). Stub until milestone M9." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +kvitt-domain = { version = "0.1.0", path = "../kvitt-domain" } +kvitt-server = { version = "0.1.0", path = "../kvitt-server" } diff --git a/server/crates/kvitt-import/src/main.rs b/server/crates/kvitt-import/src/main.rs new file mode 100644 index 0000000..1629148 --- /dev/null +++ b/server/crates/kvitt-import/src/main.rs @@ -0,0 +1,20 @@ +//! `kvitt-import`: the Splitwise CSV importer (SPEC §10.8). M9 owns this crate; in M2a it prints its usage and +//! exits 2. + +// No unwrap/expect outside tests (CLAUDE.md server rule); integration tests are separate crates and may use them. +#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))] + +use std::process::ExitCode; + +// The importer reuses the server crate for database access and audit writing and the domain crate for the money +// rules (SPEC §6.3); referenced here so the dependency edges exist from the start. +use kvitt_domain as _; +use kvitt_server as _; + +const USAGE: &str = + "usage: kvitt-import (Splitwise CSV importer, SPEC §10.8; not implemented in M2a)"; + +fn main() -> ExitCode { + eprintln!("{USAGE}"); + ExitCode::from(2) +} diff --git a/server/crates/kvitt-server/Cargo.toml b/server/crates/kvitt-server/Cargo.toml new file mode 100644 index 0000000..07596bd --- /dev/null +++ b/server/crates/kvitt-server/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "kvitt-server" +description = "Kvitt sync server (SPEC §6.3)." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "kvitt-server" +path = "src/main.rs" + +[dependencies] +axum = "0.8.9" +chrono = { version = "0.4.45", default-features = false, features = ["clock", "std"] } +chrono-tz = "0.10.4" +clap = { version = "4.6.7", features = ["derive", "env"] } +kvitt-domain = { version = "0.1.0", path = "../kvitt-domain" } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.154" +sha2 = "0.11.1" +sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite", "migrate", "macros"] } +thiserror = "2.0.21" +tokio = { version = "1.53.3", features = ["rt-multi-thread", "macros", "net", "signal", "time", "sync", "io-util"] } +tower = { version = "0.5.3", features = ["util"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["json", "env-filter"] } +utoipa = { version = "6.0.0", features = ["uuid"] } +uuid = { version = "1.28.0", features = ["v7", "serde"] } + +[dev-dependencies] +http-body-util = "0.1.5" +serde_norway = "0.9.42" diff --git a/server/crates/kvitt-server/migrations/0001_init.sql b/server/crates/kvitt-server/migrations/0001_init.sql new file mode 100644 index 0000000..7273ff5 --- /dev/null +++ b/server/crates/kvitt-server/migrations/0001_init.sql @@ -0,0 +1,230 @@ +-- Kvitt server schema v1. Do not edit; add a new migration and bump the schema version (SPEC §14.8). + +CREATE TABLE meta ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL +) STRICT; + +-- One global, strictly increasing counter for server_version. Exactly one row. +CREATE TABLE sync_sequence ( + id INTEGER PRIMARY KEY CHECK (id = 1), + value INTEGER NOT NULL +) STRICT; + +CREATE TABLE users ( + id TEXT PRIMARY KEY NOT NULL, + role TEXT NOT NULL CHECK (role IN ('admin', 'member')), + display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 40), + picture_version INTEGER CHECK (picture_version IS NULL OR picture_version >= 1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + server_version INTEGER NOT NULL UNIQUE +) STRICT; +CREATE UNIQUE INDEX users_single_admin ON users (role) WHERE role = 'admin'; + +CREATE TABLE devices ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES users (id), + name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 60), + token_hash TEXT NOT NULL UNIQUE, + fcm_token TEXT, + app_version_code INTEGER, + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + revoked_at TEXT, + revoked_by_user_id TEXT REFERENCES users (id) +) STRICT; +CREATE INDEX devices_user ON devices (user_id); + +CREATE TABLE invites ( + id TEXT PRIMARY KEY NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('admin_setup', 'partner', 'device')), + code_hash TEXT NOT NULL UNIQUE, + target_user_id TEXT REFERENCES users (id), + created_by_user_id TEXT REFERENCES users (id), + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + accepted_at TEXT, + accepted_device_id TEXT REFERENCES devices (id), + cancelled_at TEXT, + CHECK ((kind = 'device') = (target_user_id IS NOT NULL)), + CHECK ((kind = 'admin_setup') = (created_by_user_id IS NULL) OR kind = 'device') +) STRICT; + +CREATE TABLE accounts ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 60), + name_key TEXT NOT NULL, + created_by_user_id TEXT NOT NULL REFERENCES users (id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + archived_at TEXT, + deleted_at TEXT, + server_version INTEGER NOT NULL UNIQUE +) STRICT; +-- name_key = Unicode-lowercased, trimmed name, computed in Rust. +CREATE UNIQUE INDEX accounts_name_live ON accounts (name_key) WHERE deleted_at IS NULL; + +CREATE TABLE account_default_shares ( + account_id TEXT NOT NULL REFERENCES accounts (id), + user_id TEXT NOT NULL REFERENCES users (id), + share_bp INTEGER NOT NULL CHECK (share_bp BETWEEN 0 AND 10000), + PRIMARY KEY (account_id, user_id) +) STRICT, WITHOUT ROWID; + +CREATE TABLE categories ( + id TEXT PRIMARY KEY NOT NULL, + default_key TEXT UNIQUE CHECK (default_key IS NULL OR default_key IN + ('groceries', 'restaurants', 'home', 'utilities', 'transport', + 'travel', 'entertainment', 'health', 'shopping', 'other')), + name TEXT CHECK (name IS NULL OR length(name) BETWEEN 1 AND 40), + icon TEXT NOT NULL, + color TEXT NOT NULL, + sort_order INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + archived_at TEXT, + deleted_at TEXT, + server_version INTEGER NOT NULL UNIQUE, + CHECK (name IS NOT NULL OR default_key IS NOT NULL) +) STRICT; + +CREATE TABLE recurring_templates ( + id TEXT PRIMARY KEY NOT NULL, + account_id TEXT NOT NULL REFERENCES accounts (id), + category_id TEXT NOT NULL REFERENCES categories (id), + amount_ore INTEGER NOT NULL CHECK (amount_ore BETWEEN 1 AND 1000000000), + description TEXT NOT NULL CHECK (length(description) BETWEEN 1 AND 100), + notes TEXT NOT NULL DEFAULT '' CHECK (length(notes) <= 1000), + payer_user_id TEXT NOT NULL REFERENCES users (id), + split_mode TEXT NOT NULL CHECK (split_mode IN ('default', 'ratio', 'exact', 'full')), + day_of_month INTEGER NOT NULL CHECK (day_of_month BETWEEN 1 AND 31), + start_period TEXT NOT NULL, + end_period TEXT, + paused INTEGER NOT NULL DEFAULT 0 CHECK (paused IN (0, 1)), + last_generated_period TEXT, + created_by_user_id TEXT NOT NULL REFERENCES users (id), + updated_by_user_id TEXT NOT NULL REFERENCES users (id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + server_version INTEGER NOT NULL UNIQUE +) STRICT; +CREATE INDEX recurring_templates_account ON recurring_templates (account_id); + +-- Modes ratio and full: share_bp set, share_ore NULL. Mode exact: share_ore set, share_bp NULL. Mode default: no rows. +CREATE TABLE recurring_template_splits ( + template_id TEXT NOT NULL REFERENCES recurring_templates (id), + user_id TEXT NOT NULL REFERENCES users (id), + share_bp INTEGER CHECK (share_bp IS NULL OR share_bp BETWEEN 0 AND 10000), + share_ore INTEGER CHECK (share_ore IS NULL OR share_ore >= 0), + PRIMARY KEY (template_id, user_id), + CHECK ((share_bp IS NULL) <> (share_ore IS NULL)) +) STRICT, WITHOUT ROWID; + +CREATE TABLE expenses ( + id TEXT PRIMARY KEY NOT NULL, + account_id TEXT NOT NULL REFERENCES accounts (id), + category_id TEXT NOT NULL REFERENCES categories (id), + amount_ore INTEGER NOT NULL CHECK (amount_ore BETWEEN 1 AND 1000000000), + description TEXT NOT NULL CHECK (length(description) BETWEEN 1 AND 100), + notes TEXT NOT NULL DEFAULT '' CHECK (length(notes) <= 1000), + expense_date TEXT NOT NULL, + payer_user_id TEXT NOT NULL REFERENCES users (id), + split_mode TEXT NOT NULL CHECK (split_mode IN ('default', 'ratio', 'exact', 'full')), + recurring_template_id TEXT REFERENCES recurring_templates (id), + recurring_period TEXT, + import_key TEXT UNIQUE, + created_by_user_id TEXT NOT NULL REFERENCES users (id), + updated_by_user_id TEXT NOT NULL REFERENCES users (id), + updated_by_device_id TEXT REFERENCES devices (id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + server_received_at TEXT NOT NULL, + server_version INTEGER NOT NULL UNIQUE, + CHECK ((recurring_template_id IS NULL) = (recurring_period IS NULL)) +) STRICT; +CREATE UNIQUE INDEX expenses_recurring_once ON expenses (recurring_template_id, recurring_period) + WHERE recurring_template_id IS NOT NULL; +CREATE INDEX expenses_account ON expenses (account_id); +CREATE INDEX expenses_category ON expenses (category_id); + +-- Exactly two rows per expense, one per household user. share_bp NULL for mode exact. +CREATE TABLE expense_splits ( + expense_id TEXT NOT NULL REFERENCES expenses (id), + user_id TEXT NOT NULL REFERENCES users (id), + share_ore INTEGER NOT NULL CHECK (share_ore >= 0), + share_bp INTEGER CHECK (share_bp IS NULL OR share_bp BETWEEN 0 AND 10000), + PRIMARY KEY (expense_id, user_id) +) STRICT, WITHOUT ROWID; + +CREATE TABLE settlements ( + id TEXT PRIMARY KEY NOT NULL, + from_user_id TEXT NOT NULL REFERENCES users (id), + to_user_id TEXT NOT NULL REFERENCES users (id), + amount_ore INTEGER NOT NULL CHECK (amount_ore BETWEEN 1 AND 1000000000), + settlement_date TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '' CHECK (length(note) <= 200), + import_key TEXT UNIQUE, + created_by_user_id TEXT NOT NULL REFERENCES users (id), + updated_by_user_id TEXT NOT NULL REFERENCES users (id), + updated_by_device_id TEXT REFERENCES devices (id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + server_received_at TEXT NOT NULL, + server_version INTEGER NOT NULL UNIQUE, + CHECK (from_user_id <> to_user_id) +) STRICT; + +CREATE TABLE settlement_allocations ( + settlement_id TEXT NOT NULL REFERENCES settlements (id), + account_id TEXT NOT NULL REFERENCES accounts (id), + amount_ore INTEGER NOT NULL CHECK (amount_ore >= 1), + PRIMARY KEY (settlement_id, account_id) +) STRICT, WITHOUT ROWID; +CREATE INDEX settlement_allocations_account ON settlement_allocations (account_id); + +-- Append-only. changes is a JSON array of {"field","old","new"} (SPEC §14.4.7). +CREATE TABLE audit_log ( + id TEXT PRIMARY KEY NOT NULL, + entity_type TEXT NOT NULL CHECK (entity_type IN ('expense', 'settlement')), + entity_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('create', 'update', 'delete')), + source TEXT NOT NULL CHECK (source IN ('app', 'recurring', 'import')), + actor_user_id TEXT NOT NULL REFERENCES users (id), + actor_device_id TEXT REFERENCES devices (id), + client_at TEXT NOT NULL, + server_at TEXT NOT NULL, + changes TEXT NOT NULL, + server_version INTEGER NOT NULL UNIQUE +) STRICT; +CREATE INDEX audit_log_entity ON audit_log (entity_type, entity_id); + +-- Idempotency of POST /sync. Rows older than 90 days are pruned daily. +CREATE TABLE sync_mutations ( + mutation_id TEXT PRIMARY KEY NOT NULL, + device_id TEXT NOT NULL REFERENCES devices (id), + entity_type TEXT NOT NULL CHECK (entity_type IN ('expense', 'settlement')), + entity_id TEXT NOT NULL, + result TEXT NOT NULL, + received_at TEXT NOT NULL +) STRICT; +CREATE INDEX sync_mutations_received ON sync_mutations (received_at); + +INSERT INTO sync_sequence (id, value) VALUES (1, 10); +INSERT INTO meta (key, value) VALUES ('schema_version', '1'); +INSERT INTO meta (key, value) VALUES ('sync_epoch', lower(hex(randomblob(16)))); + +INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version) VALUES + ('01920000-0000-7000-8000-000000000001', 'groceries', NULL, 'shopping_cart', 'green', 10, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 1), + ('01920000-0000-7000-8000-000000000002', 'restaurants', NULL, 'restaurant', 'orange', 20, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 2), + ('01920000-0000-7000-8000-000000000003', 'home', NULL, 'home', 'brown', 30, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 3), + ('01920000-0000-7000-8000-000000000004', 'utilities', NULL, 'bolt', 'yellow', 40, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 4), + ('01920000-0000-7000-8000-000000000005', 'transport', NULL, 'directions_car', 'blue', 50, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 5), + ('01920000-0000-7000-8000-000000000006', 'travel', NULL, 'flight', 'teal', 60, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 6), + ('01920000-0000-7000-8000-000000000007', 'entertainment', NULL, 'movie', 'purple', 70, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 7), + ('01920000-0000-7000-8000-000000000008', 'health', NULL, 'medical_services', 'red', 80, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 8), + ('01920000-0000-7000-8000-000000000009', 'shopping', NULL, 'shopping_bag', 'pink', 90, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 9), + ('01920000-0000-7000-8000-00000000000a', 'other', NULL, 'category', 'grey', 100, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 10); diff --git a/server/crates/kvitt-server/src/api/accounts.rs b/server/crates/kvitt-server/src/api/accounts.rs new file mode 100644 index 0000000..17b48f0 --- /dev/null +++ b/server/crates/kvitt-server/src/api/accounts.rs @@ -0,0 +1,103 @@ +//! Accounts: E-18 to E-22 (FR-6). +//! +//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501 +//! `not_implemented`). + +use crate::api::{IdPath, not_implemented}; +use crate::auth::AuthDevice; +use crate::error::AppError; +use crate::openapi::responses::{BadRequest, Conflict, NotFound, Unprocessable}; +use crate::wire::{Account, CreateAccountRequest, PatchAccountRequest, UuidSchema}; + +#[utoipa::path( + post, + path = "/accounts", + operation_id = "createAccount", + summary = "E-18 Create account", + request_body(content = CreateAccountRequest, content_type = "application/json; charset=utf-8"), + responses( + (status = 201, description = "The new account.", body = Account, content_type = "application/json; charset=utf-8"), + (status = 400, response = BadRequest), + (status = 409, response = Conflict), + (status = 422, response = Unprocessable), + ) +)] +pub async fn create_account(_device: AuthDevice) -> AppError { + not_implemented() +} + +#[utoipa::path( + patch, + path = "/accounts/{account_id}", + operation_id = "patchAccount", + summary = "E-19 Rename, change default ratio", + params( + ("account_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."), + ), + request_body(content = PatchAccountRequest, content_type = "application/json; charset=utf-8"), + responses( + (status = 200, description = "The updated account.", body = Account, content_type = "application/json; charset=utf-8"), + (status = 400, response = BadRequest), + (status = 404, response = NotFound), + (status = 409, response = Conflict), + (status = 422, response = Unprocessable), + ) +)] +pub async fn patch_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> AppError { + not_implemented() +} + +#[utoipa::path( + post, + path = "/accounts/{account_id}/archive", + operation_id = "archiveAccount", + summary = "E-20 Archive account", + description = "No body. Idempotent.", + params( + ("account_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."), + ), + responses( + (status = 200, description = "The account.", body = Account, content_type = "application/json; charset=utf-8"), + (status = 404, response = NotFound), + ) +)] +pub async fn archive_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> AppError { + not_implemented() +} + +#[utoipa::path( + post, + path = "/accounts/{account_id}/unarchive", + operation_id = "unarchiveAccount", + summary = "E-21 Unarchive account", + description = "No body. Idempotent.", + params( + ("account_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."), + ), + responses( + (status = 200, description = "The account.", body = Account, content_type = "application/json; charset=utf-8"), + (status = 404, response = NotFound), + ) +)] +pub async fn unarchive_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> AppError { + not_implemented() +} + +#[utoipa::path( + delete, + path = "/accounts/{account_id}", + operation_id = "deleteAccount", + summary = "E-22 Soft delete when empty", + description = "Refused while a non-deleted expense, an allocation of a non-deleted settlement or a non-deleted recurring template references the account (AC-6.5).", + params( + ("account_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."), + ), + responses( + (status = 200, description = "The tombstone.", body = Account, content_type = "application/json; charset=utf-8"), + (status = 404, response = NotFound), + (status = 409, response = Conflict), + ) +)] +pub async fn delete_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> AppError { + not_implemented() +} diff --git a/server/crates/kvitt-server/src/api/apk.rs b/server/crates/kvitt-server/src/api/apk.rs new file mode 100644 index 0000000..a7e672e --- /dev/null +++ b/server/crates/kvitt-server/src/api/apk.rs @@ -0,0 +1,42 @@ +//! E-5 latest APK metadata and E-6 APK download (SPEC §10.6). +//! +//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501 +//! `not_implemented`). + +use crate::api::not_implemented; +use crate::error::AppError; +use crate::openapi::responses::NotFound; +use crate::wire::{ApkBytes, ApkLatest, IntSchema}; + +#[utoipa::path( + get, + path = "/apk/latest", + operation_id = "getApkLatest", + summary = "E-5 Latest APK metadata", + security(()), + responses( + (status = 200, description = "The content of `latest.json` (SPEC §10.6), read on every request.", body = ApkLatest, content_type = "application/json; charset=utf-8"), + (status = 404, response = NotFound), + ) +)] +pub async fn get_apk_latest() -> AppError { + not_implemented() +} + +#[utoipa::path( + get, + path = "/apk/files/{file_name}", + operation_id = "getApkFile", + summary = "E-6 APK download", + security(()), + params( + ("file_name" = String, Path, description = "Served only when it matches `^kvitt-[0-9]+\\.apk$` and exists (SPEC §10.6)."), + ), + responses( + (status = 200, description = "The APK bytes.", body = ApkBytes, content_type = "application/vnd.android.package-archive", headers(("Content-Length" = IntSchema))), + (status = 404, response = NotFound), + ) +)] +pub async fn get_apk_file() -> AppError { + not_implemented() +} diff --git a/server/crates/kvitt-server/src/api/categories.rs b/server/crates/kvitt-server/src/api/categories.rs new file mode 100644 index 0000000..bbbdad8 --- /dev/null +++ b/server/crates/kvitt-server/src/api/categories.rs @@ -0,0 +1,103 @@ +//! Categories: E-23 to E-27 (FR-7). +//! +//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501 +//! `not_implemented`). + +use crate::api::{IdPath, not_implemented}; +use crate::auth::AuthDevice; +use crate::error::AppError; +use crate::openapi::responses::{BadRequest, Conflict, NotFound, Unprocessable}; +use crate::wire::{Category, CreateCategoryRequest, PatchCategoryRequest, UuidSchema}; + +#[utoipa::path( + post, + path = "/categories", + operation_id = "createCategory", + summary = "E-23 Create category", + description = "New categories get `sort_order` = current maximum + 10 (AC-7.3). `icon` and `color` are tokens of SPEC §9.7.", + request_body(content = CreateCategoryRequest, content_type = "application/json; charset=utf-8"), + responses( + (status = 201, description = "The new category.", body = Category, content_type = "application/json; charset=utf-8"), + (status = 400, response = BadRequest), + (status = 422, response = Unprocessable), + ) +)] +pub async fn create_category(_device: AuthDevice) -> AppError { + not_implemented() +} + +#[utoipa::path( + patch, + path = "/categories/{category_id}", + operation_id = "patchCategory", + summary = "E-24 Rename, icon, colour, order, reset name", + description = "`name: null` resets a default category's name. Reordering several categories is one PATCH per category whose `sort_order` changes.", + params( + ("category_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."), + ), + request_body(content = PatchCategoryRequest, content_type = "application/json; charset=utf-8"), + responses( + (status = 200, description = "The updated category.", body = Category, content_type = "application/json; charset=utf-8"), + (status = 400, response = BadRequest), + (status = 404, response = NotFound), + (status = 409, response = Conflict), + (status = 422, response = Unprocessable), + ) +)] +pub async fn patch_category(_device: AuthDevice, IdPath(_category_id): IdPath) -> AppError { + not_implemented() +} + +#[utoipa::path( + post, + path = "/categories/{category_id}/archive", + operation_id = "archiveCategory", + summary = "E-25 Archive category", + description = "No body.", + params( + ("category_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."), + ), + responses( + (status = 200, description = "The category.", body = Category, content_type = "application/json; charset=utf-8"), + (status = 404, response = NotFound), + ) +)] +pub async fn archive_category(_device: AuthDevice, IdPath(_category_id): IdPath) -> AppError { + not_implemented() +} + +#[utoipa::path( + post, + path = "/categories/{category_id}/unarchive", + operation_id = "unarchiveCategory", + summary = "E-26 Unarchive category", + description = "No body.", + params( + ("category_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."), + ), + responses( + (status = 200, description = "The category.", body = Category, content_type = "application/json; charset=utf-8"), + (status = 404, response = NotFound), + ) +)] +pub async fn unarchive_category(_device: AuthDevice, IdPath(_category_id): IdPath) -> AppError { + not_implemented() +} + +#[utoipa::path( + delete, + path = "/categories/{category_id}", + operation_id = "deleteCategory", + summary = "E-27 Soft delete when unused and not default", + params( + ("category_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."), + ), + responses( + (status = 200, description = "The tombstone.", body = Category, content_type = "application/json; charset=utf-8"), + (status = 404, response = NotFound), + (status = 409, response = Conflict), + ) +)] +pub async fn delete_category(_device: AuthDevice, IdPath(_category_id): IdPath) -> AppError { + not_implemented() +} diff --git a/server/crates/kvitt-server/src/api/devices.rs b/server/crates/kvitt-server/src/api/devices.rs new file mode 100644 index 0000000..0db3486 --- /dev/null +++ b/server/crates/kvitt-server/src/api/devices.rs @@ -0,0 +1,58 @@ +//! Devices: E-15 list, E-16 revoke, E-17 push token (FR-3, SPEC §8.4). +//! +//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501 +//! `not_implemented`). + +use crate::api::{IdPath, not_implemented}; +use crate::auth::AuthDevice; +use crate::error::AppError; +use crate::openapi::responses::{BadRequest, Conflict, Forbidden, NotFound}; +use crate::wire::{DeviceList, PushTokenRequest, UuidSchema}; + +#[utoipa::path( + get, + path = "/devices", + operation_id = "listDevices", + summary = "E-15 List devices (admin all, member own)", + responses( + (status = 200, description = "Ordered by `created_at`, revoked devices included (with `revoked_at`).", body = DeviceList, content_type = "application/json; charset=utf-8"), + ) +)] +pub async fn list_devices(_device: AuthDevice) -> AppError { + not_implemented() +} + +#[utoipa::path( + delete, + path = "/devices/{device_id}", + operation_id = "revokeDevice", + summary = "E-16 Revoke a device", + description = "Revoking an already revoked device is 204. Admin any device except the current one; member own devices except the current one (SPEC §8.4).", + params( + ("device_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."), + ), + responses( + (status = 204, description = "Revoked."), + (status = 403, response = Forbidden), + (status = 404, response = NotFound), + (status = 409, response = Conflict), + ) +)] +pub async fn revoke_device(_device: AuthDevice, IdPath(_device_id): IdPath) -> AppError { + not_implemented() +} + +#[utoipa::path( + put, + path = "/devices/current/push-token", + operation_id = "putPushToken", + summary = "E-17 Set or clear this device's FCM token", + request_body(content = PushTokenRequest, content_type = "application/json; charset=utf-8"), + responses( + (status = 204, description = "Stored (or cleared)."), + (status = 400, response = BadRequest), + ) +)] +pub async fn put_push_token(_device: AuthDevice) -> AppError { + not_implemented() +} diff --git a/server/crates/kvitt-server/src/api/health.rs b/server/crates/kvitt-server/src/api/health.rs new file mode 100644 index 0000000..b20ce80 --- /dev/null +++ b/server/crates/kvitt-server/src/api/health.rs @@ -0,0 +1,60 @@ +//! E-1 `GET /health` (FR-20, SPEC §14.3.4). + +use std::time::Duration; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; + +use crate::api::JsonBody; +use crate::wire::{Health, HealthStatus}; +use crate::{AppState, SERVER_VERSION, db}; + +/// The database check must answer well within the 100 ms budget of AC-20.1; beyond this it counts as failed. +const DB_CHECK_TIMEOUT: Duration = Duration::from_millis(80); + +#[utoipa::path( + get, + path = "/health", + operation_id = "getHealth", + summary = "E-1 Liveness and DB check", + security(()), + responses( + (status = 200, description = "Server and database are up.", body = Health, + content_type = "application/json; charset=utf-8"), + (status = 503, description = "The database check (`SELECT 1`) failed: the `Health` shape with `status` \ + `degraded` and `db_ok` false. An `Error` with code `unavailable` when the server is shutting down.", + body = HealthOrError, content_type = "application/json; charset=utf-8"), + ) +)] +pub async fn get_health(State(state): State) -> Response { + let db_ok = matches!( + tokio::time::timeout(DB_CHECK_TIMEOUT, db::ping(&state.db)).await, + Ok(Ok(())) + ); + let body = Health { + status: if db_ok { + HealthStatus::Ok + } else { + HealthStatus::Degraded + }, + server_version: SERVER_VERSION.to_owned(), + schema_version: db::SCHEMA_VERSION, + db_ok, + }; + let status = if db_ok { + StatusCode::OK + } else { + tracing::warn!(error_code = "unavailable", "health: database check failed"); + StatusCode::SERVICE_UNAVAILABLE + }; + (status, JsonBody(body)).into_response() +} + +/// The 503 body of E-1: the health object, or an `Error` from a server that is shutting down (SPEC §14.3.4 E-1). +#[derive(serde::Serialize, utoipa::ToSchema)] +#[serde(untagged)] +pub enum HealthOrError { + Health(Health), + Error(crate::error::ErrorBody), +} diff --git a/server/crates/kvitt-server/src/api/invites.rs b/server/crates/kvitt-server/src/api/invites.rs new file mode 100644 index 0000000..6c3ca04 --- /dev/null +++ b/server/crates/kvitt-server/src/api/invites.rs @@ -0,0 +1,82 @@ +//! Invites: E-3 accept (all three kinds), E-12 create, E-13 list, E-14 cancel (SPEC §8.3, FR-2). +//! +//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501 +//! `not_implemented`). + +use crate::api::{IdPath, not_implemented}; +use crate::auth::AdminDevice; +use crate::error::AppError; +use crate::openapi::responses::{BadRequest, Conflict, Forbidden, NotFound, Unprocessable}; +use crate::wire::{ + AcceptInviteRequest, AcceptInviteResponse, CreateInviteRequest, Invite, InviteList, UuidSchema, +}; + +#[utoipa::path( + post, + path = "/invites/accept", + operation_id = "acceptInvite", + summary = "E-3 Pair a device with an invite code (all three invite kinds)", + security(()), + request_body(content = AcceptInviteRequest, content_type = "application/json; charset=utf-8"), + responses( + (status = 200, description = "Paired. The token is shown only here.", body = AcceptInviteResponse, content_type = "application/json; charset=utf-8"), + (status = 400, response = BadRequest), + (status = 409, response = Conflict), + (status = 422, response = Unprocessable), + ) +)] +pub async fn accept_invite() -> AppError { + not_implemented() +} + +#[utoipa::path( + post, + path = "/invites", + operation_id = "createInvite", + summary = "E-12 Create a partner or device invite (admin)", + description = "Creating a `partner` invite cancels any pending one (AC-2.1).", + request_body(content = CreateInviteRequest, content_type = "application/json; charset=utf-8"), + responses( + (status = 201, description = "The invite with `code` (12 characters, no hyphens) and `link` (`/i/`).", body = Invite, content_type = "application/json; charset=utf-8"), + (status = 400, response = BadRequest), + (status = 403, response = Forbidden), + (status = 404, response = NotFound), + (status = 409, response = Conflict), + ) +)] +pub async fn create_invite(_admin: AdminDevice) -> AppError { + not_implemented() +} + +#[utoipa::path( + get, + path = "/invites", + operation_id = "listInvites", + summary = "E-13 List pending invites (admin)", + responses( + (status = 200, description = "Pending invites only (not accepted, not cancelled, not expired), `code` and `link` null.", body = InviteList, content_type = "application/json; charset=utf-8"), + (status = 403, response = Forbidden), + ) +)] +pub async fn list_invites(_admin: AdminDevice) -> AppError { + not_implemented() +} + +#[utoipa::path( + delete, + path = "/invites/{invite_id}", + operation_id = "cancelInvite", + summary = "E-14 Cancel an invite (admin)", + description = "Idempotent for an already cancelled invite.", + params( + ("invite_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."), + ), + responses( + (status = 204, description = "Cancelled."), + (status = 403, response = Forbidden), + (status = 404, response = NotFound), + ) +)] +pub async fn cancel_invite(_admin: AdminDevice, IdPath(_invite_id): IdPath) -> AppError { + not_implemented() +} diff --git a/server/crates/kvitt-server/src/api/landing.rs b/server/crates/kvitt-server/src/api/landing.rs new file mode 100644 index 0000000..7995c3c --- /dev/null +++ b/server/crates/kvitt-server/src/api/landing.rs @@ -0,0 +1,26 @@ +//! E-4 invite landing page (AC-2.5). +//! +//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501 +//! `not_implemented`). + +use crate::api::not_implemented; +use crate::error::AppError; + +#[utoipa::path( + get, + path = "/i/{code}", + operation_id = "getInviteLanding", + summary = "E-4 Invite landing page (HTML)", + description = "HTML per AC-2.5 for any `code` of 1 to 32 characters; validity is not checked. Swedish when `Accept-Language` prefers `sv`, English otherwise.", + security(()), + params( + ("code" = String, Path), + ), + responses( + (status = 200, description = "Landing page.", body = String, content_type = "text/html; charset=utf-8"), + (status = 404, description = "`code` not 1 to 32 characters: an HTML 404 page.", body = String, content_type = "text/html; charset=utf-8"), + ) +)] +pub async fn get_invite_landing() -> AppError { + not_implemented() +} diff --git a/server/crates/kvitt-server/src/api/me.rs b/server/crates/kvitt-server/src/api/me.rs new file mode 100644 index 0000000..134cfb2 --- /dev/null +++ b/server/crates/kvitt-server/src/api/me.rs @@ -0,0 +1,39 @@ +//! E-7 `GET /me` and E-8 `PATCH /me` (FR-4). +//! +//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501 +//! `not_implemented`). + +use crate::api::not_implemented; +use crate::auth::AuthDevice; +use crate::error::AppError; +use crate::openapi::responses::{BadRequest, Unprocessable}; +use crate::wire::{MeResponse, PatchMeRequest, User}; + +#[utoipa::path( + get, + path = "/me", + operation_id = "getMe", + summary = "E-7 Current user, device and household users", + responses( + (status = 200, description = "The caller's user and device.", body = MeResponse, content_type = "application/json; charset=utf-8"), + ) +)] +pub async fn get_me(_device: AuthDevice) -> AppError { + not_implemented() +} + +#[utoipa::path( + patch, + path = "/me", + operation_id = "patchMe", + summary = "E-8 Change own display name", + request_body(content = PatchMeRequest, content_type = "application/json; charset=utf-8"), + responses( + (status = 200, description = "The updated user.", body = User, content_type = "application/json; charset=utf-8"), + (status = 400, response = BadRequest), + (status = 422, response = Unprocessable), + ) +)] +pub async fn patch_me(_device: AuthDevice) -> AppError { + not_implemented() +} diff --git a/server/crates/kvitt-server/src/api/mod.rs b/server/crates/kvitt-server/src/api/mod.rs new file mode 100644 index 0000000..eb8a7ca --- /dev/null +++ b/server/crates/kvitt-server/src/api/mod.rs @@ -0,0 +1,116 @@ +//! HTTP handlers, one file per area (SPEC §14.7), and the extractors and response types they share. +//! +//! M2a implements `health` (E-1) and `setup` (E-2); every other handler is registered and annotated but returns 501 +//! `not_implemented` until M3 (E-3 to E-30, E-33) and M4 (E-31, E-32) fill in the bodies. + +pub mod accounts; +pub mod apk; +pub mod categories; +pub mod devices; +pub mod health; +pub mod invites; +pub mod landing; +pub mod me; +pub mod pictures; +pub mod proposal; +pub mod rate_limit; +pub mod recurring; +pub mod setup; +pub mod sync; + +use axum::extract::rejection::{JsonRejection, PathRejection}; +use axum::extract::{FromRequest, FromRequestParts, Request}; +use axum::http::request::Parts; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use uuid::Uuid; + +use crate::error::AppError; + +/// The content type of every JSON body (SPEC §14.3.1). +pub const JSON_CONTENT_TYPE: &str = "application/json; charset=utf-8"; + +/// A JSON response body with `Content-Type: application/json; charset=utf-8`. +#[derive(Debug, Clone)] +pub struct JsonBody(pub T); + +impl IntoResponse for JsonBody { + fn into_response(self) -> Response { + match serde_json::to_vec(&self.0) { + Ok(bytes) => { + let mut response = bytes.into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static(JSON_CONTENT_TYPE), + ); + response + } + Err(e) => { + tracing::error!(detail = %e, "response serialisation failed"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal").into_response() + } + } + } +} + +/// A JSON request body whose rejections map to the error codes of SPEC §14.3.2: malformed JSON, missing fields and +/// wrong types are 400 `invalid_request`; a body over the limit is 413 `payload_too_large`. +#[derive(Debug, Clone)] +pub struct ApiJson(pub T); + +impl FromRequest for ApiJson +where + T: DeserializeOwned, + S: Send + Sync, +{ + type Rejection = AppError; + + async fn from_request(request: Request, state: &S) -> Result { + match axum::Json::::from_request(request, state).await { + Ok(axum::Json(value)) => Ok(Self(value)), + Err(rejection) => Err(json_rejection(rejection)), + } + } +} + +fn json_rejection(rejection: JsonRejection) -> AppError { + if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE { + return AppError::new( + crate::error::ErrorCode::PayloadTooLarge, + "request body too large", + ); + } + match rejection { + JsonRejection::MissingJsonContentType(_) => { + AppError::invalid_request("Content-Type must be application/json") + } + other => AppError::invalid_request(other.body_text()), + } +} + +/// One UUID path segment. Anything that is not a UUID is 404 `not_found` (SPEC §14.3.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IdPath(pub Uuid); + +impl FromRequestParts for IdPath { + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let axum::extract::Path(text): axum::extract::Path = + axum::extract::Path::from_request_parts(parts, state) + .await + .map_err(|_: PathRejection| AppError::not_found())?; + Uuid::parse_str(&text) + .ok() + .filter(|id| id.hyphenated().to_string() == text) + .map(Self) + .ok_or_else(AppError::not_found) + } +} + +/// The body of every handler that is registered but not yet implemented (501 `not_implemented`). +pub fn not_implemented() -> AppError { + AppError::not_implemented() +} diff --git a/server/crates/kvitt-server/src/api/pictures.rs b/server/crates/kvitt-server/src/api/pictures.rs new file mode 100644 index 0000000..6372fd7 --- /dev/null +++ b/server/crates/kvitt-server/src/api/pictures.rs @@ -0,0 +1,64 @@ +//! Pictures: E-9 upload, E-10 remove, E-11 download (AC-4.4). +//! +//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501 +//! `not_implemented`). + +use crate::api::{IdPath, not_implemented}; +use crate::auth::AuthDevice; +use crate::error::AppError; +use crate::openapi::responses::{ + BadRequest, NotFound, PayloadTooLarge, Unprocessable, UnsupportedMediaType, +}; +use crate::wire::{IntSchema, JpegBytes, PictureSizeSchema, PngBytes, User, UuidSchema, WebpBytes}; + +#[utoipa::path( + put, + path = "/me/picture", + operation_id = "putMePicture", + summary = "E-9 Upload own picture", + description = "JPEG, PNG or WebP up to 5 MiB and 8000 × 8000 px; processed per AC-4.4.", + request_body(content((JpegBytes = "image/jpeg"), (PngBytes = "image/png"), (WebpBytes = "image/webp"))), + responses( + (status = 200, description = "The user with the new `picture_version`.", body = User, content_type = "application/json; charset=utf-8"), + (status = 413, response = PayloadTooLarge), + (status = 415, response = UnsupportedMediaType), + (status = 422, response = Unprocessable), + ) +)] +pub async fn put_me_picture(_device: AuthDevice) -> AppError { + not_implemented() +} + +#[utoipa::path( + delete, + path = "/me/picture", + operation_id = "deleteMePicture", + summary = "E-10 Remove own picture", + description = "Idempotent.", + responses( + (status = 200, description = "The user with `picture_version` null.", body = User, content_type = "application/json; charset=utf-8"), + ) +)] +pub async fn delete_me_picture(_device: AuthDevice) -> AppError { + not_implemented() +} + +#[utoipa::path( + get, + path = "/users/{user_id}/picture", + operation_id = "getUserPicture", + summary = "E-11 Download a user's picture", + params( + ("user_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."), + ("size" = Option, Query, description = "Other values are `invalid_request`."), + ("v" = Option, Query, description = "The `picture_version`; a cache key only, ignored by the server."), + ), + responses( + (status = 200, description = "The square JPEG.", body = JpegBytes, content_type = "image/jpeg", headers(("ETag" = String, description = "`\"\"`"), ("Cache-Control" = String, description = "`private, max-age=31536000, immutable`"))), + (status = 400, response = BadRequest), + (status = 404, response = NotFound), + ) +)] +pub async fn get_user_picture(_device: AuthDevice, IdPath(_user_id): IdPath) -> AppError { + not_implemented() +} diff --git a/server/crates/kvitt-server/src/api/proposal.rs b/server/crates/kvitt-server/src/api/proposal.rs new file mode 100644 index 0000000..354cd8f --- /dev/null +++ b/server/crates/kvitt-server/src/api/proposal.rs @@ -0,0 +1,28 @@ +//! E-33 settlement allocation proposal (SPEC §9.4). +//! +//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501 +//! `not_implemented`). + +use crate::api::not_implemented; +use crate::auth::AuthDevice; +use crate::error::AppError; +use crate::openapi::responses::{BadRequest, Conflict, Unprocessable}; +use crate::wire::{ProposalRequest, ProposalResponse}; + +#[utoipa::path( + post, + path = "/settlements/proposal", + operation_id = "proposeSettlement", + summary = "E-33 Server-side allocation proposal", + description = "Computed with `kvitt-domain::propose_allocation` (SPEC §9.4) over the server's non-deleted rows.", + request_body(content = ProposalRequest, content_type = "application/json; charset=utf-8"), + responses( + (status = 200, description = "The proposal.", body = ProposalResponse, content_type = "application/json; charset=utf-8"), + (status = 400, response = BadRequest), + (status = 409, response = Conflict), + (status = 422, response = Unprocessable), + ) +)] +pub async fn propose_settlement(_device: AuthDevice) -> AppError { + not_implemented() +} diff --git a/server/crates/kvitt-server/src/api/rate_limit.rs b/server/crates/kvitt-server/src/api/rate_limit.rs new file mode 100644 index 0000000..a9823ce --- /dev/null +++ b/server/crates/kvitt-server/src/api/rate_limit.rs @@ -0,0 +1,8 @@ +//! Rate limiting (SPEC §8.6). M3 owns this file; in M2a the layer lets every request through. + +use tower::layer::util::Identity; + +/// The rate-limit layer `router.rs` attaches to every route. +pub fn layer() -> Identity { + Identity::new() +} diff --git a/server/crates/kvitt-server/src/api/recurring.rs b/server/crates/kvitt-server/src/api/recurring.rs new file mode 100644 index 0000000..af83101 --- /dev/null +++ b/server/crates/kvitt-server/src/api/recurring.rs @@ -0,0 +1,74 @@ +//! Recurring templates: E-28 to E-30 (FR-9, SPEC §9.5). +//! +//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501 +//! `not_implemented`). + +use crate::api::{IdPath, not_implemented}; +use crate::auth::AuthDevice; +use crate::error::AppError; +use crate::openapi::responses::{BadRequest, Conflict, NotFound, Unprocessable}; +use crate::wire::{ + CreateRecurringTemplateRequest, PatchRecurringTemplateRequest, RecurringTemplate, UuidSchema, +}; + +#[utoipa::path( + post, + path = "/recurring-templates", + operation_id = "createRecurringTemplate", + summary = "E-28 Create recurring template", + description = "If a period is already due (SPEC §9.5), the server generates it before responding.", + request_body(content = CreateRecurringTemplateRequest, content_type = "application/json; charset=utf-8"), + responses( + (status = 201, description = "The new template.", body = RecurringTemplate, content_type = "application/json; charset=utf-8"), + (status = 400, response = BadRequest), + (status = 409, response = Conflict), + (status = 422, response = Unprocessable), + ) +)] +pub async fn create_recurring_template(_device: AuthDevice) -> AppError { + not_implemented() +} + +#[utoipa::path( + patch, + path = "/recurring-templates/{template_id}", + operation_id = "patchRecurringTemplate", + summary = "E-29 Edit, pause, resume", + description = "Any subset of the E-28 fields; `split_mode` and `splits` must be sent together.", + params( + ("template_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."), + ), + request_body(content = PatchRecurringTemplateRequest, content_type = "application/json; charset=utf-8"), + responses( + (status = 200, description = "The updated template.", body = RecurringTemplate, content_type = "application/json; charset=utf-8"), + (status = 400, response = BadRequest), + (status = 404, response = NotFound), + (status = 422, response = Unprocessable), + ) +)] +pub async fn patch_recurring_template( + _device: AuthDevice, + IdPath(_template_id): IdPath, +) -> AppError { + not_implemented() +} + +#[utoipa::path( + delete, + path = "/recurring-templates/{template_id}", + operation_id = "deleteRecurringTemplate", + summary = "E-30 Soft delete recurring template", + params( + ("template_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."), + ), + responses( + (status = 200, description = "The tombstone.", body = RecurringTemplate, content_type = "application/json; charset=utf-8"), + (status = 404, response = NotFound), + ) +)] +pub async fn delete_recurring_template( + _device: AuthDevice, + IdPath(_template_id): IdPath, +) -> AppError { + not_implemented() +} diff --git a/server/crates/kvitt-server/src/api/setup.rs b/server/crates/kvitt-server/src/api/setup.rs new file mode 100644 index 0000000..478a178 --- /dev/null +++ b/server/crates/kvitt-server/src/api/setup.rs @@ -0,0 +1,30 @@ +//! E-2 `GET /setup/status` (AC-1.5): whether the household has been bootstrapped, that is whether the +//! `admin_setup` invite has been accepted and the admin user exists. + +use axum::extract::State; + +use crate::AppState; +use crate::api::JsonBody; +use crate::error::AppError; +use crate::wire::SetupStatus; + +#[utoipa::path( + get, + path = "/setup/status", + operation_id = "getSetupStatus", + summary = "E-2 Whether the household has been bootstrapped", + security(()), + responses( + (status = 200, description = "`initialized` is true once the admin_setup invite has been accepted (AC-1.5).", + body = SetupStatus, content_type = "application/json; charset=utf-8"), + ) +)] +pub async fn get_setup_status( + State(state): State, +) -> Result, AppError> { + let initialized: bool = + sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin')") + .fetch_one(&state.db) + .await?; + Ok(JsonBody(SetupStatus { initialized })) +} diff --git a/server/crates/kvitt-server/src/api/sync.rs b/server/crates/kvitt-server/src/api/sync.rs new file mode 100644 index 0000000..2df21a7 --- /dev/null +++ b/server/crates/kvitt-server/src/api/sync.rs @@ -0,0 +1,49 @@ +//! Sync: E-31 pull and E-32 push (SPEC §14.4). +//! +//! Registered and annotated by M2a; M4 owns this file and implements the handlers (until then 501 +//! `not_implemented`). + +use crate::api::not_implemented; +use crate::auth::AuthDevice; +use crate::error::AppError; +use crate::openapi::responses::{BadRequest, Conflict, PayloadTooLarge, Unprocessable}; +use crate::wire::{SyncLimitSchema, SyncPullResponse, SyncPushRequest, SyncPushResponse}; + +#[utoipa::path( + get, + path = "/sync", + operation_id = "syncPull", + summary = "E-31 Pull changes since a cursor", + description = "SPEC §14.4.2.", + params( + ("since" = i64, Query, description = "The highest `server_version` the phone has applied (0 initially)."), + ("limit" = Option, Query, description = "Rows per page, 1 to 1000 (`invalid_request` otherwise)."), + ), + responses( + (status = 200, description = "One page. Every array is present (possibly empty) and sorted by `server_version`.", body = SyncPullResponse, content_type = "application/json; charset=utf-8"), + (status = 400, response = BadRequest), + (status = 409, response = Conflict), + ) +)] +pub async fn sync_pull(_device: AuthDevice) -> AppError { + not_implemented() +} + +#[utoipa::path( + post, + path = "/sync", + operation_id = "syncPush", + summary = "E-32 Push local expense and settlement changes", + description = "SPEC §14.4.3 and §14.4.4. 1 to 100 mutations, body at most 1 MiB. One result per mutation, in request order.", + request_body(content = SyncPushRequest, content_type = "application/json; charset=utf-8"), + responses( + (status = 200, description = "Per-mutation results.", body = SyncPushResponse, content_type = "application/json; charset=utf-8"), + (status = 400, response = BadRequest), + (status = 409, response = Conflict), + (status = 413, response = PayloadTooLarge), + (status = 422, response = Unprocessable), + ) +)] +pub async fn sync_push(_device: AuthDevice) -> AppError { + not_implemented() +} diff --git a/server/crates/kvitt-server/src/audit/mod.rs b/server/crates/kvitt-server/src/audit/mod.rs new file mode 100644 index 0000000..e948a7f --- /dev/null +++ b/server/crates/kvitt-server/src/audit/mod.rs @@ -0,0 +1,63 @@ +//! The audit log seam (SPEC §14.4.7). M2a defines the trait and a no-op; M4 owns `src/audit/**` and replaces the +//! implementation with one that computes the field diff and inserts the `audit_log` row. +//! +//! Callers (sync push in M4, recurring generation in E-28 and the scheduler, the importer in M9) call +//! [`AuditWriter::write`] inside their own `BEGIN IMMEDIATE` transaction, passing the wire object before and after +//! the change; the writer derives `changes` per SPEC §14.4.7 and takes the entry's `server_version` from the same +//! transaction. + +use std::future::Future; +use std::pin::Pin; + +use sqlx::SqliteConnection; +use uuid::Uuid; + +use crate::error::AppError; +use crate::wire::{AuditAction, AuditSource, EntityType}; + +/// A boxed, sendable future (object-safe async trait methods). +pub type BoxFuture<'a, T> = Pin + Send + 'a>>; + +/// One audit event to record. +#[derive(Debug, Clone, PartialEq)] +pub struct AuditEvent { + pub entity_type: EntityType, + pub entity_id: Uuid, + pub action: AuditAction, + pub source: AuditSource, + pub actor_user_id: Uuid, + /// `None` for the scheduler and the importer. + pub actor_device_id: Option, + /// The pushed `updated_at` (for `delete`, the pushed `deleted_at`); server now for scheduler and importer. + pub client_at: String, + /// Server now. + pub server_at: String, + /// The full wire object (`Expense` or `Settlement` as JSON) before the change; `None` for `create`. + pub before: Option, + /// The full wire object after the change. + pub after: serde_json::Value, +} + +/// Writes audit entries. +pub trait AuditWriter: Send + Sync + 'static { + /// Records `event` on `conn`, which is inside the caller's write transaction. Returns the entry's id. + fn write<'a>( + &'a self, + conn: &'a mut SqliteConnection, + event: AuditEvent, + ) -> BoxFuture<'a, Result>; +} + +/// Writes nothing. Used until M4 lands its writer. +#[derive(Debug, Clone, Copy, Default)] +pub struct NoopAuditWriter; + +impl AuditWriter for NoopAuditWriter { + fn write<'a>( + &'a self, + _conn: &'a mut SqliteConnection, + _event: AuditEvent, + ) -> BoxFuture<'a, Result> { + Box::pin(async { Ok(Uuid::now_v7()) }) + } +} diff --git a/server/crates/kvitt-server/src/auth.rs b/server/crates/kvitt-server/src/auth.rs new file mode 100644 index 0000000..510726a --- /dev/null +++ b/server/crates/kvitt-server/src/auth.rs @@ -0,0 +1,226 @@ +//! Authentication and authorization (SPEC §8.2, §8.4, §14.3.1). +//! +//! * [`require_protocol`] is the middleware on every authenticated route: `X-Kvitt-Protocol` missing or not `1` is +//! 426 `protocol_unsupported`. It runs before the token check, so an outdated app always learns it must update. +//! * [`AuthDevice`] is the extractor for authenticated handlers: `Authorization: Bearer `, SHA-256 hex lookup +//! of a device; missing, malformed or unknown is 401 `unauthorized`, a revoked device 401 `device_revoked`. +//! `last_seen_at` and `app_version_code` are refreshed at most once per hour per device. +//! * [`AdminDevice`] additionally requires the `admin` role (403 `forbidden`, SPEC §8.4). + +use std::fmt::Write as _; + +use axum::extract::{FromRequestParts, Request}; +use axum::http::HeaderMap; +use axum::http::request::Parts; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use chrono::Duration; +use sha2::{Digest, Sha256}; +use sqlx::Row; +use uuid::Uuid; + +use crate::AppState; +use crate::error::AppError; +use crate::time::{format_ts, parse_ts}; +use crate::wire::Role; + +/// Header carrying the sync protocol version (SPEC §14.3.1). +pub const PROTOCOL_HEADER: &str = "x-kvitt-protocol"; +/// The only supported protocol version. +pub const PROTOCOL_VERSION: &str = "1"; +/// Header carrying the app's `versionCode`. +pub const APP_VERSION_HEADER: &str = "x-kvitt-app-version"; +/// Prefix of every device token (SPEC §8.2). +pub const TOKEN_PREFIX: &str = "kvt_"; +/// Length of a token: `kvt_` plus 43 base64url characters (32 bytes, no padding). +pub const TOKEN_LEN: usize = 47; + +/// How often `last_seen_at` / `app_version_code` are written at most (SPEC §8.2). +const TOUCH_INTERVAL: Duration = Duration::hours(1); + +/// Lowercase hex SHA-256 of `text` (token and invite code hashes, SPEC §8.2, §8.3). +pub fn sha256_hex(text: &str) -> String { + let digest = Sha256::digest(text.as_bytes()); + let mut hex = String::with_capacity(64); + for byte in digest.iter() { + let _ = write!(hex, "{byte:02x}"); + } + hex +} + +/// Whether `token` has the shape of a device token (prefix, length, base64url alphabet). +pub fn is_token_shaped(token: &str) -> bool { + token.len() == TOKEN_LEN + && token.starts_with(TOKEN_PREFIX) + && token[TOKEN_PREFIX.len()..] + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +/// Middleware: 426 `protocol_unsupported` unless `X-Kvitt-Protocol: 1`. +pub async fn require_protocol(request: Request, next: Next) -> Response { + let ok = request + .headers() + .get(PROTOCOL_HEADER) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.trim() == PROTOCOL_VERSION); + if ok { + next.run(request).await + } else { + AppError::protocol_unsupported().into_response() + } +} + +/// The calling device, authenticated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthDevice { + pub device_id: Uuid, + pub user_id: Uuid, + pub role: Role, +} + +/// The calling device of the admin user. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdminDevice(pub AuthDevice); + +fn bearer_token(headers: &HeaderMap) -> Option<&str> { + let value = headers + .get(axum::http::header::AUTHORIZATION)? + .to_str() + .ok()?; + let (scheme, token) = value.split_once(' ')?; + scheme.eq_ignore_ascii_case("bearer").then(|| token.trim()) +} + +fn app_version_code(headers: &HeaderMap) -> Option { + headers + .get(APP_VERSION_HEADER)? + .to_str() + .ok()? + .trim() + .parse::() + .ok() + .filter(|v| *v >= 0) +} + +impl AuthDevice { + /// Authenticates the request headers against the database. + pub async fn authenticate(headers: &HeaderMap, state: &AppState) -> Result { + let token = bearer_token(headers).ok_or_else(AppError::unauthorized)?; + if !is_token_shaped(token) { + return Err(AppError::unauthorized()); + } + let row = sqlx::query( + "SELECT d.id, d.user_id, d.revoked_at, d.last_seen_at, u.role \ + FROM devices d JOIN users u ON u.id = d.user_id WHERE d.token_hash = ?", + ) + .bind(sha256_hex(token)) + .fetch_optional(&state.db) + .await? + .ok_or_else(AppError::unauthorized)?; + + let revoked_at: Option = row.try_get("revoked_at")?; + if revoked_at.is_some() { + return Err(AppError::device_revoked()); + } + let parse_id = |column: &str| -> Result { + let text: String = row.try_get(column)?; + Uuid::parse_str(&text).map_err(|e| { + AppError::internal(format!("devices/users.{column} is not a uuid: {e}")) + }) + }; + let device_id = parse_id("id")?; + let user_id = parse_id("user_id")?; + let role_text: String = row.try_get("role")?; + let role = Role::from_db(&role_text) + .ok_or_else(|| AppError::internal(format!("unknown role {role_text:?}")))?; + + let last_seen: String = row.try_get("last_seen_at")?; + let now = state.clock.now(); + let due = parse_ts(&last_seen).is_none_or(|seen| now - seen >= TOUCH_INTERVAL); + if due { + let mut tx = crate::db::begin_immediate(&state.db).await?; + sqlx::query( + "UPDATE devices SET last_seen_at = ?, app_version_code = COALESCE(?, app_version_code) \ + WHERE id = ?", + ) + .bind(format_ts(now)) + .bind(app_version_code(headers)) + .bind(device_id.to_string()) + .execute(&mut *tx) + .await?; + tx.commit().await?; + } + + tracing::Span::current().record("device_id", tracing::field::display(device_id)); + Ok(Self { + device_id, + user_id, + role, + }) + } +} + +impl FromRequestParts for AuthDevice { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + if let Some(device) = parts.extensions.get::() { + return Ok(device.clone()); + } + let device = Self::authenticate(&parts.headers, state).await?; + parts.extensions.insert(device.clone()); + Ok(device) + } +} + +impl FromRequestParts for AdminDevice { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let device = AuthDevice::from_request_parts(parts, state).await?; + if device.role == Role::Admin { + Ok(Self(device)) + } else { + Err(AppError::forbidden()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha256_hex_known_value() { + assert_eq!( + sha256_hex("abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn token_shape() { + let good = format!("kvt_{}", "A".repeat(43)); + assert!(is_token_shaped(&good)); + assert!(!is_token_shaped(&format!("kvt_{}", "A".repeat(42)))); + assert!(!is_token_shaped(&format!("xyz_{}", "A".repeat(43)))); + assert!(!is_token_shaped(&format!("kvt_{}=", "A".repeat(42)))); + } + + #[test] + fn bearer_parsing() { + let mut headers = HeaderMap::new(); + assert_eq!(bearer_token(&headers), None); + headers.insert("authorization", "Bearer kvt_x".parse().unwrap()); + assert_eq!(bearer_token(&headers), Some("kvt_x")); + headers.insert("authorization", "Basic abc".parse().unwrap()); + assert_eq!(bearer_token(&headers), None); + } +} diff --git a/server/crates/kvitt-server/src/cli/bootstrap.rs b/server/crates/kvitt-server/src/cli/bootstrap.rs new file mode 100644 index 0000000..595f3b8 --- /dev/null +++ b/server/crates/kvitt-server/src/cli/bootstrap.rs @@ -0,0 +1,11 @@ +//! `kvitt-server bootstrap` (FR-1, AC-1.1 to AC-1.3). M3 owns this file; until then it prints +//! `not implemented in M2a` and exits 2. + +use std::process::ExitCode; + +use crate::config::Config; + +pub async fn run(config: &Config) -> ExitCode { + let _ = config; + super::not_implemented("bootstrap") +} diff --git a/server/crates/kvitt-server/src/cli/healthcheck.rs b/server/crates/kvitt-server/src/cli/healthcheck.rs new file mode 100644 index 0000000..254d753 --- /dev/null +++ b/server/crates/kvitt-server/src/cli/healthcheck.rs @@ -0,0 +1,59 @@ +//! `kvitt-server healthcheck`: `GET /health` on this host, exit 0 if 200, else 1 (SPEC §10.4, AC-20.2). Plain +//! HTTP/1.1 over a TCP stream, so the image needs no HTTP client library or curl. + +use std::net::{Ipv4Addr, SocketAddr}; +use std::process::ExitCode; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +use crate::config::Config; + +const TIMEOUT: Duration = Duration::from_secs(5); + +/// The address the check calls: `127.0.0.1` on the port of `KVITT_BIND` (8080 by default). +pub fn target(config: &Config) -> SocketAddr { + SocketAddr::from((Ipv4Addr::LOCALHOST, config.bind.port())) +} + +pub async fn run(config: &Config) -> ExitCode { + match tokio::time::timeout(TIMEOUT, status_of_health(target(config))).await { + Ok(Ok(200)) => ExitCode::SUCCESS, + Ok(Ok(status)) => { + eprintln!("healthcheck: GET /health returned {status}"); + ExitCode::FAILURE + } + Ok(Err(e)) => { + eprintln!("healthcheck: {e}"); + ExitCode::FAILURE + } + Err(_) => { + eprintln!("healthcheck: timed out"); + ExitCode::FAILURE + } + } +} + +/// The HTTP status of `GET /health` at `addr`. +pub async fn status_of_health(addr: SocketAddr) -> std::io::Result { + let mut stream = TcpStream::connect(addr).await?; + let request = format!( + "GET /health HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\nUser-Agent: kvitt-healthcheck\r\n\r\n" + ); + stream.write_all(request.as_bytes()).await?; + let mut head = Vec::with_capacity(64); + let mut buf = [0u8; 256]; + while !head.contains(&b'\n') { + let n = stream.read(&mut buf).await?; + if n == 0 { + break; + } + head.extend_from_slice(&buf[..n]); + } + let line = String::from_utf8_lossy(&head); + line.split_whitespace() + .nth(1) + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| std::io::Error::other("malformed HTTP status line")) +} diff --git a/server/crates/kvitt-server/src/cli/mod.rs b/server/crates/kvitt-server/src/cli/mod.rs new file mode 100644 index 0000000..83bb215 --- /dev/null +++ b/server/crates/kvitt-server/src/cli/mod.rs @@ -0,0 +1,183 @@ +//! `kvitt-server` subcommands (SPEC §10.4, exhaustive): `serve`, `migrate` and `healthcheck` are implemented here +//! by M2a; `bootstrap` and `pair-device` belong to M3, `rotate-epoch` to M4, and until then print +//! `not implemented in M2a` and exit 2. + +pub mod bootstrap; +pub mod healthcheck; +pub mod pair_device; +pub mod rotate_epoch; + +use std::process::ExitCode; +use std::time::Duration; + +use clap::{Parser, Subcommand}; +use tokio::net::TcpListener; +use tokio::sync::Notify; + +use crate::config::Config; +use crate::error::AppError; +use crate::{AppState, db, router, tasks}; + +/// How long `serve` lets in-flight requests finish after SIGTERM (SPEC §10.4). +pub const SHUTDOWN_DRAIN: Duration = Duration::from_secs(10); + +/// Exit code of a subcommand that is not implemented yet. +pub const EXIT_NOT_IMPLEMENTED: u8 = 2; + +#[derive(Debug, Parser)] +#[command( + name = "kvitt-server", + version, + about = "Kvitt sync server (SPEC §10.4)" +)] +pub struct Cli { + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Run migrations, start the background tasks and the HTTP server. + Serve, + /// Create the admin_setup invite (FR-1). Exit 0 ok, 2 already initialized. + Bootstrap, + /// Create a device invite for an existing user (AC-1.6). Exit 0 ok, 3 unknown user. + PairDevice { + /// The user's id. + #[arg(long = "user", value_name = "USER_ID")] + user: String, + }, + /// Set a new random sync_epoch (SPEC §10.9). + RotateEpoch, + /// GET /health on this host; exit 0 if 200, 1 otherwise. + Healthcheck, + /// Run migrations only. + Migrate, +} + +/// Runs a subcommand to completion and returns the process exit code. +pub async fn run(command: Command, config: Config) -> ExitCode { + let result = match command { + Command::Serve => serve(config).await, + Command::Migrate => migrate(config).await, + Command::Healthcheck => return healthcheck::run(&config).await, + Command::Bootstrap => return bootstrap::run(&config).await, + Command::PairDevice { user } => return pair_device::run(&config, &user).await, + Command::RotateEpoch => return rotate_epoch::run(&config).await, + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + tracing::error!(error_code = error.code.as_str(), detail = %error.message, "command failed"); + eprintln!("{error}"); + ExitCode::FAILURE + } + } +} + +/// Prints the M2a placeholder message and returns exit code 2. +pub fn not_implemented(command: &str) -> ExitCode { + eprintln!("{command}: not implemented in M2a"); + ExitCode::from(EXIT_NOT_IMPLEMENTED) +} + +/// Opens (creating if needed) the data directory and the database, and runs the migrations. +pub async fn open_database(config: &Config) -> Result { + std::fs::create_dir_all(&config.data_dir).map_err(|e| { + AppError::internal(format!("cannot create {}: {e}", config.data_dir.display())) + })?; + let pool = db::connect(&config.database_path()).await?; + db::migrate(&pool).await?; + Ok(pool) +} + +async fn migrate(config: Config) -> Result<(), AppError> { + let pool = open_database(&config).await?; + pool.close().await; + tracing::info!("migrations applied"); + Ok(()) +} + +async fn serve(config: Config) -> Result<(), AppError> { + let pool = open_database(&config).await?; + if !config.fcm_credentials.exists() { + tracing::warn!( + path = %config.fcm_credentials.display(), + "FCM credentials file missing: push is disabled" + ); + } + let bind = config.bind; + let state = AppState::new(config, pool.clone()); + tasks::start(state.clone()).await?; + + let listener = TcpListener::bind(bind) + .await + .map_err(|e| AppError::internal(format!("cannot bind {bind}: {e}")))?; + tracing::info!(%bind, version = crate::SERVER_VERSION, "listening"); + + let shutdown = std::sync::Arc::new(Notify::new()); + let graceful = { + let shutdown = shutdown.clone(); + async move { shutdown.notified().await } + }; + let server = axum::serve(listener, router::router(state)).with_graceful_shutdown(graceful); + let mut server = tokio::spawn(async move { server.await }); + + tokio::select! { + result = &mut server => { + return finish(result, &pool).await; + } + () = shutdown_signal() => {} + } + tracing::info!(drain_secs = SHUTDOWN_DRAIN.as_secs(), "shutting down"); + shutdown.notify_one(); + match tokio::time::timeout(SHUTDOWN_DRAIN, &mut server).await { + Ok(result) => finish(result, &pool).await, + Err(_) => { + tracing::warn!("in-flight requests did not finish within the drain period"); + server.abort(); + pool.close().await; + Ok(()) + } + } +} + +async fn finish( + result: Result, tokio::task::JoinError>, + pool: &db::Db, +) -> Result<(), AppError> { + pool.close().await; + match result { + Ok(Ok(())) => Ok(()), + Ok(Err(e)) => Err(AppError::internal(format!("server error: {e}"))), + Err(e) => Err(AppError::internal(format!("server task failed: {e}"))), + } +} + +/// Resolves on SIGTERM or Ctrl-C. +async fn shutdown_signal() { + let ctrl_c = async { + if let Err(e) = tokio::signal::ctrl_c().await { + tracing::warn!(detail = %e, "cannot listen for Ctrl-C"); + std::future::pending::<()>().await; + } + }; + #[cfg(unix)] + let terminate = async { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut signal) => { + signal.recv().await; + } + Err(e) => { + tracing::warn!(detail = %e, "cannot listen for SIGTERM"); + std::future::pending::<()>().await; + } + } + }; + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + tokio::select! { + () = ctrl_c => {} + () = terminate => {} + } +} diff --git a/server/crates/kvitt-server/src/cli/pair_device.rs b/server/crates/kvitt-server/src/cli/pair_device.rs new file mode 100644 index 0000000..aa25c1f --- /dev/null +++ b/server/crates/kvitt-server/src/cli/pair_device.rs @@ -0,0 +1,11 @@ +//! `kvitt-server pair-device --user ` (AC-1.6). M3 owns this file; until then it prints +//! `not implemented in M2a` and exits 2. + +use std::process::ExitCode; + +use crate::config::Config; + +pub async fn run(config: &Config, user_id: &str) -> ExitCode { + let _ = (config, user_id); + super::not_implemented("pair-device") +} diff --git a/server/crates/kvitt-server/src/cli/rotate_epoch.rs b/server/crates/kvitt-server/src/cli/rotate_epoch.rs new file mode 100644 index 0000000..ee470b8 --- /dev/null +++ b/server/crates/kvitt-server/src/cli/rotate_epoch.rs @@ -0,0 +1,11 @@ +//! `kvitt-server rotate-epoch` (SPEC §10.9). M4 owns this file; until then it prints `not implemented in M2a` and +//! exits 2. + +use std::process::ExitCode; + +use crate::config::Config; + +pub async fn run(config: &Config) -> ExitCode { + let _ = config; + super::not_implemented("rotate-epoch") +} diff --git a/server/crates/kvitt-server/src/config.rs b/server/crates/kvitt-server/src/config.rs new file mode 100644 index 0000000..a1168f3 --- /dev/null +++ b/server/crates/kvitt-server/src/config.rs @@ -0,0 +1,136 @@ +//! Server configuration from the environment (SPEC §10.4). The variable list is exhaustive. + +use std::net::SocketAddr; +use std::path::PathBuf; + +use crate::error::AppError; + +/// Default of `KVITT_DATA_DIR`. +pub const DEFAULT_DATA_DIR: &str = "/data"; +/// Default of `KVITT_BIND`. +pub const DEFAULT_BIND: &str = "0.0.0.0:8080"; +/// Default of `KVITT_PUBLIC_URL`. +pub const DEFAULT_PUBLIC_URL: &str = "https://icehack.asuscomm.com:8443"; +/// Default of `KVITT_FCM_CREDENTIALS`. +pub const DEFAULT_FCM_CREDENTIALS: &str = "/secrets/fcm-service-account.json"; +/// Default of `KVITT_FCM_PROJECT_ID`. +pub const DEFAULT_FCM_PROJECT_ID: &str = "kvitt-1337"; +/// Default of `KVITT_TRUSTED_PROXY`. +pub const DEFAULT_TRUSTED_PROXY: &str = "caddy"; +/// Default of `RUST_LOG`. +pub const DEFAULT_LOG_FILTER: &str = "info"; + +/// File name of the database inside the data directory. +pub const DATABASE_FILE: &str = "kvitt.sqlite"; + +/// The server's configuration. `RUST_LOG` is read by the tracing set-up in `main`, not stored here. +/// +/// Nothing here touches the network or the file system: a trusted proxy host that does not resolve and a missing +/// FCM key file are both normal (no proxy: the peer address is the client; no key: push disabled). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Config { + /// `KVITT_DATA_DIR`: holds `kvitt.sqlite`, `pictures/`, `apk/`. + pub data_dir: PathBuf, + /// `KVITT_BIND`: listen address. + pub bind: SocketAddr, + /// `KVITT_PUBLIC_URL`: used in invite links and the landing page. Stored without a trailing slash. + pub public_url: String, + /// `KVITT_FCM_CREDENTIALS`: service-account key; push is disabled if the file is missing. + pub fcm_credentials: PathBuf, + /// `KVITT_FCM_PROJECT_ID`. + pub fcm_project_id: String, + /// `KVITT_TRUSTED_PROXY`: host name whose resolved address may set `X-Forwarded-For`. Resolved lazily by the + /// rate limiter (M3); an unresolvable name means "no proxy". + pub trusted_proxy: String, +} + +impl Config { + /// Reads the process environment. + pub fn from_env() -> Result { + Self::from_lookup(|key| std::env::var(key).ok()) + } + + /// Reads the variables through `lookup` (testable without touching the process environment). Empty values count + /// as unset. + pub fn from_lookup(lookup: impl Fn(&str) -> Option) -> Result { + let get = |key: &str, default: &str| { + lookup(key) + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| default.to_owned()) + }; + let bind_text = get("KVITT_BIND", DEFAULT_BIND); + let bind: SocketAddr = bind_text.parse().map_err(|e| { + AppError::new( + crate::error::ErrorCode::Internal, + format!("KVITT_BIND {bind_text:?} is not ip:port: {e}"), + ) + })?; + Ok(Self { + data_dir: PathBuf::from(get("KVITT_DATA_DIR", DEFAULT_DATA_DIR)), + bind, + public_url: get("KVITT_PUBLIC_URL", DEFAULT_PUBLIC_URL) + .trim_end_matches('/') + .to_owned(), + fcm_credentials: PathBuf::from(get("KVITT_FCM_CREDENTIALS", DEFAULT_FCM_CREDENTIALS)), + fcm_project_id: get("KVITT_FCM_PROJECT_ID", DEFAULT_FCM_PROJECT_ID), + trusted_proxy: get("KVITT_TRUSTED_PROXY", DEFAULT_TRUSTED_PROXY), + }) + } + + /// `/kvitt.sqlite`. + pub fn database_path(&self) -> PathBuf { + self.data_dir.join(DATABASE_FILE) + } + + /// `/pictures`. + pub fn pictures_dir(&self) -> PathBuf { + self.data_dir.join("pictures") + } + + /// `/apk`. + pub fn apk_dir(&self) -> PathBuf { + self.data_dir.join("apk") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_when_unset_or_empty() { + let config = Config::from_lookup(|k| (k == "KVITT_BIND").then(String::new)).unwrap(); + assert_eq!(config.data_dir, PathBuf::from("/data")); + assert_eq!(config.bind, "0.0.0.0:8080".parse().unwrap()); + assert_eq!(config.public_url, "https://icehack.asuscomm.com:8443"); + assert_eq!( + config.fcm_credentials, + PathBuf::from("/secrets/fcm-service-account.json") + ); + assert_eq!(config.fcm_project_id, "kvitt-1337"); + assert_eq!(config.trusted_proxy, "caddy"); + assert_eq!(config.database_path(), PathBuf::from("/data/kvitt.sqlite")); + } + + #[test] + fn overrides() { + let config = Config::from_lookup(|k| match k { + "KVITT_DATA_DIR" => Some("/tmp/kvitt".into()), + "KVITT_BIND" => Some("127.0.0.1:9000".into()), + "KVITT_PUBLIC_URL" => Some("http://192.168.1.10:8080/".into()), + _ => None, + }) + .unwrap(); + assert_eq!( + config.database_path(), + PathBuf::from("/tmp/kvitt/kvitt.sqlite") + ); + assert_eq!(config.bind, "127.0.0.1:9000".parse().unwrap()); + assert_eq!(config.public_url, "http://192.168.1.10:8080"); + } + + #[test] + fn bad_bind_is_an_error() { + assert!(Config::from_lookup(|k| (k == "KVITT_BIND").then(|| "nope".into())).is_err()); + } +} diff --git a/server/crates/kvitt-server/src/db/mod.rs b/server/crates/kvitt-server/src/db/mod.rs new file mode 100644 index 0000000..5398b3f --- /dev/null +++ b/server/crates/kvitt-server/src/db/mod.rs @@ -0,0 +1,24 @@ +//! Database access: the pool (SPEC §8.9 pragmas), migrations (SPEC §14.1) and the `server_version` sequence. + +pub mod pool; +pub mod sequence; + +pub use pool::{Db, begin_immediate, connect}; +pub use sequence::next_server_version; + +/// The schema version the embedded migrations produce (`meta.schema_version`, SPEC §14.1). +pub const SCHEMA_VERSION: i64 = 1; + +/// The embedded migrations of `migrations/` (`0001_init.sql` is byte-identical to `contracts/schema/server-v1.sql`). +pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); + +/// Runs every pending migration. +pub async fn migrate(db: &Db) -> Result<(), crate::error::AppError> { + MIGRATOR.run(db).await?; + Ok(()) +} + +/// `SELECT 1` (the health check of SPEC §14.3.4 E-1). +pub async fn ping(db: &Db) -> Result<(), sqlx::Error> { + sqlx::query("SELECT 1").execute(db).await.map(|_| ()) +} diff --git a/server/crates/kvitt-server/src/db/pool.rs b/server/crates/kvitt-server/src/db/pool.rs new file mode 100644 index 0000000..981e577 --- /dev/null +++ b/server/crates/kvitt-server/src/db/pool.rs @@ -0,0 +1,45 @@ +//! The SQLite pool. Every connection sets `foreign_keys = ON`, `journal_mode = WAL`, `synchronous = NORMAL` and +//! `busy_timeout = 5000` (SPEC §8.9). + +use std::path::Path; +use std::time::Duration; + +use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}; +use sqlx::{Sqlite, SqlitePool, Transaction}; + +use crate::error::AppError; + +/// The pool type used everywhere. +pub type Db = SqlitePool; + +/// `busy_timeout` (SPEC §8.9). +pub const BUSY_TIMEOUT: Duration = Duration::from_millis(5000); + +/// At most this many connections; two users and one writer at a time need few. +const MAX_CONNECTIONS: u32 = 4; + +/// The connect options for the database file at `path` (created if missing). +pub fn options(path: &Path) -> SqliteConnectOptions { + SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true) + .foreign_keys(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Normal) + .busy_timeout(BUSY_TIMEOUT) +} + +/// Opens the pool for the database file at `path`. The parent directory must exist. +pub async fn connect(path: &Path) -> Result { + let pool = SqlitePoolOptions::new() + .max_connections(MAX_CONNECTIONS) + .acquire_timeout(BUSY_TIMEOUT) + .connect_with(options(path)) + .await?; + Ok(pool) +} + +/// Starts a write transaction with `BEGIN IMMEDIATE` (SPEC §14.1: every write transaction does). +pub async fn begin_immediate(db: &Db) -> Result, AppError> { + Ok(db.begin_with("BEGIN IMMEDIATE").await?) +} diff --git a/server/crates/kvitt-server/src/db/sequence.rs b/server/crates/kvitt-server/src/db/sequence.rs new file mode 100644 index 0000000..89e1967 --- /dev/null +++ b/server/crates/kvitt-server/src/db/sequence.rs @@ -0,0 +1,24 @@ +//! The global `server_version` sequence (SPEC §14.1, §5.2). + +use sqlx::SqliteConnection; + +use crate::error::AppError; + +/// Takes the next `server_version`. Must run inside a `BEGIN IMMEDIATE` transaction (see +/// [`crate::db::begin_immediate`]) so versions commit in order. +pub async fn next_server_version(conn: &mut SqliteConnection) -> Result { + let value: i64 = sqlx::query_scalar( + "UPDATE sync_sequence SET value = value + 1 WHERE id = 1 RETURNING value", + ) + .fetch_one(conn) + .await?; + Ok(value) +} + +/// The current value of the sequence (the highest `server_version` handed out). +pub async fn current_server_version(conn: &mut SqliteConnection) -> Result { + let value: i64 = sqlx::query_scalar("SELECT value FROM sync_sequence WHERE id = 1") + .fetch_one(conn) + .await?; + Ok(value) +} diff --git a/server/crates/kvitt-server/src/error.rs b/server/crates/kvitt-server/src/error.rs new file mode 100644 index 0000000..2d6d6ec --- /dev/null +++ b/server/crates/kvitt-server/src/error.rs @@ -0,0 +1,317 @@ +//! The error type and its wire shape (SPEC §14.3.2): `{ "code", "message", "field" }` with the HTTP status of the +//! code. `message` is English developer text, never shown to users. + +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Every error code of SPEC §14.3.2 (exhaustive), in the order of that table. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + InvalidRequest, + InviteInvalid, + Unauthorized, + DeviceRevoked, + Forbidden, + NotFound, + HouseholdFull, + HouseholdIncomplete, + NameTaken, + AccountNotEmpty, + CategoryInUse, + CategoryIsDefault, + CannotRevokeCurrentDevice, + NoAccounts, + CursorAhead, + PayloadTooLarge, + UnsupportedMediaType, + ValidationFailed, + ImageInvalid, + BatchTooLarge, + ProtocolUnsupported, + RateLimited, + Internal, + NotImplemented, + Unavailable, +} + +impl ErrorCode { + /// Every code, in the order of SPEC §14.3.2. + pub const ALL: [ErrorCode; 25] = [ + Self::InvalidRequest, + Self::InviteInvalid, + Self::Unauthorized, + Self::DeviceRevoked, + Self::Forbidden, + Self::NotFound, + Self::HouseholdFull, + Self::HouseholdIncomplete, + Self::NameTaken, + Self::AccountNotEmpty, + Self::CategoryInUse, + Self::CategoryIsDefault, + Self::CannotRevokeCurrentDevice, + Self::NoAccounts, + Self::CursorAhead, + Self::PayloadTooLarge, + Self::UnsupportedMediaType, + Self::ValidationFailed, + Self::ImageInvalid, + Self::BatchTooLarge, + Self::ProtocolUnsupported, + Self::RateLimited, + Self::Internal, + Self::NotImplemented, + Self::Unavailable, + ]; + + /// The HTTP status of the code (SPEC §14.3.2). + pub fn status(self) -> StatusCode { + match self { + Self::InvalidRequest | Self::InviteInvalid => StatusCode::BAD_REQUEST, + Self::Unauthorized | Self::DeviceRevoked => StatusCode::UNAUTHORIZED, + Self::Forbidden => StatusCode::FORBIDDEN, + Self::NotFound => StatusCode::NOT_FOUND, + Self::HouseholdFull + | Self::HouseholdIncomplete + | Self::NameTaken + | Self::AccountNotEmpty + | Self::CategoryInUse + | Self::CategoryIsDefault + | Self::CannotRevokeCurrentDevice + | Self::NoAccounts + | Self::CursorAhead => StatusCode::CONFLICT, + Self::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, + Self::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE, + Self::ValidationFailed | Self::ImageInvalid | Self::BatchTooLarge => { + StatusCode::UNPROCESSABLE_ENTITY + } + Self::ProtocolUnsupported => StatusCode::UPGRADE_REQUIRED, + Self::RateLimited => StatusCode::TOO_MANY_REQUESTS, + Self::Internal => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotImplemented => StatusCode::NOT_IMPLEMENTED, + Self::Unavailable => StatusCode::SERVICE_UNAVAILABLE, + } + } + + /// The snake_case token, as on the wire. + pub fn as_str(self) -> &'static str { + match self { + Self::InvalidRequest => "invalid_request", + Self::InviteInvalid => "invite_invalid", + Self::Unauthorized => "unauthorized", + Self::DeviceRevoked => "device_revoked", + Self::Forbidden => "forbidden", + Self::NotFound => "not_found", + Self::HouseholdFull => "household_full", + Self::HouseholdIncomplete => "household_incomplete", + Self::NameTaken => "name_taken", + Self::AccountNotEmpty => "account_not_empty", + Self::CategoryInUse => "category_in_use", + Self::CategoryIsDefault => "category_is_default", + Self::CannotRevokeCurrentDevice => "cannot_revoke_current_device", + Self::NoAccounts => "no_accounts", + Self::CursorAhead => "cursor_ahead", + Self::PayloadTooLarge => "payload_too_large", + Self::UnsupportedMediaType => "unsupported_media_type", + Self::ValidationFailed => "validation_failed", + Self::ImageInvalid => "image_invalid", + Self::BatchTooLarge => "batch_too_large", + Self::ProtocolUnsupported => "protocol_unsupported", + Self::RateLimited => "rate_limited", + Self::Internal => "internal", + Self::NotImplemented => "not_implemented", + Self::Unavailable => "unavailable", + } + } +} + +impl std::fmt::Display for ErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// The wire shape of an error (SPEC §14.3.2). `field` is always present, `null` when no field is concerned. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[schema(as = Error)] +pub struct ErrorBody { + pub code: ErrorCode, + pub message: String, + #[schema(required = true)] + pub field: Option, +} + +/// An error a handler returns. Converts into the JSON response of SPEC §14.3.2. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{code}: {message}")] +pub struct AppError { + pub code: ErrorCode, + pub message: String, + pub field: Option, + /// Seconds for `Retry-After` (only with `rate_limited`, SPEC §8.6). + pub retry_after_secs: Option, +} + +impl AppError { + pub fn new(code: ErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + field: None, + retry_after_secs: None, + } + } + + /// Sets the JSON key concerned. + pub fn with_field(mut self, field: impl Into) -> Self { + self.field = Some(field.into()); + self + } + + pub fn invalid_request(message: impl Into) -> Self { + Self::new(ErrorCode::InvalidRequest, message) + } + + /// `validation_failed` for `field` (SPEC §9.6). + pub fn validation(field: impl Into, message: impl Into) -> Self { + Self::new(ErrorCode::ValidationFailed, message).with_field(field) + } + + pub fn not_found() -> Self { + Self::new(ErrorCode::NotFound, "not found") + } + + pub fn unauthorized() -> Self { + Self::new( + ErrorCode::Unauthorized, + "missing, malformed or unknown bearer token", + ) + } + + pub fn device_revoked() -> Self { + Self::new(ErrorCode::DeviceRevoked, "this device has been revoked") + } + + pub fn forbidden() -> Self { + Self::new(ErrorCode::Forbidden, "your role does not allow this action") + } + + pub fn protocol_unsupported() -> Self { + Self::new( + ErrorCode::ProtocolUnsupported, + "X-Kvitt-Protocol must be 1 on authenticated endpoints", + ) + } + + pub fn payload_too_large(limit_bytes: usize) -> Self { + Self::new( + ErrorCode::PayloadTooLarge, + format!("request body exceeds {limit_bytes} bytes"), + ) + } + + pub fn not_implemented() -> Self { + Self::new(ErrorCode::NotImplemented, "not implemented") + } + + pub fn rate_limited(retry_after_secs: u64) -> Self { + Self { + retry_after_secs: Some(retry_after_secs), + ..Self::new(ErrorCode::RateLimited, "too many requests") + } + } + + /// `internal`. The detail is logged here; the client sees only a request id (SPEC §14.3.2). + pub fn internal(detail: impl std::fmt::Display) -> Self { + let request_id = uuid::Uuid::now_v7(); + tracing::error!(error_code = "internal", %request_id, detail = %detail, "internal error"); + Self::new( + ErrorCode::Internal, + format!("internal error, request id {request_id}"), + ) + } + + pub fn unavailable(message: impl Into) -> Self { + Self::new(ErrorCode::Unavailable, message) + } + + /// The wire body. + pub fn body(&self) -> ErrorBody { + ErrorBody { + code: self.code, + message: self.message.clone(), + field: self.field.clone(), + } + } +} + +impl From for AppError { + /// `SQLITE_BUSY` beyond `busy_timeout` and pool time-outs are `unavailable`; anything else is `internal`. + fn from(error: sqlx::Error) -> Self { + let busy = match &error { + sqlx::Error::PoolTimedOut | sqlx::Error::PoolClosed => true, + sqlx::Error::Database(db) => db + .code() + .and_then(|c| c.parse::().ok()) + .is_some_and(|c| c & 0xff == 5 || c & 0xff == 6), + _ => false, + }; + if busy { + tracing::warn!(error_code = "unavailable", detail = %error, "database busy"); + Self::unavailable("database busy") + } else { + Self::internal(error) + } + } +} + +impl From for AppError { + fn from(error: sqlx::migrate::MigrateError) -> Self { + Self::internal(error) + } +} + +/// The error code a response carried, stored in the response extensions for the request log (SPEC §8.7). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LoggedErrorCode(pub ErrorCode); + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status = self.code.status(); + let mut response = (status, crate::api::JsonBody(self.body())).into_response(); + if let Some(secs) = self.retry_after_secs { + response + .headers_mut() + .insert(header::RETRY_AFTER, HeaderValue::from(secs)); + } + response.extensions_mut().insert(LoggedErrorCode(self.code)); + response + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serde_token_matches_as_str_for_every_code() { + for code in ErrorCode::ALL { + assert_eq!( + serde_json::to_value(code).unwrap(), + serde_json::json!(code.as_str()) + ); + } + } + + #[test] + fn body_has_field_null() { + let v = serde_json::to_value(AppError::not_implemented().body()).unwrap(); + assert_eq!( + v, + serde_json::json!({"code": "not_implemented", "message": "not implemented", "field": null}) + ); + } +} diff --git a/server/crates/kvitt-server/src/lib.rs b/server/crates/kvitt-server/src/lib.rs new file mode 100644 index 0000000..4251757 --- /dev/null +++ b/server/crates/kvitt-server/src/lib.rs @@ -0,0 +1,59 @@ +//! The Kvitt server as a library: `main.rs` runs it, `kvitt-import` reuses it for database access and audit writing +//! (SPEC §6.3), and the integration tests drive the router directly. + +// No unwrap/expect outside tests (CLAUDE.md server rule); integration tests are separate crates and may use them. +#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))] + +use std::sync::Arc; + +pub mod api; +pub mod audit; +pub mod auth; +pub mod cli; +pub mod config; +pub mod db; +pub mod error; +pub mod openapi; +pub mod push; +pub mod router; +pub mod tasks; +pub mod time; +pub mod wire; + +use audit::{AuditWriter, NoopAuditWriter}; +use config::Config; +use db::Db; +use push::{NoopPushQueue, PushQueue}; +use time::{Clock, SystemClock}; + +/// The crate version, sent in `X-Kvitt-Server-Version` and in `GET /health` (SPEC §14.3.1). +pub const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Shared state of every handler. Cheap to clone. +#[derive(Clone)] +pub struct AppState { + pub config: Arc, + pub db: Db, + pub clock: Arc, + pub audit: Arc, + pub push: Arc, +} + +impl AppState { + /// State with the system clock and the default audit writer and push queue (no-ops until M4). + pub fn new(config: Config, db: Db) -> Self { + Self { + config: Arc::new(config), + db, + clock: Arc::new(SystemClock), + audit: Arc::new(NoopAuditWriter), + push: Arc::new(NoopPushQueue), + } + } + + /// Replaces the clock (tests). + pub fn with_clock(mut self, clock: Arc) -> Self { + self.clock = clock; + self + } +} diff --git a/server/crates/kvitt-server/src/main.rs b/server/crates/kvitt-server/src/main.rs new file mode 100644 index 0000000..c627a54 --- /dev/null +++ b/server/crates/kvitt-server/src/main.rs @@ -0,0 +1,50 @@ +//! `kvitt-server`: parses the subcommand, sets up JSON logging and the runtime (2 worker threads, SPEC §6.3), and +//! runs it. + +// No unwrap/expect outside tests (CLAUDE.md server rule); integration tests are separate crates and may use them. +#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))] + +use std::process::ExitCode; + +use clap::Parser; +use kvitt_server::cli::{self, Cli}; +use kvitt_server::config::{Config, DEFAULT_LOG_FILTER}; +use tracing_subscriber::EnvFilter; + +/// Worker threads of the tokio runtime (SPEC §6.3). +const WORKER_THREADS: usize = 2; + +fn main() -> ExitCode { + let cli = Cli::parse(); + + let filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(DEFAULT_LOG_FILTER)); + tracing_subscriber::fmt() + .json() + .with_env_filter(filter) + .with_current_span(true) + .with_span_list(false) + .with_writer(std::io::stdout) + .init(); + + let config = match Config::from_env() { + Ok(config) => config, + Err(error) => { + eprintln!("configuration: {}", error.message); + return ExitCode::FAILURE; + } + }; + + let runtime = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(WORKER_THREADS) + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + eprintln!("cannot start the runtime: {error}"); + return ExitCode::FAILURE; + } + }; + runtime.block_on(cli::run(cli.command, config)) +} diff --git a/server/crates/kvitt-server/src/openapi.rs b/server/crates/kvitt-server/src/openapi.rs new file mode 100644 index 0000000..f20f003 --- /dev/null +++ b/server/crates/kvitt-server/src/openapi.rs @@ -0,0 +1,388 @@ +//! The utoipa OpenAPI document. `contracts/openapi.yaml` is the committed contract; `tests/openapi_contract.rs` +//! asserts that [`document`] equals it (SPEC §14.3, §11.3). +//! +//! Per-operation facts (path, method, operation id, summary, parameters, body, success response, the error +//! responses specific to the operation) live in the `#[utoipa::path]` annotation of each handler. The rules that +//! hold for every operation are applied once here, by [`CommonRules`], so they cannot drift per handler: +//! +//! * SPEC §14.3.1 headers: every operation takes `X-Kvitt-Protocol` (required on authenticated operations, optional +//! on the unauthenticated ones), `X-Kvitt-App-Version` and `Accept-Language`; every response carries +//! `X-Kvitt-Server-Version`. +//! * SPEC §14.3.2 always-possible errors: `401`, `426`, `429`, `500`, `503` on authenticated operations and `429`, +//! `500`, `503` on unauthenticated ones, unless the operation documents its own. +//! * Security: the document-level requirement is `device` (bearer, SPEC §8.2); unauthenticated operations declare +//! `security(())` in their annotation, which becomes an empty `security: []`. +//! * Every documented response header is always sent, so each is marked `required: true`. utoipa's `Header` type +//! has no `required` field; the keyword is written through the header's extension map. +//! * `dependentRequired` of `PatchRecurringTemplateRequest` (E-29: `split_mode` and `splits` together), for which +//! utoipa has no attribute, is written the same way. + +use utoipa::openapi::extensions::Extensions; +use utoipa::openapi::header::{Header, HeaderBuilder}; +use utoipa::openapi::path::{Operation, ParameterBuilder, ParameterIn}; +use utoipa::openapi::response::{Response, ResponseBuilder}; +use utoipa::openapi::schema::{ObjectBuilder, Schema, SchemaType, Type}; +use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityRequirement, SecurityScheme}; +use utoipa::openapi::server::Server; +use utoipa::openapi::{Content, Ref, RefOr, Required}; +use utoipa::{Modify, OpenApi}; + +use crate::api; +use crate::wire; + +/// The document info description (SPEC §14.3.1 common rules). +const INFO_DESCRIPTION: &str = "Shared-expense server for a two-person household (SPEC.md). Money is integer öre, \ +timestamps are RFC 3339 UTC with millisecond precision (`2026-10-17T06:00:00.000Z`), ids are lowercase UUIDv7 \ +strings. Common rules: SPEC §14.3.1."; + +/// The production base URL (SPEC §7). +pub const PUBLIC_SERVER_URL: &str = "https://icehack.asuscomm.com:8443"; + +const JSON: &str = api::JSON_CONTENT_TYPE; +const SERVER_VERSION_HEADER: &str = "X-Kvitt-Server-Version"; + +#[derive(OpenApi)] +#[openapi( + info(title = "Kvitt", version = "1"), + paths( + api::health::get_health, + api::setup::get_setup_status, + api::invites::accept_invite, + api::landing::get_invite_landing, + api::apk::get_apk_latest, + api::apk::get_apk_file, + api::me::get_me, + api::me::patch_me, + api::pictures::put_me_picture, + api::pictures::delete_me_picture, + api::pictures::get_user_picture, + api::invites::create_invite, + api::invites::list_invites, + api::invites::cancel_invite, + api::devices::list_devices, + api::devices::revoke_device, + api::devices::put_push_token, + api::accounts::create_account, + api::accounts::patch_account, + api::accounts::archive_account, + api::accounts::unarchive_account, + api::accounts::delete_account, + api::categories::create_category, + api::categories::patch_category, + api::categories::archive_category, + api::categories::unarchive_category, + api::categories::delete_category, + api::recurring::create_recurring_template, + api::recurring::patch_recurring_template, + api::recurring::delete_recurring_template, + api::sync::sync_pull, + api::sync::sync_push, + api::proposal::propose_settlement, + ), + components( + schemas( + crate::error::ErrorCode, + crate::error::ErrorBody, + wire::RejectionReason, + wire::AuditEntry, + wire::UuidSchema, + wire::IntSchema, + wire::PictureSizeSchema, + wire::SyncLimitSchema, + ), + responses( + responses::BadRequest, + responses::Unauthorized, + responses::Forbidden, + responses::NotFound, + responses::Conflict, + responses::PayloadTooLarge, + responses::UnsupportedMediaType, + responses::Unprocessable, + responses::ProtocolUnsupported, + responses::RateLimited, + responses::Internal, + responses::NotImplemented, + responses::Unavailable, + ), + ), + modifiers(&CommonRules), +)] +pub struct ApiDoc; + +/// The generated document. +pub fn document() -> utoipa::openapi::OpenApi { + ApiDoc::openapi() +} + +/// A response header that is always sent (`required: true`, see the module docs). +pub fn required_header(header: HeaderBuilder) -> Header { + let mut header = header.build(); + mark_required(&mut header); + header +} + +fn mark_required(header: &mut Header) { + header + .extensions + .get_or_insert_with(Extensions::default) + .insert("required".to_owned(), serde_json::Value::Bool(true)); +} + +fn header_ref(name: &str) -> Ref { + Ref::new(format!("#/components/headers/{name}")) +} + +fn plain_schema(t: Type) -> ObjectBuilder { + ObjectBuilder::new().schema_type(SchemaType::Type(t)) +} + +/// The reusable responses of `components.responses`, each an `Error` body with the server version header. +pub mod responses { + use super::*; + + pub(super) fn error_response(description: &str, retry_after: bool) -> Response { + let mut builder = ResponseBuilder::new() + .description(description) + .header_ref(SERVER_VERSION_HEADER, header_ref("ServerVersion")) + .content(JSON, Content::new(Some(Ref::from_schema_name("Error")))); + if retry_after { + builder = builder.header_ref("Retry-After", header_ref("RetryAfter")); + } + builder.build() + } + + macro_rules! error_responses { + ($($name:ident => $description:literal $(, $retry:literal)?;)*) => {$( + #[doc = $description] + pub struct $name; + + impl<'r> utoipa::ToResponse<'r> for $name { + fn response() -> (&'r str, RefOr) { + let retry_after = false $(|| $retry)?; + (stringify!($name), RefOr::T(error_response($description, retry_after))) + } + } + )*}; + } + + error_responses! { + BadRequest => "`invalid_request` or `invite_invalid`."; + Unauthorized => "`unauthorized` or `device_revoked`."; + Forbidden => "`forbidden`."; + NotFound => "`not_found`."; + Conflict => "A 409 error code of SPEC §14.3.2."; + PayloadTooLarge => "`payload_too_large`."; + UnsupportedMediaType => "`unsupported_media_type`."; + Unprocessable => "A 422 error code of SPEC §14.3.2."; + ProtocolUnsupported => "`protocol_unsupported`."; + RateLimited => "`rate_limited` (SPEC §8.6).", true; + Internal => "`internal` (logged with a request id, returned in `message`)."; + NotImplemented => "`not_implemented` (route registered but not yet implemented; only during milestones M2a to M4)."; + Unavailable => "`unavailable` (database busy beyond `busy_timeout`, or server shutting down)."; + } +} + +/// Applies the document-wide rules of SPEC §14.3.1 and §14.3.2 (see the module docs). +pub struct CommonRules; + +impl Modify for CommonRules { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + openapi.info.description = Some(INFO_DESCRIPTION.to_owned()); + openapi.info.license = None; + openapi.info.contact = None; + openapi.servers = Some(vec![Server::new(PUBLIC_SERVER_URL)]); + openapi.security = Some(vec![SecurityRequirement::new( + "device", + Vec::::new(), + )]); + + let components = openapi.components.get_or_insert_with(Default::default); + let mut bearer = Http::new(HttpAuthScheme::Bearer); + bearer.description = Some( + "`Authorization: Bearer kvt_<43 base64url characters>` (SPEC §8.2). Missing or unknown token: 401 \ + `unauthorized`; revoked device: 401 `device_revoked`. Admin-only operations (E-12, E-13, E-14) \ + return 403 `forbidden` for a member (SPEC §8.4)." + .to_owned(), + ); + components + .security_schemes + .insert("device".to_owned(), RefOr::T(SecurityScheme::Http(bearer))); + + let protocol_schema = || { + plain_schema(Type::String) + .enum_values(Some([crate::auth::PROTOCOL_VERSION])) + .build() + }; + let header_param = + |name: &str, required: bool, description: &str, schema: ObjectBuilder| { + RefOr::T( + ParameterBuilder::new() + .name(name) + .parameter_in(ParameterIn::Header) + .required(if required { + Required::True + } else { + Required::False + }) + .description(Some(description)) + .schema(Some(schema.build())) + .build(), + ) + }; + components.parameters.insert( + "Protocol".to_owned(), + header_param( + "X-Kvitt-Protocol", + true, + "Sync protocol version. Missing or other than `1` on an authenticated endpoint is 426 \ + `protocol_unsupported`.", + ObjectBuilder::from(protocol_schema()), + ), + ); + components.parameters.insert( + "ProtocolOptional".to_owned(), + header_param( + "X-Kvitt-Protocol", + false, + "Sent by the app on every call; not enforced on unauthenticated endpoints.", + ObjectBuilder::from(protocol_schema()), + ), + ); + components.parameters.insert( + "AppVersion".to_owned(), + header_param( + "X-Kvitt-App-Version", + false, + "The app's `versionCode`, sent by the app on every call.", + plain_schema(Type::Integer), + ), + ); + components.parameters.insert( + "AcceptLanguage".to_owned(), + header_param( + "Accept-Language", + false, + "The device locale.", + plain_schema(Type::String), + ), + ); + + components.headers.insert( + "ServerVersion".to_owned(), + RefOr::T(required_header( + HeaderBuilder::new() + .schema(Some(plain_schema(Type::String).build())) + .description(Some("The server crate version.")), + )), + ); + components.headers.insert( + "RetryAfter".to_owned(), + RefOr::T(required_header( + HeaderBuilder::new() + .schema(Some(plain_schema(Type::Integer).build())) + .description(Some("Seconds until the client may retry.")), + )), + ); + // JSON Schema keywords utoipa has no attribute for, written through the schema's extension map. + if let Some(RefOr::T(Schema::Object(object))) = + components.schemas.get_mut("PatchRecurringTemplateRequest") + { + object + .extensions + .get_or_insert_with(Extensions::default) + .insert( + "dependentRequired".to_owned(), + wire::recurring::patch_template_dependent_required(), + ); + } + for response in components.responses.values_mut() { + if let RefOr::T(response) = response { + mark_inline_headers_required(response); + } + } + + for item in openapi.paths.paths.values_mut() { + for operation in [ + item.get.as_mut(), + item.put.as_mut(), + item.post.as_mut(), + item.delete.as_mut(), + item.patch.as_mut(), + ] + .into_iter() + .flatten() + { + apply_operation_rules(operation); + } + } + } +} + +fn is_unauthenticated(operation: &Operation) -> bool { + operation.security.as_ref().is_some_and(|requirements| { + requirements.iter().all(|r| { + serde_json::to_value(r).is_ok_and(|v| v.as_object().is_some_and(|o| o.is_empty())) + }) + }) +} + +fn apply_operation_rules(operation: &mut Operation) { + let unauthenticated = is_unauthenticated(operation); + if unauthenticated { + operation.security = Some(Vec::new()); + } + + let parameters = operation.parameters.get_or_insert_with(Vec::new); + let protocol = if unauthenticated { + "ProtocolOptional" + } else { + "Protocol" + }; + for name in [protocol, "AppVersion", "AcceptLanguage"] { + parameters.push(RefOr::Ref(Ref::new(format!( + "#/components/parameters/{name}" + )))); + } + + let always: &[(&str, &str)] = if unauthenticated { + &[ + ("429", "RateLimited"), + ("500", "Internal"), + ("503", "Unavailable"), + ] + } else { + &[ + ("401", "Unauthorized"), + ("426", "ProtocolUnsupported"), + ("429", "RateLimited"), + ("500", "Internal"), + ("503", "Unavailable"), + ] + }; + for (status, name) in always { + operation + .responses + .responses + .entry((*status).to_owned()) + .or_insert_with(|| RefOr::Ref(Ref::from_response_name(*name))); + } + + for response in operation.responses.responses.values_mut() { + if let RefOr::T(response) = response { + response + .headers + .entry(SERVER_VERSION_HEADER.to_owned()) + .or_insert_with(|| RefOr::Ref(header_ref("ServerVersion"))); + mark_inline_headers_required(response); + } + } +} + +fn mark_inline_headers_required(response: &mut Response) { + for header in response.headers.values_mut() { + if let RefOr::T(header) = header { + mark_required(header); + } + } +} diff --git a/server/crates/kvitt-server/src/push/mod.rs b/server/crates/kvitt-server/src/push/mod.rs new file mode 100644 index 0000000..963537f --- /dev/null +++ b/server/crates/kvitt-server/src/push/mod.rs @@ -0,0 +1,66 @@ +//! The push notification seam (SPEC §14.6). M2a defines the trait and a no-op; M4 owns `src/push/**` and replaces +//! the implementation with the bounded queue and FCM sender. +//! +//! [`PushQueue::enqueue`] never blocks and never fails the caller: sending happens on a background task and never +//! delays an HTTP response (SPEC §14.6). Call it after the write transaction has committed. + +use uuid::Uuid; + +/// The notification types of SPEC §14.6 (exhaustive). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PushType { + ExpenseCreated, + ExpenseUpdated, + ExpenseDeleted, + SettlementCreated, + SettlementUpdated, + SettlementDeleted, + RecurringExpenseCreated, + SyncBatch, +} + +impl PushType { + /// The `type` value of the data message. + pub fn as_str(self) -> &'static str { + match self { + Self::ExpenseCreated => "expense_created", + Self::ExpenseUpdated => "expense_updated", + Self::ExpenseDeleted => "expense_deleted", + Self::SettlementCreated => "settlement_created", + Self::SettlementUpdated => "settlement_updated", + Self::SettlementDeleted => "settlement_deleted", + Self::RecurringExpenseCreated => "recurring_expense_created", + Self::SyncBatch => "sync_batch", + } + } +} + +/// One notification to send. Ids only, never personal content (SPEC §8.7). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PushMessage { + pub push_type: PushType, + /// `None` for `sync_batch` (sent as `""` with `entity_type` `""`). + pub entity_id: Option, + /// The creator, editor, deleter, template creator or pusher. + pub actor_user_id: Uuid, + /// 1, or the number of notifiable changes for `sync_batch`. + pub count: u32, +} + +/// Queues notifications for the background sender, which resolves the target devices per SPEC §14.6. +pub trait PushQueue: Send + Sync + 'static { + fn enqueue(&self, message: PushMessage); +} + +/// Drops every message. Used until M4 lands the sender, and when push is disabled. +#[derive(Debug, Clone, Copy, Default)] +pub struct NoopPushQueue; + +impl PushQueue for NoopPushQueue { + fn enqueue(&self, message: PushMessage) { + tracing::debug!( + push_type = message.push_type.as_str(), + "push disabled, message dropped" + ); + } +} diff --git a/server/crates/kvitt-server/src/router.rs b/server/crates/kvitt-server/src/router.rs new file mode 100644 index 0000000..25b0b2e --- /dev/null +++ b/server/crates/kvitt-server/src/router.rs @@ -0,0 +1,155 @@ +//! The router: every route of SPEC §7 and the layers of SPEC §14.3.1 and §8.6. +//! +//! M2a owns this file. M3 and M4 fill in handler bodies in their own `api/*.rs` files; the routes are all here. + +use std::time::Instant; + +use axum::Router; +use axum::extract::{DefaultBodyLimit, MatchedPath, Request}; +use axum::http::HeaderValue; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post, put}; + +use crate::api::{ + accounts, apk, categories, devices, health, invites, landing, me, pictures, proposal, + rate_limit, recurring, setup, sync, +}; +use crate::error::{AppError, LoggedErrorCode}; +use crate::{AppState, SERVER_VERSION, auth}; + +/// Request body limit (SPEC §8.6). +pub const BODY_LIMIT_BYTES: usize = 1024 * 1024; +/// Request body limit of `PUT /me/picture` (SPEC §8.6). +pub const PICTURE_BODY_LIMIT_BYTES: usize = 5 * 1024 * 1024; +/// Response header carrying the crate version (SPEC §14.3.1). +pub const SERVER_VERSION_HEADER: &str = "x-kvitt-server-version"; + +/// Builds the application. +pub fn router(state: AppState) -> Router { + // E-1 to E-6: no authentication, no protocol check. + let public = Router::new() + .route("/health", get(health::get_health)) + .route("/setup/status", get(setup::get_setup_status)) + .route("/invites/accept", post(invites::accept_invite)) + .route("/i/{code}", get(landing::get_invite_landing)) + .route("/apk/latest", get(apk::get_apk_latest)) + .route("/apk/files/{file_name}", get(apk::get_apk_file)); + + // E-7 to E-33: device token (the handlers' `AuthDevice` / `AdminDevice` extractors) and `X-Kvitt-Protocol: 1`. + let authenticated = Router::new() + .route("/me", get(me::get_me).patch(me::patch_me)) + .route( + "/me/picture", + put(pictures::put_me_picture) + .layer(DefaultBodyLimit::max(PICTURE_BODY_LIMIT_BYTES)) + .delete(pictures::delete_me_picture), + ) + .route("/users/{user_id}/picture", get(pictures::get_user_picture)) + .route( + "/invites", + post(invites::create_invite).get(invites::list_invites), + ) + .route( + "/invites/{invite_id}", + axum::routing::delete(invites::cancel_invite), + ) + .route("/devices", get(devices::list_devices)) + .route( + "/devices/{device_id}", + axum::routing::delete(devices::revoke_device), + ) + .route("/devices/current/push-token", put(devices::put_push_token)) + .route("/accounts", post(accounts::create_account)) + .route( + "/accounts/{account_id}", + axum::routing::patch(accounts::patch_account).delete(accounts::delete_account), + ) + .route( + "/accounts/{account_id}/archive", + post(accounts::archive_account), + ) + .route( + "/accounts/{account_id}/unarchive", + post(accounts::unarchive_account), + ) + .route("/categories", post(categories::create_category)) + .route( + "/categories/{category_id}", + axum::routing::patch(categories::patch_category).delete(categories::delete_category), + ) + .route( + "/categories/{category_id}/archive", + post(categories::archive_category), + ) + .route( + "/categories/{category_id}/unarchive", + post(categories::unarchive_category), + ) + .route( + "/recurring-templates", + post(recurring::create_recurring_template), + ) + .route( + "/recurring-templates/{template_id}", + axum::routing::patch(recurring::patch_recurring_template) + .delete(recurring::delete_recurring_template), + ) + .route("/sync", get(sync::sync_pull).post(sync::sync_push)) + .route("/settlements/proposal", post(proposal::propose_settlement)) + .route_layer(middleware::from_fn(auth::require_protocol)); + + Router::new() + .merge(public) + .merge(authenticated) + .fallback(fallback) + .layer(DefaultBodyLimit::max(BODY_LIMIT_BYTES)) + .layer(rate_limit::layer()) // M3: the rate limiter of SPEC §8.6 attaches here (src/api/rate_limit.rs). + .layer(middleware::from_fn(log_request)) + .layer(middleware::map_response(add_server_version)) + .with_state(state) +} + +/// Unknown paths: 404 `not_found` in the error shape. +async fn fallback() -> AppError { + AppError::not_found() +} + +/// Every response carries `X-Kvitt-Server-Version` (SPEC §14.3.1). +async fn add_server_version(mut response: Response) -> Response { + response.headers_mut().insert( + SERVER_VERSION_HEADER, + HeaderValue::from_static(SERVER_VERSION), + ); + response +} + +/// One log line per request with the fields SPEC §8.7 allows: method, route template (never the raw path, which can +/// hold invite codes), status, latency, device id (recorded by the auth extractor) and error code. +async fn log_request(request: Request, next: Next) -> Response { + let method = request.method().to_string(); + let route = request + .extensions() + .get::() + .map(|p| p.as_str().to_owned()) + .unwrap_or_else(|| "".to_owned()); + let span = tracing::info_span!( + "request", + method = %method, + route = %route, + device_id = tracing::field::Empty + ); + let started = Instant::now(); + let response = tracing::Instrument::instrument(next.run(request), span.clone()).await; + let latency_ms = started.elapsed().as_secs_f64() * 1000.0; + let status = response.status().as_u16(); + let error_code = response + .extensions() + .get::() + .map(|c| c.0.as_str()) + .unwrap_or(""); + span.in_scope(|| { + tracing::info!(status, latency_ms, error_code, "request"); + }); + response.into_response() +} diff --git a/server/crates/kvitt-server/src/tasks/mod.rs b/server/crates/kvitt-server/src/tasks/mod.rs new file mode 100644 index 0000000..f5df872 --- /dev/null +++ b/server/crates/kvitt-server/src/tasks/mod.rs @@ -0,0 +1,11 @@ +//! Background tasks started by `serve`: the recurring scheduler (SPEC §9.5), the push sender (§14.6) and +//! `sync_mutations` pruning (§14.1). M4 owns `src/tasks/**`; in M2a this starts nothing. + +use crate::AppState; +use crate::error::AppError; + +/// Starts the background tasks (spawned on the runtime; this returns once they are started). +pub async fn start(state: AppState) -> Result<(), AppError> { + let _ = state; + Ok(()) +} diff --git a/server/crates/kvitt-server/src/time.rs b/server/crates/kvitt-server/src/time.rs new file mode 100644 index 0000000..7e81700 --- /dev/null +++ b/server/crates/kvitt-server/src/time.rs @@ -0,0 +1,114 @@ +//! Time: the fixed RFC 3339 form (SPEC conventions), the `Europe/Stockholm` constant (SPEC §10.4) and an injectable +//! clock so tests control "now". + +use std::sync::{Arc, Mutex}; + +use chrono::{DateTime, NaiveDate, SecondsFormat, Utc}; +use chrono_tz::Tz; + +/// The household's time zone; a constant in code, not configuration (SPEC §10.4). +pub const STOCKHOLM: Tz = chrono_tz::Europe::Stockholm; + +/// Formats an instant in the fixed form `2026-10-17T06:00:00.000Z` (millisecond precision, literal `Z`). +pub fn format_ts(instant: DateTime) -> String { + instant.to_rfc3339_opts(SecondsFormat::Millis, true) +} + +/// Parses a timestamp in exactly the fixed form; anything else (other offsets, other precision) is `None`. +pub fn parse_ts(text: &str) -> Option> { + let instant = DateTime::parse_from_rfc3339(text).ok()?.with_timezone(&Utc); + (format_ts(instant) == text).then_some(instant) +} + +/// Parses a calendar date `YYYY-MM-DD` (exactly that form). +pub fn parse_date(text: &str) -> Option { + let date = NaiveDate::parse_from_str(text, "%Y-%m-%d").ok()?; + (date.format("%Y-%m-%d").to_string() == text).then_some(date) +} + +/// Today's calendar date in Europe/Stockholm. +pub fn stockholm_today(now: DateTime) -> NaiveDate { + now.with_timezone(&STOCKHOLM).date_naive() +} + +/// Source of "now". The server uses [`SystemClock`]; tests use [`FixedClock`]. +pub trait Clock: Send + Sync + 'static { + fn now(&self) -> DateTime; + + /// `now` in the fixed RFC 3339 form. + fn now_ts(&self) -> String { + format_ts(self.now()) + } +} + +/// The real clock. +#[derive(Debug, Clone, Copy, Default)] +pub struct SystemClock; + +impl Clock for SystemClock { + fn now(&self) -> DateTime { + Utc::now() + } +} + +/// A clock that returns a set instant until changed. +#[derive(Debug, Clone)] +pub struct FixedClock(Arc>>); + +impl FixedClock { + pub fn new(instant: DateTime) -> Self { + Self(Arc::new(Mutex::new(instant))) + } + + /// Sets the instant every clone of this clock returns from now on. + pub fn set(&self, instant: DateTime) { + match self.0.lock() { + Ok(mut guard) => *guard = instant, + Err(poisoned) => *poisoned.into_inner() = instant, + } + } +} + +impl Clock for FixedClock { + fn now(&self) -> DateTime { + match self.0.lock() { + Ok(guard) => *guard, + Err(poisoned) => *poisoned.into_inner(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fixed_form_round_trip() { + let t = parse_ts("2026-10-17T06:00:00.000Z").unwrap(); + assert_eq!(format_ts(t), "2026-10-17T06:00:00.000Z"); + assert!(parse_ts("2026-10-17T06:00:00Z").is_none()); + assert!(parse_ts("2026-10-17T08:00:00.000+02:00").is_none()); + } + + #[test] + fn dates() { + assert!(parse_date("2026-02-29").is_none()); + assert!(parse_date("2026-2-01").is_none()); + assert_eq!(parse_date("2028-02-29").unwrap().to_string(), "2028-02-29"); + } + + #[test] + fn stockholm_day_boundary() { + // 23:30 UTC on 17 October is 01:30 on 18 October in Stockholm (CEST, UTC+2). + let t = parse_ts("2026-10-17T23:30:00.000Z").unwrap(); + assert_eq!(stockholm_today(t).to_string(), "2026-10-18"); + } + + #[test] + fn fixed_clock_is_shared_by_clones() { + let clock = FixedClock::new(parse_ts("2026-10-17T06:00:00.000Z").unwrap()); + let other = clock.clone(); + clock.set(parse_ts("2026-10-18T06:00:00.000Z").unwrap()); + assert_eq!(other.now_ts(), "2026-10-18T06:00:00.000Z"); + } +} diff --git a/server/crates/kvitt-server/src/wire/account.rs b/server/crates/kvitt-server/src/wire/account.rs new file mode 100644 index 0000000..36ad72d --- /dev/null +++ b/server/crates/kvitt-server/src/wire/account.rs @@ -0,0 +1,65 @@ +//! Account objects (SPEC §14.3.3, §14.3.4 E-18, E-19). + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use super::{ServerVersionSchema, TimestampSchema, UuidSchema}; + +/// A share of an account default. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct Share { + #[schema(value_type = UuidSchema)] + pub user_id: Uuid, + #[schema(schema_with = super::int)] + pub share_bp: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct Account { + #[schema(value_type = UuidSchema)] + pub id: Uuid, + pub name: String, + /// Sorted by `user_id` ([`Account::sort`]). + #[schema(min_items = 2, max_items = 2)] + pub default_shares: Vec, + #[schema(value_type = UuidSchema)] + pub created_by_user_id: Uuid, + #[schema(value_type = TimestampSchema)] + pub created_at: String, + #[schema(value_type = TimestampSchema)] + pub updated_at: String, + #[schema(value_type = Option, required = true)] + pub archived_at: Option, + #[schema(value_type = Option, required = true)] + pub deleted_at: Option, + #[schema(value_type = ServerVersionSchema)] + pub server_version: i64, +} + +impl Account { + /// Sorts `default_shares` by `user_id` (SPEC §14.3.3). + pub fn sort(&mut self) { + self.default_shares.sort_by_key(|s| s.user_id); + } +} + +/// E-18 `POST /accounts`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct CreateAccountRequest { + pub name: String, + #[schema(min_items = 2, max_items = 2)] + pub default_shares: Vec, +} + +/// E-19 `PATCH /accounts/{account_id}`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[schema(min_properties = 1)] +pub struct PatchAccountRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = String)] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Vec, min_items = 2, max_items = 2)] + pub default_shares: Option>, +} diff --git a/server/crates/kvitt-server/src/wire/audit.rs b/server/crates/kvitt-server/src/wire/audit.rs new file mode 100644 index 0000000..d629cee --- /dev/null +++ b/server/crates/kvitt-server/src/wire/audit.rs @@ -0,0 +1,104 @@ +//! Audit entries (SPEC §14.3.3, §14.4.7). + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use super::{ServerVersionSchema, TimestampSchema, UuidSchema}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum EntityType { + Expense, + Settlement, +} + +impl EntityType { + pub fn as_str(self) -> &'static str { + match self { + Self::Expense => "expense", + Self::Settlement => "settlement", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum AuditAction { + Create, + Update, + Delete, +} + +impl AuditAction { + pub fn as_str(self) -> &'static str { + match self { + Self::Create => "create", + Self::Update => "update", + Self::Delete => "delete", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum AuditSource { + App, + Recurring, + Import, +} + +impl AuditSource { + pub fn as_str(self) -> &'static str { + match self { + Self::App => "app", + Self::Recurring => "recurring", + Self::Import => "import", + } + } +} + +/// One field of the audit diff (SPEC §14.4.7). `old` and `new` are JSON values of the field's type, or null. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct AuditChange { + pub field: String, + #[schema(value_type = AnyValue, required = true)] + pub old: serde_json::Value, + #[schema(value_type = AnyValue, required = true)] + pub new: serde_json::Value, +} + +/// `{}`: any JSON value, `null` included. +pub struct AnyValue; + +impl utoipa::PartialSchema for AnyValue { + fn schema() -> utoipa::openapi::RefOr { + utoipa::openapi::RefOr::T(utoipa::openapi::schema::Schema::Object( + utoipa::openapi::schema::ObjectBuilder::new() + .schema_type(utoipa::openapi::schema::SchemaType::AnyValue) + .build(), + )) + } +} + +impl ToSchema for AnyValue {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct AuditEntry { + #[schema(value_type = UuidSchema)] + pub id: Uuid, + pub entity_type: EntityType, + #[schema(value_type = UuidSchema)] + pub entity_id: Uuid, + pub action: AuditAction, + pub source: AuditSource, + #[schema(value_type = UuidSchema)] + pub actor_user_id: Uuid, + #[schema(value_type = TimestampSchema)] + pub client_at: String, + #[schema(value_type = TimestampSchema)] + pub server_at: String, + pub changes: Vec, + #[schema(value_type = ServerVersionSchema)] + pub server_version: i64, +} diff --git a/server/crates/kvitt-server/src/wire/category.rs b/server/crates/kvitt-server/src/wire/category.rs new file mode 100644 index 0000000..5e598d2 --- /dev/null +++ b/server/crates/kvitt-server/src/wire/category.rs @@ -0,0 +1,79 @@ +//! Category objects (SPEC §14.3.3, §14.3.4 E-23, E-24). + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use super::{ServerVersionSchema, TimestampSchema, UuidSchema}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum DefaultCategoryKey { + Groceries, + Restaurants, + Home, + Utilities, + Transport, + Travel, + Entertainment, + Health, + Shopping, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct Category { + #[schema(value_type = UuidSchema)] + pub id: Uuid, + #[schema(required = true)] + pub default_key: Option, + #[schema(required = true)] + pub name: Option, + /// An icon token of SPEC §9.7. + pub icon: String, + /// A colour token of SPEC §9.7. + pub color: String, + #[schema(schema_with = super::int)] + pub sort_order: i64, + #[schema(value_type = TimestampSchema)] + pub created_at: String, + #[schema(value_type = TimestampSchema)] + pub updated_at: String, + #[schema(value_type = Option, required = true)] + pub archived_at: Option, + #[schema(value_type = Option, required = true)] + pub deleted_at: Option, + #[schema(value_type = ServerVersionSchema)] + pub server_version: i64, +} + +/// E-23 `POST /categories`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct CreateCategoryRequest { + pub name: String, + pub icon: String, + pub color: String, +} + +/// E-24 `PATCH /categories/{category_id}`. `name`: absent = unchanged (`None`), `null` = reset a default category's +/// name (`Some(None)`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[schema(min_properties = 1)] +pub struct PatchCategoryRequest { + #[serde( + default, + deserialize_with = "super::double_option", + skip_serializing_if = "Option::is_none" + )] + #[schema(value_type = Option, required = false)] + pub name: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = String)] + pub icon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = String)] + pub color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(schema_with = super::int)] + pub sort_order: Option, +} diff --git a/server/crates/kvitt-server/src/wire/device.rs b/server/crates/kvitt-server/src/wire/device.rs new file mode 100644 index 0000000..324a50f --- /dev/null +++ b/server/crates/kvitt-server/src/wire/device.rs @@ -0,0 +1,40 @@ +//! Device objects (SPEC §14.3.3, §14.3.4 E-15, E-17). + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use super::{TimestampSchema, UuidSchema}; + +/// Only in `/me`, `/devices` and `/invites/accept`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct Device { + #[schema(value_type = UuidSchema)] + pub id: Uuid, + #[schema(value_type = UuidSchema)] + pub user_id: Uuid, + pub name: String, + #[schema(schema_with = super::nullable_int, required = true)] + pub app_version_code: Option, + #[schema(value_type = TimestampSchema)] + pub created_at: String, + #[schema(value_type = TimestampSchema)] + pub last_seen_at: String, + #[schema(value_type = Option, required = true)] + pub revoked_at: Option, + pub is_current: bool, +} + +/// E-15 `GET /devices`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct DeviceList { + pub devices: Vec, +} + +/// E-17 `PUT /devices/current/push-token`: the key is required, its value nullable (null clears). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct PushTokenRequest { + #[serde(deserialize_with = "super::required_nullable")] + #[schema(required = true)] + pub fcm_token: Option, +} diff --git a/server/crates/kvitt-server/src/wire/expense.rs b/server/crates/kvitt-server/src/wire/expense.rs new file mode 100644 index 0000000..57a517b --- /dev/null +++ b/server/crates/kvitt-server/src/wire/expense.rs @@ -0,0 +1,74 @@ +//! Expense objects (SPEC §14.3.3). + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use super::{ + DateSchema, OreSchema, PeriodSchema, ServerVersionSchema, TimestampSchema, UuidSchema, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum SplitMode { + Default, + Ratio, + Exact, + Full, +} + +/// A split of an expense; `share_bp` null for mode exact. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct Split { + #[schema(value_type = UuidSchema)] + pub user_id: Uuid, + #[schema(value_type = OreSchema)] + pub share_ore: i64, + #[schema(schema_with = super::nullable_int, required = true)] + pub share_bp: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct Expense { + #[schema(value_type = UuidSchema)] + pub id: Uuid, + #[schema(value_type = UuidSchema)] + pub account_id: Uuid, + #[schema(value_type = UuidSchema)] + pub category_id: Uuid, + #[schema(value_type = OreSchema)] + pub amount_ore: i64, + pub description: String, + pub notes: String, + #[schema(value_type = DateSchema)] + pub expense_date: String, + #[schema(value_type = UuidSchema)] + pub payer_user_id: Uuid, + pub split_mode: SplitMode, + /// Sorted by `user_id` ([`Expense::sort`]). + #[schema(min_items = 2, max_items = 2)] + pub splits: Vec, + #[schema(value_type = Option, required = true)] + pub recurring_template_id: Option, + #[schema(value_type = Option, required = true)] + pub recurring_period: Option, + #[schema(value_type = UuidSchema)] + pub created_by_user_id: Uuid, + #[schema(value_type = UuidSchema)] + pub updated_by_user_id: Uuid, + #[schema(value_type = TimestampSchema)] + pub created_at: String, + #[schema(value_type = TimestampSchema)] + pub updated_at: String, + #[schema(value_type = Option, required = true)] + pub deleted_at: Option, + #[schema(value_type = ServerVersionSchema)] + pub server_version: i64, +} + +impl Expense { + /// Sorts `splits` by `user_id` (SPEC §14.3.3). + pub fn sort(&mut self) { + self.splits.sort_by_key(|s| s.user_id); + } +} diff --git a/server/crates/kvitt-server/src/wire/invite.rs b/server/crates/kvitt-server/src/wire/invite.rs new file mode 100644 index 0000000..26b19d6 --- /dev/null +++ b/server/crates/kvitt-server/src/wire/invite.rs @@ -0,0 +1,80 @@ +//! Invite objects (SPEC §14.3.3, §14.3.4 E-3, E-12, E-13). + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use super::device::Device; +use super::user::User; +use super::{TimestampSchema, UuidSchema}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum InviteKind { + AdminSetup, + Partner, + Device, +} + +/// `code` and `link` only in the `POST /invites` response; `null` in `GET /invites`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct Invite { + #[schema(value_type = UuidSchema)] + pub id: Uuid, + pub kind: InviteKind, + #[schema(value_type = Option, required = true)] + pub target_user_id: Option, + #[schema(required = true)] + pub code: Option, + #[schema(required = true)] + pub link: Option, + #[schema(value_type = TimestampSchema)] + pub created_at: String, + #[schema(value_type = TimestampSchema)] + pub expires_at: String, +} + +/// E-13 `GET /invites`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct InviteList { + pub invites: Vec, +} + +/// E-3 `POST /invites/accept`. `display_name` is required for `admin_setup` and `partner` invites +/// (`invalid_request` if missing) and ignored for `device`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct AcceptInviteRequest { + pub code: String, + pub device_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = String)] + pub display_name: Option, +} + +/// E-3 response. The token is shown only here. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct AcceptInviteResponse { + pub token: String, + pub user: User, + pub device: Device, + pub household: Vec, +} + +/// The kinds an admin may create (E-12). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum CreateInviteKind { + Partner, + Device, +} + +/// E-12 `POST /invites`. `target_user_id` is optional and nullable (absent = null); required for `device`, must be +/// null for `partner`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct CreateInviteRequest { + #[schema(inline)] + pub kind: CreateInviteKind, + #[serde(default)] + #[schema(value_type = Option)] + pub target_user_id: Option, +} diff --git a/server/crates/kvitt-server/src/wire/mod.rs b/server/crates/kvitt-server/src/wire/mod.rs new file mode 100644 index 0000000..7170b93 --- /dev/null +++ b/server/crates/kvitt-server/src/wire/mod.rs @@ -0,0 +1,240 @@ +//! Wire objects (SPEC §14.3.3, §14.4) with their serde form and utoipa schemas. +//! +//! Conventions: ids are [`uuid::Uuid`] (lowercase, hyphenated on the wire); timestamps, dates and periods are +//! `String`s in the fixed forms of the SPEC conventions; optional response fields are `Option`s that serialise as +//! `null`, never omitted. Arrays are sorted as SPEC §14.3.3 says by the code that builds them (`sort_*` helpers). +//! Request bodies ignore unknown fields (serde's default). + +pub mod account; +pub mod audit; +pub mod category; +pub mod device; +pub mod expense; +pub mod invite; +pub mod recurring; +pub mod settlement; +pub mod sync; +pub mod user; + +pub use account::{Account, CreateAccountRequest, PatchAccountRequest, Share}; +pub use audit::{AuditAction, AuditChange, AuditEntry, AuditSource, EntityType}; +pub use category::{Category, CreateCategoryRequest, DefaultCategoryKey, PatchCategoryRequest}; +pub use device::{Device, DeviceList, PushTokenRequest}; +pub use expense::{Expense, Split, SplitMode}; +pub use invite::{ + AcceptInviteRequest, AcceptInviteResponse, CreateInviteKind, CreateInviteRequest, Invite, + InviteKind, InviteList, +}; +pub use recurring::{ + CreateRecurringTemplateRequest, PatchRecurringTemplateRequest, RecurringTemplate, TemplateSplit, +}; +pub use settlement::{Allocation, ProposalRequest, ProposalResponse, Settlement}; +pub use sync::{ + AcceptedResult, ExpenseMutation, Mutation, MutationResult, PushedExpense, PushedSettlement, + RejectedResult, RejectionReason, SettlementMutation, SyncPullResponse, SyncPushRequest, + SyncPushResponse, +}; +pub use user::{ + ApkLatest, Health, HealthStatus, MeResponse, PatchMeRequest, Role, SetupStatus, User, +}; + +use utoipa::openapi::schema::{ObjectBuilder, Schema, SchemaFormat, SchemaType, Type}; +use utoipa::openapi::{KnownFormat, RefOr}; +use utoipa::{PartialSchema, ToSchema}; + +/// Declares a schema-only marker type for a primitive of SPEC §14.3.3 (used with `#[schema(value_type = …)]`). +macro_rules! primitive_schema { + ($(#[$doc:meta])* $marker:ident, $name:literal, $schema:expr) => { + $(#[$doc])* + pub struct $marker; + + impl PartialSchema for $marker { + fn schema() -> RefOr { + let builder: ObjectBuilder = $schema; + RefOr::T(Schema::Object(builder.build())) + } + } + + impl ToSchema for $marker { + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed($name) + } + } + }; +} + +primitive_schema!( + /// `uuid`: lowercase, hyphenated, 36 characters (UUIDv7). + UuidSchema, + "Uuid", + ObjectBuilder::new() + .schema_type(SchemaType::Type(Type::String)) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::Uuid))) + .description(Some("Lowercase, hyphenated, 36 characters (UUIDv7).")) + .pattern(Some("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")) +); + +primitive_schema!( + /// `ts`: RFC 3339 UTC with millisecond precision and a literal `Z`. + TimestampSchema, + "Timestamp", + ObjectBuilder::new() + .schema_type(SchemaType::Type(Type::String)) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::DateTime))) + .description(Some("RFC 3339 UTC, millisecond precision, literal Z.")) + .pattern(Some(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$")) +); + +primitive_schema!( + /// `date`: `YYYY-MM-DD`. + DateSchema, + "Date", + ObjectBuilder::new() + .schema_type(SchemaType::Type(Type::String)) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::Date))) + .pattern(Some(r"^\d{4}-\d{2}-\d{2}$")) +); + +primitive_schema!( + /// `period`: a month, `YYYY-MM`. + PeriodSchema, + "Period", + ObjectBuilder::new() + .schema_type(SchemaType::Type(Type::String)) + .description(Some("A month, YYYY-MM.")) + .pattern(Some(r"^\d{4}-(0[1-9]|1[0-2])$")) +); + +primitive_schema!( + /// Money in öre (1/100 SEK), 64-bit. + OreSchema, + "Ore", + ObjectBuilder::new() + .schema_type(SchemaType::Type(Type::Integer)) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int64))) + .description(Some("Money in öre (1/100 SEK).")) +); + +primitive_schema!( + /// A `server_version` from the global sequence. + ServerVersionSchema, + "ServerVersionNumber", + ObjectBuilder::new() + .schema_type(SchemaType::Type(Type::Integer)) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int64))) + .description(Some("From the one global, strictly increasing sequence (SPEC §5.2).")) +); + +/// `{ "type": "integer" }` without a format: the contract leaves small integers (bp, sort order, day of month, +/// versions) unformatted. Used with `#[schema(schema_with = int)]`. +pub fn int() -> Schema { + Schema::Object( + ObjectBuilder::new() + .schema_type(SchemaType::Type(Type::Integer)) + .build(), + ) +} + +/// `{ "type": ["integer", "null"] }`. +pub fn nullable_int() -> Schema { + Schema::Object( + ObjectBuilder::new() + .schema_type(SchemaType::from_iter([Type::Integer, Type::Null])) + .build(), + ) +} + +/// `{ "type": ["integer", "null"], "format": "int64" }`. +pub fn nullable_int64() -> Schema { + Schema::Object( + ObjectBuilder::new() + .schema_type(SchemaType::from_iter([Type::Integer, Type::Null])) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int64))) + .build(), + ) +} + +/// Deserialises a field that distinguishes "absent" (`None`, via `#[serde(default)]`) from `null` (`Some(None)`), +/// for PATCH bodies where `null` means something (SPEC §14.3.4 E-24 `name`). +pub fn double_option<'de, T, D>(deserializer: D) -> Result>, D::Error> +where + T: serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + as serde::Deserialize>::deserialize(deserializer).map(Some) +} + +/// Deserialises a key that must be present but may be `null` (with `deserialize_with`, serde no longer treats a +/// missing `Option` as `None`, so absence is `invalid_request`). SPEC §14.3.4 E-17 `fcm_token`, E-28 `end_period`. +pub fn required_nullable<'de, T, D>(deserializer: D) -> Result, D::Error> +where + T: serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + as serde::Deserialize>::deserialize(deserializer) +} + +primitive_schema!( + /// `{ "type": "integer" }` without a format (query parameters and headers). + IntSchema, + "Int", + ObjectBuilder::new().schema_type(SchemaType::Type(Type::Integer)) +); + +primitive_schema!( + /// The `size` query parameter of E-11: 128 or 512, default 512. + PictureSizeSchema, + "PictureSize", + ObjectBuilder::new() + .schema_type(SchemaType::Type(Type::Integer)) + .enum_values(Some([128, 512])) + .default(Some(serde_json::json!(512))) +); + +primitive_schema!( + /// The `limit` query parameter of E-31: default 500. + SyncLimitSchema, + "SyncLimit", + ObjectBuilder::new() + .schema_type(SchemaType::Type(Type::Integer)) + .default(Some(serde_json::json!(500))) +); + +/// Declares a schema-only marker for a raw binary body of the given media type. +macro_rules! binary_schema { + ($(#[$doc:meta])* $marker:ident, $name:literal, $media:literal) => { + primitive_schema!( + $(#[$doc])* + $marker, + $name, + ObjectBuilder::new() + .schema_type(SchemaType::Type(Type::String)) + .content_media_type($media) + ); + }; +} + +binary_schema!( + /// Raw JPEG bytes. + JpegBytes, + "JpegBytes", + "image/jpeg" +); +binary_schema!( + /// Raw PNG bytes. + PngBytes, + "PngBytes", + "image/png" +); +binary_schema!( + /// Raw WebP bytes. + WebpBytes, + "WebpBytes", + "image/webp" +); +binary_schema!( + /// Raw APK bytes. + ApkBytes, + "ApkBytes", + "application/vnd.android.package-archive" +); diff --git a/server/crates/kvitt-server/src/wire/recurring.rs b/server/crates/kvitt-server/src/wire/recurring.rs new file mode 100644 index 0000000..3c8bb58 --- /dev/null +++ b/server/crates/kvitt-server/src/wire/recurring.rs @@ -0,0 +1,150 @@ +//! Recurring template objects (SPEC §14.3.3, §14.3.4 E-28, E-29). + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use super::expense::SplitMode; +use super::{ + DateSchema, OreSchema, PeriodSchema, ServerVersionSchema, TimestampSchema, UuidSchema, +}; + +/// Exactly one of `share_bp` / `share_ore` non-null. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct TemplateSplit { + #[schema(value_type = UuidSchema)] + pub user_id: Uuid, + #[schema(schema_with = super::nullable_int, required = true)] + pub share_bp: Option, + #[schema(schema_with = super::nullable_int64, required = true)] + pub share_ore: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct RecurringTemplate { + #[schema(value_type = UuidSchema)] + pub id: Uuid, + #[schema(value_type = UuidSchema)] + pub account_id: Uuid, + #[schema(value_type = UuidSchema)] + pub category_id: Uuid, + #[schema(value_type = OreSchema)] + pub amount_ore: i64, + pub description: String, + pub notes: String, + #[schema(value_type = UuidSchema)] + pub payer_user_id: Uuid, + pub split_mode: SplitMode, + /// Sorted by `user_id`; empty for mode default ([`RecurringTemplate::sort`]). + pub splits: Vec, + #[schema(schema_with = super::int)] + pub day_of_month: i64, + #[schema(value_type = PeriodSchema)] + pub start_period: String, + #[schema(value_type = Option, required = true)] + pub end_period: Option, + pub paused: bool, + #[schema(value_type = Option, required = true)] + pub last_generated_period: Option, + /// Computed with `kvitt_domain::next_occurrence_date` (SPEC §9.5). + #[schema(value_type = Option, required = true)] + pub next_occurrence_date: Option, + #[schema(value_type = UuidSchema)] + pub created_by_user_id: Uuid, + #[schema(value_type = UuidSchema)] + pub updated_by_user_id: Uuid, + #[schema(value_type = TimestampSchema)] + pub created_at: String, + #[schema(value_type = TimestampSchema)] + pub updated_at: String, + #[schema(value_type = Option, required = true)] + pub deleted_at: Option, + #[schema(value_type = ServerVersionSchema)] + pub server_version: i64, +} + +impl RecurringTemplate { + /// Sorts `splits` by `user_id` (SPEC §14.3.3). + pub fn sort(&mut self) { + self.splits.sort_by_key(|s| s.user_id); + } +} + +/// E-28 `POST /recurring-templates`. `splits` per SPEC §9.1: none for `default`; two bp entries for `ratio` and +/// `full`; two öre entries summing to the amount for `exact`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct CreateRecurringTemplateRequest { + #[schema(value_type = UuidSchema)] + pub account_id: Uuid, + #[schema(value_type = UuidSchema)] + pub category_id: Uuid, + #[schema(value_type = OreSchema)] + pub amount_ore: i64, + pub description: String, + pub notes: String, + #[schema(value_type = UuidSchema)] + pub payer_user_id: Uuid, + pub split_mode: SplitMode, + pub splits: Vec, + #[schema(schema_with = super::int)] + pub day_of_month: i64, + #[schema(value_type = PeriodSchema)] + pub start_period: String, + #[serde(deserialize_with = "super::required_nullable")] + #[schema(value_type = Option, required = true)] + pub end_period: Option, + pub paused: bool, +} + +/// E-29 `PATCH /recurring-templates/{template_id}`: any subset of the E-28 fields; `split_mode` and `splits` must +/// be sent together (the schema's `dependentRequired`, added by [`patch_template_dependent_required`]). +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, ToSchema)] +#[schema(min_properties = 1)] +pub struct PatchRecurringTemplateRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = UuidSchema)] + pub account_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = UuidSchema)] + pub category_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = OreSchema)] + pub amount_ore: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = String)] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = String)] + pub notes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = UuidSchema)] + pub payer_user_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = SplitMode)] + pub split_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Vec)] + pub splits: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(schema_with = super::int)] + pub day_of_month: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = PeriodSchema)] + pub start_period: Option, + #[serde( + default, + deserialize_with = "super::double_option", + skip_serializing_if = "Option::is_none" + )] + #[schema(value_type = Option, required = false)] + pub end_period: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = bool)] + pub paused: Option, +} + +/// The `dependentRequired` keyword of [`PatchRecurringTemplateRequest`]: `split_mode` and `splits` together. utoipa +/// has no attribute for it, so `openapi.rs` writes it into the generated schema. +pub fn patch_template_dependent_required() -> serde_json::Value { + serde_json::json!({ "split_mode": ["splits"], "splits": ["split_mode"] }) +} diff --git a/server/crates/kvitt-server/src/wire/settlement.rs b/server/crates/kvitt-server/src/wire/settlement.rs new file mode 100644 index 0000000..a155346 --- /dev/null +++ b/server/crates/kvitt-server/src/wire/settlement.rs @@ -0,0 +1,73 @@ +//! Settlement and proposal objects (SPEC §14.3.3, §14.3.4 E-33). + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use super::{DateSchema, OreSchema, ServerVersionSchema, TimestampSchema, UuidSchema}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct Allocation { + #[schema(value_type = UuidSchema)] + pub account_id: Uuid, + #[schema(value_type = OreSchema)] + pub amount_ore: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct Settlement { + #[schema(value_type = UuidSchema)] + pub id: Uuid, + #[schema(value_type = UuidSchema)] + pub from_user_id: Uuid, + #[schema(value_type = UuidSchema)] + pub to_user_id: Uuid, + #[schema(value_type = OreSchema)] + pub amount_ore: i64, + #[schema(value_type = DateSchema)] + pub settlement_date: String, + pub note: String, + /// Sorted by `account_id` ([`Settlement::sort`]). + pub allocations: Vec, + #[schema(value_type = UuidSchema)] + pub created_by_user_id: Uuid, + #[schema(value_type = UuidSchema)] + pub updated_by_user_id: Uuid, + #[schema(value_type = TimestampSchema)] + pub created_at: String, + #[schema(value_type = TimestampSchema)] + pub updated_at: String, + #[schema(value_type = Option, required = true)] + pub deleted_at: Option, + #[schema(value_type = ServerVersionSchema)] + pub server_version: i64, +} + +impl Settlement { + /// Sorts `allocations` by `account_id` (SPEC §14.3.3). + pub fn sort(&mut self) { + self.allocations.sort_by_key(|a| a.account_id); + } +} + +/// E-33 `POST /settlements/proposal`. `exclude_settlement_id` is optional and nullable (absent = null). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct ProposalRequest { + #[schema(value_type = UuidSchema)] + pub payer_user_id: Uuid, + #[schema(value_type = OreSchema)] + pub amount_ore: i64, + #[serde(default)] + #[schema(value_type = Option)] + pub exclude_settlement_id: Option, +} + +/// E-33 response, computed with `kvitt_domain::propose_allocation`. Allocations in the order of SPEC §9.4. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct ProposalResponse { + pub allocations: Vec, + #[schema(value_type = OreSchema)] + pub excess_ore: i64, + #[schema(value_type = Vec)] + pub over_settled_account_ids: Vec, +} diff --git a/server/crates/kvitt-server/src/wire/sync.rs b/server/crates/kvitt-server/src/wire/sync.rs new file mode 100644 index 0000000..5d5a32d --- /dev/null +++ b/server/crates/kvitt-server/src/wire/sync.rs @@ -0,0 +1,242 @@ +//! Sync protocol objects (SPEC §14.4.2, §14.4.3, §14.4.6). + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use super::account::Account; +use super::audit::{AuditEntry, EntityType}; +use super::category::Category; +use super::expense::{Expense, Split, SplitMode}; +use super::recurring::RecurringTemplate; +use super::settlement::{Allocation, Settlement}; +use super::user::User; +use super::{DateSchema, OreSchema, ServerVersionSchema, TimestampSchema, UuidSchema}; + +/// E-31 `GET /sync` response: one page; every array present (possibly empty) and sorted by `server_version`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct SyncPullResponse { + /// `meta.sync_epoch`, 32 lowercase hex characters. + #[schema(pattern = "^[0-9a-f]{32}$")] + pub epoch: String, + pub since: i64, + /// The highest `server_version` in the page, or `since` when the page is empty. + pub next_since: i64, + pub has_more: bool, + #[schema(value_type = TimestampSchema)] + pub server_time: String, + pub users: Vec, + pub categories: Vec, + pub accounts: Vec, + pub recurring_templates: Vec, + pub expenses: Vec, + pub settlements: Vec, + pub audit_entries: Vec, +} + +/// Exactly the client-writable fields of an expense (SPEC §14.4.3); server-owned fields are ignored if sent. A +/// delete is the full entity with `deleted_at` set. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct PushedExpense { + #[schema(value_type = UuidSchema)] + pub id: Uuid, + #[schema(value_type = UuidSchema)] + pub account_id: Uuid, + #[schema(value_type = UuidSchema)] + pub category_id: Uuid, + #[schema(value_type = OreSchema)] + pub amount_ore: i64, + pub description: String, + pub notes: String, + #[schema(value_type = DateSchema)] + pub expense_date: String, + #[schema(value_type = UuidSchema)] + pub payer_user_id: Uuid, + pub split_mode: SplitMode, + pub splits: Vec, + #[schema(value_type = TimestampSchema)] + pub created_at: String, + #[schema(value_type = TimestampSchema)] + pub updated_at: String, + #[serde(deserialize_with = "super::required_nullable")] + #[schema(value_type = Option, required = true)] + pub deleted_at: Option, +} + +/// Exactly the client-writable fields of a settlement (SPEC §14.4.3). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct PushedSettlement { + #[schema(value_type = UuidSchema)] + pub id: Uuid, + #[schema(value_type = UuidSchema)] + pub from_user_id: Uuid, + #[schema(value_type = UuidSchema)] + pub to_user_id: Uuid, + #[schema(value_type = OreSchema)] + pub amount_ore: i64, + #[schema(value_type = DateSchema)] + pub settlement_date: String, + pub note: String, + pub allocations: Vec, + #[schema(value_type = TimestampSchema)] + pub created_at: String, + #[schema(value_type = TimestampSchema)] + pub updated_at: String, + #[serde(deserialize_with = "super::required_nullable")] + #[schema(value_type = Option, required = true)] + pub deleted_at: Option, +} + +/// The constant `"expense"` (the discriminator value of [`ExpenseMutation`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub enum ExpenseTag { + #[serde(rename = "expense")] + Expense, +} + +/// The constant `"settlement"` (the discriminator value of [`SettlementMutation`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub enum SettlementTag { + #[serde(rename = "settlement")] + Settlement, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct ExpenseMutation { + #[schema(value_type = UuidSchema)] + pub mutation_id: Uuid, + #[schema(inline)] + pub entity_type: ExpenseTag, + pub entity: PushedExpense, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct SettlementMutation { + #[schema(value_type = UuidSchema)] + pub mutation_id: Uuid, + #[schema(inline)] + pub entity_type: SettlementTag, + pub entity: PushedSettlement, +} + +/// One pushed change, discriminated by `entity_type`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(untagged)] +#[schema(discriminator(property_name = "entity_type", mapping( + ("expense" = "#/components/schemas/ExpenseMutation"), + ("settlement" = "#/components/schemas/SettlementMutation"), +)))] +pub enum Mutation { + Expense(ExpenseMutation), + Settlement(SettlementMutation), +} + +impl Mutation { + pub fn mutation_id(&self) -> Uuid { + match self { + Self::Expense(m) => m.mutation_id, + Self::Settlement(m) => m.mutation_id, + } + } + + pub fn entity_type(&self) -> EntityType { + match self { + Self::Expense(_) => EntityType::Expense, + Self::Settlement(_) => EntityType::Settlement, + } + } +} + +/// E-32 `POST /sync` request: 1 to 100 mutations (more is 422 `batch_too_large`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct SyncPushRequest { + pub mutations: Vec, +} + +/// A full wire object of the type named by `entity_type`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(untagged)] +pub enum SyncEntity { + Expense(Expense), + Settlement(Settlement), +} + +/// The constant `"accepted"`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub enum AcceptedTag { + #[serde(rename = "accepted")] + Accepted, +} + +/// The constant `"rejected"`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub enum RejectedTag { + #[serde(rename = "rejected")] + Rejected, +} + +/// Exhaustive (SPEC §14.4.6). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum RejectionReason { + Stale, + Deleted, + ValidationFailed, + ReferenceNotFound, + ClockSkew, +} + +/// An accepted mutation. Accepted and rejected results have different key sets (SPEC §14.4.3). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct AcceptedResult { + #[schema(value_type = UuidSchema)] + pub mutation_id: Uuid, + pub entity_type: EntityType, + #[schema(value_type = UuidSchema)] + pub entity_id: Uuid, + #[schema(inline)] + pub status: AcceptedTag, + #[schema(value_type = ServerVersionSchema)] + pub server_version: i64, + // The full wire object after processing. (A doc comment here would make utoipa wrap the schema in `allOf`.) + #[schema(inline)] + pub entity: SyncEntity, +} + +/// A rejected mutation (SPEC §14.4.6). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct RejectedResult { + #[schema(value_type = UuidSchema)] + pub mutation_id: Uuid, + pub entity_type: EntityType, + #[schema(value_type = UuidSchema)] + pub entity_id: Uuid, + #[schema(inline)] + pub status: RejectedTag, + pub reason: RejectionReason, + /// For validation failures, the domain error token of SPEC §14.5.3. + pub message: String, + // The stored row, or null when the server has no row with that id. + #[schema(inline, required = true)] + pub current: Option, +} + +/// One result per mutation, discriminated by `status`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(untagged)] +#[schema(discriminator(property_name = "status", mapping( + ("accepted" = "#/components/schemas/AcceptedResult"), + ("rejected" = "#/components/schemas/RejectedResult"), +)))] +pub enum MutationResult { + Accepted(AcceptedResult), + Rejected(RejectedResult), +} + +/// E-32 response: one result per mutation, in request order. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct SyncPushResponse { + #[schema(value_type = TimestampSchema)] + pub server_time: String, + pub results: Vec, +} diff --git a/server/crates/kvitt-server/src/wire/user.rs b/server/crates/kvitt-server/src/wire/user.rs new file mode 100644 index 0000000..68b20a1 --- /dev/null +++ b/server/crates/kvitt-server/src/wire/user.rs @@ -0,0 +1,105 @@ +//! User, health, setup, `/me` and APK objects (SPEC §14.3.3, §14.3.4 E-1, E-2, E-5, E-7, E-8). + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use super::device::Device; +use super::{ServerVersionSchema, TimestampSchema, UuidSchema}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum Role { + Admin, + Member, +} + +impl Role { + pub fn as_str(self) -> &'static str { + match self { + Self::Admin => "admin", + Self::Member => "member", + } + } + + /// The `users.role` column value. + pub fn from_db(text: &str) -> Option { + match text { + "admin" => Some(Self::Admin), + "member" => Some(Self::Member), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct User { + #[schema(value_type = UuidSchema)] + pub id: Uuid, + pub role: Role, + pub display_name: String, + #[schema(schema_with = super::nullable_int, required = true)] + pub picture_version: Option, + #[schema(value_type = TimestampSchema)] + pub created_at: String, + #[schema(value_type = TimestampSchema)] + pub updated_at: String, + #[schema(value_type = ServerVersionSchema)] + pub server_version: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum HealthStatus { + Ok, + Degraded, +} + +/// E-1 `GET /health`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[schema(as = Health)] +pub struct Health { + #[schema(inline)] + pub status: HealthStatus, + pub server_version: String, + #[schema(schema_with = super::int)] + pub schema_version: i64, + pub db_ok: bool, +} + +/// E-2 `GET /setup/status`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct SetupStatus { + pub initialized: bool, +} + +/// E-5 `GET /apk/latest`: the content of `latest.json` (SPEC §10.6). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct ApkLatest { + #[schema(schema_with = super::int)] + pub version_code: i64, + pub version_name: String, + pub file_name: String, + pub size_bytes: i64, + pub sha256: String, + #[schema(value_type = TimestampSchema)] + pub released_at: String, +} + +/// E-7 `GET /me`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct MeResponse { + pub user: User, + pub device: Device, + pub household: Vec, + pub public_url: String, +} + +/// E-8 `PATCH /me`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[schema(min_properties = 1)] +pub struct PatchMeRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = String)] + pub display_name: Option, +} diff --git a/server/crates/kvitt-server/tests/openapi_contract.rs b/server/crates/kvitt-server/tests/openapi_contract.rs new file mode 100644 index 0000000..e9ab4da --- /dev/null +++ b/server/crates/kvitt-server/tests/openapi_contract.rs @@ -0,0 +1,511 @@ +//! The utoipa document equals `contracts/openapi.yaml` (SPEC §14.3, §11.2 M2a). +//! +//! Both documents are parsed into JSON values and brought to one canonical form before comparing. The comparison +//! fails on any added, missing or differently typed field, path, operation, parameter, request body, response, +//! response header, security requirement or enum value. The normalisations, all of which are representational (they +//! do not change what a client may send or will receive): +//! +//! 1. Key order: JSON objects are compared as maps. +//! 2. `$ref` naming and placement: every local `$ref` is replaced by the (normalised) value it points to, so it does +//! not matter whether something is a named component or inline, or what the component is called. Sibling keys +//! next to a `$ref` override the target's (OpenAPI 3.1 Reference Object). Discriminator `mapping` values are +//! references too and are resolved the same way. Components that no operation reaches are compared by name. +//! 3. Documentation text: `description` and `example`/`examples` keywords are dropped everywhere (property *names* +//! called `description` are kept). Operation `summary` and `operationId` are compared. +//! 4. Nullable spellings: `type: [T, "null"]` is rewritten to `oneOf: [{type: T, …}, {type: "null"}]`; nested +//! `oneOf`s are flattened and their members compared as a set (order of alternatives carries no meaning). +//! 5. `const: v` is rewritten to `enum: [v]` with the JSON type of `v`; `enum` values and `required` lists are +//! compared as sets. +//! 6. Defaults written out or left implicit: `required: false` and `deprecated: false` are dropped; a path parameter +//! is always required. +//! 7. Parameters are compared as a set keyed by `(in, name)` (their order carries no meaning). +//! 8. Security: each operation carries its effective requirement (its own `security`, or the document's); the +//! document-level key itself is then dropped. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +use serde_json::{Map, Value, json}; + +const NAME_MAPS: &[&str] = &[ + "properties", + "patternProperties", + "dependentRequired", + "headers", + "content", + "responses", + "mapping", + "securitySchemes", + "$defs", +]; +const DOC_KEYWORDS: &[&str] = &["description", "example", "examples"]; +const METHODS: &[&str] = &[ + "get", "put", "post", "delete", "patch", "options", "head", "trace", +]; +const COMPONENT_KINDS: &[&str] = &[ + "schemas", + "responses", + "parameters", + "headers", + "requestBodies", +]; + +fn contract() -> Value { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../contracts/openapi.yaml"); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + serde_norway::from_str(&text).expect("contracts/openapi.yaml is YAML") +} + +fn generated() -> Value { + serde_json::to_value(kvitt_server::openapi::document()).expect("document serialises") +} + +/// Resolves `#/a/b/c` in `root`. +fn pointer<'a>(root: &'a Value, reference: &str) -> &'a Value { + let pointer = reference + .strip_prefix('#') + .unwrap_or_else(|| panic!("only local refs are supported: {reference}")); + root.pointer(pointer) + .unwrap_or_else(|| panic!("dangling $ref {reference}")) +} + +struct Resolver<'a> { + root: &'a Value, + reached: BTreeSet, +} + +impl Resolver<'_> { + /// Replaces every `$ref` (and discriminator mapping value) by its target, recursively. + fn inline(&mut self, value: &Value, stack: &mut Vec) -> Value { + match value { + Value::Object(map) => { + if let Some(Value::String(reference)) = map.get("$ref") { + assert!(!stack.contains(reference), "recursive $ref {reference}"); + self.reached.insert(reference.clone()); + stack.push(reference.clone()); + let target = self.inline(pointer(self.root, reference), stack); + stack.pop(); + let mut merged = match target { + Value::Object(t) => t, + other => return other, + }; + for (k, v) in map { + if k != "$ref" { + merged.insert(k.clone(), self.inline(v, stack)); + } + } + return Value::Object(merged); + } + let mut out = Map::new(); + for (k, v) in map { + if k == "discriminator" { + let mut d = v.clone(); + if let Some(Value::Object(mapping)) = v.get("mapping") { + let resolved: Map = mapping + .iter() + .map(|(name, target)| { + let reference = + target.as_str().expect("mapping value is a ref string"); + ( + name.clone(), + self.inline(&json!({ "$ref": reference }), stack), + ) + }) + .collect(); + d["mapping"] = Value::Object(resolved); + } + out.insert(k.clone(), d); + } else { + out.insert(k.clone(), self.inline(v, stack)); + } + } + Value::Object(out) + } + Value::Array(items) => { + Value::Array(items.iter().map(|v| self.inline(v, stack)).collect()) + } + other => other.clone(), + } + } +} + +fn json_type(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(n) if n.is_i64() || n.is_u64() => "integer", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +fn sorted_set(items: Vec) -> Vec { + let mut by_text: BTreeMap = BTreeMap::new(); + for item in items { + by_text.insert(item.to_string(), item); + } + by_text.into_values().collect() +} + +/// The canonical form (normalisations 3 to 7). `names` is true when the object's keys are names, not keywords. +fn canon(value: &Value, names: bool) -> Value { + match value { + Value::Object(map) if names => Value::Object( + map.iter() + .map(|(k, v)| (k.clone(), canon(v, false))) + .collect(), + ), + Value::Object(map) => { + let mut out = Map::new(); + for (k, v) in map { + if DOC_KEYWORDS.contains(&k.as_str()) { + continue; + } + if (k == "required" || k == "deprecated") && *v == Value::Bool(false) { + continue; + } + let child = if k == "parameters" { + canon_parameters(v) + } else { + canon(v, NAME_MAPS.contains(&k.as_str())) + }; + out.insert(k.clone(), child); + } + if let Some(constant) = out.remove("const") { + out.entry("type") + .or_insert_with(|| json!(json_type(&constant))); + out.insert("enum".into(), json!([constant])); + } + if let Some(Value::Array(types)) = out.get("type").cloned() { + if types.len() > 1 { + out.remove("type"); + let mut base = out.clone(); + let mut alternatives = Vec::new(); + for t in types { + if t == "null" { + alternatives.push(json!({ "type": "null" })); + } else { + base.insert("type".into(), t); + alternatives.push(Value::Object(base.clone())); + } + } + out = Map::new(); + out.insert("oneOf".into(), json!(alternatives)); + } else if let Some(t) = types.into_iter().next() { + out.insert("type".into(), t); + } + } + for key in ["oneOf", "anyOf"] { + if let Some(Value::Array(alternatives)) = out.remove(key) { + let mut flat = Vec::new(); + for alternative in alternatives { + let alternative = canon(&alternative, false); + match alternative.get(key) { + Some(Value::Array(inner)) + if alternative.as_object().is_some_and(|o| o.len() == 1) => + { + flat.extend(inner.iter().cloned()) + } + _ => flat.push(alternative), + } + } + out.insert(key.into(), Value::Array(sorted_set(flat))); + } + } + for key in ["enum", "required"] { + if let Some(Value::Array(items)) = out.remove(key) { + out.insert(key.into(), Value::Array(sorted_set(items))); + } + } + Value::Object(out) + } + Value::Array(items) => Value::Array(items.iter().map(|v| canon(v, false)).collect()), + other => other.clone(), + } +} + +/// Normalisation 7 (and the path-parameter half of 6): parameters as a map keyed by `in name`. +fn canon_parameters(value: &Value) -> Value { + let Value::Array(items) = value else { + return canon(value, true); + }; + let mut out = Map::new(); + for item in items { + let mut p = canon(item, false); + let location = p["in"].as_str().unwrap_or_default().to_owned(); + let name = p["name"].as_str().unwrap_or_default().to_owned(); + if location == "path" { + p["required"] = json!(true); + } + let key = format!("{location} {name}"); + assert!(!out.contains_key(&key), "duplicate parameter {key}"); + out.insert(key, p); + } + Value::Object(out) +} + +/// The whole normalised document. +fn normalise(doc: &Value) -> Value { + let mut resolver = Resolver { + root: doc, + reached: BTreeSet::new(), + }; + let root_security = doc.get("security").cloned().unwrap_or(json!([])); + let mut operations = Map::new(); + for (path, item) in doc["paths"].as_object().expect("paths") { + let item = resolver.inline(item, &mut Vec::new()); + let mut methods = Map::new(); + for (method, operation) in item.as_object().expect("path item") { + assert!( + METHODS.contains(&method.as_str()), + "{path}: path-level key {method} is not supported by this comparison" + ); + let mut operation = operation.clone(); + let security = operation + .get("security") + .cloned() + .unwrap_or_else(|| root_security.clone()); + operation["security"] = security; + operation.as_object_mut().map(|o| o.remove("tags")); + methods.insert(method.clone(), canon(&operation, false)); + } + operations.insert(path.clone(), Value::Object(methods)); + } + + let mut unreachable = Map::new(); + if let Some(components) = doc.get("components") { + for kind in COMPONENT_KINDS { + for (name, value) in components + .get(*kind) + .and_then(Value::as_object) + .into_iter() + .flatten() + { + let reference = format!("#/components/{kind}/{name}"); + if !resolver.reached.contains(&reference) { + let mut inner = Resolver { + root: doc, + reached: BTreeSet::new(), + }; + unreachable.insert( + reference, + canon(&inner.inline(value, &mut Vec::new()), false), + ); + } + } + } + } + + json!({ + "openapi": doc["openapi"], + "info": { "title": doc["info"]["title"], "version": doc["info"]["version"] }, + "servers": canon(&doc["servers"], false), + "securitySchemes": canon(&doc["components"]["securitySchemes"], true), + "operations": Value::Object(operations), + "unreachable_components": Value::Object(unreachable), + "info_extra_keys": doc["info"].as_object().map(|o| { + o.keys().filter(|k| !["title", "version", "description"].contains(&k.as_str())).cloned().collect::>() + }), + }) +} + +/// Paths (JSON-pointer style) where `a` and `b` differ. +fn short(v: &Value) -> String { + let text = v.to_string(); + if text.chars().count() > 300 { + format!("{}…", text.chars().take(300).collect::()) + } else { + text + } +} + +fn diff(a: &Value, b: &Value, at: String, out: &mut Vec) { + match (a, b) { + (Value::Object(x), Value::Object(y)) => { + let keys: BTreeSet<&String> = x.keys().chain(y.keys()).collect(); + for k in keys { + let here = format!("{at}/{}", k.replace('~', "~0").replace('/', "~1")); + match (x.get(k), y.get(k)) { + (Some(v), Some(w)) => diff(v, w, here, out), + (Some(v), None) => out.push(format!("{here}: only in contract: {}", short(v))), + (None, Some(w)) => out.push(format!("{here}: only in generated: {}", short(w))), + (None, None) => {} + } + } + } + (Value::Array(x), Value::Array(y)) if x.len() == y.len() => { + for (i, (v, w)) in x.iter().zip(y).enumerate() { + diff(v, w, format!("{at}/{i}"), out); + } + } + _ if a == b => {} + _ => out.push(format!( + "{at}: contract {} != generated {}", + short(a), + short(b) + )), + } +} + +fn differences(contract: &Value, generated: &Value) -> Vec { + let mut out = Vec::new(); + diff( + &normalise(contract), + &normalise(generated), + String::new(), + &mut out, + ); + out +} + +#[test] +fn generated_document_equals_contract() { + let contract = contract(); + let generated = generated(); + let diffs = differences(&contract, &generated); + assert!( + diffs.is_empty(), + "{} differences between contracts/openapi.yaml and the utoipa document:\n{}", + diffs.len(), + diffs + .iter() + .take(80) + .cloned() + .collect::>() + .join("\n") + ); + // The contract has 33 operations (SPEC §7, E-1 to E-33); make sure none were lost on both sides. + let count = normalise(&generated)["operations"] + .as_object() + .map(|paths| { + paths + .values() + .filter_map(Value::as_object) + .map(|m| m.len()) + .sum::() + }) + .unwrap_or(0); + assert_eq!(count, 33); +} + +// The comparison itself must be strict: each mutation of the contract below must be detected. + +fn mutated(f: impl FnOnce(&mut Value)) -> Vec { + let contract = contract(); + let mut other = contract.clone(); + f(&mut other); + differences(&contract, &other) +} + +#[test] +fn detects_an_added_property() { + assert!( + !mutated(|d| { + d["components"]["schemas"]["User"]["properties"]["extra"] = json!({ "type": "string" }); + }) + .is_empty() + ); +} + +#[test] +fn detects_a_missing_required_field() { + assert!( + !mutated(|d| { + d["components"]["schemas"]["Account"]["required"] + .as_array_mut() + .unwrap() + .retain(|v| v != "deleted_at"); + }) + .is_empty() + ); +} + +#[test] +fn detects_a_changed_type() { + assert!( + !mutated(|d| { + d["components"]["schemas"]["Share"]["properties"]["share_bp"] = + json!({ "type": "string" }); + }) + .is_empty() + ); +} + +#[test] +fn detects_a_removed_enum_value() { + assert!( + !mutated(|d| { + d["components"]["schemas"]["ErrorCode"]["enum"] + .as_array_mut() + .unwrap() + .pop(); + }) + .is_empty() + ); +} + +#[test] +fn detects_a_removed_parameter() { + assert!( + !mutated(|d| { + d["paths"]["/sync"]["get"]["parameters"] + .as_array_mut() + .unwrap() + .remove(0); + }) + .is_empty() + ); +} + +#[test] +fn detects_a_changed_security_requirement() { + assert!( + !mutated(|d| { + d["paths"]["/me"]["get"]["security"] = json!([]); + }) + .is_empty() + ); +} + +#[test] +fn detects_a_removed_operation_and_response() { + assert!( + !mutated(|d| { + d["paths"]["/me"].as_object_mut().unwrap().remove("patch"); + }) + .is_empty() + ); + assert!( + !mutated(|d| { + d["paths"]["/me"]["get"]["responses"] + .as_object_mut() + .unwrap() + .remove("426"); + }) + .is_empty() + ); +} + +#[test] +fn ignores_key_order_ref_naming_and_descriptions() { + assert!( + mutated(|d| { + // Rename a schema component and point every reference at the new name. + let text = serde_json::to_string(d).unwrap().replace( + "#/components/schemas/Share\"", + "#/components/schemas/DefaultShare\"", + ); + *d = serde_json::from_str(&text).unwrap(); + let share = d["components"]["schemas"] + .as_object_mut() + .unwrap() + .remove("Share") + .unwrap(); + d["components"]["schemas"]["DefaultShare"] = share; + d["components"]["schemas"]["User"]["description"] = json!("changed wording"); + }) + .is_empty() + ); +} diff --git a/server/crates/kvitt-server/tests/skeleton/errors.rs b/server/crates/kvitt-server/tests/skeleton/errors.rs new file mode 100644 index 0000000..9ceedc8 --- /dev/null +++ b/server/crates/kvitt-server/tests/skeleton/errors.rs @@ -0,0 +1,93 @@ +//! Every error code of SPEC §14.3.2 serialises like `contracts/examples/errors/.json`, with its HTTP status. + +use std::path::PathBuf; + +use axum::http::StatusCode; +use axum::response::IntoResponse; +use http_body_util::BodyExt; +use kvitt_server::error::{AppError, ErrorBody, ErrorCode}; +use serde_json::Value; + +const STATUS: &[(&str, u16)] = &[ + ("invalid_request", 400), + ("invite_invalid", 400), + ("unauthorized", 401), + ("device_revoked", 401), + ("forbidden", 403), + ("not_found", 404), + ("household_full", 409), + ("household_incomplete", 409), + ("name_taken", 409), + ("account_not_empty", 409), + ("category_in_use", 409), + ("category_is_default", 409), + ("cannot_revoke_current_device", 409), + ("no_accounts", 409), + ("cursor_ahead", 409), + ("payload_too_large", 413), + ("unsupported_media_type", 415), + ("validation_failed", 422), + ("image_invalid", 422), + ("batch_too_large", 422), + ("protocol_unsupported", 426), + ("rate_limited", 429), + ("internal", 500), + ("not_implemented", 501), + ("unavailable", 503), +]; + +#[test] +fn every_code_has_the_status_of_the_spec_table() { + assert_eq!(ErrorCode::ALL.len(), STATUS.len()); + for (code, (token, status)) in ErrorCode::ALL.iter().zip(STATUS) { + assert_eq!(code.as_str(), *token); + assert_eq!(code.status().as_u16(), *status, "{token}"); + } +} + +#[tokio::test] +async fn every_error_example_round_trips_through_app_error() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../contracts/examples/errors"); + let mut seen = 0; + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + let example: Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + let body: ErrorBody = serde_json::from_value(example.clone()).unwrap(); + let stem = path.file_stem().unwrap().to_string_lossy().into_owned(); + assert_eq!( + body.code.as_str(), + stem, + "{}: file name is the code", + path.display() + ); + + let mut error = AppError::new(body.code, body.message.clone()); + if let Some(field) = &body.field { + error = error.with_field(field); + } + let response = error.into_response(); + let status = response.status(); + assert_eq!( + response.headers()["content-type"], + "application/json; charset=utf-8" + ); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let json: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(json, example, "{stem}"); + let expected = STATUS + .iter() + .find(|(t, _)| *t == stem) + .map(|(_, s)| *s) + .unwrap(); + assert_eq!(status, StatusCode::from_u16(expected).unwrap()); + seen += 1; + } + assert_eq!(seen, ErrorCode::ALL.len(), "one example per error code"); +} + +#[test] +fn rate_limited_carries_retry_after() { + let response = AppError::rate_limited(30).into_response(); + assert_eq!(response.headers()["retry-after"], "30"); +} diff --git a/server/crates/kvitt-server/tests/skeleton/main.rs b/server/crates/kvitt-server/tests/skeleton/main.rs new file mode 100644 index 0000000..e074347 --- /dev/null +++ b/server/crates/kvitt-server/tests/skeleton/main.rs @@ -0,0 +1,8 @@ +//! Integration tests of the M2a server skeleton: migration identity and pragmas, routes, authentication and the +//! protocol rule, error shapes, and the wire types against `contracts/examples/`. + +mod errors; +mod migration; +mod routes; +mod support; +mod wire_examples; diff --git a/server/crates/kvitt-server/tests/skeleton/migration.rs b/server/crates/kvitt-server/tests/skeleton/migration.rs new file mode 100644 index 0000000..3028877 --- /dev/null +++ b/server/crates/kvitt-server/tests/skeleton/migration.rs @@ -0,0 +1,96 @@ +//! `0001_init.sql` is the contract schema byte for byte (SPEC §14.1), and connections carry the §8.9 pragmas. + +use std::path::PathBuf; + +use kvitt_server::db; + +use crate::support::TestApp; + +fn manifest(path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path) +} + +#[test] +fn migration_0001_is_byte_identical_to_contract_schema() { + let migration = std::fs::read(manifest("migrations/0001_init.sql")).unwrap(); + let contract = std::fs::read(manifest("../../../contracts/schema/server-v1.sql")).unwrap(); + assert!( + migration == contract, + "migrations/0001_init.sql differs from contracts/schema/server-v1.sql" + ); +} + +#[test] +fn the_embedded_migrator_holds_exactly_that_migration() { + let contract = + std::fs::read_to_string(manifest("../../../contracts/schema/server-v1.sql")).unwrap(); + let migrations: Vec<_> = db::MIGRATOR.iter().collect(); + assert_eq!(migrations.len(), 1); + assert_eq!(migrations[0].version, 1); + assert_eq!(migrations[0].sql.as_ref(), contract.as_str()); +} + +#[tokio::test] +async fn migrated_database_has_schema_version_seed_rows_and_pragmas() { + let app = TestApp::empty().await; + let pool = &app.state.db; + let version: String = sqlx::query_scalar("SELECT value FROM meta WHERE key = 'schema_version'") + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(version, db::SCHEMA_VERSION.to_string()); + let epoch: String = sqlx::query_scalar("SELECT value FROM meta WHERE key = 'sync_epoch'") + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(epoch.len(), 32); + let categories: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM categories") + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(categories, 10); + + let mut conn = pool.acquire().await.unwrap(); + let journal: String = sqlx::query_scalar("PRAGMA journal_mode") + .fetch_one(&mut *conn) + .await + .unwrap(); + assert_eq!(journal, "wal"); + let fk: i64 = sqlx::query_scalar("PRAGMA foreign_keys") + .fetch_one(&mut *conn) + .await + .unwrap(); + assert_eq!(fk, 1); + let sync: i64 = sqlx::query_scalar("PRAGMA synchronous") + .fetch_one(&mut *conn) + .await + .unwrap(); + assert_eq!(sync, 1, "synchronous = NORMAL"); + let busy: i64 = sqlx::query_scalar("PRAGMA busy_timeout") + .fetch_one(&mut *conn) + .await + .unwrap(); + assert_eq!(busy, 5000); +} + +#[tokio::test] +async fn migrating_twice_is_a_no_op() { + let app = TestApp::empty().await; + db::migrate(&app.state.db).await.unwrap(); +} + +#[tokio::test] +async fn server_version_sequence_starts_after_the_seed_and_increments() { + let app = TestApp::empty().await; + let mut tx = db::begin_immediate(&app.state.db).await.unwrap(); + assert_eq!(db::next_server_version(&mut tx).await.unwrap(), 11); + assert_eq!(db::next_server_version(&mut tx).await.unwrap(), 12); + tx.commit().await.unwrap(); + let mut conn = app.state.db.acquire().await.unwrap(); + assert_eq!( + db::sequence::current_server_version(&mut conn) + .await + .unwrap(), + 12 + ); +} diff --git a/server/crates/kvitt-server/tests/skeleton/routes.rs b/server/crates/kvitt-server/tests/skeleton/routes.rs new file mode 100644 index 0000000..47bbfa5 --- /dev/null +++ b/server/crates/kvitt-server/tests/skeleton/routes.rs @@ -0,0 +1,336 @@ +//! Every route of SPEC §7 is registered; E-1 and E-2 work; the rest answer 501 `not_implemented` behind the +//! protocol rule (SPEC §14.3.1) and the auth extractor (SPEC §8.2, §8.4). + +use axum::Router; +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use axum::routing::post; +use chrono::Duration; +use http_body_util::BodyExt; +use kvitt_server::api::ApiJson; +use kvitt_server::time::{Clock, parse_ts}; +use serde_json::{Value, json}; +use tower::ServiceExt; + +use crate::support::*; + +const ID: &str = "0192a003-0000-7000-8000-000000000001"; + +/// (method, path, admin only) of every authenticated route, E-7 to E-33. +fn authenticated_routes() -> Vec<(Method, String, bool)> { + let id = |p: &str| p.replace("{id}", ID); + vec![ + (Method::GET, "/me".into(), false), + (Method::PATCH, "/me".into(), false), + (Method::PUT, "/me/picture".into(), false), + (Method::DELETE, "/me/picture".into(), false), + (Method::GET, id("/users/{id}/picture"), false), + (Method::POST, "/invites".into(), true), + (Method::GET, "/invites".into(), true), + (Method::DELETE, id("/invites/{id}"), true), + (Method::GET, "/devices".into(), false), + (Method::DELETE, id("/devices/{id}"), false), + (Method::PUT, "/devices/current/push-token".into(), false), + (Method::POST, "/accounts".into(), false), + (Method::PATCH, id("/accounts/{id}"), false), + (Method::POST, id("/accounts/{id}/archive"), false), + (Method::POST, id("/accounts/{id}/unarchive"), false), + (Method::DELETE, id("/accounts/{id}"), false), + (Method::POST, "/categories".into(), false), + (Method::PATCH, id("/categories/{id}"), false), + (Method::POST, id("/categories/{id}/archive"), false), + (Method::POST, id("/categories/{id}/unarchive"), false), + (Method::DELETE, id("/categories/{id}"), false), + (Method::POST, "/recurring-templates".into(), false), + (Method::PATCH, id("/recurring-templates/{id}"), false), + (Method::DELETE, id("/recurring-templates/{id}"), false), + (Method::GET, "/sync?since=0".into(), false), + (Method::POST, "/sync".into(), false), + (Method::POST, "/settlements/proposal".into(), false), + ] +} + +/// E-3 to E-6 (E-1 and E-2 are implemented). +fn public_stub_routes() -> Vec<(Method, &'static str)> { + vec![ + (Method::POST, "/invites/accept"), + (Method::GET, "/i/7K3MQX9D2HPA"), + (Method::GET, "/apk/latest"), + (Method::GET, "/apk/files/kvitt-1.apk"), + ] +} + +fn assert_error(reply: &Reply, status: StatusCode, code: &str) { + assert_eq!(reply.status, status, "{:?}", reply.json); + assert_eq!(reply.code(), code); + assert!(reply.json["message"].is_string()); + assert!(reply.json.as_object().unwrap().contains_key("field")); + assert_eq!( + reply.headers["content-type"], + "application/json; charset=utf-8" + ); +} + +#[tokio::test] +async fn every_route_is_registered_and_answers_501_until_implemented() { + let app = TestApp::household().await; + let routes = authenticated_routes(); + assert_eq!( + routes.len() + public_stub_routes().len() + 2, + 33, + "SPEC §7 has 33 operations" + ); + for (method, path, _) in routes { + let reply = app + .send(authed(method.clone(), &path, &admin_token())) + .await; + assert_error(&reply, StatusCode::NOT_IMPLEMENTED, "not_implemented"); + assert_eq!( + reply.json, + json!({"code": "not_implemented", "message": "not implemented", "field": null}), + "{method} {path}" + ); + assert_eq!( + reply.headers["x-kvitt-server-version"], + env!("CARGO_PKG_VERSION") + ); + } + for (method, path) in public_stub_routes() { + let reply = app.send(request(method.clone(), path, None, None)).await; + assert_error(&reply, StatusCode::NOT_IMPLEMENTED, "not_implemented"); + } +} + +#[tokio::test] +async fn health_reports_ok_with_versions() { + let app = TestApp::empty().await; + let reply = app.send(request(Method::GET, "/health", None, None)).await; + assert_eq!(reply.status, StatusCode::OK); + assert_eq!( + reply.json, + json!({"status": "ok", "server_version": env!("CARGO_PKG_VERSION"), "schema_version": 1, "db_ok": true}) + ); + assert_eq!( + reply.headers["x-kvitt-server-version"], + env!("CARGO_PKG_VERSION") + ); + assert_eq!( + reply.headers["content-type"], + "application/json; charset=utf-8" + ); +} + +#[tokio::test] +async fn health_is_503_degraded_when_the_database_is_gone() { + let app = TestApp::empty().await; + app.state.db.close().await; + let reply = app.send(request(Method::GET, "/health", None, None)).await; + assert_eq!(reply.status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(reply.json["status"], "degraded"); + assert_eq!(reply.json["db_ok"], false); + assert_eq!(reply.json["schema_version"], 1); +} + +#[tokio::test] +async fn setup_status_follows_the_admin_user() { + let empty = TestApp::empty().await; + let reply = empty + .send(request(Method::GET, "/setup/status", None, None)) + .await; + assert_eq!(reply.status, StatusCode::OK); + assert_eq!(reply.json, json!({"initialized": false})); + + let household = TestApp::household().await; + let reply = household + .send(request(Method::GET, "/setup/status", None, None)) + .await; + assert_eq!(reply.json, json!({"initialized": true})); +} + +#[tokio::test] +async fn authenticated_routes_need_protocol_1_before_anything_else() { + let app = TestApp::household().await; + for (method, path, _) in authenticated_routes() { + let missing = app + .send(request(method.clone(), &path, Some(&admin_token()), None)) + .await; + assert_error( + &missing, + StatusCode::UPGRADE_REQUIRED, + "protocol_unsupported", + ); + let wrong = app + .send(request( + method.clone(), + &path, + Some(&admin_token()), + Some("2"), + )) + .await; + assert_error(&wrong, StatusCode::UPGRADE_REQUIRED, "protocol_unsupported"); + let no_token = app.send(request(method.clone(), &path, None, None)).await; + assert_error( + &no_token, + StatusCode::UPGRADE_REQUIRED, + "protocol_unsupported", + ); + } + // Unauthenticated endpoints do not enforce it. + let reply = app + .send(request(Method::GET, "/health", None, Some("2"))) + .await; + assert_eq!(reply.status, StatusCode::OK); +} + +#[tokio::test] +async fn bearer_token_rules() { + let app = TestApp::household().await; + let missing = app.send(request(Method::GET, "/me", None, Some("1"))).await; + assert_error(&missing, StatusCode::UNAUTHORIZED, "unauthorized"); + let malformed = app.send(authed(Method::GET, "/me", "kvt_short")).await; + assert_error(&malformed, StatusCode::UNAUTHORIZED, "unauthorized"); + let unknown = app.send(authed(Method::GET, "/me", &token('U'))).await; + assert_error(&unknown, StatusCode::UNAUTHORIZED, "unauthorized"); + let revoked = app.send(authed(Method::GET, "/me", &revoked_token())).await; + assert_error(&revoked, StatusCode::UNAUTHORIZED, "device_revoked"); + let basic = Request::builder() + .uri("/me") + .header("x-kvitt-protocol", "1") + .header("authorization", format!("Basic {}", admin_token())) + .body(Body::empty()) + .unwrap(); + assert_error( + &app.send(basic).await, + StatusCode::UNAUTHORIZED, + "unauthorized", + ); +} + +#[tokio::test] +async fn admin_only_routes_are_forbidden_for_the_member() { + let app = TestApp::household().await; + for (method, path, admin_only) in authenticated_routes() { + let reply = app + .send(authed(method.clone(), &path, &member_token())) + .await; + if admin_only { + assert_error(&reply, StatusCode::FORBIDDEN, "forbidden"); + } else { + assert_error(&reply, StatusCode::NOT_IMPLEMENTED, "not_implemented"); + } + } +} + +#[tokio::test] +async fn path_ids_that_are_not_uuids_are_404() { + let app = TestApp::household().await; + for path in [ + "/accounts/not-a-uuid", + "/accounts/0192A003-0000-7000-8000-000000000001", + "/categories/123/archive", + "/users/x/picture", + "/invites/nope", + ] { + let method = if path.starts_with("/accounts/") || path.starts_with("/invites/") { + Method::DELETE + } else if path.ends_with("archive") { + Method::POST + } else { + Method::GET + }; + let reply = app.send(authed(method, path, &admin_token())).await; + assert_error(&reply, StatusCode::NOT_FOUND, "not_found"); + } +} + +#[tokio::test] +async fn unknown_paths_are_404_in_the_error_shape() { + let app = TestApp::household().await; + let reply = app.send(request(Method::GET, "/nope", None, None)).await; + assert_error(&reply, StatusCode::NOT_FOUND, "not_found"); + assert_eq!( + reply.headers["x-kvitt-server-version"], + env!("CARGO_PKG_VERSION") + ); +} + +async fn last_seen(app: &TestApp) -> (String, Option) { + sqlx::query_as("SELECT last_seen_at, app_version_code FROM devices WHERE id = ?") + .bind(ADMIN_DEVICE_ID) + .fetch_one(&app.state.db) + .await + .unwrap() +} + +fn with_version(path: &str, version: &str) -> Request { + let mut r = authed(Method::GET, path, &admin_token()); + r.headers_mut() + .insert("x-kvitt-app-version", version.parse().unwrap()); + r +} + +#[tokio::test] +async fn last_seen_and_app_version_are_written_at_most_hourly() { + let app = TestApp::household().await; + app.send(with_version("/me", "41")).await; + assert_eq!(last_seen(&app).await, (NOW.to_owned(), Some(41))); + + let start = app.clock.now(); + app.clock.set(start + Duration::minutes(59)); + app.send(with_version("/me", "42")).await; + assert_eq!(last_seen(&app).await, (NOW.to_owned(), Some(41))); + + app.clock.set(start + Duration::minutes(60)); + app.send(with_version("/me", "42")).await; + assert_eq!( + last_seen(&app).await, + ("2026-10-17T13:00:00.000Z".to_owned(), Some(42)) + ); + assert!(parse_ts(&last_seen(&app).await.0).is_some()); +} + +#[tokio::test] +async fn json_bodies_map_rejections_to_error_codes() { + async fn echo(ApiJson(v): ApiJson) -> String { + v.to_string() + } + let app = Router::new() + .route("/echo", post(echo)) + .layer(axum::extract::DefaultBodyLimit::max(16)); + let send = |body: &'static str, content_type: Option<&'static str>| { + let app = app.clone(); + async move { + let mut builder = Request::builder().method(Method::POST).uri("/echo"); + if let Some(ct) = content_type { + builder = builder.header("content-type", ct); + } + let response = app + .oneshot(builder.body(Body::from(body)).unwrap()) + .await + .unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + ( + status, + serde_json::from_slice::(&bytes).unwrap_or(Value::Null), + ) + } + }; + let (status, json) = send("{\"a\":1}", Some("application/json; charset=utf-8")).await; + assert_eq!((status, json), (StatusCode::OK, json!({"a": 1}))); + let (status, json) = send("{\"a\":", Some("application/json")).await; + assert_eq!( + (status, json["code"].clone()), + (StatusCode::BAD_REQUEST, json!("invalid_request")) + ); + let (status, json) = send("{\"a\":\"0123456789abcdef\"}", Some("application/json")).await; + assert_eq!( + (status, json["code"].clone()), + (StatusCode::PAYLOAD_TOO_LARGE, json!("payload_too_large")) + ); + let (status, json) = send("{}", None).await; + assert_eq!( + (status, json["code"].clone()), + (StatusCode::BAD_REQUEST, json!("invalid_request")) + ); +} diff --git a/server/crates/kvitt-server/tests/skeleton/support.rs b/server/crates/kvitt-server/tests/skeleton/support.rs new file mode 100644 index 0000000..e33f50d --- /dev/null +++ b/server/crates/kvitt-server/tests/skeleton/support.rs @@ -0,0 +1,176 @@ +//! Shared helpers of the skeleton tests: a migrated temporary database, seeded users and devices, a fixed +//! clock and a one-shot request helper. + +use std::path::PathBuf; +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use http_body_util::BodyExt; +use kvitt_server::config::Config; +use kvitt_server::time::{FixedClock, parse_ts}; +use kvitt_server::{AppState, auth, db, router}; +use serde_json::Value; +use tower::ServiceExt; +use uuid::Uuid; + +pub const ADMIN_ID: &str = "0192a000-0000-7000-8000-000000000001"; +pub const MEMBER_ID: &str = "0192a000-0000-7000-8000-000000000002"; +pub const ADMIN_DEVICE_ID: &str = "0192a001-0000-7000-8000-000000000001"; +pub const MEMBER_DEVICE_ID: &str = "0192a001-0000-7000-8000-000000000002"; +pub const REVOKED_DEVICE_ID: &str = "0192a001-0000-7000-8000-000000000003"; +pub const NOW: &str = "2026-10-17T12:00:00.000Z"; + +pub fn token(tag: char) -> String { + format!("kvt_{}", tag.to_string().repeat(43)) +} + +pub fn admin_token() -> String { + token('A') +} +pub fn member_token() -> String { + token('M') +} +pub fn revoked_token() -> String { + token('R') +} + +/// A data directory removed on drop. +pub struct TempDir(pub PathBuf); + +impl TempDir { + pub fn new() -> Self { + let dir = std::env::temp_dir().join(format!("kvitt-test-{}", Uuid::now_v7())); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +pub struct TestApp { + /// Kept alive so the directory is removed when the app is dropped. + pub _dir: TempDir, + pub state: AppState, + pub clock: FixedClock, +} + +impl TestApp { + /// A migrated, empty database (no users). + pub async fn empty() -> Self { + let dir = TempDir::new(); + let config = Config::from_lookup(|k| { + (k == "KVITT_DATA_DIR").then(|| dir.0.to_string_lossy().into_owned()) + }) + .unwrap(); + let pool = db::connect(&config.database_path()).await.unwrap(); + db::migrate(&pool).await.unwrap(); + let clock = FixedClock::new(parse_ts(NOW).unwrap()); + let state = AppState::new(config, pool).with_clock(Arc::new(clock.clone())); + Self { + _dir: dir, + state, + clock, + } + } + + /// Admin and member users, one device each, plus a revoked device of the member. + pub async fn household() -> Self { + let app = Self::empty().await; + let pool = &app.state.db; + for (id, role, name, version) in [ + (ADMIN_ID, "admin", "Jacob", 11), + (MEMBER_ID, "member", "Partner", 12), + ] { + sqlx::query( + "INSERT INTO users (id, role, display_name, picture_version, created_at, updated_at, server_version) \ + VALUES (?, ?, ?, NULL, ?, ?, ?)", + ) + .bind(id) + .bind(role) + .bind(name) + .bind("2026-10-01T00:00:00.000Z") + .bind("2026-10-01T00:00:00.000Z") + .bind(version) + .execute(pool) + .await + .unwrap(); + } + for (id, user, tok, revoked) in [ + (ADMIN_DEVICE_ID, ADMIN_ID, admin_token(), None), + (MEMBER_DEVICE_ID, MEMBER_ID, member_token(), None), + ( + REVOKED_DEVICE_ID, + MEMBER_ID, + revoked_token(), + Some("2026-10-10T00:00:00.000Z"), + ), + ] { + sqlx::query( + "INSERT INTO devices (id, user_id, name, token_hash, created_at, last_seen_at, revoked_at) \ + VALUES (?, ?, 'Phone', ?, '2026-10-01T00:00:00.000Z', '2026-10-01T00:00:00.000Z', ?)", + ) + .bind(id) + .bind(user) + .bind(auth::sha256_hex(&tok)) + .bind(revoked) + .execute(pool) + .await + .unwrap(); + } + app + } + + pub async fn send(&self, request: Request) -> Reply { + let response = router::router(self.state.clone()) + .oneshot(request) + .await + .unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let json = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + Reply { + status, + headers, + json, + } + } +} + +pub struct Reply { + pub status: StatusCode, + pub headers: axum::http::HeaderMap, + pub json: Value, +} + +impl Reply { + pub fn code(&self) -> &str { + self.json["code"].as_str().unwrap_or("") + } +} + +/// A request builder with the app's usual headers. +pub fn request( + method: Method, + path: &str, + token: Option<&str>, + protocol: Option<&str>, +) -> Request { + let mut builder = Request::builder().method(method).uri(path); + if let Some(token) = token { + builder = builder.header("authorization", format!("Bearer {token}")); + } + if let Some(protocol) = protocol { + builder = builder.header("x-kvitt-protocol", protocol); + } + builder.body(Body::empty()).unwrap() +} + +pub fn authed(method: Method, path: &str, token: &str) -> Request { + request(method, path, Some(token), Some("1")) +} diff --git a/server/crates/kvitt-server/tests/skeleton/wire_examples.rs b/server/crates/kvitt-server/tests/skeleton/wire_examples.rs new file mode 100644 index 0000000..cc06936 --- /dev/null +++ b/server/crates/kvitt-server/tests/skeleton/wire_examples.rs @@ -0,0 +1,133 @@ +//! Every request and response example of `contracts/examples/` (SPEC §14.3.5) decodes into its wire type and +//! re-encodes to the same JSON value. + +use std::path::PathBuf; + +use kvitt_server::error::ErrorBody; +use kvitt_server::wire::*; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; + +fn round_trip(name: &str, example: &Value) { + let decoded: T = serde_json::from_value(example.clone()).unwrap_or_else(|e| { + panic!( + "{name}: does not decode as {}: {e}", + std::any::type_name::() + ) + }); + let encoded = serde_json::to_value(&decoded).unwrap(); + assert_eq!(&encoded, example, "{name}: re-encoding differs"); +} + +fn check(name: &str, example: &Value) -> bool { + macro_rules! map { + ($($file:literal => $t:ty,)*) => { + match name { + $($file => { round_trip::<$t>(name, example); true })* + _ => false, + } + }; + } + map! { + "E-1-health/response.json" => Health, + "E-2-setup-status/response.json" => SetupStatus, + "E-3-invites-accept/request.json" => AcceptInviteRequest, + "E-3-invites-accept/response.json" => AcceptInviteResponse, + "E-5-apk-latest/response.json" => ApkLatest, + "E-7-me/response.json" => MeResponse, + "E-8-patch-me/request.json" => PatchMeRequest, + "E-8-patch-me/response.json" => User, + "E-9-put-me-picture/response.json" => User, + "E-10-delete-me-picture/response.json" => User, + "E-12-create-invite/request.json" => CreateInviteRequest, + "E-12-create-invite/response.json" => Invite, + "E-13-list-invites/response.json" => InviteList, + "E-15-list-devices/response.json" => DeviceList, + "E-17-push-token/request.json" => PushTokenRequest, + "E-18-create-account/request.json" => CreateAccountRequest, + "E-18-create-account/response.json" => Account, + "E-19-patch-account/request.json" => PatchAccountRequest, + "E-19-patch-account/response.json" => Account, + "E-20-archive-account/response.json" => Account, + "E-21-unarchive-account/response.json" => Account, + "E-22-delete-account/response.json" => Account, + "E-23-create-category/request.json" => CreateCategoryRequest, + "E-23-create-category/response.json" => Category, + "E-24-patch-category/request.json" => PatchCategoryRequest, + "E-24-patch-category/response.json" => Category, + "E-25-archive-category/response.json" => Category, + "E-26-unarchive-category/response.json" => Category, + "E-27-delete-category/response.json" => Category, + "E-28-create-recurring-template/request.json" => CreateRecurringTemplateRequest, + "E-28-create-recurring-template/response.json" => RecurringTemplate, + "E-29-patch-recurring-template/request.json" => PatchRecurringTemplateRequest, + "E-29-patch-recurring-template/response.json" => RecurringTemplate, + "E-30-delete-recurring-template/response.json" => RecurringTemplate, + "E-31-sync-pull/response.json" => SyncPullResponse, + "E-32-sync-push/request.json" => SyncPushRequest, + "E-32-sync-push/response.json" => SyncPushResponse, + "E-33-settlement-proposal/request.json" => ProposalRequest, + "E-33-settlement-proposal/response.json" => ProposalResponse, + "sync/pull-empty-page.json" => SyncPullResponse, + "sync/pull-page.json" => SyncPullResponse, + "sync/push-request.json" => SyncPushRequest, + "sync/push-request-delete.json" => SyncPushRequest, + "sync/push-request-rejections.json" => SyncPushRequest, + "sync/push-response.json" => SyncPushResponse, + "sync/push-response-delete.json" => SyncPushResponse, + "sync/push-response-rejections.json" => SyncPushResponse, + } +} + +#[test] +fn every_example_round_trips() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../contracts/examples"); + let mut unmapped = Vec::new(); + let mut checked = 0; + let mut dirs: Vec<_> = std::fs::read_dir(&root) + .unwrap() + .map(|e| e.unwrap().path()) + .collect(); + dirs.sort(); + for dir in dirs.into_iter().filter(|p| p.is_dir()) { + let dir_name = dir.file_name().unwrap().to_string_lossy().into_owned(); + for file in std::fs::read_dir(&dir).unwrap() { + let path = file.unwrap().path(); + let name = format!("{dir_name}/{}", path.file_name().unwrap().to_string_lossy()); + let example: Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + if dir_name == "errors" { + round_trip::(&name, &example); + checked += 1; + } else if dir_name == "push" { + // FCM payloads (SPEC §14.6) are built by M4's sender, not by a wire type of this crate. + continue; + } else if check(&name, &example) { + checked += 1; + } else { + unmapped.push(name); + } + } + } + assert!( + unmapped.is_empty(), + "examples without a wire type: {unmapped:?}" + ); + assert!(checked > 60, "only {checked} examples checked"); +} + +#[test] +fn absent_and_null_are_distinct_where_the_contract_says_so() { + let patch: PatchCategoryRequest = serde_json::from_str(r#"{"name": null}"#).unwrap(); + assert_eq!(patch.name, Some(None)); + let patch: PatchCategoryRequest = serde_json::from_str(r#"{"icon": "home"}"#).unwrap(); + assert_eq!(patch.name, None); + // E-17: the key is required, the value nullable. + assert!(serde_json::from_str::("{}").is_err()); + let clear: PushTokenRequest = serde_json::from_str(r#"{"fcm_token": null}"#).unwrap(); + assert_eq!(clear.fcm_token, None); + // E-12: target_user_id may be absent. + let invite: CreateInviteRequest = serde_json::from_str(r#"{"kind": "partner"}"#).unwrap(); + assert_eq!(invite.target_user_id, None); +} diff --git a/server/rust-toolchain.toml b/server/rust-toolchain.toml new file mode 100644 index 0000000..955e964 --- /dev/null +++ b/server/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.99.1" +components = ["rustfmt", "clippy"] +profile = "minimal"