From 85fe1d23df4eec54e2b1018759a9177730ac8cc2 Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 30 Aug 2026 13:05:13 +0900 Subject: [PATCH 01/15] chore: scaffold workspace, nix flake, justfile, compose, DB migrations - workspace Cargo.toml (resolver 2) + core crate with validated dep set - 7 sqlx migrations: folders/files/file_objects/admin_tokens/billing_rules/storage_usage/plan_assignments - frozen contracts: config (full env parsing), error (JSON error model), state, auth/billing/drive/storage stubs - flake.nix (rust-overlay devShell), justfile, root compose.yml (postgres16+minio+mc init) - main.rs: rustls aws-lc-rs provider install, migrations at startup, ensure_bucket, /healthz /readyz /openapi.json /swagger-ui - checked-in openapi.json artifact Refs: ShuttlePub/Booskiff#1 --- .gitignore | 9 + Cargo.lock | 4009 ++++++++++++++++++++ Cargo.toml | 12 + compose.yml | 47 + core/Cargo.toml | 52 + core/migrations/00001_folders.sql | 10 + core/migrations/00002_files.sql | 16 + core/migrations/00003_file_objects.sql | 12 + core/migrations/00004_admin_tokens.sql | 9 + core/migrations/00005_billing_rules.sql | 14 + core/migrations/00006_storage_usage.sql | 9 + core/migrations/00007_plan_assignments.sql | 9 + core/src/admin/mod.rs | 9 + core/src/api_doc.rs | 13 + core/src/auth/admin_auth.rs | 15 + core/src/auth/extractor.rs | 10 + core/src/auth/jwks.rs | 32 + core/src/auth/jwt.rs | 24 + core/src/auth/mod.rs | 7 + core/src/auth/rate_limit.rs | 40 + core/src/billing/mod.rs | 7 + core/src/billing/plans.rs | 35 + core/src/billing/provider.rs | 25 + core/src/billing/resolve.rs | 115 + core/src/billing/usage.rs | 27 + core/src/config.rs | 343 ++ core/src/drive/files.rs | 1 + core/src/drive/folders.rs | 1 + core/src/drive/mod.rs | 5 + core/src/drive/upload_body.rs | 1 + core/src/error.rs | 119 + core/src/health.rs | 12 + core/src/lib.rs | 21 + core/src/main.rs | 115 + core/src/model.rs | 85 + core/src/public.rs | 2 + core/src/state.rs | 45 + core/src/storage.rs | 144 + e2e/run-e2e.sh | 3 + flake.nix | 49 + justfile | 30 + openapi.json | 14 + rust-toolchain.toml | 2 + 43 files changed, 5559 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 compose.yml create mode 100644 core/Cargo.toml create mode 100644 core/migrations/00001_folders.sql create mode 100644 core/migrations/00002_files.sql create mode 100644 core/migrations/00003_file_objects.sql create mode 100644 core/migrations/00004_admin_tokens.sql create mode 100644 core/migrations/00005_billing_rules.sql create mode 100644 core/migrations/00006_storage_usage.sql create mode 100644 core/migrations/00007_plan_assignments.sql create mode 100644 core/src/admin/mod.rs create mode 100644 core/src/api_doc.rs create mode 100644 core/src/auth/admin_auth.rs create mode 100644 core/src/auth/extractor.rs create mode 100644 core/src/auth/jwks.rs create mode 100644 core/src/auth/jwt.rs create mode 100644 core/src/auth/mod.rs create mode 100644 core/src/auth/rate_limit.rs create mode 100644 core/src/billing/mod.rs create mode 100644 core/src/billing/plans.rs create mode 100644 core/src/billing/provider.rs create mode 100644 core/src/billing/resolve.rs create mode 100644 core/src/billing/usage.rs create mode 100644 core/src/config.rs create mode 100644 core/src/drive/files.rs create mode 100644 core/src/drive/folders.rs create mode 100644 core/src/drive/mod.rs create mode 100644 core/src/drive/upload_body.rs create mode 100644 core/src/error.rs create mode 100644 core/src/health.rs create mode 100644 core/src/lib.rs create mode 100644 core/src/main.rs create mode 100644 core/src/model.rs create mode 100644 core/src/public.rs create mode 100644 core/src/state.rs create mode 100644 core/src/storage.rs create mode 100755 e2e/run-e2e.sh create mode 100644 flake.nix create mode 100644 justfile create mode 100644 openapi.json create mode 100644 rust-toolchain.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d9d506 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/target +*.log +logs/ +.env +.direnv +.idea +.vscode +e2e/.tmp +*.tmp diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d35c563 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4009 @@ +# 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 = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[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 = "aws-config" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a767267da9e2c2e189b2f9df8b5657e850ecf5352644734ba130d4a57095cf1b" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.5.0", + "sha1 0.10.7", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "aws-runtime" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9007227e10b5fed2f3e0a2beff489211e2b5604c400b7a9d5d81ca9d64c24bb" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 0.2.12", + "http 1.5.0", + "http-body 0.4.6", + "http-body 1.1.0", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.144.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30dc8bf6baaf7d46336a0ca2c69f223d9b90d7a801fb3e28f7ea17b00dc6b1de" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.5.0", + "http-body 1.1.0", + "lru", + "percent-encoding", + "regex-lite", + "sha2 0.11.0", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.108.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c15301b04372832947916607983b114b3374b9db0be058a00fb7513800de1f05" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.5.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.110.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72cc2c205cb27108183cf1856333f7d584c2ba0f505421b4209ca5828f9ea899" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.5.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.113.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68182ecb449f7537db0f4d5d25917789cf41e32074a9fe47b6a0b847fe1d2032" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.5.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "crypto-bigint", + "form_urlencoded", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.5.0", + "p256", + "percent-encoding", + "sha2 0.11.0", + "subtle", + "time", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-async" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.65.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67ecd999972b58e67cab052f5129906c08c25883bd0788ceefc55ef97d61307" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "md-5", + "pin-project-lite", + "sha1 0.11.0", + "sha2 0.11.0", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6de526c7b567420a31bc283657a7921b45c4cafe0827fdf2490713dcc770c28f" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebfd138fac0337cee7516c352757ea73b9f2266e57d0bcb5bc70e9547e45aef1" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.19", + "http 0.2.12", + "http 1.5.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.11.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.9", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.43", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dc65a121adb4b33729919fcfa14fa36fb33c1555a8f06bb0e2188dbfdc1d9ef" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512346c7212ab7436df2d77a16d976a468ae44a418835511d2a69269810aaf62" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b82e438d30e02a825d363bd639a9efaed68a8089d86101054b0081e7e0d3e606" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.5.0", + "http-body 0.4.6", + "http-body 1.1.0", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "954c563ce84507722d2679f07a35d21b9c6466b3872d513020d0281fc8112ac9" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.5.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.5.0", +] + +[[package]] +name = "aws-smithy-types" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fce83ce9abbb198d25bc7131e468d0f9fe1257125e58c39f3f9fc9f5098c9647" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.5.0", + "http-body 0.4.6", + "http-body 1.1.0", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce84f71c72fee2cbbadde6e7d082f5fb466e3a84733855295fa7aafd1b31b7d8" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec1cd5469f328c782dc3e33d4153cf118a54e33cbb3356d60d16f89883e1f94" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] + +[[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 1.5.0", + "http-body 1.1.0", + "http-body-util", + "hyper 1.11.1", + "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 1.5.0", + "http-body 1.1.0", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +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 = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[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 0.10.1", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core" +version = "0.1.0" +dependencies = [ + "aws-config", + "aws-sdk-s3", + "aws-smithy-types", + "axum", + "base64", + "dotenvy", + "governor", + "http-body 1.1.0", + "http-body-util", + "jsonwebtoken", + "rand 0.10.2", + "reqwest", + "rstest", + "rustls 0.23.43", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx", + "thiserror", + "time", + "tokio", + "tower", + "tower-http 0.7.0", + "tracing", + "tracing-subscriber", + "utoipa", + "utoipa-swagger-ui", + "uuid", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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 = "crc-fast" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +dependencies = [ + "digest 0.10.7", + "spin 0.10.1", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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", + "const-oid 0.9.6", + "crypto-common 0.1.6", + "subtle", +] + +[[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 0.10.2", + "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.4", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[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 = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "zlib-rs", +] + +[[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 0.9.9", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[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 = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[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-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[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-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[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-macro", + "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", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.5", + "smallvec", + "spinning_top", + "web-time", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.5.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[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" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[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 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[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 = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[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 = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http 1.5.0", +] + +[[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 1.5.0", + "http-body 1.1.0", + "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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.19", + "http 1.5.0", + "http-body 1.1.0", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.5.0", + "hyper 1.11.1", + "hyper-util", + "rustls 0.23.43", + "rustls-native-certs", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "hyper 1.11.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.5", + "tokio", + "tower-service", + "tracing", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "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 = "lru" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[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 = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[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 = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[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 = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[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 = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.43", + "socket2 0.6.5", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls 0.23.43", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + +[[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 = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[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.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[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" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[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-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "hyper 1.11.1", + "hyper-rustls 0.27.9", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.43", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.119", + "unicode-ident", +] + +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "sha2 0.11.0", + "walkdir", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.15", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted 0.9.0", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[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 = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted 0.9.0", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +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 = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[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", + "rustls 0.23.43", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots", +] + +[[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-mysql", + "sqlx-postgres", + "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 0.11.0", + "sha2 0.11.0", + "sqlx-core", + "thiserror", + "time", + "tracing", + "uuid", +] + +[[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 0.13.0", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.2", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "time", + "tracing", + "uuid", + "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", + "time", + "tracing", + "url", + "uuid", +] + +[[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 = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +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" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[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 = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[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.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[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.4", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.43", + "tokio", +] + +[[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 = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[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-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-http" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" +dependencies = [ + "bitflags", + "bytes", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "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-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", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[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.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[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 = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.119", + "uuid", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" +dependencies = [ + "axum", + "base64", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "utoipa-swagger-ui-vendored", + "zip", +] + +[[package]] +name = "utoipa-swagger-ui-vendored" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "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 = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[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 = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.4", +] + +[[package]] +name = "zip" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f984f0d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[workspace] +resolver = "2" +members = ["core"] + +[workspace.lints.clippy] +all = { level = "warn" } + +[workspace.package] +edition = "2024" +rust-version = "1.97.1" +license = "MIT" +repository = "https://github.com/ShuttlePub/Booskiff" diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..5369d7b --- /dev/null +++ b/compose.yml @@ -0,0 +1,47 @@ +# Dev/E2E infrastructure: Postgres + MinIO (S3-compatible). +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: booskiff + POSTGRES_PASSWORD: booskiff + POSTGRES_DB: booskiff + ports: + - "127.0.0.1:${POSTGRES_PORT:-5432}:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U booskiff -d booskiff"] + interval: 2s + timeout: 3s + retries: 30 + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: booskiff + MINIO_ROOT_PASSWORD: booskiff-secret + ports: + - "127.0.0.1:${MINIO_PORT:-9000}:9000" + - "127.0.0.1:9001:9001" + volumes: + - miniodata:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 2s + timeout: 3s + retries: 30 + + # Create the dev bucket once MinIO is healthy; always exits 0 so the + # compose project reports healthy even when the bucket already exists. + mc-init: + image: minio/mc + depends_on: + minio: + condition: service_healthy + entrypoint: ["/bin/sh", "-c", "sleep 2 && mc alias set local http://minio:9000 booskiff booskiff-secret && (mc mb -p local/booskiff-default || true)"] + +volumes: + pgdata: + miniodata: diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000..8ccf71a --- /dev/null +++ b/core/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "core" +version = "0.1.0" +edition = "2024" +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +# Doctests are disabled because rustdoc links this crate as `core`, +# shadowing the builtin `core` paths that derive macros generate (the +# package itself must be named `core`). +[lib] +doctest = false + +[[bin]] +name = "core" +path = "src/main.rs" + +[dependencies] +axum = "0.8.9" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "time"] } +tower = "0.5" +tower-http = { version = "0.7", features = ["trace", "limit", "timeout"] } +sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-aws-lc-rs", "postgres", "macros", "migrate", "uuid", "time", "json"] } +aws-config = { version = "1.8", features = ["behavior-version-latest"] } +aws-sdk-s3 = "1.144" +aws-smithy-types = { version = "1.6", features = ["http-body-1-x"] } +rustls = { version = "0.23", features = ["aws-lc-rs"] } +jsonwebtoken = { version = "11", features = ["aws_lc_rs"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +utoipa = { version = "5.5", features = ["axum_extras", "uuid", "time"] } +utoipa-swagger-ui = { version = "9.0", features = ["axum", "vendored"] } +governor = "0.10" +uuid = { version = "1", features = ["v7", "serde"] } +time = { version = "0.3", features = ["serde", "formatting", "parsing"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +sha2 = "0.10" +rand = "0.10" +base64 = "0.22" +thiserror = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dotenvy = "0.15" + +[dev-dependencies] +rstest = "0.26" +http-body-util = "0.1" +http-body = "1" diff --git a/core/migrations/00001_folders.sql b/core/migrations/00001_folders.sql new file mode 100644 index 0000000..b3ac066 --- /dev/null +++ b/core/migrations/00001_folders.sql @@ -0,0 +1,10 @@ +CREATE TABLE folders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_type TEXT NOT NULL, + owner_id TEXT NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (owner_type, owner_id, name) +); + +COMMENT ON TABLE folders IS 'owner_type is polymorphic (''account'' default; org types allowed upstream, not decided here)'; diff --git a/core/migrations/00002_files.sql b/core/migrations/00002_files.sql new file mode 100644 index 0000000..30b1e69 --- /dev/null +++ b/core/migrations/00002_files.sql @@ -0,0 +1,16 @@ +CREATE TABLE files ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_type TEXT NOT NULL, + owner_id TEXT NOT NULL, + folder_id UUID NULL REFERENCES folders(id) ON DELETE SET NULL, + name TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes BIGINT NOT NULL CHECK (size_bytes >= 0), + public_key TEXT NULL UNIQUE, + is_public BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX files_owner_idx ON files (owner_type, owner_id); +CREATE INDEX files_folder_idx ON files (folder_id); +CREATE INDEX files_public_key_idx ON files (public_key) WHERE is_public; diff --git a/core/migrations/00003_file_objects.sql b/core/migrations/00003_file_objects.sql new file mode 100644 index 0000000..d89b592 --- /dev/null +++ b/core/migrations/00003_file_objects.sql @@ -0,0 +1,12 @@ +CREATE TABLE file_objects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE, + object_kind TEXT NOT NULL, + storage_key TEXT NOT NULL UNIQUE, + size_bytes BIGINT NOT NULL CHECK (size_bytes >= 0), + mime_type TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (file_id, object_kind) +); + +COMMENT ON TABLE file_objects IS 'object_kind=''original'' always present; derived kinds (thumbnail etc.) added non-destructively later (D16)'; diff --git a/core/migrations/00004_admin_tokens.sql b/core/migrations/00004_admin_tokens.sql new file mode 100644 index 0000000..775a358 --- /dev/null +++ b/core/migrations/00004_admin_tokens.sql @@ -0,0 +1,9 @@ +CREATE TABLE admin_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + token_hash TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ NULL +); + +COMMENT ON TABLE admin_tokens IS 'token_hash is SHA-256 hex of the raw token; raw token never stored'; diff --git a/core/migrations/00005_billing_rules.sql b/core/migrations/00005_billing_rules.sql new file mode 100644 index 0000000..f8a4427 --- /dev/null +++ b/core/migrations/00005_billing_rules.sql @@ -0,0 +1,14 @@ +CREATE TABLE billing_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_type TEXT NULL, + owner_id TEXT NULL, + key TEXT NOT NULL, + value JSONB NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX billing_rules_scope_key_uq + ON billing_rules ((COALESCE(owner_type, '')), (COALESCE(owner_id, '')), key); + +COMMENT ON TABLE billing_rules IS 'NULL owner means global rule; resolution order plan default -> global -> owner-specific'; diff --git a/core/migrations/00006_storage_usage.sql b/core/migrations/00006_storage_usage.sql new file mode 100644 index 0000000..0deba7d --- /dev/null +++ b/core/migrations/00006_storage_usage.sql @@ -0,0 +1,9 @@ +CREATE TABLE storage_usage ( + owner_type TEXT NOT NULL, + owner_id TEXT NOT NULL, + used_bytes BIGINT NOT NULL DEFAULT 0 CHECK (used_bytes >= 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (owner_type, owner_id) +); + +COMMENT ON TABLE storage_usage IS 'metering = received bytes only; decrement uses GREATEST(used_bytes - delta, 0)'; diff --git a/core/migrations/00007_plan_assignments.sql b/core/migrations/00007_plan_assignments.sql new file mode 100644 index 0000000..d5e2625 --- /dev/null +++ b/core/migrations/00007_plan_assignments.sql @@ -0,0 +1,9 @@ +CREATE TABLE plan_assignments ( + owner_type TEXT NOT NULL, + owner_id TEXT NOT NULL, + plan TEXT NOT NULL, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (owner_type, owner_id) +); + +COMMENT ON TABLE plan_assignments IS 'used in premium_mode=''mirror''; ignored in ''everyone'''; diff --git a/core/src/admin/mod.rs b/core/src/admin/mod.rs new file mode 100644 index 0000000..5d3e611 --- /dev/null +++ b/core/src/admin/mod.rs @@ -0,0 +1,9 @@ +//! Admin API surface. TODO(Wave3): token-authenticated management routes +//! (admin users, billing rules, plan assignments). + +use axum::Router; + +/// Admin router — placeholder until Wave-3 adds routes. +pub fn admin_router() -> Router<()> { + Router::new() +} diff --git a/core/src/api_doc.rs b/core/src/api_doc.rs new file mode 100644 index 0000000..cc4705c --- /dev/null +++ b/core/src/api_doc.rs @@ -0,0 +1,13 @@ +//! OpenAPI document generation and Swagger UI wiring. + +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi(info(title = "Booskiff", version = "0.1.0"))] +struct ApiDoc {} + +/// The OpenAPI document; endpoint annotations are attached to handlers in +/// later waves. +pub fn openapi_spec() -> utoipa::openapi::OpenApi { + ApiDoc::openapi() +} diff --git a/core/src/auth/admin_auth.rs b/core/src/auth/admin_auth.rs new file mode 100644 index 0000000..a0f7fe6 --- /dev/null +++ b/core/src/auth/admin_auth.rs @@ -0,0 +1,15 @@ +//! Admin token principal extraction. TODO(Wave2/Wave3): bearer token +//! hashing (SHA-256) and lookup against `admin_tokens`. + +/// Authenticated admin identity. +pub struct AdminPrincipal { + pub token_id: uuid::Uuid, + pub name: String, + pub role: Role, +} + +/// Admin roles. Only `Admin` exists so far. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + Admin, +} diff --git a/core/src/auth/extractor.rs b/core/src/auth/extractor.rs new file mode 100644 index 0000000..1fb972c --- /dev/null +++ b/core/src/auth/extractor.rs @@ -0,0 +1,10 @@ +//! Request extractor for the authenticated account. TODO(Wave2): +//! `FromRequestParts` implementation verifying the bearer token +//! and resolving the owner. + +use crate::model::Owner; + +/// The account identity attached to an authenticated request. +pub struct AccountContext { + pub owner: Owner, +} diff --git a/core/src/auth/jwks.rs b/core/src/auth/jwks.rs new file mode 100644 index 0000000..76c4bf3 --- /dev/null +++ b/core/src/auth/jwks.rs @@ -0,0 +1,32 @@ +//! JWKS fetching and key caching. TODO(Wave2): fetch, cache, and rotate +//! keys per trusted issuer. + +use std::sync::Arc; + +use crate::config::TrustedIssuer; +use crate::error::AppError; + +/// Cache of JWKS keys per trusted issuer. Cheap to clone; later waves +/// share the inner cache across requests. +#[derive(Clone)] +pub struct JwksCache { + issuers: Arc<[TrustedIssuer]>, +} + +impl JwksCache { + pub fn new(issuers: Vec) -> Self { + Self { + issuers: Arc::from(issuers), + } + } + + pub fn issuers(&self) -> &[TrustedIssuer] { + &self.issuers + } + + /// Return the decoding key for `issuer`. TODO(Wave2): real fetch with + /// caching and refresh. + pub async fn fetch(&self, _issuer: &str) -> Result { + Err(AppError::Internal("jwks fetch not implemented".into())) + } +} diff --git a/core/src/auth/jwt.rs b/core/src/auth/jwt.rs new file mode 100644 index 0000000..96d1ee6 --- /dev/null +++ b/core/src/auth/jwt.rs @@ -0,0 +1,24 @@ +//! JWT claim shapes and token verification. TODO(Wave2): verification +//! against the JWKS cache. + +use crate::error::AppError; +use serde::Deserialize; + +/// Verified JWT claims. Token extensions (including the owner claim named +/// by `Config::jwt_owner_type_claim`) land in `extra`. +#[derive(Debug, Clone, Deserialize)] +pub struct Claims { + pub iss: String, + pub sub: String, + pub exp: i64, + #[serde(default)] + pub aud: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +/// Verify `token` and return its claims. TODO(Wave2): signature, issuer, +/// audience, and expiry checks. +pub async fn verify_token(_token: &str) -> Result { + Err(AppError::Internal("jwt verify not implemented".into())) +} diff --git a/core/src/auth/mod.rs b/core/src/auth/mod.rs new file mode 100644 index 0000000..efab5a0 --- /dev/null +++ b/core/src/auth/mod.rs @@ -0,0 +1,7 @@ +//! JWT-based authentication and per-owner rate limiting. TODO(Wave2). + +pub mod admin_auth; +pub mod extractor; +pub mod jwks; +pub mod jwt; +pub mod rate_limit; diff --git a/core/src/auth/rate_limit.rs b/core/src/auth/rate_limit.rs new file mode 100644 index 0000000..34fad13 --- /dev/null +++ b/core/src/auth/rate_limit.rs @@ -0,0 +1,40 @@ +//! Per-owner rate limiting backed by governor. + +use governor::{DefaultDirectRateLimiter, Quota, RateLimiter}; +use std::num::NonZeroU32; + +/// A fixed-window-ish limiter allowing `rpm` requests per minute with full +/// burst capacity available immediately. +pub struct OwnerRateLimiter { + pub rpm: u32, + limiter: DefaultDirectRateLimiter, +} + +impl OwnerRateLimiter { + /// `rpm` of 0 is clamped to 1 (governor requires a non-zero quota). + pub fn new(rpm: u32) -> Self { + let quota = Quota::per_minute(NonZeroU32::new(rpm).unwrap_or(NonZeroU32::MIN)); + Self { + rpm, + limiter: RateLimiter::direct(quota), + } + } + + /// `true` if one request fits under the current quota. + pub fn check(&self) -> bool { + self.limiter.check().is_ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn limiter_allows_exactly_burst_then_denies() { + let limiter = OwnerRateLimiter::new(2); + assert!(limiter.check()); + assert!(limiter.check()); + assert!(!limiter.check()); + } +} diff --git a/core/src/billing/mod.rs b/core/src/billing/mod.rs new file mode 100644 index 0000000..81344fb --- /dev/null +++ b/core/src/billing/mod.rs @@ -0,0 +1,7 @@ +//! Billing: plans, usage metering, rules resolution, payment providers. +//! TODO(Wave5). + +pub mod plans; +pub mod provider; +pub mod resolve; +pub mod usage; diff --git a/core/src/billing/plans.rs b/core/src/billing/plans.rs new file mode 100644 index 0000000..2b9b0e8 --- /dev/null +++ b/core/src/billing/plans.rs @@ -0,0 +1,35 @@ +//! Config-driven plan limits. + +use crate::config::Config; +use crate::model::{Limits, Plan}; + +/// Limits for `plan`, straight from static configuration. +pub fn plan_limits(config: &Config, plan: Plan) -> Limits { + match plan { + Plan::Default => Limits { + storage_quota_bytes: config.plan_default_storage_quota_bytes, + max_file_bytes: config.plan_default_max_file_bytes, + rate_limit_rpm: config.plan_default_rate_limit_rpm, + }, + Plan::Premium => Limits { + storage_quota_bytes: config.plan_premium_storage_quota_bytes, + max_file_bytes: config.plan_premium_max_file_bytes, + rate_limit_rpm: config.plan_premium_rate_limit_rpm, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn premium_plan_splits_higher_limits_than_default() { + let config = Config::default(); + let base = plan_limits(&config, Plan::Default); + let premium = plan_limits(&config, Plan::Premium); + assert!(premium.storage_quota_bytes > base.storage_quota_bytes); + assert!(premium.max_file_bytes > base.max_file_bytes); + assert!(premium.rate_limit_rpm > base.rate_limit_rpm); + } +} diff --git a/core/src/billing/provider.rs b/core/src/billing/provider.rs new file mode 100644 index 0000000..9986406 --- /dev/null +++ b/core/src/billing/provider.rs @@ -0,0 +1,25 @@ +//! Payment provider abstraction. + +/// External payment integration handle. +pub trait PaymentProvider { + fn enabled(&self) -> bool; +} + +/// The no-op provider used while payments are disabled. +pub struct DisabledProvider; + +impl PaymentProvider for DisabledProvider { + fn enabled(&self) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn disabled_provider_is_not_enabled() { + assert!(!DisabledProvider.enabled()); + } +} diff --git a/core/src/billing/resolve.rs b/core/src/billing/resolve.rs new file mode 100644 index 0000000..6f4479a --- /dev/null +++ b/core/src/billing/resolve.rs @@ -0,0 +1,115 @@ +//! Billing rule resolution. TODO(Wave5/Wave2): `effective_limits` loads +//! plan assignment + billing rules from the database. + +use crate::model::Limits; + +/// Apply rule overrides onto `base`. Rules are applied in order; a later +/// rule targeting the same key wins. Unknown keys and value shapes that +/// do not fit the limit type are ignored. +pub fn merge_limits( + base: Limits, + rules: impl Iterator, +) -> Limits { + let mut limits = base; + for (key, value) in rules { + match key.as_str() { + "storage_quota_bytes" => { + if let Some(bytes) = as_i64(&value) { + limits.storage_quota_bytes = bytes; + } + } + "max_file_bytes" => { + if let Some(bytes) = as_i64(&value) { + limits.max_file_bytes = bytes; + } + } + "rate_limit_rpm" => { + if let Some(rpm) = as_i64(&value).and_then(|rpm| u32::try_from(rpm).ok()) { + limits.rate_limit_rpm = rpm; + } + } + _ => {} + } + } + limits +} + +fn as_i64(value: &serde_json::Value) -> Option { + value + .as_i64() + .or_else(|| value.as_str().and_then(|raw| raw.parse().ok())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base() -> Limits { + Limits { + storage_quota_bytes: 100, + max_file_bytes: 10, + rate_limit_rpm: 5, + } + } + + fn rule(key: &str, value: serde_json::Value) -> (String, serde_json::Value) { + (key.to_owned(), value) + } + + #[test] + fn unknown_keys_are_ignored() { + let merged = merge_limits( + base(), + [rule("future_setting", serde_json::json!(999))].into_iter(), + ); + assert_eq!(merged, base()); + } + + #[test] + fn later_rule_wins_for_same_key() { + let merged = merge_limits( + base(), + [ + rule("max_file_bytes", serde_json::json!(20)), + rule("max_file_bytes", serde_json::json!(30)), + ] + .into_iter(), + ); + assert_eq!(merged.max_file_bytes, 30); + } + + #[test] + fn applies_all_three_known_keys() { + let merged = merge_limits( + base(), + [ + rule("storage_quota_bytes", serde_json::json!(200)), + rule("max_file_bytes", serde_json::json!("40")), + rule("rate_limit_rpm", serde_json::json!(15)), + ] + .into_iter(), + ); + assert_eq!( + merged, + Limits { + storage_quota_bytes: 200, + max_file_bytes: 40, + rate_limit_rpm: 15, + } + ); + } + + #[test] + fn ill_fitting_values_are_ignored() { + let merged = merge_limits( + base(), + [ + rule("rate_limit_rpm", serde_json::json!(-3)), + rule("max_file_bytes", serde_json::json!("not-a-number")), + rule("storage_quota_bytes", serde_json::json!(true)), + ] + .into_iter(), + ); + assert_eq!(merged, base()); + } +} diff --git a/core/src/billing/usage.rs b/core/src/billing/usage.rs new file mode 100644 index 0000000..b59353a --- /dev/null +++ b/core/src/billing/usage.rs @@ -0,0 +1,27 @@ +//! Storage usage metering repos. TODO(Wave5): SQL implementations. + +use crate::error::AppError; +use crate::model::Owner; + +/// Received bytes currently metered for `owner`. +pub async fn used_bytes(_pool: &sqlx::PgPool, _owner: &Owner) -> Result { + Err(AppError::Internal("usage repo not implemented yet".into())) +} + +/// Add `delta` received bytes to `owner`'s usage (insert-or-increment). +pub async fn increment_used_bytes( + _pool: &sqlx::PgPool, + _owner: &Owner, + _delta: i64, +) -> Result<(), AppError> { + Err(AppError::Internal("usage repo not implemented yet".into())) +} + +/// Subtract `delta` from `owner`'s usage, clamped at zero. +pub async fn decrement_used_bytes( + _pool: &sqlx::PgPool, + _owner: &Owner, + _delta: i64, +) -> Result<(), AppError> { + Err(AppError::Internal("usage repo not implemented yet".into())) +} diff --git a/core/src/config.rs b/core/src/config.rs new file mode 100644 index 0000000..66eaa70 --- /dev/null +++ b/core/src/config.rs @@ -0,0 +1,343 @@ +// allow: SIZE_OK — one responsibility (loading one Config from env) and +// data-dense by nature: 24 fields each appear once in the struct, the +// dev-default literal, and the env override table; the inline tests are +// mandated by the Wave-1 contract. Splitting would scatter that mapping. +//! Service configuration loaded from environment variables. + +use std::fmt::Display; + +/// A trusted JWT issuer and the JWKS endpoint its keys are fetched from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrustedIssuer { + pub issuer: String, + pub jwks_url: String, +} + +/// Configuration loading/validation failures. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ConfigError { + #[error("invalid {field}: {reason}")] + Invalid { field: &'static str, reason: String }, +} + +/// How premium limits are assigned to owners. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PremiumMode { + /// Every owner gets premium limits. + Everyone, + /// Only owners with a premium plan assignment get premium limits. + Mirror, +} + +/// Payment provider wiring (only the disabled variant exists so far). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaymentProviderCfg { + Disabled, +} + +/// Process configuration. Defaults match the dev docker compose stack +/// (`compose.yml`), so `cargo run` works with zero environment setup. +#[derive(Debug, Clone)] +pub struct Config { + pub database_url: String, + pub s3_endpoint: String, + /// Endpoint used to build externally reachable presigned URLs + /// (`None` = same as `s3_endpoint`). + pub s3_endpoint_public: Option, + pub s3_region: String, + pub s3_bucket: String, + pub s3_access_key: String, + pub s3_secret_key: String, + pub jwt_trusted_issuers: Vec, + pub jwt_audience: Option, + pub jwt_owner_type_claim: String, + pub listen_addr: String, + pub premium_mode: PremiumMode, + pub payment_provider: PaymentProviderCfg, + pub plan_default_storage_quota_bytes: i64, + pub plan_default_max_file_bytes: i64, + pub plan_default_rate_limit_rpm: u32, + pub plan_premium_storage_quota_bytes: i64, + pub plan_premium_max_file_bytes: i64, + pub plan_premium_rate_limit_rpm: u32, + pub presigned_get_ttl_secs: u64, + pub public_base_url: String, +} + +impl Default for Config { + fn default() -> Self { + Self { + database_url: "postgres://booskiff:booskiff@127.0.0.1:5432/booskiff".into(), + s3_endpoint: "http://127.0.0.1:9000".into(), + s3_endpoint_public: None, + s3_region: "us-east-1".into(), + s3_bucket: "booskiff-default".into(), + s3_access_key: "booskiff".into(), + s3_secret_key: "booskiff-secret".into(), + jwt_trusted_issuers: Vec::new(), + jwt_audience: None, + jwt_owner_type_claim: "owner_type".into(), + listen_addr: "0.0.0.0:3000".into(), + premium_mode: PremiumMode::Everyone, + payment_provider: PaymentProviderCfg::Disabled, + plan_default_storage_quota_bytes: 1024 * 1024 * 1024, + plan_default_max_file_bytes: 100 * 1024 * 1024, + plan_default_rate_limit_rpm: 100, + plan_premium_storage_quota_bytes: 10 * 1024 * 1024 * 1024, + plan_premium_max_file_bytes: 500 * 1024 * 1024, + plan_premium_rate_limit_rpm: 300, + presigned_get_ttl_secs: 900, + public_base_url: "http://localhost:3000".into(), + } + } +} + +impl Config { + /// Load the configuration: `.env` (if present), then real environment + /// variables, then dev defaults. + pub fn load() -> Result { + dotenvy::dotenv().ok(); + let d = Self::default(); + Ok(Self { + database_url: std::env::var("BOOSKIFF_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or(d.database_url), + s3_endpoint: override_str(d.s3_endpoint, "BOOSKIFF_S3_ENDPOINT"), + s3_endpoint_public: override_opt_str( + d.s3_endpoint_public, + "BOOSKIFF_S3_ENDPOINT_PUBLIC", + ), + s3_region: override_str(d.s3_region, "BOOSKIFF_S3_REGION"), + s3_bucket: override_str(d.s3_bucket, "BOOSKIFF_S3_BUCKET"), + s3_access_key: override_str(d.s3_access_key, "BOOSKIFF_S3_ACCESS_KEY"), + s3_secret_key: override_str(d.s3_secret_key, "BOOSKIFF_S3_SECRET_KEY"), + jwt_trusted_issuers: parse_trusted_issuers( + "BOOSKIFF_JWT_TRUSTED_ISSUERS", + std::env::var("BOOSKIFF_JWT_TRUSTED_ISSUERS") + .unwrap_or_default() + .as_str(), + )?, + jwt_audience: override_opt_str(d.jwt_audience, "BOOSKIFF_JWT_AUDIENCE"), + jwt_owner_type_claim: override_str( + d.jwt_owner_type_claim, + "BOOSKIFF_JWT_OWNER_TYPE_CLAIM", + ), + listen_addr: override_str(d.listen_addr, "BOOSKIFF_LISTEN_ADDR"), + premium_mode: parse_premium_mode( + "BOOSKIFF_PREMIUM_MODE", + std::env::var("BOOSKIFF_PREMIUM_MODE") + .unwrap_or_else(|_| "everyone".into()) + .as_str(), + )?, + payment_provider: parse_payment_provider( + "BOOSKIFF_PAYMENT_PROVIDER", + std::env::var("BOOSKIFF_PAYMENT_PROVIDER") + .unwrap_or_default() + .as_str(), + )?, + plan_default_storage_quota_bytes: override_parse( + d.plan_default_storage_quota_bytes, + "BOOSKIFF_PLAN_DEFAULT_STORAGE_QUOTA_BYTES", + "plan_default_storage_quota_bytes", + )?, + plan_default_max_file_bytes: override_parse( + d.plan_default_max_file_bytes, + "BOOSKIFF_PLAN_DEFAULT_MAX_FILE_BYTES", + "plan_default_max_file_bytes", + )?, + plan_default_rate_limit_rpm: override_parse( + d.plan_default_rate_limit_rpm, + "BOOSKIFF_PLAN_DEFAULT_RATE_LIMIT_RPM", + "plan_default_rate_limit_rpm", + )?, + plan_premium_storage_quota_bytes: override_parse( + d.plan_premium_storage_quota_bytes, + "BOOSKIFF_PLAN_PREMIUM_STORAGE_QUOTA_BYTES", + "plan_premium_storage_quota_bytes", + )?, + plan_premium_max_file_bytes: override_parse( + d.plan_premium_max_file_bytes, + "BOOSKIFF_PLAN_PREMIUM_MAX_FILE_BYTES", + "plan_premium_max_file_bytes", + )?, + plan_premium_rate_limit_rpm: override_parse( + d.plan_premium_rate_limit_rpm, + "BOOSKIFF_PLAN_PREMIUM_RATE_LIMIT_RPM", + "plan_premium_rate_limit_rpm", + )?, + presigned_get_ttl_secs: override_parse( + d.presigned_get_ttl_secs, + "BOOSKIFF_PRESIGNED_GET_TTL_SECS", + "presigned_get_ttl_secs", + )?, + public_base_url: override_str(d.public_base_url, "BOOSKIFF_PUBLIC_BASE_URL"), + }) + } +} + +fn override_str(default: String, key: &str) -> String { + std::env::var(key).unwrap_or(default) +} + +fn override_opt_str(default: Option, key: &str) -> Option { + std::env::var(key) + .ok() + .and_then(|value| (!value.trim().is_empty()).then_some(value)) + .or(default) +} + +fn override_parse(default: T, key: &str, field: &'static str) -> Result +where + T: std::str::FromStr, + T::Err: Display, +{ + match std::env::var(key) { + Ok(value) => value.parse::().map_err(|err| ConfigError::Invalid { + field, + reason: err.to_string(), + }), + Err(_) => Ok(default), + } +} + +/// `BOOSKIFF_JWT_TRUSTED_ISSUERS` = `iss1[|jwks_url],iss2[|jwks_url]`; +/// empty `|jwks_url` defaults to `{iss}/.well-known/jwks.json`. +fn parse_trusted_issuers( + field: &'static str, + raw: &str, +) -> Result, ConfigError> { + raw.split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(|entry| { + let (issuer, jwks_url) = match entry.split_once('|') { + Some((issuer, jwks)) => (issuer.trim(), jwks.trim()), + None => (entry, ""), + }; + if issuer.is_empty() { + return Err(ConfigError::Invalid { + field, + reason: format!("empty issuer in entry {entry:?}"), + }); + } + let jwks_url = if jwks_url.is_empty() { + format!("{issuer}/.well-known/jwks.json") + } else { + jwks_url.to_owned() + }; + Ok(TrustedIssuer { + issuer: issuer.to_owned(), + jwks_url, + }) + }) + .collect() +} + +fn parse_premium_mode(field: &'static str, raw: &str) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + "everyone" => Ok(PremiumMode::Everyone), + "mirror" => Ok(PremiumMode::Mirror), + other => Err(ConfigError::Invalid { + field, + reason: format!("unknown premium mode {other:?} (expected 'everyone' or 'mirror')"), + }), + } +} + +fn parse_payment_provider( + field: &'static str, + raw: &str, +) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + "" | "none" | "disabled" => Ok(PaymentProviderCfg::Disabled), + other => Err(ConfigError::Invalid { + field, + reason: format!( + "unsupported payment provider {other:?} (only 'none'/'disabled' is available)" + ), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case( + "https://id.example.com", + "https://id.example.com/.well-known/jwks.json" + )] + #[case( + " https://id.example.com ", + "https://id.example.com/.well-known/jwks.json" + )] + fn issuer_without_jwks_defaults_to_well_known(#[case] issuer: &str, #[case] jwks_url: &str) { + let parsed = parse_trusted_issuers("test", issuer).unwrap(); + assert_eq!( + parsed, + vec![TrustedIssuer { + issuer: issuer.trim().to_owned(), + jwks_url: jwks_url.to_owned() + }] + ); + } + + #[test] + fn issuer_with_jwks_url_override_wins() { + let parsed = parse_trusted_issuers( + "test", + "https://a.example.com|https://keys.example.com/jwks,https://b.example.com", + ) + .unwrap(); + assert_eq!( + parsed, + vec![ + TrustedIssuer { + issuer: "https://a.example.com".into(), + jwks_url: "https://keys.example.com/jwks".into(), + }, + TrustedIssuer { + issuer: "https://b.example.com".into(), + jwks_url: "https://b.example.com/.well-known/jwks.json".into(), + }, + ] + ); + } + + #[test] + fn empty_issuer_entry_is_rejected() { + assert!(parse_trusted_issuers("test", "|https://keys.example.com").is_err()); + } + + #[rstest] + #[case("everyone", PremiumMode::Everyone)] + #[case("MIRROR", PremiumMode::Mirror)] + fn premium_mode_parses(#[case] raw: &str, #[case] expected: PremiumMode) { + assert_eq!(parse_premium_mode("test", raw).unwrap(), expected); + } + + #[test] + fn premium_mode_rejects_unknown_value() { + assert!(parse_premium_mode("test", "sometimes").is_err()); + } + + #[rstest] + #[case("")] + #[case("none")] + #[case("disabled")] + #[case("DISABLED")] + fn payment_provider_accepts_disabled_forms(#[case] raw: &str) { + assert_eq!( + parse_payment_provider("test", raw).unwrap(), + PaymentProviderCfg::Disabled + ); + } + + #[test] + fn payment_provider_rejects_unknown_provider() { + let err = parse_payment_provider("test", "stripe").unwrap_err(); + assert!(err.to_string().contains("unsupported payment provider")); + } +} diff --git a/core/src/drive/files.rs b/core/src/drive/files.rs new file mode 100644 index 0000000..6651a58 --- /dev/null +++ b/core/src/drive/files.rs @@ -0,0 +1 @@ +//! File CRUD, listing, and versioned upload handlers. TODO(Wave2/T5). diff --git a/core/src/drive/folders.rs b/core/src/drive/folders.rs new file mode 100644 index 0000000..15920e1 --- /dev/null +++ b/core/src/drive/folders.rs @@ -0,0 +1 @@ +//! Folder CRUD handlers. TODO(Wave2/T6). diff --git a/core/src/drive/mod.rs b/core/src/drive/mod.rs new file mode 100644 index 0000000..2b816a6 --- /dev/null +++ b/core/src/drive/mod.rs @@ -0,0 +1,5 @@ +//! Drive API: files, folders, and upload body handling. TODO(Wave2). + +pub mod files; +pub mod folders; +pub mod upload_body; diff --git a/core/src/drive/upload_body.rs b/core/src/drive/upload_body.rs new file mode 100644 index 0000000..fbf1c63 --- /dev/null +++ b/core/src/drive/upload_body.rs @@ -0,0 +1 @@ +//! Content-Length-checked streaming upload body handling. TODO(Wave2/T4). diff --git a/core/src/error.rs b/core/src/error.rs new file mode 100644 index 0000000..f5b5701 --- /dev/null +++ b/core/src/error.rs @@ -0,0 +1,119 @@ +//! Application error type and its HTTP mapping. + +use axum::Json; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; + +/// Errors convertible into HTTP responses across all modules. +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("{0}")] + Unauthorized(String), + #[error("{0}")] + Forbidden(String), + #[error("{0}")] + NotFound(String), + #[error("{0}")] + Conflict(String), + #[error("{0}")] + Validation(String), + #[error("{0}")] + PayloadTooLarge(String), + #[error("{0}")] + LengthRequired(String), + #[error("{0}")] + InsufficientStorage(String), + #[error("rate limit exceeded")] + RateLimited, + #[error("{0}")] + Internal(String), +} + +/// JSON body shape: `{"error":{"code":...,"message":...}}`. +#[derive(Debug, Serialize)] +pub struct ErrorBody { + pub error: ErrorDetail, +} + +#[derive(Debug, Serialize)] +pub struct ErrorDetail { + pub code: &'static str, + pub message: String, +} + +impl AppError { + fn status_and_code(&self) -> (StatusCode, &'static str) { + match self { + Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"), + Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"), + Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"), + Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"), + Self::Validation(_) => (StatusCode::BAD_REQUEST, "validation"), + Self::PayloadTooLarge(_) => (StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large"), + Self::LengthRequired(_) => (StatusCode::LENGTH_REQUIRED, "length_required"), + Self::InsufficientStorage(_) => { + (StatusCode::INSUFFICIENT_STORAGE, "insufficient_storage") + } + Self::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "rate_limited"), + Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal"), + } + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, code) = self.status_and_code(); + let message = match self { + Self::Internal(source) => { + tracing::error!(error = %source, "internal error"); + "internal error".to_owned() + } + other => other.to_string(), + }; + let body = ErrorBody { + error: ErrorDetail { code, message }, + }; + (status, Json(body)).into_response() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http_body_util::BodyExt; + use rstest::rstest; + + #[rstest] + #[case(AppError::Unauthorized("no token".into()), 401, "unauthorized")] + #[case(AppError::Forbidden("not yours".into()), 403, "forbidden")] + #[case(AppError::NotFound("missing".into()), 404, "not_found")] + #[case(AppError::Conflict("exists".into()), 409, "conflict")] + #[case(AppError::Validation("bad name".into()), 400, "validation")] + #[case(AppError::PayloadTooLarge("too big".into()), 413, "payload_too_large")] + #[case(AppError::LengthRequired("no length".into()), 411, "length_required")] + #[case(AppError::InsufficientStorage("quota exceeded".into()), 507, "insufficient_storage")] + #[case(AppError::RateLimited, 429, "rate_limited")] + #[case(AppError::Internal("db exploded".into()), 500, "internal")] + #[tokio::test] + async fn maps_variant_to_status_and_code( + #[case] error: AppError, + #[case] status: u16, + #[case] code: &str, + ) { + let response = error.into_response(); + assert_eq!(response.status().as_u16(), status); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["error"]["code"], code); + } + + #[tokio::test] + async fn internal_variant_hides_details_from_client() { + let response = AppError::Internal("db exploded".into()).into_response(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["error"]["code"], "internal"); + assert_eq!(body["error"]["message"], "internal error"); + } +} diff --git a/core/src/health.rs b/core/src/health.rs new file mode 100644 index 0000000..2075bd0 --- /dev/null +++ b/core/src/health.rs @@ -0,0 +1,12 @@ +//! Liveness/readiness endpoints. + +use axum::Router; +use axum::routing::get; + +/// `/healthz` (liveness) and `/readyz` (readiness). Readiness always +/// reports ok until Wave-3 wires real dependency checks. +pub fn health_router() -> Router<()> { + Router::new() + .route("/healthz", get(|| async { "ok" })) + .route("/readyz", get(|| async { "ok" })) +} diff --git a/core/src/lib.rs b/core/src/lib.rs new file mode 100644 index 0000000..4738523 --- /dev/null +++ b/core/src/lib.rs @@ -0,0 +1,21 @@ +//! Booskiff core service — drive/storage API foundation. +//! +//! Wave-1 scaffolding: frozen module contracts with minimal compiling +//! bodies; later waves fill in handlers, extractors, and repos. +//! +//! NOTE: runtime queries only (no `sqlx::query!` macros). All database +//! access goes through runtime-checked queries so the crate builds +//! without a live database. + +pub mod admin; +pub mod api_doc; +pub mod auth; +pub mod billing; +pub mod config; +pub mod drive; +pub mod error; +pub mod health; +pub mod model; +pub mod public; +pub mod state; +pub mod storage; diff --git a/core/src/main.rs b/core/src/main.rs new file mode 100644 index 0000000..1d4fa21 --- /dev/null +++ b/core/src/main.rs @@ -0,0 +1,115 @@ +use axum::Json; +use axum::routing::get; +use core::api_doc::openapi_spec; +use core::auth::jwks::JwksCache; +use core::config::Config; +use core::state::{AppState, RateLimiters}; +use core::{health, storage::Storage}; +use std::sync::Arc; + +fn main() { + // The process-wide aws-lc-rs provider must be installed before any + // rustls consumer initializes (sqlx TLS, reqwest, presigning). + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .ok(); + + if std::env::args().nth(1).as_deref() == Some("openapi") { + match serde_json::to_string_pretty(&openapi_spec()) { + Ok(spec) => println!("{spec}"), + Err(err) => { + eprintln!("failed to serialize openapi spec: {err}"); + std::process::exit(1); + } + } + return; + } + + // Manual multi-thread runtime instead of `#[tokio::main]`: the macro's + // expansion references `core::` paths, which this package's own lib + // name (`core`) shadows. + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap_or_else(|err| { + eprintln!("failed to start tokio runtime: {err}"); + std::process::exit(1); + }); + runtime.block_on(run()); +} + +async fn run() { + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + tracing_subscriber::fmt().with_env_filter(filter).init(); + + let config = match Config::load() { + Ok(config) => config, + Err(err) => { + eprintln!("configuration error: {err}"); + std::process::exit(1); + } + }; + let listen_addr = config.listen_addr.clone(); + + let pool = match sqlx::postgres::PgPoolOptions::new() + .max_connections(10) + .connect(&config.database_url) + .await + { + Ok(pool) => pool, + Err(err) => { + eprintln!("failed to connect to database: {err}"); + std::process::exit(1); + } + }; + if let Err(err) = sqlx::migrate!("./migrations").run(&pool).await { + eprintln!("failed to run database migrations: {err}"); + std::process::exit(1); + } + + let storage = match Storage::build(&config).await { + Ok(storage) => storage, + Err(err) => { + eprintln!("failed to initialize object storage: {err}"); + std::process::exit(1); + } + }; + if let Err(err) = storage.ensure_bucket().await { + eprintln!("failed to prepare object storage bucket: {err}"); + std::process::exit(1); + } + + let jwks_cache = JwksCache::new(config.jwt_trusted_issuers.clone()); + let state = AppState { + pool, + s3: storage, + config, + jwks_cache, + rate_limiters: Arc::new(RateLimiters::default()), + }; + + // Wave-1 mounts only stateless routes; state is baked in up front. + // Wave 2+ restructures as stateful routers land (axum 0.8 lacks a + // From> for Router conversion for late merges). + let app = axum::Router::::new() + .with_state(state) + .merge(health::health_router()) + .route("/openapi.json", get(|| async { Json(openapi_spec()) })) + .merge( + utoipa_swagger_ui::SwaggerUi::new("/swagger-ui") + .url("/api-docs/openapi.json", openapi_spec()), + ); + + let listener = tokio::net::TcpListener::bind(&listen_addr) + .await + .unwrap_or_else(|err| { + eprintln!("failed to bind {listen_addr}: {err}"); + std::process::exit(1); + }); + tracing::info!("listening on http://{listen_addr}"); + if let Err(err) = axum::serve(listener, app).await { + eprintln!("server error: {err}"); + std::process::exit(1); + } +} diff --git a/core/src/model.rs b/core/src/model.rs new file mode 100644 index 0000000..41320a6 --- /dev/null +++ b/core/src/model.rs @@ -0,0 +1,85 @@ +//! Core domain models shared across modules. + +/// Polymorphic owner reference (`owner_type:owner_id`). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Owner { + pub owner_type: String, + pub owner_id: String, +} + +impl Owner { + pub fn new(owner_type: impl Into, owner_id: impl Into) -> Self { + Self { + owner_type: owner_type.into(), + owner_id: owner_id.into(), + } + } + + /// Composite key used for storage prefixes and metering rows. + pub fn key(&self) -> String { + format!("{}:{}", self.owner_type, self.owner_id) + } +} + +/// Object kind of the always-present original upload. +pub const OBJECT_KIND_ORIGINAL: &str = "original"; + +/// Billing plan tiers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Plan { + Default, + Premium, +} + +impl Plan { + pub fn as_str(&self) -> &'static str { + match self { + Self::Default => "default", + Self::Premium => "premium", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("unknown plan: {0}")] +pub struct UnknownPlanError(pub String); + +impl std::str::FromStr for Plan { + type Err = UnknownPlanError; + + fn from_str(value: &str) -> Result { + match value { + "default" => Ok(Self::Default), + "premium" => Ok(Self::Premium), + other => Err(UnknownPlanError(other.to_owned())), + } + } +} + +/// Effective limits for an owner after plan/billing resolution. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Limits { + pub storage_quota_bytes: i64, + pub max_file_bytes: i64, + pub rate_limit_rpm: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn owner_key_joins_type_and_id() { + let owner = Owner::new("account", "alice"); + assert_eq!(owner.key(), "account:alice"); + } + + #[test] + fn plan_roundtrips_through_str() { + use std::str::FromStr; + for plan in [Plan::Default, Plan::Premium] { + assert_eq!(Plan::from_str(plan.as_str()), Ok(plan)); + } + assert!(Plan::from_str("unknown").is_err()); + } +} diff --git a/core/src/public.rs b/core/src/public.rs new file mode 100644 index 0000000..6b644c0 --- /dev/null +++ b/core/src/public.rs @@ -0,0 +1,2 @@ +//! Public (unauthenticated) download endpoints. TODO(Wave3): public_key +//! resolution and file streaming. diff --git a/core/src/state.rs b/core/src/state.rs new file mode 100644 index 0000000..c980ae4 --- /dev/null +++ b/core/src/state.rs @@ -0,0 +1,45 @@ +//! Shared application state passed to handlers. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use crate::auth::jwks::JwksCache; +use crate::auth::rate_limit::OwnerRateLimiter; +use crate::config::Config; +use crate::storage::Storage; + +/// Per-owner rate limiter registry, shared behind `AppState::rate_limiters`. +pub struct RateLimiters { + inner: Mutex>>, +} + +impl Default for RateLimiters { + fn default() -> Self { + Self { + inner: Mutex::new(HashMap::new()), + } + } +} + +impl RateLimiters { + /// Return the limiter for `key`, creating it with `rpm` on first use. + pub fn get_or_insert(&self, key: &str, rpm: u32) -> Arc { + let mut map = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + map.entry(key.to_owned()) + .or_insert_with(|| Arc::new(OwnerRateLimiter::new(rpm))) + .clone() + } +} + +/// Cheaply cloneable state shared by all request handlers. +#[derive(Clone)] +pub struct AppState { + pub pool: sqlx::PgPool, + pub s3: Storage, + pub config: Config, + pub jwks_cache: JwksCache, + pub rate_limiters: Arc, +} diff --git a/core/src/storage.rs b/core/src/storage.rs new file mode 100644 index 0000000..a43812b --- /dev/null +++ b/core/src/storage.rs @@ -0,0 +1,144 @@ +//! S3-compatible object storage access (MinIO in dev). + +use aws_smithy_types::body::SdkBody; +use aws_smithy_types::byte_stream::ByteStream; + +use crate::config::Config; +use crate::error::AppError; +use crate::model::Owner; + +/// Thin wrapper over the S3 client bound to one bucket. +#[derive(Clone)] +pub struct Storage { + client: aws_sdk_s3::Client, + bucket: String, + public_endpoint: Option, +} + +impl Storage { + /// Build a client from config: static credentials, explicit region and + /// endpoint, path-style addressing (required by MinIO). + pub async fn build(config: &Config) -> Result { + let shared = aws_config::from_env() + .region(aws_sdk_s3::config::Region::new(config.s3_region.clone())) + .endpoint_url(config.s3_endpoint.clone()) + .credentials_provider(aws_sdk_s3::config::Credentials::new( + config.s3_access_key.clone(), + config.s3_secret_key.clone(), + None, + None, + "booskiff-config", + )) + .load() + .await; + let s3_config = aws_sdk_s3::Config::from(&shared) + .to_builder() + .force_path_style(true) + .build(); + Ok(Self { + client: aws_sdk_s3::Client::from_conf(s3_config), + bucket: config.s3_bucket.clone(), + public_endpoint: config.s3_endpoint_public.clone(), + }) + } + + pub fn bucket(&self) -> &str { + &self.bucket + } + + /// Endpoint used for presigned URLs handed to external clients + /// (`None` = same endpoint the client talks to). + pub fn public_endpoint(&self) -> Option<&str> { + self.public_endpoint.as_deref() + } + + /// Storage key for one object of a file: + /// `{owner_type}/{owner_id}/{file_id}/{object_kind}`. + pub fn object_key(owner: &Owner, file_id: &uuid::Uuid, kind: &str) -> String { + format!( + "{}/{}/{}/{}", + owner.owner_type, owner.owner_id, file_id, kind + ) + } + + /// Create the configured bucket if it does not exist yet. + pub async fn ensure_bucket(&self) -> Result<(), AppError> { + match self.client.head_bucket().bucket(&self.bucket).send().await { + Ok(_) => Ok(()), + Err(err) => { + // HeadBucket carries no modeled errors in aws-sdk-s3, so a + // 404 status is the bucket-missing signal. + let missing = err + .raw_response() + .is_some_and(|response| response.status().as_u16() == 404); + if !missing { + return Err(AppError::Internal(format!( + "head bucket {}: {err}", + self.bucket + ))); + } + self.client + .create_bucket() + .bucket(&self.bucket) + .send() + .await + .map_err(|err| { + AppError::Internal(format!("create bucket {}: {err}", self.bucket)) + })?; + Ok(()) + } + } + } + + /// Upload `body` to `key`. TODO(Wave2/T4): real implementation. + pub async fn put_streaming( + &self, + _key: &str, + _body: SdkBody, + _content_length: i64, + _content_type: &str, + ) -> Result<(), AppError> { + Err(AppError::Internal("storage not implemented yet".into())) + } + + /// Return a presigned GET URL valid for `ttl`. TODO(Wave2/T4). + pub async fn presign_get( + &self, + _key: &str, + _ttl: std::time::Duration, + ) -> Result { + Err(AppError::Internal("storage not implemented yet".into())) + } + + /// Return `(mime_type, size_bytes, stream)` for `key`. TODO(Wave2/T4). + pub async fn get_streaming(&self, _key: &str) -> Result<(String, i64, ByteStream), AppError> { + Err(AppError::Internal("storage not implemented yet".into())) + } + + /// Delete a single object, tolerating a missing key. TODO(Wave2/T4). + pub async fn delete_object(&self, _key: &str) -> Result<(), AppError> { + Err(AppError::Internal("storage not implemented yet".into())) + } + + /// List and delete every object under `prefix`, tolerating missing + /// objects. TODO(Wave2/T4). + pub async fn delete_prefix(&self, _prefix: &str) -> Result<(), AppError> { + Err(AppError::Internal("storage not implemented yet".into())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::OBJECT_KIND_ORIGINAL; + + #[test] + fn object_key_places_owner_then_file_then_kind() { + let owner = Owner::new("account", "alice"); + let file_id = uuid::Uuid::parse_str("01890622-0d3a-7abc-8def-0123456789ab").unwrap(); + assert_eq!( + Storage::object_key(&owner, &file_id, OBJECT_KIND_ORIGINAL), + "account/alice/01890622-0d3a-7abc-8def-0123456789ab/original" + ); + } +} diff --git a/e2e/run-e2e.sh b/e2e/run-e2e.sh new file mode 100755 index 0000000..c9eb528 --- /dev/null +++ b/e2e/run-e2e.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +echo "e2e harness installed by Wave 5" diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..e5695ec --- /dev/null +++ b/flake.nix @@ -0,0 +1,49 @@ +{ + description = "Booskiff — drive/storage service"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, rust-overlay, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ (import rust-overlay) ]; + }; + rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; + in + { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + rustToolchain + just + jq + sqlx-cli + pkg-config + openssl + # aws-lc-rs compiles aws-lc from source and needs cmake + perl. + cmake + perl + ]; + PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig"; + LD_LIBRARY_PATH = "${pkgs.openssl.out}/lib"; + }; + + packages.default = pkgs.rustPlatform.buildRustPackage { + pname = "booskiff"; + version = "0.1.0"; + src = pkgs.lib.cleanSource ./.; + cargoLock.lockFile = ./Cargo.lock; + # aws-lc-rs builds the aws-lc C library via cmake. + nativeBuildInputs = with pkgs; [ cmake perl ]; + doCheck = false; + }; + }); +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..eda8ff4 --- /dev/null +++ b/justfile @@ -0,0 +1,30 @@ +# Booskiff recipes + +check: + cargo check + +test: + cargo test + +clippy: + cargo clippy --all-targets -- -D warnings + +fmt: + cargo fmt + +fmt-check: + cargo fmt --check + +openapi: + cargo run -p core -- openapi > openapi.json + +compose-up: + docker compose up -d + +compose-down: + docker compose down -v + +e2e: + bash e2e/run-e2e.sh + +verify: fmt-check check clippy test diff --git a/openapi.json b/openapi.json new file mode 100644 index 0000000..255d4bc --- /dev/null +++ b/openapi.json @@ -0,0 +1,14 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Booskiff", + "description": "", + "license": { + "name": "MIT", + "identifier": "MIT" + }, + "version": "0.1.0" + }, + "paths": {}, + "components": {} +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..7243604 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "1.97.1" From 2f51741b5464b8e2a3635cb723b205cff6ec2432 Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 30 Aug 2026 13:36:30 +0900 Subject: [PATCH 02/15] feat(auth): JWKS RS256 verification, account extractor, rate limiting, admin token middleware --- core/src/auth/admin_auth.rs | 228 ++++++++++++- core/src/auth/extractor.rs | 300 +++++++++++++++- core/src/auth/jwks.rs | 308 ++++++++++++++++- core/src/auth/jwt.rs | 323 +++++++++++++++++- core/src/state.rs | 80 +++++ core/tests/fixtures/README.md | 6 + core/tests/fixtures/test_only_jwks.json | 1 + core/tests/fixtures/test_only_rsa_private.pem | 27 ++ 8 files changed, 1251 insertions(+), 22 deletions(-) create mode 100644 core/tests/fixtures/README.md create mode 100644 core/tests/fixtures/test_only_jwks.json create mode 100644 core/tests/fixtures/test_only_rsa_private.pem diff --git a/core/src/auth/admin_auth.rs b/core/src/auth/admin_auth.rs index a0f7fe6..1835133 100644 --- a/core/src/auth/admin_auth.rs +++ b/core/src/auth/admin_auth.rs @@ -1,9 +1,17 @@ -//! Admin token principal extraction. TODO(Wave2/Wave3): bearer token -//! hashing (SHA-256) and lookup against `admin_tokens`. +//! Admin token principal extraction. + +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use axum::response::{IntoResponse, Response}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::state::AppState; /// Authenticated admin identity. pub struct AdminPrincipal { - pub token_id: uuid::Uuid, + pub token_id: Uuid, pub name: String, pub role: Role, } @@ -13,3 +21,217 @@ pub struct AdminPrincipal { pub enum Role { Admin, } + +/// One active row of `admin_tokens`. +#[derive(Debug, sqlx::FromRow)] +pub struct AdminTokenRow { + pub id: Uuid, + pub name: String, +} + +/// Lowercase SHA-256 hex digest of a raw admin token. The database only +/// ever stores the digest; the raw token never persists anywhere. +pub fn hash_admin_token(token: &str) -> String { + let digest = Sha256::digest(token.as_bytes()); + let mut hex = String::with_capacity(2 * digest.len()); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(hex, "{byte:02x}"); + } + hex +} + +/// The non-revoked `admin_tokens` row for `token_hash`, if any. +pub async fn find_active_by_hash( + pool: &sqlx::PgPool, + token_hash: &str, +) -> Result, AppError> { + sqlx::query_as::<_, AdminTokenRow>( + "SELECT id, name FROM admin_tokens WHERE token_hash = $1 AND revoked_at IS NULL", + ) + .bind(token_hash) + .fetch_optional(pool) + .await + .map_err(|err| AppError::Internal(format!("admin token lookup failed: {err}"))) +} + +impl FromRequestParts for AdminPrincipal { + type Rejection = Response; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let token = admin_token(parts).map_err(|err| err.into_response())?; + let token_hash = hash_admin_token(&token); + let row = find_active_by_hash(&state.pool, &token_hash) + .await + .map_err(|err| err.into_response())? + .ok_or_else(|| AppError::Unauthorized("invalid admin token".into()).into_response())?; + // Admin principals are rate-limited under the default-plan rpm: + // admin endpoints never inspect plans or owners (D18), so the + // default plan's limit is the natural shared budget for admin + // traffic. + if !state.rate_limiters.check( + &format!("admin:{}", row.id), + state.config.plan_default_rate_limit_rpm, + ) { + return Err(AppError::RateLimited.into_response()); + } + Ok(AdminPrincipal { + token_id: row.id, + name: row.name, + role: Role::Admin, + }) + } +} + +/// The `X-Admin-Token` header value for the request. +fn admin_token(parts: &Parts) -> Result { + parts + .headers + .get(axum::http::header::HeaderName::from_static("x-admin-token")) + .and_then(|value| value.to_str().ok()) + .filter(|token| !token.is_empty()) + .ok_or_else(|| AppError::Unauthorized("admin token required".into())) + .map(ToOwned::to_owned) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use axum::Json; + use axum::http::{Request, StatusCode}; + use axum::routing::get; + use http_body_util::BodyExt; + use tower::ServiceExt; + + use crate::auth::jwks::JwksCache; + use crate::config::Config; + use crate::state::RateLimiters; + use crate::storage::Storage; + + /// Compose postgres (compose.yml), used only by `#[ignore]` tests. + const PUBLISH_DB_URL: &str = "postgres://booskiff:booskiff@127.0.0.1:5432/booskiff"; + + #[test] + fn hash_matches_known_sha256_digests() { + // sha256("test-admin-token") + assert_eq!( + hash_admin_token("test-admin-token"), + "17d6bfe05d1b1fb7bc499f8e3f639c7b3eda4c40f321eef8887a0c04c89a99c5" + ); + // sha256("") + assert_eq!( + hash_admin_token(""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!(hash_admin_token("test-admin-token").len(), 64); + } + + /// Router state whose pool is lazy and never connects (the + /// missing-header path returns before any database access). + async fn no_db_app_state() -> AppState { + let config = Config::default(); + AppState { + pool: sqlx::PgPool::connect_lazy("postgres://127.0.0.1:9/never") + .expect("lazy pool never connects"), + s3: Storage::build(&config).await.unwrap(), + jwks_cache: JwksCache::new(Vec::new()), + config, + rate_limiters: Arc::new(RateLimiters::default()), + } + } + + fn admin_router(state: AppState) -> axum::Router { + async fn echo_admin(principal: AdminPrincipal) -> (StatusCode, Json) { + ( + StatusCode::OK, + Json(serde_json::json!({ + "name": principal.name, + "is_admin": principal.role == Role::Admin, + })), + ) + } + axum::Router::new() + .route("/admin/test", get(echo_admin)) + .with_state(state) + } + + fn request(token: Option<&str>) -> Request { + let mut request = Request::builder().uri("/admin/test"); + if let Some(token) = token { + request = request.header("x-admin-token", token); + } + request.body(axum::body::Body::empty()).unwrap() + } + + #[tokio::test] + async fn missing_admin_token_header_is_unauthorized() { + let state = no_db_app_state().await; + let router = admin_router(state); + let response = router.oneshot(request(None)).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["error"]["code"], "unauthorized"); + assert_eq!(body["error"]["message"], "admin token required"); + } + + #[tokio::test] + #[ignore = "requires the compose postgres stack"] + async fn active_token_authenticates_and_revoked_token_is_rejected() { + let pool = sqlx::PgPool::connect(PUBLISH_DB_URL).await.unwrap(); + let name = "wave2-t3-admin-auth-test"; + let token = "wave2-t3-admin-auth-test-token"; + let token_hash = hash_admin_token(token); + sqlx::query("DELETE FROM admin_tokens WHERE name = $1") + .bind(name) + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO admin_tokens (name, token_hash) VALUES ($1, $2)") + .bind(name) + .bind(&token_hash) + .execute(&pool) + .await + .unwrap(); + + let config = Config::default(); + let state = AppState { + s3: Storage::build(&config).await.unwrap(), + pool, + jwks_cache: JwksCache::new(Vec::new()), + config, + rate_limiters: Arc::new(RateLimiters::default()), + }; + let router = admin_router(state.clone()); + + let response = router.clone().oneshot(request(Some(token))).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["name"], name); + assert_eq!(body["is_admin"], true); + + sqlx::query("UPDATE admin_tokens SET revoked_at = now() WHERE name = $1") + .bind(name) + .execute(&state.pool) + .await + .unwrap(); + let response = router.clone().oneshot(request(Some(token))).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["error"]["code"], "unauthorized"); + assert_eq!(body["error"]["message"], "invalid admin token"); + + sqlx::query("DELETE FROM admin_tokens WHERE name = $1") + .bind(name) + .execute(&state.pool) + .await + .unwrap(); + } +} diff --git a/core/src/auth/extractor.rs b/core/src/auth/extractor.rs index 1fb972c..d52762f 100644 --- a/core/src/auth/extractor.rs +++ b/core/src/auth/extractor.rs @@ -1,10 +1,300 @@ -//! Request extractor for the authenticated account. TODO(Wave2): -//! `FromRequestParts` implementation verifying the bearer token -//! and resolving the owner. +//! Request extractor for the authenticated account. -use crate::model::Owner; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use axum::response::{IntoResponse, Response}; -/// The account identity attached to an authenticated request. +use crate::auth::jwt::{owner_from_claims, verify_token}; +use crate::billing::resolve::effective_limits; +use crate::error::AppError; +use crate::model::{Limits, Owner}; +use crate::state::AppState; + +/// The account identity attached to an authenticated request, together +/// with the effective limits used for the rate-limit decision. pub struct AccountContext { pub owner: Owner, + pub limits: Limits, +} + +impl FromRequestParts for AccountContext { + type Rejection = Response; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let token = bearer_token(parts).map_err(|err| err.into_response())?; + let claims = verify_token(&state.jwks_cache, &state.config, token) + .await + .map_err(|err| err.into_response())?; + let owner = owner_from_claims(&claims, &state.config.jwt_owner_type_claim) + .map_err(|err| err.into_response())?; + let limits = effective_limits(&state.pool, &state.config, &owner) + .await + .map_err(|err| err.into_response())?; + if !state + .rate_limiters + .check(&owner.key(), limits.rate_limit_rpm) + { + return Err(AppError::RateLimited.into_response()); + } + Ok(AccountContext { owner, limits }) + } +} + +/// `Authorization: Bearer ` value for the request. +fn bearer_token(parts: &Parts) -> Result<&str, AppError> { + parts + .headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .filter(|token| !token.is_empty()) + .ok_or_else(|| AppError::Unauthorized("missing bearer token".into())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + use axum::Json; + use axum::http::{Request, StatusCode, header}; + use axum::routing::get; + use http_body_util::BodyExt; + use tower::ServiceExt; + + use crate::auth::jwks::JwksCache; + use crate::config::{Config, TrustedIssuer}; + use crate::state::RateLimiters; + use crate::storage::Storage; + + const TEST_ISSUER: &str = "https://issuer.test"; + const JWKS_FIXTURE: &str = include_str!("../../tests/fixtures/test_only_jwks.json"); + const RSA_PRIVATE_PEM: &str = include_str!("../../tests/fixtures/test_only_rsa_private.pem"); + + fn unix_now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + } + + fn mint_token(sub: &str) -> String { + let header = jsonwebtoken::Header { + alg: jsonwebtoken::Algorithm::RS256, + kid: Some("test-only-key-1".into()), + ..Default::default() + }; + let claims = serde_json::json!({ + "iss": TEST_ISSUER, + "sub": sub, + "exp": unix_now() + 3600, + "owner_type": "account", + }); + let key = jsonwebtoken::EncodingKey::from_rsa_pem(RSA_PRIVATE_PEM.as_bytes()).unwrap(); + jsonwebtoken::encode(&header, &claims, &key).unwrap() + } + + /// Serve the fixture JWKS, counting requests, on a random local port. + async fn jwks_server() -> (String, Arc) { + let hits = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&hits); + let app = axum::Router::new().route( + "/jwks.json", + get(move || { + let counter = Arc::clone(&counter); + async move { + counter.fetch_add(1, Ordering::SeqCst); + JWKS_FIXTURE + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + (format!("http://{addr}/jwks.json"), hits) + } + + /// Compose postgres (compose.yml), required by the DB-backed tests: + /// the billing `effective_limits` resolution queries `billing_rules` + /// on every request, so the 200/429 paths need a live database. + const PUBLISH_DB_URL: &str = "postgres://booskiff:booskiff@127.0.0.1:5432/booskiff"; + + /// Router state for the DB-free paths (header parsing and token + /// verification both reject before any database access): the pool is + /// lazy and never connects. `premium_rpm` is only consumed by the + /// DB-backed tests. + async fn no_db_app_state() -> AppState { + let (jwks_url, _hits) = jwks_server().await; + let config = Config { + jwt_trusted_issuers: vec![TrustedIssuer { + issuer: TEST_ISSUER.into(), + jwks_url, + }], + ..Config::default() + }; + AppState { + pool: sqlx::PgPool::connect_lazy("postgres://127.0.0.1:9/never") + .expect("lazy pool never connects"), + s3: Storage::build(&config).await.unwrap(), + jwks_cache: JwksCache::new(config.jwt_trusted_issuers.clone()), + config, + rate_limiters: Arc::new(RateLimiters::default()), + } + } + + /// Router state backed by the compose postgres so `effective_limits` + /// resolves: `PremiumMode::Everyone` routes owners to premium plan + /// limits, whose tiny injected rpm makes the limiter trip cheap to + /// observe. The compose `billing_rules` table stays empty, so no rule + /// layer overrides the injected rpm. + async fn db_app_state(premium_rpm: u32) -> AppState { + let (jwks_url, _hits) = jwks_server().await; + let config = Config { + jwt_trusted_issuers: vec![TrustedIssuer { + issuer: TEST_ISSUER.into(), + jwks_url, + }], + plan_premium_rate_limit_rpm: premium_rpm, + ..Config::default() + }; + AppState { + pool: sqlx::PgPool::connect(PUBLISH_DB_URL).await.unwrap(), + s3: Storage::build(&config).await.unwrap(), + jwks_cache: JwksCache::new(config.jwt_trusted_issuers.clone()), + config, + rate_limiters: Arc::new(RateLimiters::default()), + } + } + + /// Router with one account-authenticated route echoing the owner key. + fn account_router(state: AppState) -> axum::Router { + async fn echo_owner(context: AccountContext) -> (StatusCode, Json) { + ( + StatusCode::OK, + Json(serde_json::json!({ "owner": context.owner.key() })), + ) + } + axum::Router::new() + .route("/test", get(echo_owner)) + .with_state(state) + } + + fn request(authorization: Option) -> Request { + let mut request = Request::builder().uri("/test"); + if let Some(authorization) = authorization { + request = request.header(header::AUTHORIZATION, authorization); + } + request.body(axum::body::Body::empty()).unwrap() + } + + async fn response_body_json( + response: axum::response::Response, + ) -> (StatusCode, serde_json::Value) { + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body = serde_json::from_slice(&bytes).unwrap(); + (status, body) + } + + #[tokio::test] + async fn missing_authorization_header_is_unauthorized() { + let state = no_db_app_state().await; + let router = account_router(state); + let (status, body) = response_body_json(router.oneshot(request(None)).await.unwrap()).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["error"]["code"], "unauthorized"); + assert_eq!(body["error"]["message"], "missing bearer token"); + } + + #[tokio::test] + async fn malformed_authorization_header_is_unauthorized() { + let state = no_db_app_state().await; + let router = account_router(state); + for header_value in ["", "Basic abc", "Bearer", "bearer no-capitals"] { + let (status, body) = response_body_json( + router + .clone() + .oneshot(request(Some(header_value.into()))) + .await + .unwrap(), + ) + .await; + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "header {header_value:?} must be rejected" + ); + assert_eq!(body["error"]["code"], "unauthorized"); + } + } + + #[tokio::test] + async fn invalid_bearer_token_is_unauthorized() { + let state = no_db_app_state().await; + let router = account_router(state); + let (status, body) = response_body_json( + router + .oneshot(request(Some("Bearer not-a-jwt".into()))) + .await + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["error"]["code"], "unauthorized"); + assert_eq!(body["error"]["message"], "malformed token"); + } + + #[tokio::test] + #[ignore = "requires the compose postgres stack"] + async fn valid_bearer_token_extracts_owner() { + let state = db_app_state(100).await; + let router = account_router(state); + let token = mint_token("alice"); + let (status, body) = response_body_json( + router + .oneshot(request(Some(format!("Bearer {token}")))) + .await + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["owner"], "account:alice"); + } + + #[tokio::test] + #[ignore = "requires the compose postgres stack"] + async fn requests_beyond_rpm_are_rate_limited() { + let state = db_app_state(2).await; + // Governor refills one token per 60/rpm seconds, so within a test + // run only the initial burst of `rpm` requests passes. + let router = account_router(state); + let token = mint_token("alice"); + for expected in [StatusCode::OK, StatusCode::OK] { + let (status, _body) = response_body_json( + router + .clone() + .oneshot(request(Some(format!("Bearer {token}")))) + .await + .unwrap(), + ) + .await; + assert_eq!(status, expected); + } + let (status, body) = response_body_json( + router + .oneshot(request(Some(format!("Bearer {token}")))) + .await + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!(body["error"]["code"], "rate_limited"); + } } diff --git a/core/src/auth/jwks.rs b/core/src/auth/jwks.rs index 76c4bf3..d219163 100644 --- a/core/src/auth/jwks.rs +++ b/core/src/auth/jwks.rs @@ -1,22 +1,130 @@ -//! JWKS fetching and key caching. TODO(Wave2): fetch, cache, and rotate -//! keys per trusted issuer. +//! JWKS fetching and key caching per trusted issuer. use std::sync::Arc; +use std::time::{Duration, Instant}; + +use jsonwebtoken::DecodingKey; +use jsonwebtoken::jwk::{AlgorithmParameters, JwkSet}; +use tokio::sync::Mutex; use crate::config::TrustedIssuer; use crate::error::AppError; -/// Cache of JWKS keys per trusted issuer. Cheap to clone; later waves -/// share the inner cache across requests. +/// How long a fetched JwkSet is served from cache before the next +/// on-demand refresh (Emumet-style minimum refresh interval: issuers rotate +/// keys rarely, so this bounds both staleness and upstream fetch load). +const CACHE_TTL: Duration = Duration::from_secs(300); + +/// Minimum spacing between kid-miss ("forced") refreshes for one issuer. +/// Without this cooldown an attacker sending unknown kids could turn every +/// request into an upstream JWKS fetch (refresh storm). +const FORCED_REFRESH_COOLDOWN: Duration = Duration::from_secs(60); + +/// Upper bound for one outgoing JWKS fetch (a hung issuer must not hang +/// request handlers indefinitely). +const FETCH_TIMEOUT: Duration = Duration::from_secs(10); + +/// Cached state for one trusted issuer. +/// +/// Interior mutability choice: the `std::sync::RwLock` serves the read +/// path (cache reads never await, so a plain lock avoids task-thread +/// parking), while the `tokio::sync::Mutex` serializes refreshes so +/// concurrent misses for one issuer collapse into a single JWKS fetch +/// (single-flight). +struct IssuerSlot { + cache: std::sync::RwLock, + refresh: Mutex<()>, +} + +/// All fields are `None` until the first successful fetch. +#[derive(Default)] +struct CacheEntry { + jwks: Option, + fetched_at: Option, + forced_refresh_at: Option, +} + +impl IssuerSlot { + fn read(&self, with: impl FnOnce(&CacheEntry) -> T) -> T { + let entry = self + .cache + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + with(&entry) + } + + /// The cached set regardless of freshness (stale kids still resolve; + /// the TTL only gates `jwks_for` reads and background re-fetches). + fn jwks(&self) -> Option { + self.read(|entry| entry.jwks.clone()) + } + + /// The cached set when it was fetched within [`CACHE_TTL`]. + fn fresh_jwks(&self) -> Option { + self.read(|entry| match (&entry.jwks, entry.fetched_at) { + (Some(jwks), Some(fetched_at)) if fetched_at.elapsed() < CACHE_TTL => { + Some(jwks.clone()) + } + _ => None, + }) + } + + /// Whether a forced refresh ran within [`FORCED_REFRESH_COOLDOWN`]. + fn forced_refresh_recent(&self) -> bool { + self.read(|entry| { + entry + .forced_refresh_at + .is_some_and(|at| at.elapsed() < FORCED_REFRESH_COOLDOWN) + }) + } + + fn note_forced_refresh(&self) { + let mut entry = self + .cache + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + entry.forced_refresh_at = Some(Instant::now()); + } + + fn store(&self, jwks: JwkSet) { + let mut entry = self + .cache + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + entry.jwks = Some(jwks); + entry.fetched_at = Some(Instant::now()); + } +} + +/// Cache of JWKS keys per trusted issuer. Cheap to clone: every clone +/// shares the same per-issuer slots and HTTP client. #[derive(Clone)] pub struct JwksCache { issuers: Arc<[TrustedIssuer]>, + /// Parallel to `issuers` (same index = same issuer). + slots: Arc<[Arc]>, + client: reqwest::Client, } impl JwksCache { pub fn new(issuers: Vec) -> Self { + let slots = issuers + .iter() + .map(|_| { + Arc::new(IssuerSlot { + cache: std::sync::RwLock::new(CacheEntry::default()), + refresh: Mutex::new(()), + }) + }) + .collect::>(); + let client = reqwest::Client::builder() + .timeout(FETCH_TIMEOUT) + .build() + .unwrap_or_else(|err| panic!("failed to build JWKS http client: {err}")); Self { issuers: Arc::from(issuers), + slots: Arc::from(slots), + client, } } @@ -24,9 +132,193 @@ impl JwksCache { &self.issuers } - /// Return the decoding key for `issuer`. TODO(Wave2): real fetch with - /// caching and refresh. - pub async fn fetch(&self, _issuer: &str) -> Result { - Err(AppError::Internal("jwks fetch not implemented".into())) + fn find(&self, issuer: &str) -> Result<(&TrustedIssuer, &IssuerSlot), AppError> { + self.issuers + .iter() + .zip(self.slots.iter()) + .find(|(trusted, _)| trusted.issuer == issuer) + .map(|(trusted, slot)| (trusted, slot.as_ref())) + .ok_or_else(|| AppError::Unauthorized("untrusted issuer".into())) + } + + /// The (cached) JW document for a known issuer. Unknown issuers are + /// rejected without any outgoing request. + pub async fn jwks_for(&self, issuer: &str) -> Result { + let (trusted, slot) = self.find(issuer)?; + if let Some(jwks) = slot.fresh_jwks() { + return Ok(jwks); + } + // Single-flight: a concurrent task may refresh while we wait on + // the mutex, so re-check freshness after acquiring the guard. + let _guard = slot.refresh.lock().await; + if let Some(jwks) = slot.fresh_jwks() { + return Ok(jwks); + } + let jwks = self.fetch_jwks(&trusted.jwks_url).await?; + slot.store(jwks.clone()); + Ok(jwks) + } + + /// The decoding key for `kid` as published by `issuer`. A `kid` miss + /// on a live cache triggers at most one forced refresh per + /// [`FORCED_REFRESH_COOLDOWN`] window (per issuer) to prevent refresh + /// storms; a cold or expired cache first gets a regular refresh. + pub async fn key_for(&self, issuer: &str, kid: &str) -> Result { + let (trusted, slot) = self.find(issuer)?; + if let Some(key) = slot.jwks().and_then(|jwks| decoding_key(&jwks, kid)) { + return Ok(key); + } + // Single-flight: a concurrent task may refresh while we wait on + // the mutex, so re-check the kid after acquiring the guard. + let _guard = slot.refresh.lock().await; + if let Some(key) = slot.jwks().and_then(|jwks| decoding_key(&jwks, kid)) { + return Ok(key); + } + // Cold or expired cache: a regular refresh. It does not consume + // the forced-refresh cooldown — a cold start must not suppress + // the kid-miss probes below (e.g. key rotation checks). + if slot.fresh_jwks().is_none() { + let jwks = self.fetch_jwks(&trusted.jwks_url).await?; + slot.store(jwks.clone()); + if let Some(key) = decoding_key(&jwks, kid) { + return Ok(key); + } + // The set was fetched moments ago: a forced re-fetch would + // return the same keys, so this miss is final — but it still + // stamps the cooldown so repeated misses do not re-fetch. + slot.note_forced_refresh(); + return Err(AppError::Unauthorized(format!( + "unknown kid {kid:?} for issuer {issuer:?}" + ))); + } + // Kid miss on a live cache: at most one forced refresh per + // cooldown window (per issuer) to prevent refresh storms. + if slot.forced_refresh_recent() { + return Err(AppError::Unauthorized(format!( + "unknown kid {kid:?} for issuer {issuer:?}" + ))); + } + // Stamp the cooldown before (not after) fetching so a failing or + // slow JWKS endpoint is shielded from refresh storms too. + slot.note_forced_refresh(); + let jwks = self.fetch_jwks(&trusted.jwks_url).await?; + slot.store(jwks.clone()); + decoding_key(&jwks, kid).ok_or_else(|| { + AppError::Unauthorized(format!("unknown kid {kid:?} for issuer {issuer:?}")) + }) + } + + async fn fetch_jwks(&self, jwks_url: &str) -> Result { + self.client + .get(jwks_url) + .send() + .await + .and_then(|response| response.error_for_status()) + .map_err(|err| AppError::Internal(format!("jwks fetch {jwks_url} failed: {err}")))? + .json::() + .await + .map_err(|err| AppError::Internal(format!("jwks parse {jwks_url} failed: {err}"))) + } +} + +/// Build a decoding key for `kid` from the set. A matching non-RSA key +/// counts as not found (only RS256 tokens are accepted downstream). +fn decoding_key(jwks: &JwkSet, kid: &str) -> Option { + let jwk = jwks + .keys + .iter() + .find(|jwk| jwk.common.key_id.as_deref() == Some(kid))?; + match &jwk.algorithm { + AlgorithmParameters::RSA(params) => { + DecodingKey::from_rsa_components(¶ms.n, ¶ms.e).ok() + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc as SharedArc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use axum::routing::get; + + const TEST_ISSUER: &str = "https://issuer.test"; + const JWKS_FIXTURE: &str = include_str!("../../tests/fixtures/test_only_jwks.json"); + + /// Serve the fixture JWKS, counting requests, on a random local port. + async fn jwks_server() -> (String, SharedArc) { + let hits = SharedArc::new(AtomicUsize::new(0)); + let counter = SharedArc::clone(&hits); + let app = axum::Router::new().route( + "/jwks.json", + get(move || { + let counter = SharedArc::clone(&counter); + async move { + counter.fetch_add(1, Ordering::SeqCst); + JWKS_FIXTURE + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + (format!("http://{addr}/jwks.json"), hits) + } + + fn cache_with(jwks_url: &str) -> JwksCache { + JwksCache::new(vec![TrustedIssuer { + issuer: TEST_ISSUER.into(), + jwks_url: jwks_url.into(), + }]) + } + + #[tokio::test] + async fn unknown_issuer_is_rejected_without_fetching() { + let (url, hits) = jwks_server().await; + let cache = cache_with(&url); + let err = cache.jwks_for("https://evil.test").await.unwrap_err(); + assert!( + matches!(err, AppError::Unauthorized(message) if message.contains("untrusted issuer")) + ); + let err = cache + .key_for("https://evil.test", "test-only-key-1") + .await + .unwrap_err(); + assert!(matches!(err, AppError::Unauthorized(_))); + assert_eq!(hits.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn jwks_for_fetches_once_then_serves_from_cache() { + let (url, hits) = jwks_server().await; + let cache = cache_with(&url); + let first = cache.jwks_for(TEST_ISSUER).await.unwrap(); + let second = cache.jwks_for(TEST_ISSUER).await.unwrap(); + assert_eq!(first, second); + assert_eq!(hits.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn key_for_builds_decoding_key_for_known_kid() { + let (url, hits) = jwks_server().await; + let cache = cache_with(&url); + cache.key_for(TEST_ISSUER, "test-only-key-1").await.unwrap(); + assert_eq!(hits.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn key_for_unknown_kid_forces_one_refresh_per_cooldown() { + let (url, hits) = jwks_server().await; + let cache = cache_with(&url); + let err = cache.key_for(TEST_ISSUER, "missing-kid").await.unwrap_err(); + assert!(matches!(err, AppError::Unauthorized(_))); + // A repeat miss inside the 60s cooldown must not re-fetch. + let err = cache.key_for(TEST_ISSUER, "missing-kid").await.unwrap_err(); + assert!(matches!(err, AppError::Unauthorized(_))); + assert_eq!(hits.load(Ordering::SeqCst), 1); } } diff --git a/core/src/auth/jwt.rs b/core/src/auth/jwt.rs index 96d1ee6..f82ff87 100644 --- a/core/src/auth/jwt.rs +++ b/core/src/auth/jwt.rs @@ -1,7 +1,14 @@ -//! JWT claim shapes and token verification. TODO(Wave2): verification -//! against the JWKS cache. +// allow: SIZE_OK — one responsibility (JWT verification + owner mapping); +// the line count is dominated by spec-mandated test volume (the accept/ +// reject matrix needs an in-test JWKS server plus token minting, and the +// owner-claim matrix is required coverage), and this task's file-ownership +// boundary forbids splitting into new files or submodules. +//! JWT claim shapes and token verification. +use crate::auth::jwks::JwksCache; +use crate::config::Config; use crate::error::AppError; +use crate::model::Owner; use serde::Deserialize; /// Verified JWT claims. Token extensions (including the owner claim named @@ -17,8 +24,312 @@ pub struct Claims { pub extra: serde_json::Map, } -/// Verify `token` and return its claims. TODO(Wave2): signature, issuer, -/// audience, and expiry checks. -pub async fn verify_token(_token: &str) -> Result { - Err(AppError::Internal("jwt verify not implemented".into())) +/// `iss` claim read without any validation, purely to route a token to +/// its issuer's JWKS before the fully validated decode. +#[derive(Deserialize)] +struct UnverifiedIssuer { + iss: String, +} + +/// Verify `token` against the JWKS of its (unverified) issuer and return +/// the decoded claims. Only RS256 tokens signed by a trusted issuer's +/// published key are accepted. +pub async fn verify_token( + cache: &JwksCache, + config: &Config, + token: &str, +) -> Result { + let header = jsonwebtoken::decode_header(token) + .map_err(|_| AppError::Unauthorized("malformed token".into()))?; + let Some(kid) = header.kid else { + return Err(AppError::Unauthorized("token header missing kid".into())); + }; + let unverified = jsonwebtoken::dangerous::insecure_decode::(token) + .map_err(|_| AppError::Unauthorized("malformed token".into()))?; + let iss = unverified.claims.iss; + if !cache.issuers().iter().any(|trusted| trusted.issuer == iss) { + return Err(AppError::Unauthorized("untrusted issuer".into())); + } + let key = cache.key_for(&iss, &kid).await?; + let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256); + validation.set_issuer(&[iss]); + match &config.jwt_audience { + Some(audience) => validation.set_audience(&[audience]), + None => validation.validate_aud = false, + } + // Leeway 0: exact expiry, no slack. `exp` is a required claim by + // default in jsonwebtoken 11. + validation.leeway = 0; + let data = jsonwebtoken::decode::(token, &key, &validation) + .map_err(|_| AppError::Unauthorized("invalid token".into()))?; + Ok(data.claims) +} + +/// Owner identity derived from verified claims: `sub` is the owner id and +/// the claim named `claim` (config: `jwt_owner_type_claim`) the owner +/// type, defaulting to `account` when absent or non-string. An empty id +/// or empty owner type is an invalid identity. +pub fn owner_from_claims(claims: &Claims, claim: &str) -> Result { + let owner_type = match claims.extra.get(claim).and_then(serde_json::Value::as_str) { + // A present-but-empty owner type is an invalid identity, not a + // missing one — it must not fall through to the "account" default. + Some("") => return Err(AppError::Unauthorized("invalid subject".into())), + Some(owner_type) => owner_type, + None => "account", + }; + if claims.sub.is_empty() { + return Err(AppError::Unauthorized("invalid subject".into())); + } + Ok(Owner::new(owner_type, claims.sub.clone())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + use axum::routing::get; + use rstest::rstest; + + use crate::config::TrustedIssuer; + + const TEST_ISSUER: &str = "https://issuer.test"; + const JWKS_FIXTURE: &str = include_str!("../../tests/fixtures/test_only_jwks.json"); + const RSA_PRIVATE_PEM: &str = include_str!("../../tests/fixtures/test_only_rsa_private.pem"); + + fn unix_now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + } + + fn mint_token(claims: &serde_json::Value) -> String { + mint_token_with(claims, "test-only-key-1") + } + + fn mint_token_with(claims: &serde_json::Value, kid: &str) -> String { + let header = jsonwebtoken::Header { + alg: jsonwebtoken::Algorithm::RS256, + kid: Some(kid.into()), + ..Default::default() + }; + let key = jsonwebtoken::EncodingKey::from_rsa_pem(RSA_PRIVATE_PEM.as_bytes()).unwrap(); + jsonwebtoken::encode(&header, claims, &key).unwrap() + } + + fn valid_claims() -> serde_json::Value { + serde_json::json!({ + "iss": TEST_ISSUER, + "sub": "alice", + "exp": unix_now() + 3600, + "owner_type": "drive", + }) + } + + /// Serve the fixture JWKS, counting requests, on a random local port. + async fn jwks_server() -> (String, Arc) { + let hits = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&hits); + let app = axum::Router::new().route( + "/jwks.json", + get(move || { + let counter = Arc::clone(&counter); + async move { + counter.fetch_add(1, Ordering::SeqCst); + JWKS_FIXTURE + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + (format!("http://{addr}/jwks.json"), hits) + } + + async fn cache_and_config() -> (JwksCache, Config) { + let (url, _hits) = jwks_server().await; + let cache = JwksCache::new(vec![TrustedIssuer { + issuer: TEST_ISSUER.into(), + jwks_url: url, + }]); + (cache, Config::default()) + } + + #[tokio::test] + async fn valid_token_verifies_and_yields_claims() { + let (cache, config) = cache_and_config().await; + let claims = verify_token(&cache, &config, &mint_token(&valid_claims())) + .await + .unwrap(); + assert_eq!(claims.iss, TEST_ISSUER); + assert_eq!(claims.sub, "alice"); + assert_eq!( + claims + .extra + .get("owner_type") + .and_then(serde_json::Value::as_str), + Some("drive") + ); + } + + #[tokio::test] + async fn expired_token_is_rejected() { + let (cache, config) = cache_and_config().await; + let mut claims = valid_claims(); + claims["exp"] = (unix_now() - 10).into(); + let err = verify_token(&cache, &config, &mint_token(&claims)) + .await + .unwrap_err(); + assert!(matches!(err, AppError::Unauthorized(_))); + } + + #[tokio::test] + async fn untrusted_issuer_is_rejected() { + let (cache, config) = cache_and_config().await; + let mut claims = valid_claims(); + claims["iss"] = "https://evil.test".into(); + let err = verify_token(&cache, &config, &mint_token(&claims)) + .await + .unwrap_err(); + assert!( + matches!(err, AppError::Unauthorized(message) if message.contains("untrusted issuer")) + ); + } + + #[tokio::test] + async fn token_without_kid_is_rejected() { + let (cache, config) = cache_and_config().await; + let header = jsonwebtoken::Header { + alg: jsonwebtoken::Algorithm::RS256, + kid: None, + ..Default::default() + }; + let key = jsonwebtoken::EncodingKey::from_rsa_pem(RSA_PRIVATE_PEM.as_bytes()).unwrap(); + let token = jsonwebtoken::encode(&header, &valid_claims(), &key).unwrap(); + let err = verify_token(&cache, &config, &token).await.unwrap_err(); + assert!(matches!(err, AppError::Unauthorized(message) if message.contains("missing kid"))); + } + + #[tokio::test] + async fn unknown_kid_forces_single_refresh_then_rejects() { + let (url, hits) = jwks_server().await; + let cache = JwksCache::new(vec![TrustedIssuer { + issuer: TEST_ISSUER.into(), + jwks_url: url, + }]); + let config = Config::default(); + // Prime the cache: exactly one JWKS fetch. + verify_token(&cache, &config, &mint_token(&valid_claims())) + .await + .unwrap(); + assert_eq!(hits.load(Ordering::SeqCst), 1); + // A token with an unknown kid triggers exactly one forced refresh... + let err = verify_token( + &cache, + &config, + &mint_token_with(&valid_claims(), "missing-kid"), + ) + .await + .unwrap_err(); + assert!(matches!(err, AppError::Unauthorized(_))); + assert_eq!(hits.load(Ordering::SeqCst), 2); + // ...and further misses inside the cooldown fetch nothing. + let err = verify_token( + &cache, + &config, + &mint_token_with(&valid_claims(), "missing-kid"), + ) + .await + .unwrap_err(); + assert!(matches!(err, AppError::Unauthorized(_))); + assert_eq!(hits.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn hs256_signed_token_is_rejected() { + let (cache, config) = cache_and_config().await; + let header = jsonwebtoken::Header { + alg: jsonwebtoken::Algorithm::HS256, + kid: Some("test-only-key-1".into()), + ..Default::default() + }; + let key = jsonwebtoken::EncodingKey::from_secret(b"attacker-controlled-secret"); + let token = jsonwebtoken::encode(&header, &valid_claims(), &key).unwrap(); + let err = verify_token(&cache, &config, &token).await.unwrap_err(); + assert!(matches!(err, AppError::Unauthorized(_))); + } + + #[tokio::test] + async fn wrong_audience_is_rejected() { + let (url, _hits) = jwks_server().await; + let cache = JwksCache::new(vec![TrustedIssuer { + issuer: TEST_ISSUER.into(), + jwks_url: url, + }]); + let config = Config { + jwt_audience: Some("booskiff".into()), + ..Config::default() + }; + let mut claims = valid_claims(); + claims["aud"] = "someone-else".into(); + let err = verify_token(&cache, &config, &mint_token(&claims)) + .await + .unwrap_err(); + assert!(matches!(err, AppError::Unauthorized(_))); + // The configured audience accepts the matching token. + let mut claims = valid_claims(); + claims["aud"] = "booskiff".into(); + let verified = verify_token(&cache, &config, &mint_token(&claims)) + .await + .unwrap(); + assert_eq!(verified.sub, "alice"); + } + + fn claims_with(sub: &str, owner_type: Option) -> Claims { + let mut extra = serde_json::Map::new(); + if let Some(owner_type) = owner_type { + extra.insert("owner_type".into(), owner_type); + } + Claims { + iss: TEST_ISSUER.into(), + sub: sub.into(), + exp: unix_now() + 3600, + aud: None, + extra, + } + } + + #[rstest] + #[case(Some(serde_json::json!("drive")), "drive")] + #[case(None, "account")] + #[case(Some(serde_json::json!(5)), "account")] + fn owner_type_resolves_from_claim( + #[case] owner_type: Option, + #[case] expected: &str, + ) { + let claims = claims_with("alice", owner_type); + assert_eq!( + owner_from_claims(&claims, "owner_type").unwrap(), + Owner::new(expected, "alice") + ); + } + + #[rstest] + #[case("", Some(serde_json::json!("drive")))] + #[case("alice", Some(serde_json::json!("")))] + fn owner_from_claims_rejects_invalid_identities( + #[case] sub: &str, + #[case] owner_type: Option, + ) { + let claims = claims_with(sub, owner_type); + let err = owner_from_claims(&claims, "owner_type").unwrap_err(); + assert!( + matches!(err, AppError::Unauthorized(message) if message.contains("invalid subject")) + ); + } } diff --git a/core/src/state.rs b/core/src/state.rs index c980ae4..ce1e2ea 100644 --- a/core/src/state.rs +++ b/core/src/state.rs @@ -32,8 +32,41 @@ impl RateLimiters { .or_insert_with(|| Arc::new(OwnerRateLimiter::new(rpm))) .clone() } + + /// Admit one request for `key` under `rpm`, `true` when it fits. + /// + /// A stored entry whose `rpm` differs is rebuilt from scratch: + /// billing rule edits change an owner's rpm, and a fresh limiter is + /// cheaper than reconciling existing token buckets with a new quota. + /// + /// Memory bound: at 50k tracked owners, a request from an untracked + /// key clears the whole map first — limiters refill cheaply (one + /// token per 60/rpm seconds), so an owner whose limiter was wiped + /// merely regains a small burst allowance instead of the map growing + /// without bound across the owner population. + pub fn check(&self, key: &str, rpm: u32) -> bool { + let mut map = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if map.len() >= MAX_TRACKED_OWNERS && !map.contains_key(key) { + map.clear(); + } + let limiter = match map.get(key) { + Some(existing) if existing.rpm == rpm => Arc::clone(existing), + _ => { + let limiter = Arc::new(OwnerRateLimiter::new(rpm)); + map.insert(key.to_owned(), Arc::clone(&limiter)); + limiter + } + }; + limiter.check() + } } +/// After how many tracked owners a fresh key wipes the limiter map. +const MAX_TRACKED_OWNERS: usize = 50_000; + /// Cheaply cloneable state shared by all request handlers. #[derive(Clone)] pub struct AppState { @@ -43,3 +76,50 @@ pub struct AppState { pub jwks_cache: JwksCache, pub rate_limiters: Arc, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_allows_burst_then_denies() { + let limiters = RateLimiters::default(); + assert!(limiters.check("account:alice", 2)); + assert!(limiters.check("account:alice", 2)); + assert!(!limiters.check("account:alice", 2)); + } + + #[test] + fn check_rebuilds_limiter_when_rpm_changes() { + let limiters = RateLimiters::default(); + assert!(limiters.check("account:alice", 1)); + assert!(!limiters.check("account:alice", 1)); + // A new rpm gets a freshly built limiter, not the spent old one. + assert!(limiters.check("account:alice", 3)); + } + + #[test] + fn check_ignores_unchanged_rpm_entry() { + let limiters = RateLimiters::default(); + let stored = limiters.get_or_insert("account:alice", 2); + // Exhaust the burst through the stored Arc; if `check` rebuilt the + // entry instead of reusing it, the final call would pass again. + assert!(stored.check()); + assert!(stored.check()); + assert!(!limiters.check("account:alice", 2)); + } + + #[test] + fn check_clears_map_when_beyond_bound() { + let limiters = RateLimiters::default(); + assert!(limiters.check("account:bob", 1)); + assert!(!limiters.check("account:bob", 1)); + for i in 0..MAX_TRACKED_OWNERS { + limiters.get_or_insert(&format!("owner:{i}"), 1); + } + // A request from an untracked key beyond the bound wipes the map... + assert!(limiters.check("account:carol", 1)); + // ...including bob's exhausted limiter, which rebuilds fresh. + assert!(limiters.check("account:bob", 1)); + } +} diff --git a/core/tests/fixtures/README.md b/core/tests/fixtures/README.md new file mode 100644 index 0000000..c321886 --- /dev/null +++ b/core/tests/fixtures/README.md @@ -0,0 +1,6 @@ +# Test-only RSA fixtures + +`test_only_rsa_private.pem` / `test_only_jwks.json` are a throwaway RSA-2048 +keypair used ONLY by tests (unit tests and the E2E harness) to mint RS256 +tokens and serve a JWKS. They are intentionally committed. Never reuse them +for any real issuer. diff --git a/core/tests/fixtures/test_only_jwks.json b/core/tests/fixtures/test_only_jwks.json new file mode 100644 index 0000000..bc5ead5 --- /dev/null +++ b/core/tests/fixtures/test_only_jwks.json @@ -0,0 +1 @@ +{"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":"test-only-key-1","n":"2erwWkbtKdkO86wTl94j8C-Pz155WaxYR9Hl9B868x7ldy38go9uZ31XUcZ4hBzqV1fyyW-3ESS2na49NKTdhczXxO4I75jYCzdgNvLDX4WVZsHMJZoiHXkarl3nZ_yxLAy5otzNGRCIS_4NThVCIY9IOfEBx3vUfeTkHQV681elhWQqj-9p2yMEWwyWvY0rEQbNYfRs4fLioScTrZQ6xvgjuHTesJ7Jx53eGHCr9P4fwgsTGNodhmjmUB_pufqqyeUgjP-tlvUSxFNceudmdcyjAA80n2xVNUkCwNlNyp4WhNwlUfYQqdcO1AlZCPeQcHVmBlHJ8IqsWUOqDANMSw","e":"AQAB"}]} diff --git a/core/tests/fixtures/test_only_rsa_private.pem b/core/tests/fixtures/test_only_rsa_private.pem new file mode 100644 index 0000000..1ef7d38 --- /dev/null +++ b/core/tests/fixtures/test_only_rsa_private.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEA2erwWkbtKdkO86wTl94j8C+Pz155WaxYR9Hl9B868x7ldy38 +go9uZ31XUcZ4hBzqV1fyyW+3ESS2na49NKTdhczXxO4I75jYCzdgNvLDX4WVZsHM +JZoiHXkarl3nZ/yxLAy5otzNGRCIS/4NThVCIY9IOfEBx3vUfeTkHQV681elhWQq +j+9p2yMEWwyWvY0rEQbNYfRs4fLioScTrZQ6xvgjuHTesJ7Jx53eGHCr9P4fwgsT +GNodhmjmUB/pufqqyeUgjP+tlvUSxFNceudmdcyjAA80n2xVNUkCwNlNyp4WhNwl +UfYQqdcO1AlZCPeQcHVmBlHJ8IqsWUOqDANMSwIDAQABAoIBAQCPEqUDYJXNZ+ld +KpkimIJX66IrsAdUTMcZQEIN6YM2pA5cFSE4RxthbvWphvkRJDrnZaLNhj9XThdn +p0IgEi4u7rKrZO0RAfS2SIEvEmwbhwT0FkEGXxAO8bgGyzhnGYzpMuGegAFYd3ZF +DmOF5IcqqXvuLsHQSwjULzdlpnd209p1UvHG3aEmYwc7qxf7vXlX1dzJCdcNjX+x +dn6AX6uMY9FHD+lgQVLPmIXG70le2s7LxOlbTk3hTyMa5Wr8aTvn5Fz9JurEQ5c9 +P1Kw7+oDe+4hSDy9Xr01Y1KcpJYEvh3TC55Y+mu9rzQtM3rXdX/04OKcLcBsqg8y +pXlGz5mBAoGBAOiImd9n2YjXcFv+c95KcEyJOgtJSaGcNFnRlmvglyv0ak3aMufV +ajFPgtBVLJ9ZOp15Em9TYhqAxr07q0lHHhUJkcA4xTbcRRFp45BK2apQfxTRMplw +2oGtFxdVRx5whXeWVzpRzKlnAOYMLpQSO6igfepm2dxOkJu79bDVPKAxAoGBAO/o +vlojiyJFDCFWRrB110awQw5+9gtRVLH9AdE9lwK1bbKU7XQPGATpy3zDEbCdW22v +Ha/uW0euz4SSp9qHoutfFWZdZC7Wx4t/kiOfJzSd+S4mKUXUweJDMzqAr16t3a46 +Knm9f2geALlCOnBd2cart3fVJ9fFeCZix7oTMTE7AoGAaoPIGDG1z5z/9uPDp5tp +fWWOlQTpGYI36Ly8roWeOaAn4amFj/SORDArt8qMOiHhW6jN7PjBA2rhnmTqvRI6 +kWBkxyw6Cjsz2BotBzeCJSXiUGLTTr4HGUd0QFfrz+GzAWh4ZFp76U0peDc0Ttdb +RI7fnrOmL+g9DVk33dyPfMECgYEAprt/h4/KQ5/vJUAb0smjOtqHUAzGnOkCTs0C +tVPznotO2N0OBinzTH7b0DKWGcPS5fDdVgb242PHj2CJTaQUgzZK3iCr3q2gcv/P +JfiXO8dKhVs6NfD+amy/rrn+p3N4bHy6Rm9/m3G2mQpBKRTgpGV1FdBLZ3cy4KKo +IlbySj8CgYEAkxhhqt95xy2KJZmQKQpIqfFIaAPJFq7+I7k6HrOkVmKtTTUrVyDP +GA1nJQxvusTj5zIn6FwOdQ5prnGMBASnnKVE0hylAZ3o23zWFCf6eY6++zWhhaV7 +lQtykPFlWjJcKjQ9+Xp3fHVDhzz71uuu7mzqclB6EUPH8xrv4fuHXrg= +-----END RSA PRIVATE KEY----- From 802455b860ad68daa9a66dc0c2a7170b544852cb Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 30 Aug 2026 13:36:31 +0900 Subject: [PATCH 03/15] feat(storage): S3 layer with streaming put, presigned GET, key convention --- core/src/storage.rs | 336 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 314 insertions(+), 22 deletions(-) diff --git a/core/src/storage.rs b/core/src/storage.rs index a43812b..1534008 100644 --- a/core/src/storage.rs +++ b/core/src/storage.rs @@ -1,5 +1,14 @@ +// allow: SIZE_OK — the module owns one responsibility (the object-storage +// gateway over one S3 client), but cannot be split: the public API and +// module tree are frozen for this wave, and the mandated unit + ignored +// integration tests must live in this file. //! S3-compatible object storage access (MinIO in dev). +use std::time::Duration; + +use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError}; +use aws_sdk_s3::presigning::PresigningConfig; +use aws_sdk_s3::types::{Delete, ObjectIdentifier}; use aws_smithy_types::body::SdkBody; use aws_smithy_types::byte_stream::ByteStream; @@ -7,11 +16,18 @@ use crate::config::Config; use crate::error::AppError; use crate::model::Owner; +/// MinIO enforces the S3 protocol limit of 1000 keys per +/// `DeleteObjects` request. +const DELETE_BATCH_LIMIT: usize = 1000; + /// Thin wrapper over the S3 client bound to one bucket. #[derive(Clone)] pub struct Storage { client: aws_sdk_s3::Client, bucket: String, + /// Endpoint the client talks to; presigned URLs are rewritten from + /// this prefix to `public_endpoint` when one is configured. + endpoint: String, public_endpoint: Option, } @@ -38,6 +54,7 @@ impl Storage { Ok(Self { client: aws_sdk_s3::Client::from_conf(s3_config), bucket: config.s3_bucket.clone(), + endpoint: config.s3_endpoint.clone(), public_endpoint: config.s3_endpoint_public.clone(), }) } @@ -90,40 +107,227 @@ impl Storage { } } - /// Upload `body` to `key`. TODO(Wave2/T4): real implementation. + /// Upload `body` to `key`. + /// + /// `content_length` is forwarded explicitly: the SDK needs a known + /// size or it falls back to aws-chunked encoding, which MinIO + /// mishandles on some paths. The value is the caller's accounting + /// (its counting wrapper); this method does not re-verify it. pub async fn put_streaming( &self, - _key: &str, - _body: SdkBody, - _content_length: i64, - _content_type: &str, + key: &str, + body: SdkBody, + content_length: i64, + content_type: &str, ) -> Result<(), AppError> { - Err(AppError::Internal("storage not implemented yet".into())) + self.client + .put_object() + .bucket(&self.bucket) + .key(key) + .content_type(content_type) + .content_length(content_length) + .body(ByteStream::new(body)) + .send() + .await + .map(|_| ()) + .map_err(|err| { + tracing::warn!(key, error = %err, "put object"); + AppError::Internal(format!("put object {key}: {err}")) + }) } - /// Return a presigned GET URL valid for `ttl`. TODO(Wave2/T4). - pub async fn presign_get( - &self, - _key: &str, - _ttl: std::time::Duration, - ) -> Result { - Err(AppError::Internal("storage not implemented yet".into())) + /// Return a presigned GET URL valid for `ttl`. + /// + /// The presigned URI targets `endpoint` (path style), so when a + /// `s3_endpoint_public` is configured the prefix is rewritten to it: + /// external clients get reachable URLs while the client keeps + /// talking to the internal endpoint. + pub async fn presign_get(&self, key: &str, ttl: Duration) -> Result { + let presigned = self + .client + .get_object() + .bucket(&self.bucket) + .key(key) + .presigned( + PresigningConfig::builder() + .expires_in(ttl) + .build() + .map_err(|err| AppError::Internal(format!("presign get {key}: {err}")))?, + ) + .await + .map_err(|err| { + tracing::warn!(key, error = %err, "presign get"); + AppError::Internal(format!("presign get {key}: {err}")) + })?; + let uri = presigned.uri().to_string(); + Ok(match &self.public_endpoint { + Some(public) => rewrite_endpoint(&uri, &self.endpoint, public), + None => uri, + }) } - /// Return `(mime_type, size_bytes, stream)` for `key`. TODO(Wave2/T4). - pub async fn get_streaming(&self, _key: &str) -> Result<(String, i64, ByteStream), AppError> { - Err(AppError::Internal("storage not implemented yet".into())) + /// Return `(mime_type, size_bytes, stream)` for `key`. + pub async fn get_streaming(&self, key: &str) -> Result<(String, i64, ByteStream), AppError> { + match self + .client + .get_object() + .bucket(&self.bucket) + .key(key) + .send() + .await + { + Ok(output) => Ok(( + output + .content_type() + .unwrap_or("application/octet-stream") + .to_owned(), + output.content_length().unwrap_or(0), + output.body, + )), + Err(err) if is_missing_object(&err) => Err(AppError::NotFound(format!("object {key}"))), + Err(err) => { + tracing::warn!(key, error = %err, "get object"); + Err(AppError::Internal(format!("get object {key}: {err}"))) + } + } } - /// Delete a single object, tolerating a missing key. TODO(Wave2/T4). - pub async fn delete_object(&self, _key: &str) -> Result<(), AppError> { - Err(AppError::Internal("storage not implemented yet".into())) + /// Delete a single object, tolerating a missing key: S3 deletes are + /// idempotent, so a Not-Found response is success here. + pub async fn delete_object(&self, key: &str) -> Result<(), AppError> { + match self + .client + .delete_object() + .bucket(&self.bucket) + .key(key) + .send() + .await + { + Ok(_) => Ok(()), + Err(err) if is_missing_object(&err) => Ok(()), + Err(err) => { + tracing::warn!(key, error = %err, "delete object"); + Err(AppError::Internal(format!("delete object {key}: {err}"))) + } + } } /// List and delete every object under `prefix`, tolerating missing - /// objects. TODO(Wave2/T4). - pub async fn delete_prefix(&self, _prefix: &str) -> Result<(), AppError> { - Err(AppError::Internal("storage not implemented yet".into())) + /// objects. + /// + /// Listing completes before deletion starts so the continuation + /// cursor never races the deletions. Individual deletion failures are + /// warned and processing continues best-effort; the first failure is + /// then surfaced as [`AppError::Internal`]. A failed listing aborts + /// immediately (no further pages can be fetched). + pub async fn delete_prefix(&self, prefix: &str) -> Result<(), AppError> { + let mut keys: Vec = Vec::new(); + let mut continuation_token: Option = None; + loop { + let mut request = self + .client + .list_objects_v2() + .bucket(&self.bucket) + .prefix(prefix); + if let Some(token) = continuation_token { + request = request.continuation_token(token); + } + let page = request.send().await.map_err(|err| { + tracing::warn!(prefix, error = %err, "list objects"); + AppError::Internal(format!("list objects {prefix}: {err}")) + })?; + keys.extend( + page.contents() + .iter() + .filter_map(|object| object.key()) + .map(str::to_owned), + ); + continuation_token = page.next_continuation_token().map(str::to_owned); + if continuation_token.is_none() { + break; + } + } + + let mut first_error: Option = None; + for batch in keys.chunks(DELETE_BATCH_LIMIT) { + let objects = batch + .iter() + .map(|key| ObjectIdentifier::builder().key(key).build()) + .collect::, _>>() + .map_err(|err| { + AppError::Internal(format!("build delete request {prefix}: {err}")) + })?; + let delete = Delete::builder() + .set_objects(Some(objects)) + .build() + .map_err(|err| { + AppError::Internal(format!("build delete request {prefix}: {err}")) + })?; + match self + .client + .delete_objects() + .bucket(&self.bucket) + .delete(delete) + .send() + .await + { + Ok(output) => { + if let Some(object_error) = output.errors().first() { + tracing::warn!( + prefix, + key = object_error.key().unwrap_or_default(), + code = object_error.code().unwrap_or_default(), + "object not deleted" + ); + if first_error.is_none() { + first_error = Some(format!( + "delete object {} under {prefix}: {}", + object_error.key().unwrap_or_default(), + object_error.code().unwrap_or("unknown error") + )); + } + } + } + Err(err) => { + tracing::warn!(prefix, error = %err, "batch delete objects"); + if first_error.is_none() { + first_error = Some(format!("delete objects {prefix}: {err}")); + } + } + } + } + match first_error { + Some(message) => Err(AppError::Internal(message)), + None => Ok(()), + } + } +} + +/// Whether an S3 call failed because the addressed object does not exist. +/// +/// `DeleteObject` does not model `NoSuchKey` (aws-sdk-s3), so detect it +/// via the wire error code, with the raw 404 status as fallback for +/// unparsed error bodies. +fn is_missing_object(err: &SdkError) -> bool +where + E: ProvideErrorMetadata, +{ + err.as_service_error() + .is_some_and(|service_err| service_err.code() == Some("NoSuchKey")) + || err + .raw_response() + .is_some_and(|response| response.status().as_u16() == 404) +} + +/// Replace the `from` endpoint prefix of `url` (schemes and host) with +/// `to`, keeping the `/` path and query intact; returns +/// `url` unchanged when it does not start with the `from` prefix. +fn rewrite_endpoint(url: &str, from: &str, to: &str) -> String { + let from = from.trim_end_matches('/'); + let to = to.trim_end_matches('/'); + match url.strip_prefix(from) { + Some(rest) => format!("{to}{rest}"), + None => url.to_owned(), } } @@ -132,6 +336,8 @@ mod tests { use super::*; use crate::model::OBJECT_KIND_ORIGINAL; + const PAYLOAD: &[u8] = b"hello booskiff"; + #[test] fn object_key_places_owner_then_file_then_kind() { let owner = Owner::new("account", "alice"); @@ -141,4 +347,90 @@ mod tests { "account/alice/01890622-0d3a-7abc-8def-0123456789ab/original" ); } + + #[test] + fn rewrite_endpoint_replaces_configured_prefix() { + assert_eq!( + rewrite_endpoint( + "http://127.0.0.1:9000/booskiff-default/a/b?X-Amz-Algorithm=AWS4-HMAC-SHA256", + "http://127.0.0.1:9000", + "https://cdn.example.com" + ), + "https://cdn.example.com/booskiff-default/a/b?X-Amz-Algorithm=AWS4-HMAC-SHA256" + ); + } + + #[test] + fn rewrite_endpoint_trims_trailing_slashes() { + assert_eq!( + rewrite_endpoint( + "http://127.0.0.1:9000/booskiff-original/key?sig=1", + "http://127.0.0.1:9000/", + "https://cdn.example.com/" + ), + "https://cdn.example.com/booskiff-original/key?sig=1" + ); + } + + #[test] + fn rewrite_endpoint_leaves_foreign_urls_untouched() { + let url = "https://other.example.net/booskiff-default/key?sig=1"; + assert_eq!( + rewrite_endpoint(url, "http://127.0.0.1:9000", "https://cdn.example.com"), + url + ); + } + + /// Round-trip against the dev compose MinIO (`docker compose up -d` + /// from the repo root; defaults of [`Config::default`] match it). + /// Run manually via `cargo test minio_roundtrip -- --ignored`. + #[tokio::test] + #[ignore = "requires the dev compose MinIO at 127.0.0.1:9000"] + async fn minio_roundtrip_ignored() { + let storage = Storage::build(&Config::default()).await.unwrap(); + storage.ensure_bucket().await.unwrap(); + + // Unique per-run prefix so leftover objects from a failed run + // never leak into this one. + let prefix = format!("test/minio-roundtrip/{}/", uuid::Uuid::now_v7()); + let key = format!("{prefix}{OBJECT_KIND_ORIGINAL}"); + + storage + .put_streaming( + &key, + ByteStream::from_static(PAYLOAD).into_inner(), + PAYLOAD.len() as i64, + "text/plain", + ) + .await + .unwrap(); + + let (content_type, length, stream) = storage.get_streaming(&key).await.unwrap(); + assert_eq!(content_type, "text/plain"); + assert_eq!(length, PAYLOAD.len() as i64); + assert_eq!( + stream.collect().await.unwrap().into_bytes().as_ref(), + PAYLOAD + ); + + let url = storage + .presign_get(&key, Duration::from_secs(60)) + .await + .unwrap(); + assert!( + url.starts_with("http://127.0.0.1:9000/booskiff-default/"), + "presigned URL should target the configured endpoint path-style: {url}" + ); + let response = reqwest::get(&url).await.unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!(response.bytes().await.unwrap().as_ref(), PAYLOAD); + + storage.delete_prefix(&prefix).await.unwrap(); + let result = storage.get_streaming(&key).await; + match result { + Err(AppError::NotFound(message)) => assert_eq!(message, format!("object {key}")), + Err(other) => panic!("expected NotFound, got: {other}"), + Ok(_) => panic!("expected NotFound, object still present"), + } + } } From fa2f805f3eb7b07854c869e4298edbbf7710316a Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 30 Aug 2026 13:36:31 +0900 Subject: [PATCH 04/15] feat(billing): plan defaults, rule resolution, atomic usage metering, disabled payment provider --- core/src/billing/assignments.rs | 134 ++++++++++++++ core/src/billing/mod.rs | 2 + core/src/billing/resolve.rs | 308 +++++++++++++++++++++++++++++++- core/src/billing/rules.rs | 302 +++++++++++++++++++++++++++++++ core/src/billing/usage.rs | 182 +++++++++++++++++-- 5 files changed, 908 insertions(+), 20 deletions(-) create mode 100644 core/src/billing/assignments.rs create mode 100644 core/src/billing/rules.rs diff --git a/core/src/billing/assignments.rs b/core/src/billing/assignments.rs new file mode 100644 index 0000000..5a7617d --- /dev/null +++ b/core/src/billing/assignments.rs @@ -0,0 +1,134 @@ +//! Plan assignment repos (consumed in `premium_mode = "mirror"`). + +use crate::error::AppError; +use crate::model::{Owner, Plan}; + +/// Assigned plan for `owner`; `None` when no row exists. +/// +/// Unknown plan strings downgrade to `None` with a warning: a newer +/// deployment may have written plan names this build does not know, and +/// resolution must fall back to the default plan rather than fail. +pub async fn get_plan(pool: &sqlx::PgPool, owner: &Owner) -> Result, AppError> { + let stored: Option = sqlx::query_scalar( + "SELECT plan FROM plan_assignments WHERE owner_type = $1 AND owner_id = $2", + ) + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .fetch_optional(pool) + .await + .map_err(|err| AppError::Internal(format!("load plan assignment failed: {err}")))?; + Ok(stored.and_then(|plan| match plan.parse::() { + Ok(plan) => Some(plan), + Err(err) => { + tracing::warn!(plan = %err.0, "unknown plan in plan_assignments; treating as unassigned"); + None + } + })) +} + +/// Assign `plan` to `owner`, replacing any previous assignment. +pub async fn set_plan(pool: &sqlx::PgPool, owner: &Owner, plan: Plan) -> Result<(), AppError> { + sqlx::query( + "INSERT INTO plan_assignments (owner_type, owner_id, plan, assigned_at) \ + VALUES ($1, $2, $3, now()) \ + ON CONFLICT (owner_type, owner_id) \ + DO UPDATE SET plan = EXCLUDED.plan, assigned_at = now()", + ) + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .bind(plan.as_str()) + .execute(pool) + .await + .map_err(|err| AppError::Internal(format!("set plan assignment failed: {err}")))?; + Ok(()) +} + +/// Returns whether an assignment was removed. +pub async fn delete_plan(pool: &sqlx::PgPool, owner: &Owner) -> Result { + sqlx::query("DELETE FROM plan_assignments WHERE owner_type = $1 AND owner_id = $2") + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .execute(pool) + .await + .map(|result| result.rows_affected() > 0) + .map_err(|err| AppError::Internal(format!("delete plan assignment failed: {err}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Compose-postgres-backed integration test; run manually via + /// `cargo test -p core billing -- --ignored`. + #[tokio::test] + #[ignore] + async fn pg_plan_assignment_roundtrip() { + let pool = test_pool().await; + let owner = Owner::new("test-billing-assignments", uuid::Uuid::now_v7().to_string()); + cleanup(&pool, &owner).await; + + assert_eq!(get_plan(&pool, &owner).await.expect("empty"), None); + + set_plan(&pool, &owner, Plan::Premium) + .await + .expect("assign premium"); + assert_eq!( + get_plan(&pool, &owner).await.expect("premium"), + Some(Plan::Premium) + ); + + // Reassignment overwrites in place (upsert path). + set_plan(&pool, &owner, Plan::Default) + .await + .expect("reassign default"); + assert_eq!( + get_plan(&pool, &owner).await.expect("default"), + Some(Plan::Default) + ); + + assert!(delete_plan(&pool, &owner).await.expect("delete")); + assert!(!delete_plan(&pool, &owner).await.expect("re-delete")); + assert_eq!(get_plan(&pool, &owner).await.expect("after delete"), None); + + // Unknown plan names degrade to unassigned instead of failing. + sqlx::query( + "INSERT INTO plan_assignments (owner_type, owner_id, plan) \ + VALUES ($1, $2, 'galaxy-brain') \ + ON CONFLICT (owner_type, owner_id) DO UPDATE SET plan = EXCLUDED.plan", + ) + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .execute(&pool) + .await + .expect("seed unknown plan"); + assert_eq!(get_plan(&pool, &owner).await.expect("unknown plan"), None); + + cleanup(&pool, &owner).await; + } + + async fn test_pool() -> sqlx::PgPool { + let url = std::env::var("BOOSKIFF_DATABASE_URL") + .unwrap_or_else(|_| "postgres://booskiff:booskiff@127.0.0.1:5432/booskiff".to_owned()); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(&url) + .await + .unwrap_or_else(|err| { + panic!("PG test needs a running postgres at {url} (docker compose up -d): {err}") + }); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("run migrations"); + pool + } + + async fn cleanup(pool: &sqlx::PgPool, owner: &Owner) { + sqlx::query("DELETE FROM plan_assignments WHERE owner_type = $1 AND owner_id = $2") + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .execute(pool) + .await + .expect("clear plan assignments"); + } +} diff --git a/core/src/billing/mod.rs b/core/src/billing/mod.rs index 81344fb..06dee87 100644 --- a/core/src/billing/mod.rs +++ b/core/src/billing/mod.rs @@ -1,7 +1,9 @@ //! Billing: plans, usage metering, rules resolution, payment providers. //! TODO(Wave5). +pub mod assignments; pub mod plans; pub mod provider; pub mod resolve; +pub mod rules; pub mod usage; diff --git a/core/src/billing/resolve.rs b/core/src/billing/resolve.rs index 6f4479a..ace9d26 100644 --- a/core/src/billing/resolve.rs +++ b/core/src/billing/resolve.rs @@ -1,7 +1,92 @@ -//! Billing rule resolution. TODO(Wave5/Wave2): `effective_limits` loads -//! plan assignment + billing rules from the database. +// allow: SIZE_OK — the frozen `merge_limits` and its frozen tests are +// mandated to stay in this file (append-only contract) and the PG +// integration test doubles the size; the production half is 96 LOC. +//! Billing rule resolution: plan assignment → global rules → owner rules. -use crate::model::Limits; +use crate::config::{Config, PremiumMode}; +use crate::error::AppError; +use crate::model::{Limits, Owner, Plan}; +use sqlx::Row; + +/// Global rule layer: enabled rows with NULL owner columns. +const GLOBAL_RULES_SQL: &str = "SELECT key, value FROM billing_rules \ + WHERE enabled AND owner_type IS NULL AND owner_id IS NULL"; + +/// Owner rule layer: enabled rows scoped to one owner. +const OWNER_RULES_SQL: &str = + "SELECT key, value FROM billing_rules WHERE enabled AND owner_type = $1 AND owner_id = $2"; + +/// Effective limits for `owner`. Consumed by the auth extractor (rate +/// limiting) and the upload handler (quota checks). +/// +/// Resolution order: plan (config premium mode + `plan_assignments`) → +/// global `billing_rules` → owner-scoped `billing_rules`, merged via +/// [`merge_limits`] (later layers win). +pub async fn effective_limits( + pool: &sqlx::PgPool, + config: &Config, + owner: &Owner, +) -> Result { + // `everyone` mode skips the plan_assignments table entirely. + let assignment = match config.premium_mode { + PremiumMode::Everyone => None, + PremiumMode::Mirror => crate::billing::assignments::get_plan(pool, owner).await?, + }; + let global_rules = load_rules(pool, GLOBAL_RULES_SQL, None).await?; + let owner_rules = load_rules(pool, OWNER_RULES_SQL, Some(owner)).await?; + Ok(resolve_from_parts( + config, + assignment, + global_rules, + owner_rules, + )) +} + +/// Pure layer composition mirroring [`effective_limits`] without the DB so +/// the resolution-order contract is unit-testable: plan base limits → +/// global rules → owner rules (later layers win per key). +fn resolve_from_parts( + config: &Config, + assignment: Option, + global_rules: Vec<(String, serde_json::Value)>, + owner_rules: Vec<(String, serde_json::Value)>, +) -> Limits { + let plan = match config.premium_mode { + PremiumMode::Everyone => Plan::Premium, + PremiumMode::Mirror => assignment.unwrap_or(Plan::Default), + }; + merge_limits( + merge_limits( + crate::billing::plans::plan_limits(config, plan), + global_rules.into_iter(), + ), + owner_rules.into_iter(), + ) +} + +/// Load one enabled rule layer as `(key, value)` pairs. `owner = None` +/// selects the global layer (NULL owner columns, matched by the query +/// itself); `Some(owner)` binds the owner scope. +async fn load_rules( + pool: &sqlx::PgPool, + sql: &'static str, + owner: Option<&Owner>, +) -> Result, AppError> { + let mut query = sqlx::query(sql); + if let Some(owner) = owner { + query = query.bind(&owner.owner_type).bind(&owner.owner_id); + } + query + .try_map(|row: sqlx::postgres::PgRow| { + Ok(( + row.try_get::("key")?, + row.try_get::("value")?, + )) + }) + .fetch_all(pool) + .await + .map_err(|err| AppError::Internal(format!("load billing rules failed: {err}"))) +} /// Apply rule overrides onto `base`. Rules are applied in order; a later /// rule targeting the same key wins. Unknown keys and value shapes that @@ -112,4 +197,221 @@ mod tests { ); assert_eq!(merged, base()); } + + fn mirror_config() -> Config { + Config { + premium_mode: PremiumMode::Mirror, + ..Config::default() + } + } + + fn everyone_config() -> Config { + Config { + premium_mode: PremiumMode::Everyone, + ..Config::default() + } + } + + #[test] + fn everyone_mode_ignores_assignment() { + // Even a "default" assignment must not downgrade everyone mode. + let limits = resolve_from_parts( + &everyone_config(), + Some(Plan::Default), + Vec::new(), + Vec::new(), + ); + assert_eq!( + limits, + crate::billing::plans::plan_limits(&everyone_config(), Plan::Premium) + ); + } + + #[test] + fn mirror_without_assignment_falls_back_to_default_plan() { + let limits = resolve_from_parts(&mirror_config(), None, Vec::new(), Vec::new()); + assert_eq!( + limits, + crate::billing::plans::plan_limits(&mirror_config(), Plan::Default) + ); + } + + #[test] + fn mirror_with_premium_assignment_upgrades_plan() { + let limits = resolve_from_parts( + &mirror_config(), + Some(Plan::Premium), + Vec::new(), + Vec::new(), + ); + assert_eq!( + limits.storage_quota_bytes, + crate::billing::plans::plan_limits(&mirror_config(), Plan::Premium).storage_quota_bytes + ); + } + + #[test] + fn global_rule_beats_plan_default() { + let limits = resolve_from_parts( + &mirror_config(), + None, + vec![rule("storage_quota_bytes", serde_json::json!(123))], + Vec::new(), + ); + assert_eq!(limits.storage_quota_bytes, 123); + assert_eq!( + limits.max_file_bytes, + crate::billing::plans::plan_limits(&mirror_config(), Plan::Default).max_file_bytes + ); + } + + #[test] + fn owner_rule_beats_global_rule() { + let limits = resolve_from_parts( + &mirror_config(), + None, + vec![ + rule("max_file_bytes", serde_json::json!(30)), + rule("rate_limit_rpm", serde_json::json!(7)), + ], + vec![rule("max_file_bytes", serde_json::json!(70))], + ); + // Owner layer wins the contested key… + assert_eq!(limits.max_file_bytes, 70); + // …and the global layer still fills keys the owner layer omits. + assert_eq!(limits.rate_limit_rpm, 7); + } + + #[test] + fn numeric_string_owner_rule_overrides_global() { + let limits = resolve_from_parts( + &mirror_config(), + None, + vec![rule("storage_quota_bytes", serde_json::json!(50))], + vec![rule("storage_quota_bytes", serde_json::json!("60"))], + ); + assert_eq!(limits.storage_quota_bytes, 60); + } + + /// Compose-postgres-backed integration test; run manually via + /// `cargo test -p core billing -- --ignored`. + #[tokio::test] + #[ignore] + async fn pg_effective_limits_applies_all_layers() { + let pool = test_pool().await; + let owner = Owner::new("test-billing-resolve", uuid::Uuid::now_v7().to_string()); + let _guard = global_rules_guard(&pool).await; + cleanup(&pool, &owner).await; + + // Premium assignment (mirror mode) + global and owner rules. + crate::billing::assignments::set_plan(&pool, &owner, Plan::Premium) + .await + .expect("set plan"); + let seed = r#" + INSERT INTO billing_rules (owner_type, owner_id, key, value) + VALUES + (NULL, NULL, 'storage_quota_bytes', '42'), + (NULL, NULL, 'max_file_bytes', '55'), + ($1, $2, 'max_file_bytes', '77') + "#; + sqlx::query(seed) + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .execute(&pool) + .await + .expect("seed rules"); + + let mirror = mirror_config(); + let limits = effective_limits(&pool, &mirror, &owner) + .await + .expect("mirror"); + // Owner layer wins max_file_bytes; global fills storage quota; + // untouched keys keep the premium plan value. + assert_eq!(limits.max_file_bytes, 77); + assert_eq!(limits.storage_quota_bytes, 42); + assert_eq!(limits.rate_limit_rpm, mirror.plan_premium_rate_limit_rpm); + + // Everyone mode ignores the assignment but still applies rules. + let everyone = everyone_config(); + let limits = effective_limits(&pool, &everyone, &owner) + .await + .expect("everyone"); + assert_eq!(limits.max_file_bytes, 77); + assert_eq!(limits.storage_quota_bytes, 42); + + // No assignment + no owner rules → default plan + global only. + sqlx::query("DELETE FROM billing_rules WHERE owner_type = $1 AND owner_id = $2") + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .execute(&pool) + .await + .expect("clear owner rules"); + crate::billing::assignments::delete_plan(&pool, &owner) + .await + .expect("delete plan"); + let limits = effective_limits(&pool, &mirror, &owner) + .await + .expect("default"); + assert_eq!(limits.max_file_bytes, 55); + assert_eq!(limits.storage_quota_bytes, 42); + // rate_limit_rpm carries no rule, so it exposes the plan fallback. + assert_eq!(limits.rate_limit_rpm, mirror.plan_default_rate_limit_rpm); + + cleanup(&pool, &owner).await; + } + + async fn test_pool() -> sqlx::PgPool { + let url = std::env::var("BOOSKIFF_DATABASE_URL") + .unwrap_or_else(|_| "postgres://booskiff:booskiff@127.0.0.1:5432/booskiff".to_owned()); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(&url) + .await + .unwrap_or_else(|err| { + panic!("PG test needs a running postgres at {url} (docker compose up -d): {err}") + }); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("run migrations"); + pool + } + + /// Serializes PG tests that seed global billing_rules rows; the test + /// harness runs #[ignore] tests in parallel by default. Dropping the + /// transaction (commit or unwind) releases the lock. + async fn global_rules_guard(pool: &sqlx::PgPool) -> sqlx::Transaction<'static, sqlx::Postgres> { + let mut guard = pool.begin().await.expect("begin guard tx"); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(PG_TEST_LOCK) + .execute(&mut *guard) + .await + .expect("acquire billing test lock"); + guard + } + + const PG_TEST_LOCK: i64 = 0x0062_6969; + + async fn cleanup(pool: &sqlx::PgPool, owner: &Owner) { + sqlx::query("DELETE FROM billing_rules WHERE owner_type = $1 AND owner_id = $2") + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .execute(pool) + .await + .expect("clear owner rules"); + sqlx::query( + "DELETE FROM billing_rules \ + WHERE owner_type IS NULL AND owner_id IS NULL \ + AND key IN ('max_file_bytes', 'storage_quota_bytes')", + ) + .execute(pool) + .await + .expect("clear seeded global rules"); + sqlx::query("DELETE FROM plan_assignments WHERE owner_type = $1 AND owner_id = $2") + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .execute(pool) + .await + .expect("clear plan assignments"); + } } diff --git a/core/src/billing/rules.rs b/core/src/billing/rules.rs new file mode 100644 index 0000000..4af4f4e --- /dev/null +++ b/core/src/billing/rules.rs @@ -0,0 +1,302 @@ +//! Billing rule CRUD repos (global and owner-scoped limit overrides). + +use crate::error::AppError; +use crate::model::Owner; +use uuid::Uuid; + +/// Limit keys the resolver understands. +pub const KNOWN_KEYS: [&str; 3] = ["storage_quota_bytes", "max_file_bytes", "rate_limit_rpm"]; + +/// One `billing_rules` row; NULL owner columns mean a global rule. +/// +/// Kept as a plain `Serialize` DTO for admin-API reuse. +#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)] +pub struct BillingRule { + pub id: Uuid, + pub owner_type: Option, + pub owner_id: Option, + pub key: String, + pub value: serde_json::Value, + pub enabled: bool, + pub created_at: time::OffsetDateTime, +} + +/// Reject rule keys the resolver would silently ignore. +pub fn validate_key(key: &str) -> Result<(), AppError> { + if KNOWN_KEYS.contains(&key) { + Ok(()) + } else { + Err(AppError::Validation(format!( + "unknown billing rule key {key:?} (accepted keys: {})", + KNOWN_KEYS.join(", ") + ))) + } +} + +/// All rules, global (NULL owner) layer first. +pub async fn list_rules(pool: &sqlx::PgPool) -> Result, AppError> { + sqlx::query_as( + "SELECT id, owner_type, owner_id, key, value, enabled, created_at \ + FROM billing_rules \ + ORDER BY owner_type NULLS FIRST, key", + ) + .fetch_all(pool) + .await + .map_err(|err| AppError::Internal(format!("list billing rules failed: {err}"))) +} + +/// Insert or update the rule for (`scope`, `key`). `scope = None` targets +/// the global layer. +/// +/// Manual two-step upsert: the unique constraint is an expression index +/// over `COALESCE(owner_type,''), COALESCE(owner_id,''), key`, which plain +/// `ON CONFLICT` cannot target in text queries. +pub async fn upsert_rule( + pool: &sqlx::PgPool, + scope: Option<&Owner>, + key: &str, + value: serde_json::Value, + enabled: bool, +) -> Result { + validate_key(key)?; + let parsed = value + .as_i64() + .or_else(|| value.as_str().and_then(|raw| raw.parse::().ok())); + if !parsed.is_some_and(|bytes| bytes >= 0) { + return Err(AppError::Validation(format!( + "billing rule {key:?} needs a non-negative integer or numeric string value, got {value}" + ))); + } + + let (owner_type, owner_id) = match scope { + Some(owner) => (Some(owner.owner_type.clone()), Some(owner.owner_id.clone())), + None => (None, None), + }; + + let mut update = sqlx::query_as::<_, BillingRule>( + "UPDATE billing_rules SET value = $3, enabled = $4 \ + WHERE key = $5 \ + AND ((owner_type IS NULL AND $1::text IS NULL) OR owner_type = $1) \ + AND ((owner_id IS NULL AND $2::text IS NULL) OR owner_id = $2) \ + RETURNING id, owner_type, owner_id, key, value, enabled, created_at", + ); + match scope { + Some(owner) => { + update = update.bind(&owner.owner_type).bind(&owner.owner_id); + } + None => { + update = update + .bind(Option::::None) + .bind(Option::::None); + } + } + let updated = update + .bind(&value) + .bind(enabled) + .bind(key) + .fetch_optional(pool) + .await + .map_err(|err| AppError::Internal(format!("update billing rule failed: {err}")))?; + if let Some(rule) = updated { + return Ok(rule); + } + + sqlx::query_as::<_, BillingRule>( + "INSERT INTO billing_rules (owner_type, owner_id, key, value, enabled) \ + VALUES ($1, $2, $3, $4, $5) \ + RETURNING id, owner_type, owner_id, key, value, enabled, created_at", + ) + .bind(owner_type) + .bind(owner_id) + .bind(key) + .bind(&value) + .bind(enabled) + .fetch_one(pool) + .await + .map_err(|err| AppError::Internal(format!("insert billing rule failed: {err}"))) +} + +/// Returns whether a row was removed. +pub async fn delete_rule(pool: &sqlx::PgPool, id: Uuid) -> Result { + sqlx::query("DELETE FROM billing_rules WHERE id = $1") + .bind(id) + .execute(pool) + .await + .map(|result| result.rows_affected() > 0) + .map_err(|err| AppError::Internal(format!("delete billing rule failed: {err}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[rstest::rstest] + #[case("storage_quota_bytes")] + #[case("max_file_bytes")] + #[case("rate_limit_rpm")] + fn validate_key_accepts_known_keys(#[case] key: &str) { + assert!(validate_key(key).is_ok()); + } + + #[test] + fn validate_key_rejects_unknown_key() { + for key in ["", "Storage_Quota_Bytes", "future_setting"] { + let err = validate_key(key).unwrap_err(); + assert!(matches!(err, AppError::Validation(_)), "{key}"); + assert!(err.to_string().contains("accepted keys")); + } + } + + /// Compose-postgres-backed integration test; run manually via + /// `cargo test -p core billing -- --ignored`. + #[tokio::test] + #[ignore] + async fn pg_upsert_delete_and_list_rules() { + let pool = test_pool().await; + let owner = Owner::new("test-billing-rules", uuid::Uuid::now_v7().to_string()); + let _guard = global_rules_guard(&pool).await; + cleanup(&pool, &owner).await; + + let created = upsert_rule( + &pool, + Some(&owner), + "max_file_bytes", + serde_json::json!(100), + true, + ) + .await + .expect("create owner rule"); + assert_eq!(created.owner_type.as_deref(), Some("test-billing-rules")); + assert_eq!(created.value, serde_json::json!(100)); + assert!(created.enabled); + + // Update path rewrites value + enabled on the same row. + let updated = upsert_rule( + &pool, + Some(&owner), + "max_file_bytes", + serde_json::json!(222), + false, + ) + .await + .expect("update owner rule"); + assert_eq!(updated.id, created.id); + assert_eq!(updated.value, serde_json::json!(222)); + assert!(!updated.enabled); + + // Global and owner scopes are independent rows for the same key. + let global = upsert_rule(&pool, None, "max_file_bytes", serde_json::json!(55), true) + .await + .expect("create global rule"); + assert_eq!(global.owner_type, None); + assert_eq!(global.owner_id, None); + let still_owner = upsert_rule( + &pool, + Some(&owner), + "max_file_bytes", + serde_json::json!(333), + true, + ) + .await + .expect("owner rule untouched"); + assert_eq!(still_owner.id, created.id); + + let listed = list_rules(&pool).await.expect("list rules"); + let ids: Vec = listed.iter().map(|rule| rule.id).collect(); + assert!(ids.contains(&created.id)); + assert!(ids.contains(&global.id)); + + assert!(delete_rule(&pool, created.id).await.expect("delete")); + assert!(!delete_rule(&pool, created.id).await.expect("re-delete")); + + // Invalid values and keys are rejected before touching the table. + let err = upsert_rule( + &pool, + Some(&owner), + "max_file_bytes", + serde_json::json!(-1), + true, + ) + .await + .unwrap_err(); + assert!(matches!(err, AppError::Validation(_))); + let err = upsert_rule( + &pool, + Some(&owner), + "max_file_bytes", + serde_json::json!("not-a-number"), + true, + ) + .await + .unwrap_err(); + assert!(matches!(err, AppError::Validation(_))); + let err = upsert_rule( + &pool, + Some(&owner), + "future_setting", + serde_json::json!(1), + true, + ) + .await + .unwrap_err(); + assert!(matches!(err, AppError::Validation(_))); + + // Numeric strings are accepted, mirroring merge_limits parsing. + let numeric = upsert_rule( + &pool, + Some(&owner), + "storage_quota_bytes", + serde_json::json!("4096"), + true, + ) + .await + .expect("numeric string rule"); + assert_eq!(numeric.value, serde_json::json!("4096")); + + cleanup(&pool, &owner).await; + } + + async fn test_pool() -> sqlx::PgPool { + let url = std::env::var("BOOSKIFF_DATABASE_URL") + .unwrap_or_else(|_| "postgres://booskiff:booskiff@127.0.0.1:5432/booskiff".to_owned()); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(&url) + .await + .unwrap_or_else(|err| { + panic!("PG test needs a running postgres at {url} (docker compose up -d): {err}") + }); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("run migrations"); + pool + } + + /// Same lock (and rationale) as resolve.rs's PG test: parallel global + /// billing_rules seeding would violate billing_rules_scope_key_uq. + async fn global_rules_guard(pool: &sqlx::PgPool) -> sqlx::Transaction<'static, sqlx::Postgres> { + let mut guard = pool.begin().await.expect("begin guard tx"); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(PG_TEST_LOCK) + .execute(&mut *guard) + .await + .expect("acquire billing test lock"); + guard + } + + const PG_TEST_LOCK: i64 = 0x0062_6969; + + async fn cleanup(pool: &sqlx::PgPool, owner: &Owner) { + sqlx::query("DELETE FROM billing_rules WHERE owner_type = $1 AND owner_id = $2") + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .execute(pool) + .await + .expect("clear owner rules"); + sqlx::query("DELETE FROM billing_rules WHERE owner_type IS NULL AND owner_id IS NULL AND key = 'max_file_bytes'") + .execute(pool) + .await + .expect("clear seeded global rule"); + } +} diff --git a/core/src/billing/usage.rs b/core/src/billing/usage.rs index b59353a..87dd0c5 100644 --- a/core/src/billing/usage.rs +++ b/core/src/billing/usage.rs @@ -1,27 +1,175 @@ -//! Storage usage metering repos. TODO(Wave5): SQL implementations. +//! Storage usage metering repos. use crate::error::AppError; use crate::model::Owner; -/// Received bytes currently metered for `owner`. -pub async fn used_bytes(_pool: &sqlx::PgPool, _owner: &Owner) -> Result { - Err(AppError::Internal("usage repo not implemented yet".into())) +/// One `storage_usage` metering row. +#[derive(Debug, sqlx::FromRow)] +pub struct UsageRow { + pub owner_type: String, + pub owner_id: String, + pub used_bytes: i64, + pub updated_at: time::OffsetDateTime, } -/// Add `delta` received bytes to `owner`'s usage (insert-or-increment). -pub async fn increment_used_bytes( - _pool: &sqlx::PgPool, - _owner: &Owner, - _delta: i64, -) -> Result<(), AppError> { - Err(AppError::Internal("usage repo not implemented yet".into())) +/// Add `delta` (must be non-negative) received bytes to `owner`'s usage in +/// one atomic statement; returns the post-update total, which T6 uses for +/// quota enforcement inside the same DB transaction. Call with +/// `&mut *tx` where `tx: sqlx::Transaction<'_, sqlx::Postgres>`. +pub async fn add_used_bytes( + conn: &mut sqlx::PgConnection, + owner: &Owner, + delta: i64, +) -> Result { + if delta < 0 { + return Err(AppError::Validation(format!( + "usage delta must be non-negative, got {delta}" + ))); + } + sqlx::query_scalar( + "INSERT INTO storage_usage (owner_type, owner_id, used_bytes) \ + VALUES ($1, $2, $3) \ + ON CONFLICT (owner_type, owner_id) DO UPDATE \ + SET used_bytes = storage_usage.used_bytes + EXCLUDED.used_bytes, \ + updated_at = now() \ + RETURNING used_bytes", + ) + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .bind(delta) + .fetch_one(conn) + .await + .map_err(|err| AppError::Internal(format!("add usage bytes failed: {err}"))) } -/// Subtract `delta` from `owner`'s usage, clamped at zero. -pub async fn decrement_used_bytes( - _pool: &sqlx::PgPool, - _owner: &Owner, - _delta: i64, +/// Subtract `delta` from `owner`'s usage, clamped at zero. A missing row +/// (rows_affected 0) is success. +pub async fn subtract_used_bytes( + conn: &mut sqlx::PgConnection, + owner: &Owner, + delta: i64, ) -> Result<(), AppError> { - Err(AppError::Internal("usage repo not implemented yet".into())) + sqlx::query( + "UPDATE storage_usage \ + SET used_bytes = GREATEST(used_bytes - $3, 0), updated_at = now() \ + WHERE owner_type = $1 AND owner_id = $2", + ) + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .bind(delta) + .execute(conn) + .await + .map(|_| ()) + .map_err(|err| AppError::Internal(format!("subtract usage bytes failed: {err}"))) +} + +/// Received bytes currently metered for `owner`; a missing row reads as 0. +pub async fn used_bytes(pool: &sqlx::PgPool, owner: &Owner) -> Result { + sqlx::query_scalar( + "SELECT used_bytes FROM storage_usage WHERE owner_type = $1 AND owner_id = $2", + ) + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .fetch_optional(pool) + .await + .map_err(|err| AppError::Internal(format!("load usage bytes failed: {err}"))) + .map(|used| used.unwrap_or(0)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Compose-postgres-backed integration test; run manually via + /// `cargo test -p core billing -- --ignored`. + #[tokio::test] + #[ignore] + async fn pg_add_used_bytes_in_transaction_returns_running_total() { + let pool = test_pool().await; + let owner = Owner::new("test-billing-usage", uuid::Uuid::now_v7().to_string()); + cleanup(&pool, &owner).await; + + let mut tx = pool.begin().await.expect("begin tx"); + let first = add_used_bytes(&mut tx, &owner, 87).await.expect("add 87"); + assert_eq!(first, 87); + let second = add_used_bytes(&mut tx, &owner, 13).await.expect("add 13"); + assert_eq!(second, 100); + + // Uncommitted rows stay invisible to other connections. + assert_eq!(used_bytes(&pool, &owner).await.expect("pre-commit"), 0); + + tx.commit().await.expect("commit tx"); + assert_eq!(used_bytes(&pool, &owner).await.expect("post-commit"), 100); + + let err = add_used_bytes(&mut pool.acquire().await.expect("acquire"), &owner, -1) + .await + .expect_err("negative delta"); + assert!(matches!(err, AppError::Validation(_))); + + // The metering row round-trips through UsageRow with a timestamp. + let row: UsageRow = sqlx::query_as( + "SELECT owner_type, owner_id, used_bytes, updated_at \ + FROM storage_usage WHERE owner_type = $1 AND owner_id = $2", + ) + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .fetch_one(&pool) + .await + .expect("fetch usage row"); + assert_eq!(row.used_bytes, 100); + assert!(row.updated_at <= time::OffsetDateTime::now_utc()); + + cleanup(&pool, &owner).await; + } + + #[tokio::test] + #[ignore] + async fn pg_subtract_used_bytes_clamps_at_zero() { + let pool = test_pool().await; + let owner = Owner::new("test-billing-usage", uuid::Uuid::now_v7().to_string()); + cleanup(&pool, &owner).await; + + let mut conn = pool.acquire().await.expect("acquire conn"); + add_used_bytes(&mut conn, &owner, 5).await.expect("add 5"); + + subtract_used_bytes(&mut conn, &owner, 100) + .await + .expect("overshoot subtract"); + assert_eq!(used_bytes(&pool, &owner).await.expect("clamped"), 0); + + // Subtracting from a missing row is a no-op, not an error. + let missing = Owner::new("test-billing-usage", uuid::Uuid::now_v7().to_string()); + subtract_used_bytes(&mut conn, &missing, 10) + .await + .expect("subtract on missing row"); + assert_eq!(used_bytes(&pool, &missing).await.expect("missing is 0"), 0); + + cleanup(&pool, &owner).await; + } + + async fn test_pool() -> sqlx::PgPool { + let url = std::env::var("BOOSKIFF_DATABASE_URL") + .unwrap_or_else(|_| "postgres://booskiff:booskiff@127.0.0.1:5432/booskiff".to_owned()); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(&url) + .await + .unwrap_or_else(|err| { + panic!("PG test needs a running postgres at {url} (docker compose up -d): {err}") + }); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("run migrations"); + pool + } + + async fn cleanup(pool: &sqlx::PgPool, owner: &Owner) { + sqlx::query("DELETE FROM storage_usage WHERE owner_type = $1 AND owner_id = $2") + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .execute(pool) + .await + .expect("clear usage rows"); + } } From 3785878b4abad0a05cc432fc17c33ceffbb6359d Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 30 Aug 2026 14:27:02 +0900 Subject: [PATCH 05/15] feat(drive): file upload with size/quota enforcement, CRUD, publish, download URLs --- core/Cargo.toml | 2 +- core/src/drive/files.rs | 607 +++++++++++++++++++++++++++++++++- core/src/drive/upload_body.rs | 161 ++++++++- 3 files changed, 767 insertions(+), 3 deletions(-) diff --git a/core/Cargo.toml b/core/Cargo.toml index 8ccf71a..2cd7611 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -28,6 +28,7 @@ sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", aws-config = { version = "1.8", features = ["behavior-version-latest"] } aws-sdk-s3 = "1.144" aws-smithy-types = { version = "1.6", features = ["http-body-1-x"] } +http-body = "1" rustls = { version = "0.23", features = ["aws-lc-rs"] } jsonwebtoken = { version = "11", features = ["aws_lc_rs"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } @@ -49,4 +50,3 @@ dotenvy = "0.15" [dev-dependencies] rstest = "0.26" http-body-util = "0.1" -http-body = "1" diff --git a/core/src/drive/files.rs b/core/src/drive/files.rs index 6651a58..571ec53 100644 --- a/core/src/drive/files.rs +++ b/core/src/drive/files.rs @@ -1 +1,606 @@ -//! File CRUD, listing, and versioned upload handlers. TODO(Wave2/T5). +// allow: SIZE_OK — this file owns the complete files HTTP resource; the task +// contract freezes both implementation files and requires all seven handlers, +// DTOs, upload transaction, OpenAPI annotations, and ignored integration tests. +//! File CRUD, listing, streaming upload, publishing, and download URL handlers. + +use std::time::Duration; + +use aws_smithy_types::body::SdkBody; +use axum::body::Body; +use axum::extract::{DefaultBodyLimit, Path, Query, State}; +use axum::http::{HeaderMap, StatusCode, Uri, header}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use time::format_description::well_known::Rfc3339; +use uuid::Uuid; + +use crate::auth::extractor::AccountContext; +use crate::billing::usage::{add_used_bytes, subtract_used_bytes, used_bytes}; +use crate::config::Config; +use crate::error::AppError; +use crate::model::{OBJECT_KIND_ORIGINAL, Owner}; +use crate::state::AppState; +use crate::storage::Storage; + +use super::upload_body::CountingBody; + +const DEFAULT_LIST_LIMIT: i64 = 50; +const MAX_LIST_LIMIT: i64 = 200; +const BODY_LIMIT_SLACK: i64 = 1024 * 1024; +const DOWNLOAD_URL_TTL: Duration = Duration::from_secs(15 * 60); + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct FileResponse { + pub id: Uuid, + pub name: String, + pub mime_type: String, + pub size_bytes: i64, + pub folder_id: Option, + pub is_public: bool, + pub created_at: String, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct FileListResponse { + pub items: Vec, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct UrlResponse { + pub url: String, +} + +#[derive(Debug, Deserialize)] +pub struct UploadQuery { + name: Option, + mime: Option, + folder_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ListQuery { + limit: Option, + offset: Option, + folder_id: Option, +} + +#[derive(Debug, FromRow)] +struct FileRow { + id: Uuid, + owner_type: String, + owner_id: String, + name: String, + mime_type: String, + size_bytes: i64, + folder_id: Option, + is_public: bool, + created_at: time::OffsetDateTime, +} + +struct UploadedFile { + id: Uuid, + folder_id: Option, + name: String, + mime: String, + key: String, + size_bytes: i64, +} + +impl FileRow { + fn into_response(self, owner: &Owner) -> Result { + if self.owner_type != owner.owner_type || self.owner_id != owner.owner_id { + return Err(AppError::NotFound(format!("file {}", self.id))); + } + Ok(FileResponse { + id: self.id, + name: self.name, + mime_type: self.mime_type, + size_bytes: self.size_bytes, + folder_id: self.folder_id, + is_public: self.is_public, + created_at: self + .created_at + .format(&Rfc3339) + .map_err(|err| AppError::Internal(format!("format file timestamp: {err}")))?, + }) + } +} + +pub fn files_router(config: &Config) -> Router { + let body_limit_i64 = config + .plan_premium_max_file_bytes + .saturating_add(BODY_LIMIT_SLACK); + let body_limit = usize::try_from(body_limit_i64).unwrap_or(usize::MAX); + Router::new() + .route( + "/v1/files", + post(upload_file).route_layer(DefaultBodyLimit::max(body_limit)), + ) + .route("/v1/files", get(list_files)) + .route("/v1/files/{id}", get(get_file).delete(delete_file)) + .route("/v1/files/{id}/download-url", get(download_url)) + .route( + "/v1/files/{id}/publish", + post(publish_file).delete(unpublish_file), + ) +} + +#[utoipa::path(post, path = "/v1/files", tag = "files", responses((status = 201, body = FileResponse)))] +async fn upload_file( + ctx: AccountContext, + State(state): State, + Query(query): Query, + headers: HeaderMap, + body: Body, +) -> Result<(StatusCode, Json), AppError> { + let name = query + .name + .filter(|name| !name.trim().is_empty()) + .ok_or_else(|| AppError::Validation("file name is required".into()))?; + let mime = query + .mime + .filter(|mime| !mime.trim().is_empty()) + .unwrap_or_else(|| "application/octet-stream".to_owned()); + if let Some(folder_id) = query.folder_id { + ensure_owned_folder(&state.pool, &ctx.owner, folder_id).await?; + } + let declared = parse_content_length(&headers)?; + if declared > ctx.limits.max_file_bytes { + return Err(AppError::PayloadTooLarge( + "file exceeds maximum size".into(), + )); + } + let current_usage = used_bytes(&state.pool, &ctx.owner).await?; + if current_usage.saturating_add(declared) > ctx.limits.storage_quota_bytes { + return Err(AppError::InsufficientStorage( + "storage quota exceeded".into(), + )); + } + + let file_id = Uuid::now_v7(); + let key = Storage::object_key(&ctx.owner, &file_id, OBJECT_KIND_ORIGINAL); + let cap = declared.min(ctx.limits.max_file_bytes); + let cap = u64::try_from(cap) + .map_err(|_| AppError::Validation("content length must be non-negative".into()))?; + let declared_u64 = u64::try_from(declared) + .map_err(|_| AppError::Validation("content length must be non-negative".into()))?; + let (counting_body, count) = CountingBody::new(body, cap, declared_u64); + let put_result = state + .s3 + .put_streaming(&key, SdkBody::from_body_1_x(counting_body), declared, &mime) + .await; + if let Err(error) = put_result { + let counted = count.load(); + let _ = state.s3.delete_object(&key).await; + if counted > u64::try_from(ctx.limits.max_file_bytes).unwrap_or(u64::MAX) { + return Err(AppError::PayloadTooLarge( + "file exceeds maximum size".into(), + )); + } + if counted > declared_u64 { + return Err(AppError::Validation( + "body exceeds declared content length".into(), + )); + } + return Err(error); + } + if count.load() != declared_u64 { + let _ = state.s3.delete_object(&key).await; + return Err(AppError::Validation( + "body length does not match content length".into(), + )); + } + + let response = persist_uploaded_file( + &state, + &ctx, + UploadedFile { + id: file_id, + folder_id: query.folder_id, + name, + mime, + key: key.clone(), + size_bytes: declared, + }, + ) + .await; + if response.is_err() { + let _ = state.s3.delete_object(&key).await; + } + response.map(|file| (StatusCode::CREATED, Json(file))) +} + +#[utoipa::path(get, path = "/v1/files", tag = "files", responses((status = 200, body = FileListResponse)))] +async fn list_files( + ctx: AccountContext, + State(state): State, + Query(query): Query, +) -> Result, AppError> { + let limit = query.limit.unwrap_or(DEFAULT_LIST_LIMIT); + let offset = query.offset.unwrap_or(0); + if !(1..=MAX_LIST_LIMIT).contains(&limit) { + return Err(AppError::Validation(format!( + "limit must be between 1 and {MAX_LIST_LIMIT}" + ))); + } + if offset < 0 { + return Err(AppError::Validation("offset must be non-negative".into())); + } + let rows = sqlx::query_as::<_, FileRow>( + "SELECT id, owner_type, owner_id, name, mime_type, size_bytes, folder_id, is_public, created_at \ + FROM files WHERE owner_type = $1 AND owner_id = $2 \ + AND ($3::uuid IS NULL OR folder_id = $3) \ + ORDER BY created_at DESC LIMIT $4 OFFSET $5", + ) + .bind(&ctx.owner.owner_type) + .bind(&ctx.owner.owner_id) + .bind(query.folder_id) + .bind(limit) + .bind(offset) + .fetch_all(&state.pool) + .await + .map_err(|err| AppError::Internal(format!("list files: {err}")))?; + let items = rows + .into_iter() + .map(|row| row.into_response(&ctx.owner)) + .collect::, _>>()?; + Ok(Json(FileListResponse { items })) +} + +#[utoipa::path(get, path = "/v1/files/{id}", tag = "files", params(("id" = Uuid, Path)), responses((status = 200, body = FileResponse), (status = 404)))] +async fn get_file( + ctx: AccountContext, + State(state): State, + Path(id): Path, +) -> Result, AppError> { + load_owned_file(&state.pool, &ctx.owner, id) + .await? + .into_response(&ctx.owner) + .map(Json) +} + +#[utoipa::path(delete, path = "/v1/files/{id}", tag = "files", params(("id" = Uuid, Path)), responses((status = 204), (status = 404)))] +async fn delete_file( + ctx: AccountContext, + State(state): State, + Path(id): Path, +) -> Result { + let row = load_owned_file(&state.pool, &ctx.owner, id).await?; + let mut tx = state + .pool + .begin() + .await + .map_err(|err| AppError::Internal(format!("begin delete file: {err}")))?; + sqlx::query("DELETE FROM file_objects WHERE file_id = $1") + .bind(id) + .execute(&mut *tx) + .await + .map_err(|err| AppError::Internal(format!("delete file objects: {err}")))?; + sqlx::query("DELETE FROM files WHERE id = $1") + .bind(id) + .execute(&mut *tx) + .await + .map_err(|err| AppError::Internal(format!("delete file row: {err}")))?; + subtract_used_bytes(&mut tx, &ctx.owner, row.size_bytes).await?; + tx.commit() + .await + .map_err(|err| AppError::Internal(format!("commit delete file: {err}")))?; + let prefix = format!("{}/{}/{id}/", ctx.owner.owner_type, ctx.owner.owner_id); + let _ = state.s3.delete_prefix(&prefix).await; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path(get, path = "/v1/files/{id}/download-url", tag = "files", params(("id" = Uuid, Path)), responses((status = 200, body = UrlResponse), (status = 404)))] +async fn download_url( + ctx: AccountContext, + State(state): State, + Path(id): Path, +) -> Result, AppError> { + load_owned_file(&state.pool, &ctx.owner, id).await?; + let key: Option = sqlx::query_scalar( + "SELECT storage_key FROM file_objects WHERE file_id = $1 AND object_kind = $2", + ) + .bind(id) + .bind(OBJECT_KIND_ORIGINAL) + .fetch_optional(&state.pool) + .await + .map_err(|err| AppError::Internal(format!("load original object: {err}")))?; + let key = key.ok_or_else(|| AppError::NotFound(format!("file object {id}")))?; + let url = state.s3.presign_get(&key, DOWNLOAD_URL_TTL).await?; + Ok(Json(UrlResponse { url })) +} + +#[utoipa::path(post, path = "/v1/files/{id}/publish", tag = "files", params(("id" = Uuid, Path)), responses((status = 200, body = UrlResponse), (status = 404)))] +async fn publish_file( + ctx: AccountContext, + State(state): State, + Path(id): Path, + headers: HeaderMap, + uri: Uri, +) -> Result, AppError> { + load_owned_file(&state.pool, &ctx.owner, id).await?; + let mut bytes = [0_u8; 32]; + rand::fill(&mut bytes); + let key = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let result = sqlx::query( + "UPDATE files SET is_public = TRUE, public_key = $4 \ + WHERE id = $1 AND owner_type = $2 AND owner_id = $3", + ) + .bind(id) + .bind(&ctx.owner.owner_type) + .bind(&ctx.owner.owner_id) + .bind(&key) + .execute(&state.pool) + .await + .map_err(|err| AppError::Internal(format!("publish file: {err}")))?; + if result.rows_affected() == 0 { + return Err(AppError::NotFound(format!("file {id}"))); + } + let scheme = uri.scheme_str().unwrap_or("http"); + let host = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| AppError::Validation("host header is required".into()))?; + Ok(Json(UrlResponse { + url: format!("{scheme}://{host}/public/{key}"), + })) +} + +#[utoipa::path(delete, path = "/v1/files/{id}/publish", tag = "files", params(("id" = Uuid, Path)), responses((status = 204), (status = 404)))] +async fn unpublish_file( + ctx: AccountContext, + State(state): State, + Path(id): Path, +) -> Result { + let result = sqlx::query( + "UPDATE files SET is_public = FALSE, public_key = NULL \ + WHERE id = $1 AND owner_type = $2 AND owner_id = $3", + ) + .bind(id) + .bind(&ctx.owner.owner_type) + .bind(&ctx.owner.owner_id) + .execute(&state.pool) + .await + .map_err(|err| AppError::Internal(format!("unpublish file: {err}")))?; + if result.rows_affected() == 0 { + return Err(AppError::NotFound(format!("file {id}"))); + } + Ok(StatusCode::NO_CONTENT) +} + +fn parse_content_length(headers: &HeaderMap) -> Result { + let raw = headers + .get(header::CONTENT_LENGTH) + .ok_or_else(|| AppError::LengthRequired("content-length header is required".into()))? + .to_str() + .map_err(|_| AppError::Validation("invalid content-length header".into()))?; + let declared = raw + .parse::() + .map_err(|_| AppError::Validation("invalid content-length header".into()))?; + if declared < 0 { + return Err(AppError::Validation( + "content length must be non-negative".into(), + )); + } + Ok(declared) +} + +async fn ensure_owned_folder( + pool: &sqlx::PgPool, + owner: &Owner, + folder_id: Uuid, +) -> Result<(), AppError> { + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM folders WHERE id = $1 AND owner_type = $2 AND owner_id = $3)", + ) + .bind(folder_id) + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .fetch_one(pool) + .await + .map_err(|err| AppError::Internal(format!("check upload folder: {err}")))?; + if exists { + Ok(()) + } else { + Err(AppError::NotFound(format!("folder {folder_id}"))) + } +} + +async fn load_owned_file( + pool: &sqlx::PgPool, + owner: &Owner, + id: Uuid, +) -> Result { + sqlx::query_as::<_, FileRow>( + "SELECT id, owner_type, owner_id, name, mime_type, size_bytes, folder_id, is_public, created_at \ + FROM files WHERE id = $1 AND owner_type = $2 AND owner_id = $3", + ) + .bind(id) + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .fetch_optional(pool) + .await + .map_err(|err| AppError::Internal(format!("load file: {err}")))? + .ok_or_else(|| AppError::NotFound(format!("file {id}"))) +} + +async fn persist_uploaded_file( + state: &AppState, + ctx: &AccountContext, + upload: UploadedFile, +) -> Result { + let mut tx = state + .pool + .begin() + .await + .map_err(|err| AppError::Internal(format!("begin upload transaction: {err}")))?; + let row = sqlx::query_as::<_, FileRow>( + "INSERT INTO files \ + (id, owner_type, owner_id, folder_id, name, mime_type, size_bytes, is_public, public_key) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, FALSE, NULL) \ + RETURNING id, owner_type, owner_id, name, mime_type, size_bytes, folder_id, is_public, created_at", + ) + .bind(upload.id) + .bind(&ctx.owner.owner_type) + .bind(&ctx.owner.owner_id) + .bind(upload.folder_id) + .bind(upload.name) + .bind(&upload.mime) + .bind(upload.size_bytes) + .fetch_one(&mut *tx) + .await + .map_err(|err| AppError::Internal(format!("insert file: {err}")))?; + sqlx::query( + "INSERT INTO file_objects \ + (file_id, object_kind, storage_key, size_bytes, mime_type) VALUES ($1, $2, $3, $4, $5)", + ) + .bind(upload.id) + .bind(OBJECT_KIND_ORIGINAL) + .bind(upload.key) + .bind(upload.size_bytes) + .bind(upload.mime) + .execute(&mut *tx) + .await + .map_err(|err| AppError::Internal(format!("insert file object: {err}")))?; + let new_total = add_used_bytes(&mut tx, &ctx.owner, upload.size_bytes).await?; + if new_total > ctx.limits.storage_quota_bytes { + tx.rollback() + .await + .map_err(|err| AppError::Internal(format!("rollback upload quota: {err}")))?; + return Err(AppError::InsufficientStorage( + "storage quota exceeded".into(), + )); + } + let response = row.into_response(&ctx.owner)?; + tx.commit() + .await + .map_err(|err| AppError::Internal(format!("commit upload: {err}")))?; + Ok(response) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn foreign_file_row_maps_to_not_found() { + // Given + let owner = Owner::new("account", "alice"); + let row = FileRow { + id: Uuid::now_v7(), + owner_type: "account".into(), + owner_id: "bob".into(), + name: "secret.txt".into(), + mime_type: "text/plain".into(), + size_bytes: 4, + folder_id: None, + is_public: false, + created_at: time::OffsetDateTime::now_utc(), + }; + + // When + let error = row.into_response(&owner).unwrap_err(); + + // Then + assert!(matches!(error, AppError::NotFound(_))); + } + + #[tokio::test] + #[ignore = "requires compose postgres and MinIO"] + async fn upload_and_delete_roundtrip_ignored() { + let config = Config::default(); + let pool = test_pool().await; + let storage = Storage::build(&config).await.unwrap(); + storage.ensure_bucket().await.unwrap(); + let owner = Owner::new("test-files", Uuid::now_v7().to_string()); + let payload = b"handler integration"; + let state = test_state(config, pool.clone(), storage.clone()).await; + let ctx = AccountContext { + owner: owner.clone(), + limits: crate::model::Limits { + storage_quota_bytes: 1024, + max_file_bytes: 1024, + rate_limit_rpm: 100, + }, + }; + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_LENGTH, + payload.len().to_string().parse().unwrap(), + ); + + let (_, Json(created)) = upload_file( + ctx, + State(state.clone()), + Query(UploadQuery { + name: Some("roundtrip.txt".into()), + mime: Some("text/plain".into()), + folder_id: None, + }), + headers, + Body::from(payload.as_slice()), + ) + .await + .unwrap(); + assert_eq!(created.name, "roundtrip.txt"); + assert_eq!(used_bytes(&pool, &owner).await.unwrap(), 19); + + let Json(download) = download_url( + AccountContext { + owner: owner.clone(), + limits: crate::model::Limits { + storage_quota_bytes: 1024, + max_file_bytes: 1024, + rate_limit_rpm: 100, + }, + }, + State(state.clone()), + Path(created.id), + ) + .await + .unwrap(); + assert!(download.url.contains(&created.id.to_string())); + + let status = delete_file( + AccountContext { + owner: owner.clone(), + limits: crate::model::Limits { + storage_quota_bytes: 1024, + max_file_bytes: 1024, + rate_limit_rpm: 100, + }, + }, + State(state), + Path(created.id), + ) + .await + .unwrap(); + assert_eq!(status, StatusCode::NO_CONTENT); + assert_eq!(used_bytes(&pool, &owner).await.unwrap(), 0); + } + + async fn test_state(config: Config, pool: sqlx::PgPool, s3: Storage) -> AppState { + AppState { + jwks_cache: crate::auth::jwks::JwksCache::new(config.jwt_trusted_issuers.clone()), + pool, + s3, + config, + rate_limiters: std::sync::Arc::new(crate::state::RateLimiters::default()), + } + } + + async fn test_pool() -> sqlx::PgPool { + let url = std::env::var("BOOSKIFF_DATABASE_URL") + .unwrap_or_else(|_| "postgres://booskiff:booskiff@127.0.0.1:5432/booskiff".to_owned()); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(&url) + .await + .unwrap_or_else(|err| panic!("PG test needs postgres at {url}: {err}")); + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); + pool + } +} diff --git a/core/src/drive/upload_body.rs b/core/src/drive/upload_body.rs index fbf1c63..8b4a0de 100644 --- a/core/src/drive/upload_body.rs +++ b/core/src/drive/upload_body.rs @@ -1 +1,160 @@ -//! Content-Length-checked streaming upload body handling. TODO(Wave2/T4). +//! Content-Length-checked streaming upload body handling. + +use std::pin::Pin; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; + +use axum::body::Body; +use http_body::{Frame, SizeHint}; + +#[derive(Debug, Clone)] +pub struct ByteCount(Arc); + +impl ByteCount { + pub fn load(&self) -> u64 { + self.0.load(Ordering::Relaxed) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum CountingBodyError { + #[error("upload body exceeds its byte limit")] + LimitExceeded, + #[error("upload body stream failed: {0}")] + Inner(#[from] axum::Error), + #[error("upload body lock was poisoned")] + Poisoned, +} + +impl CountingBodyError { + #[cfg(test)] + fn is_limit_exceeded(&self) -> bool { + matches!(self, Self::LimitExceeded) + } +} + +pub struct CountingBody { + inner: Mutex, + count: ByteCount, + cap: u64, + declared: u64, + failed: bool, +} + +impl std::fmt::Debug for CountingBody { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CountingBody") + .field("count", &self.count.load()) + .field("cap", &self.cap) + .field("declared", &self.declared) + .field("failed", &self.failed) + .finish_non_exhaustive() + } +} + +impl CountingBody { + pub fn new(inner: Body, cap: u64, declared: u64) -> (Self, ByteCount) { + let count = ByteCount(Arc::new(AtomicU64::new(0))); + ( + Self { + inner: Mutex::new(inner), + count: count.clone(), + cap, + declared, + failed: false, + }, + count, + ) + } +} + +impl http_body::Body for CountingBody { + type Data = axum::body::Bytes; + type Error = CountingBodyError; + + fn poll_frame( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + if self.failed { + return Poll::Ready(None); + } + let poll = match self.inner.lock() { + Ok(mut inner) => Pin::new(&mut *inner).poll_frame(cx), + Err(_) => return Poll::Ready(Some(Err(CountingBodyError::Poisoned))), + }; + match poll { + Poll::Ready(Some(Ok(frame))) => { + if let Some(data) = frame.data_ref() { + let frame_bytes = u64::try_from(data.len()).unwrap_or(u64::MAX); + let total = + self.count.0.fetch_add(frame_bytes, Ordering::Relaxed) + frame_bytes; + if total > self.cap { + self.failed = true; + return Poll::Ready(Some(Err(CountingBodyError::LimitExceeded))); + } + } + Poll::Ready(Some(Ok(frame))) + } + Poll::Ready(Some(Err(error))) => { + Poll::Ready(Some(Err(CountingBodyError::Inner(error)))) + } + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } + + fn size_hint(&self) -> SizeHint { + SizeHint::with_exact(self.declared) + } +} + +#[cfg(test)] +mod tests { + use axum::body::Body; + use http_body::Body as _; + use http_body_util::BodyExt; + + use super::CountingBody; + + #[tokio::test] + async fn counts_exact_bytes_when_body_fits_cap() { + // Given + let (body, count) = CountingBody::new(Body::from("booskiff"), 8, 8); + + // When + let bytes = body.collect().await.unwrap().to_bytes(); + + // Then + assert_eq!(bytes.as_ref(), b"booskiff"); + assert_eq!(count.load(), 8); + } + + #[tokio::test] + async fn returns_error_as_soon_as_count_exceeds_cap() { + // Given + let (body, count) = CountingBody::new(Body::from("too large"), 3, 9); + + // When + let error = body.collect().await.unwrap_err(); + + // Then + assert!(error.is_limit_exceeded()); + assert_eq!(count.load(), 9); + } + + #[test] + fn reports_declared_length_as_exact_size_hint() { + // Given + let (body, _count) = CountingBody::new(Body::empty(), 7, 42); + + // When + let hint = body.size_hint(); + + // Then + assert_eq!(hint.lower(), 42); + assert_eq!(hint.upper(), Some(42)); + } +} From fe29e4c5d77efed95de4b59879abfce612036386 Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 30 Aug 2026 14:27:02 +0900 Subject: [PATCH 06/15] feat(drive): folder CRUD --- core/src/drive/folders.rs | 288 +++++++++++++++++++++++++++++++++++++- 1 file changed, 287 insertions(+), 1 deletion(-) diff --git a/core/src/drive/folders.rs b/core/src/drive/folders.rs index 15920e1..fa0731e 100644 --- a/core/src/drive/folders.rs +++ b/core/src/drive/folders.rs @@ -1 +1,287 @@ -//! Folder CRUD handlers. TODO(Wave2/T6). +//! Folder CRUD handlers. + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::auth::extractor::AccountContext; +use crate::error::AppError; +use crate::model::Owner; +use crate::state::AppState; + +#[derive(Serialize, ToSchema)] +pub struct FolderResponse { + pub id: Uuid, + pub name: String, + pub created_at: String, +} + +#[derive(Deserialize, ToSchema)] +pub struct CreateFolderRequest { + pub name: String, +} + +#[derive(Deserialize, ToSchema)] +pub struct RenameFolderRequest { + pub name: String, +} + +#[derive(Serialize, ToSchema)] +pub struct FolderListResponse { + pub items: Vec, +} + +#[derive(Debug, sqlx::FromRow)] +struct FolderRow { + id: Uuid, + name: String, + created_at: OffsetDateTime, +} + +/// Builds the authenticated folder CRUD router. +pub fn folders_router() -> Router { + Router::new() + .route("/", post(create_folder).get(list_folders)) + .route( + "/{id}", + get(get_folder).patch(rename_folder).delete(delete_folder), + ) +} + +#[utoipa::path(post, path = "/v1/folders", tag = "folders")] +async fn create_folder( + context: AccountContext, + State(state): State, + Json(request): Json, +) -> Result<(StatusCode, Json), AppError> { + validate_folder_name(&request.name)?; + let row = sqlx::query_as::<_, FolderRow>( + "INSERT INTO folders (owner_type, owner_id, name) VALUES ($1, $2, $3) \ + RETURNING id, name, created_at", + ) + .bind(&context.owner.owner_type) + .bind(&context.owner.owner_id) + .bind(&request.name) + .fetch_one(&state.pool) + .await + .map_err(map_database_error)?; + Ok((StatusCode::CREATED, Json(folder_response(row)?))) +} + +#[utoipa::path(get, path = "/v1/folders", tag = "folders")] +async fn list_folders( + context: AccountContext, + State(state): State, +) -> Result, AppError> { + let rows = sqlx::query_as::<_, FolderRow>( + "SELECT id, name, created_at FROM folders \ + WHERE owner_type = $1 AND owner_id = $2 ORDER BY created_at ASC", + ) + .bind(&context.owner.owner_type) + .bind(&context.owner.owner_id) + .fetch_all(&state.pool) + .await + .map_err(internal_database_error)?; + let items = rows + .into_iter() + .map(folder_response) + .collect::, _>>()?; + Ok(Json(FolderListResponse { items })) +} + +#[utoipa::path(get, path = "/v1/folders/{id}", tag = "folders")] +async fn get_folder( + context: AccountContext, + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let row = find_folder(&state.pool, &context.owner, id).await?; + Ok(Json(folder_response(row)?)) +} + +#[utoipa::path(patch, path = "/v1/folders/{id}", tag = "folders")] +async fn rename_folder( + context: AccountContext, + State(state): State, + Path(id): Path, + Json(request): Json, +) -> Result, AppError> { + validate_folder_name(&request.name)?; + let row = sqlx::query_as::<_, FolderRow>( + "UPDATE folders SET name = $1 WHERE id = $2 AND owner_type = $3 AND owner_id = $4 \ + RETURNING id, name, created_at", + ) + .bind(&request.name) + .bind(id) + .bind(&context.owner.owner_type) + .bind(&context.owner.owner_id) + .fetch_optional(&state.pool) + .await + .map_err(map_database_error)? + .ok_or_else(|| AppError::NotFound("folder not found".into()))?; + Ok(Json(folder_response(row)?)) +} + +#[utoipa::path(delete, path = "/v1/folders/{id}", tag = "folders")] +async fn delete_folder( + context: AccountContext, + State(state): State, + Path(id): Path, +) -> Result { + let result = + sqlx::query("DELETE FROM folders WHERE id = $1 AND owner_type = $2 AND owner_id = $3") + .bind(id) + .bind(&context.owner.owner_type) + .bind(&context.owner.owner_id) + .execute(&state.pool) + .await + .map_err(internal_database_error)?; + if result.rows_affected() == 0 { + return Err(AppError::NotFound("folder not found".into())); + } + Ok(StatusCode::NO_CONTENT) +} + +async fn find_folder(pool: &sqlx::PgPool, owner: &Owner, id: Uuid) -> Result { + sqlx::query_as::<_, FolderRow>( + "SELECT id, name, created_at FROM folders \ + WHERE id = $1 AND owner_type = $2 AND owner_id = $3", + ) + .bind(id) + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .fetch_optional(pool) + .await + .map_err(internal_database_error)? + .ok_or_else(|| AppError::NotFound("folder not found".into())) +} + +fn folder_response(row: FolderRow) -> Result { + let created_at = row + .created_at + .format(&time::format_description::well_known::Rfc3339) + .map_err(|err| AppError::Internal(format!("format folder timestamp failed: {err}")))?; + Ok(FolderResponse { + id: row.id, + name: row.name, + created_at, + }) +} + +fn validate_folder_name(name: &str) -> Result<(), AppError> { + if name.trim().is_empty() { + return Err(AppError::Validation("folder name must not be blank".into())); + } + if name.chars().count() > 255 { + return Err(AppError::Validation("folder name is too long".into())); + } + Ok(()) +} + +fn map_database_error(error: sqlx::Error) -> AppError { + match &error { + sqlx::Error::Database(database_error) + if database_error.code().as_deref() == Some("23505") => + { + AppError::Conflict("folder name already exists".into()) + } + _ => internal_database_error(error), + } +} + +fn internal_database_error(error: sqlx::Error) -> AppError { + AppError::Internal(format!("folder database operation failed: {error}")) +} + +#[cfg(test)] +mod tests { + use super::{find_folder, map_database_error, validate_folder_name}; + use crate::error::AppError; + use uuid::Uuid; + + #[test] + fn folder_name_rejects_blank_input() { + assert!(matches!( + validate_folder_name(" \n"), + Err(AppError::Validation(_)) + )); + } + + #[test] + fn folder_name_rejects_more_than_255_characters() { + let name = "a".repeat(256); + assert!(matches!( + validate_folder_name(&name), + Err(AppError::Validation(_)) + )); + } + + #[tokio::test] + #[ignore = "requires the compose postgres stack"] + async fn pg_folder_lifecycle_is_owner_scoped_and_unlinks_files() { + let url = std::env::var("BOOSKIFF_DATABASE_URL") + .unwrap_or_else(|_| "postgres://booskiff:booskiff@127.0.0.1:5432/booskiff".to_owned()); + let pool = sqlx::PgPool::connect(&url).await.expect("connect postgres"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("migrate"); + let owner = crate::model::Owner::new("test-folders", Uuid::now_v7().to_string()); + let other = crate::model::Owner::new("test-folders", Uuid::now_v7().to_string()); + let id: Uuid = sqlx::query_scalar( + "INSERT INTO folders (owner_type, owner_id, name) VALUES ($1, $2, 'one') RETURNING id", + ) + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .fetch_one(&pool) + .await + .expect("insert folder"); + let duplicate = + sqlx::query("INSERT INTO folders (owner_type, owner_id, name) VALUES ($1, $2, 'one')") + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .execute(&pool) + .await + .expect_err("duplicate folder"); + assert!(matches!( + map_database_error(duplicate), + AppError::Conflict(_) + )); + assert!(matches!( + find_folder(&pool, &other, id).await, + Err(AppError::NotFound(_)) + )); + sqlx::query("INSERT INTO files (owner_type, owner_id, folder_id, name, mime_type, size_bytes) VALUES ($1, $2, $3, 'f', 'text/plain', 0)") + .bind(&owner.owner_type) + .bind(&owner.owner_id) + .bind(id) + .execute(&pool) + .await + .expect("insert file"); + sqlx::query("DELETE FROM folders WHERE id = $1") + .bind(id) + .execute(&pool) + .await + .expect("delete folder"); + assert!( + sqlx::query_scalar::<_, Option>( + "SELECT folder_id FROM files WHERE owner_id = $1", + ) + .bind(&owner.owner_id) + .fetch_one(&pool) + .await + .expect("file lookup") + .is_none() + ); + sqlx::query("DELETE FROM files WHERE owner_id = $1") + .bind(&owner.owner_id) + .execute(&pool) + .await + .expect("cleanup file"); + } +} From 1fba8446d9c1eccb0f5031f187c0ac1f1c0229c0 Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 30 Aug 2026 14:27:03 +0900 Subject: [PATCH 07/15] feat(admin): token, billing rule, plan assignment, usage endpoints --- core/src/admin/handlers.rs | 690 +++++++++++++++++++++++++++++++++++++ core/src/admin/mod.rs | 11 +- 2 files changed, 694 insertions(+), 7 deletions(-) create mode 100644 core/src/admin/handlers.rs diff --git a/core/src/admin/handlers.rs b/core/src/admin/handlers.rs new file mode 100644 index 0000000..886bd2a --- /dev/null +++ b/core/src/admin/handlers.rs @@ -0,0 +1,690 @@ +// allow: SIZE_OK — this file owns the complete admin HTTP resource; the task +// contract pins one handlers file requiring ten handlers, their DTOs, OpenAPI +// annotations, the router, and the unit + ignored PG lifecycle tests. +//! Admin API handlers: token lifecycle, billing rules, plan assignments, +//! and owner usage. Every route authenticates through the `AdminPrincipal` +//! extractor (`X-Admin-Token`, revocation, rate limiting happen there). + +use axum::Json; +use axum::Router; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::routing::{delete, get, post, put}; +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; +use uuid::Uuid; + +use crate::auth::admin_auth::{AdminPrincipal, hash_admin_token}; +use crate::billing::{assignments, rules, usage}; +use crate::error::AppError; +use crate::model::{Owner, Plan}; +use crate::state::AppState; + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct CreateTokenRequest { + pub name: String, +} + +/// Response of `POST /v1/admin/tokens`. `token` is the raw secret, returned +/// exactly once; only its SHA-256 digest is ever persisted. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct CreatedAdminToken { + pub id: Uuid, + pub name: String, + pub token: String, + pub created_at: String, +} + +/// One row of `GET /v1/admin/tokens`; carries no secret material. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct AdminTokenItem { + pub id: Uuid, + pub name: String, + pub created_at: String, + pub revoked_at: Option, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct AdminTokenList { + pub items: Vec, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct CreateRuleRequest { + pub owner_type: Option, + pub owner_id: Option, + pub key: String, + pub value: serde_json::Value, + pub enabled: bool, +} + +/// One billing rule as served by the admin API. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct BillingRuleItem { + pub id: Uuid, + pub owner_type: Option, + pub owner_id: Option, + pub key: String, + pub value: serde_json::Value, + pub enabled: bool, + pub created_at: String, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct BillingRuleList { + pub items: Vec, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct SetPlanRequest { + pub plan: String, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct PlanResponse { + pub plan: String, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct UsageResponse { + pub used_bytes: i64, +} + +#[derive(Debug, sqlx::FromRow)] +struct AdminTokenCreatedRow { + id: Uuid, + name: String, + created_at: OffsetDateTime, +} + +#[derive(Debug, sqlx::FromRow)] +struct AdminTokenRow { + id: Uuid, + name: String, + created_at: OffsetDateTime, + revoked_at: Option, +} + +/// 32 random bytes, base64 URL-safe without padding (43 ASCII chars). +fn generate_admin_token() -> String { + let mut bytes = [0_u8; 32]; + rand::fill(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +/// RFC3339 rendering of a database timestamp. +fn rfc3339(timestamp: OffsetDateTime) -> Result { + timestamp + .format(&Rfc3339) + .map_err(|err| AppError::Internal(format!("format timestamp: {err}"))) +} + +/// Owner scope of a rule request: `Some` only when both halves are given; +/// exactly one half is a validation error, both missing targets the global +/// layer. +fn owner_scope( + owner_type: Option, + owner_id: Option, +) -> Result, AppError> { + match (owner_type, owner_id) { + (None, None) => Ok(None), + (Some(owner_type), Some(owner_id)) => Ok(Some(Owner::new(owner_type, owner_id))), + (Some(_), None) | (None, Some(_)) => Err(AppError::Validation( + "owner_type and owner_id must be given together; omit both for a global rule" + .to_owned(), + )), + } +} + +/// Parse the plan name of a plan-assignment request. +fn parse_plan(value: &str) -> Result { + Plan::from_str(value).map_err(|err| AppError::Validation(err.to_string())) +} + +#[utoipa::path( + post, + path = "/v1/admin/tokens", + tag = "admin", + request_body = CreateTokenRequest, + responses( + (status = 201, description = "Token created; the raw token is returned exactly once", body = CreatedAdminToken), + (status = 401, description = "Missing, unknown, or revoked admin token"), + ) +)] +async fn create_token( + State(state): State, + _principal: AdminPrincipal, + Json(request): Json, +) -> Result<(StatusCode, Json), AppError> { + let token = generate_admin_token(); + let row = sqlx::query_as::<_, AdminTokenCreatedRow>( + "INSERT INTO admin_tokens (id, name, token_hash) VALUES ($1, $2, $3) \ + RETURNING id, name, created_at", + ) + .bind(Uuid::now_v7()) + .bind(&request.name) + .bind(hash_admin_token(&token)) + .fetch_one(&state.pool) + .await + .map_err(|err| AppError::Internal(format!("create admin token: {err}")))?; + Ok(( + StatusCode::CREATED, + Json(CreatedAdminToken { + id: row.id, + name: row.name, + token, + created_at: rfc3339(row.created_at)?, + }), + )) +} + +#[utoipa::path( + get, + path = "/v1/admin/tokens", + tag = "admin", + responses( + (status = 200, description = "All admin tokens without secret material", body = AdminTokenList), + (status = 401, description = "Missing, unknown, or revoked admin token"), + ) +)] +async fn list_tokens( + State(state): State, + _principal: AdminPrincipal, +) -> Result, AppError> { + let rows = sqlx::query_as::<_, AdminTokenRow>( + "SELECT id, name, created_at, revoked_at FROM admin_tokens ORDER BY created_at, id", + ) + .fetch_all(&state.pool) + .await + .map_err(|err| AppError::Internal(format!("list admin tokens: {err}")))?; + let items = rows + .into_iter() + .map(|row| { + Ok(AdminTokenItem { + id: row.id, + name: row.name, + created_at: rfc3339(row.created_at)?, + revoked_at: row.revoked_at.map(rfc3339).transpose()?, + }) + }) + .collect::, AppError>>()?; + Ok(Json(AdminTokenList { items })) +} + +#[utoipa::path( + delete, + path = "/v1/admin/tokens/{id}", + tag = "admin", + params(("id" = Uuid, Path, description = "Admin token id")), + responses( + (status = 204, description = "Revoked; the token authenticates no longer"), + (status = 404, description = "Unknown token id"), + (status = 401, description = "Missing, unknown, or revoked admin token"), + ) +)] +async fn revoke_token( + State(state): State, + _principal: AdminPrincipal, + Path(id): Path, +) -> Result { + let result = sqlx::query("UPDATE admin_tokens SET revoked_at = now() WHERE id = $1") + .bind(id) + .execute(&state.pool) + .await + .map_err(|err| AppError::Internal(format!("revoke admin token: {err}")))?; + if result.rows_affected() > 0 { + Ok(StatusCode::NO_CONTENT) + } else { + Err(AppError::NotFound(format!("admin token {id} not found"))) + } +} + +#[utoipa::path( + get, + path = "/v1/admin/billing/rules", + tag = "admin", + responses( + (status = 200, description = "All billing rules, global layer first", body = BillingRuleList), + (status = 401, description = "Missing, unknown, or revoked admin token"), + ) +)] +async fn list_billing_rules( + State(state): State, + _principal: AdminPrincipal, +) -> Result, AppError> { + let rules = rules::list_rules(&state.pool).await?; + let items = rules + .into_iter() + .map(billing_rule_item) + .collect::, AppError>>()?; + Ok(Json(BillingRuleList { items })) +} + +#[utoipa::path( + post, + path = "/v1/admin/billing/rules", + tag = "admin", + request_body = CreateRuleRequest, + responses( + (status = 200, description = "Rule inserted or updated", body = BillingRuleItem), + (status = 400, description = "Partial owner scope, unknown key, or invalid value"), + (status = 401, description = "Missing, unknown, or revoked admin token"), + ) +)] +async fn create_billing_rule( + State(state): State, + _principal: AdminPrincipal, + Json(request): Json, +) -> Result, AppError> { + let scope = owner_scope(request.owner_type, request.owner_id)?; + let rule = rules::upsert_rule( + &state.pool, + scope.as_ref(), + &request.key, + request.value, + request.enabled, + ) + .await?; + Ok(Json(billing_rule_item(rule)?)) +} + +#[utoipa::path( + delete, + path = "/v1/admin/billing/rules/{id}", + tag = "admin", + params(("id" = Uuid, Path, description = "Billing rule id")), + responses( + (status = 204, description = "Rule deleted"), + (status = 404, description = "Unknown rule id"), + (status = 401, description = "Missing, unknown, or revoked admin token"), + ) +)] +async fn delete_billing_rule( + State(state): State, + _principal: AdminPrincipal, + Path(id): Path, +) -> Result { + if rules::delete_rule(&state.pool, id).await? { + Ok(StatusCode::NO_CONTENT) + } else { + Err(AppError::NotFound(format!("billing rule {id} not found"))) + } +} + +#[utoipa::path( + put, + path = "/v1/admin/owners/{owner_type}/{owner_id}/plan", + tag = "admin", + params( + ("owner_type" = String, Path, description = "Owner type, e.g. account"), + ("owner_id" = String, Path, description = "Owner id"), + ), + request_body = SetPlanRequest, + responses( + (status = 204, description = "Plan assigned, replacing any previous assignment"), + (status = 400, description = "Unknown plan name"), + (status = 401, description = "Missing, unknown, or revoked admin token"), + ) +)] +async fn set_plan( + State(state): State, + _principal: AdminPrincipal, + Path((owner_type, owner_id)): Path<(String, String)>, + Json(request): Json, +) -> Result { + let plan = parse_plan(&request.plan)?; + assignments::set_plan(&state.pool, &Owner::new(owner_type, owner_id), plan).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + get, + path = "/v1/admin/owners/{owner_type}/{owner_id}/plan", + tag = "admin", + params( + ("owner_type" = String, Path, description = "Owner type, e.g. account"), + ("owner_id" = String, Path, description = "Owner id"), + ), + responses( + (status = 200, description = "Assigned plan; default when unassigned", body = PlanResponse), + (status = 401, description = "Missing, unknown, or revoked admin token"), + ) +)] +async fn get_plan( + State(state): State, + _principal: AdminPrincipal, + Path((owner_type, owner_id)): Path<(String, String)>, +) -> Result, AppError> { + let owner = Owner::new(owner_type, owner_id); + let plan = assignments::get_plan(&state.pool, &owner).await?; + Ok(Json(PlanResponse { + plan: plan.unwrap_or(Plan::Default).as_str().to_owned(), + })) +} + +#[utoipa::path( + delete, + path = "/v1/admin/owners/{owner_type}/{owner_id}/plan", + tag = "admin", + params( + ("owner_type" = String, Path, description = "Owner type, e.g. account"), + ("owner_id" = String, Path, description = "Owner id"), + ), + responses( + (status = 204, description = "Assignment removed"), + (status = 404, description = "No assignment for this owner"), + (status = 401, description = "Missing, unknown, or revoked admin token"), + ) +)] +async fn delete_plan( + State(state): State, + _principal: AdminPrincipal, + Path((owner_type, owner_id)): Path<(String, String)>, +) -> Result { + let owner = Owner::new(owner_type, owner_id); + if assignments::delete_plan(&state.pool, &owner).await? { + Ok(StatusCode::NO_CONTENT) + } else { + Err(AppError::NotFound(format!( + "plan assignment for {} not found", + owner.key() + ))) + } +} + +#[utoipa::path( + get, + path = "/v1/admin/owners/{owner_type}/{owner_id}/usage", + tag = "admin", + params( + ("owner_type" = String, Path, description = "Owner type, e.g. account"), + ("owner_id" = String, Path, description = "Owner id"), + ), + responses( + (status = 200, description = "Metered received bytes; 0 when never uploaded", body = UsageResponse), + (status = 401, description = "Missing, unknown, or revoked admin token"), + ) +)] +async fn get_usage( + State(state): State, + _principal: AdminPrincipal, + Path((owner_type, owner_id)): Path<(String, String)>, +) -> Result, AppError> { + let used = usage::used_bytes(&state.pool, &Owner::new(owner_type, owner_id)).await?; + Ok(Json(UsageResponse { used_bytes: used })) +} + +fn billing_rule_item(rule: rules::BillingRule) -> Result { + Ok(BillingRuleItem { + id: rule.id, + owner_type: rule.owner_type, + owner_id: rule.owner_id, + key: rule.key, + value: rule.value, + enabled: rule.enabled, + created_at: rfc3339(rule.created_at)?, + }) +} + +/// Routes relative to `/v1/admin`; the mount point adds the prefix. +pub fn admin_router() -> Router { + Router::new() + .route("/tokens", post(create_token).get(list_tokens)) + .route("/tokens/{id}", delete(revoke_token)) + .route( + "/billing/rules", + get(list_billing_rules).post(create_billing_rule), + ) + .route("/billing/rules/{id}", delete(delete_billing_rule)) + .route( + "/owners/{owner_type}/{owner_id}/plan", + put(set_plan).get(get_plan).delete(delete_plan), + ) + .route("/owners/{owner_type}/{owner_id}/usage", get(get_usage)) +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use http_body_util::BodyExt; + use rstest::rstest; + use tower::ServiceExt; + + use super::*; + use crate::auth::admin_auth::find_active_by_hash; + use crate::auth::jwks::JwksCache; + use crate::config::Config; + use crate::state::RateLimiters; + use crate::storage::Storage; + + /// Compose postgres (compose.yml), used only by `#[ignore]` tests. + const PUBLISH_DB_URL: &str = "postgres://booskiff:booskiff@127.0.0.1:5432/booskiff"; + + #[test] + fn generated_token_is_43_url_safe_chars_decoding_to_32_bytes() { + let token = generate_admin_token(); + assert_eq!(token.len(), 43, "32 bytes base64 URL_SAFE_NO_PAD"); + assert!( + token + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), + "token must be URL-safe: {token}" + ); + let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(&token) + .expect("token decodes as URL_SAFE_NO_PAD"); + assert_eq!(raw.len(), 32); + } + + #[test] + fn generated_tokens_are_unique_across_samples() { + let tokens: HashSet = (0..64).map(|_| generate_admin_token()).collect(); + assert_eq!(tokens.len(), 64, "256-bit draws must not collide"); + } + + #[test] + fn owner_scope_is_global_when_both_halves_missing() { + assert_eq!(owner_scope(None, None).unwrap(), None); + } + + #[test] + fn owner_scope_builds_owner_when_both_halves_present() { + let scope = owner_scope(Some("account".to_owned()), Some("alice".to_owned())).unwrap(); + assert_eq!(scope, Some(Owner::new("account", "alice"))); + } + + #[rstest] + #[case(Some("account".to_owned()), None)] + #[case(None, Some("alice".to_owned()))] + fn owner_scope_rejects_partial_scope( + #[case] owner_type: Option, + #[case] owner_id: Option, + ) { + let err = owner_scope(owner_type, owner_id).unwrap_err(); + assert!(matches!(err, AppError::Validation(_))); + } + + #[rstest] + #[case("default", Plan::Default)] + #[case("premium", Plan::Premium)] + fn parse_plan_accepts_known_plans(#[case] raw: &str, #[case] plan: Plan) { + assert_eq!(parse_plan(raw).unwrap(), plan); + } + + #[rstest] + #[case("")] + #[case("Default")] + #[case("galaxy-brain")] + fn parse_plan_rejects_unknown_names(#[case] raw: &str) { + let err = parse_plan(raw).unwrap_err(); + assert!(matches!(err, AppError::Validation(_))); + } + + #[test] + fn rfc3339_renders_utc_epoch() { + assert_eq!( + rfc3339(OffsetDateTime::UNIX_EPOCH).unwrap(), + "1970-01-01T00:00:00Z" + ); + } + + fn request_with_token( + method: &str, + uri: &str, + token: &str, + json_body: Option, + ) -> Request { + let mut builder = Request::builder() + .method(method) + .uri(uri) + .header("x-admin-token", token); + match json_body { + Some(body) => { + builder = builder.header("content-type", "application/json"); + builder.body(Body::from(body)).unwrap() + } + None => builder.body(Body::empty()).unwrap(), + } + } + + async fn test_pool() -> sqlx::PgPool { + let url = + std::env::var("BOOSKIFF_DATABASE_URL").unwrap_or_else(|_| PUBLISH_DB_URL.to_owned()); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(&url) + .await + .unwrap_or_else(|err| { + panic!("PG test needs a running postgres at {url} (docker compose up -d): {err}") + }); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("run migrations"); + pool + } + + /// Compose-postgres-backed lifecycle; run manually via + /// `cargo test -p core admin:: -- --ignored`. + #[tokio::test] + #[ignore] + async fn pg_token_lifecycle_hashes_at_rest_and_revocation_blocks_auth() { + let pool = test_pool().await; + let bootstrap_name = format!("wave3-t8-bootstrap-{}", Uuid::now_v7()); + let created_name = format!("wave3-t8-created-{}", Uuid::now_v7()); + // Random per run: token_hash is UNIQUE, so a fixed secret would + // collide with residue from any earlier failed run. + let bootstrap_raw = format!("wave3-t8-bootstrap-{}", Uuid::now_v7()); + sqlx::query("INSERT INTO admin_tokens (name, token_hash) VALUES ($1, $2)") + .bind(&bootstrap_name) + .bind(hash_admin_token(&bootstrap_raw)) + .execute(&pool) + .await + .expect("seed bootstrap token"); + + let config = Config::default(); + let state = AppState { + pool: pool.clone(), + s3: Storage::build(&config).await.unwrap(), + jwks_cache: JwksCache::new(Vec::new()), + config, + rate_limiters: std::sync::Arc::new(RateLimiters::default()), + }; + let router = axum::Router::new() + .nest("/v1/admin", admin_router()) + .with_state(state); + + // Create through the API, authenticated by the bootstrap token. + let response = router + .clone() + .oneshot(request_with_token( + "POST", + "/v1/admin/tokens", + &bootstrap_raw, + Some(format!(r#"{{"name":"{created_name}"}}"#)), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let created: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let raw_token = created["token"].as_str().expect("raw token").to_owned(); + let token_id: Uuid = created["id"].as_str().expect("id").parse().unwrap(); + assert_eq!(created["name"], created_name.as_str()); + assert!(created["created_at"].as_str().is_some()); + + // At rest only the digest: not the raw token, but its SHA-256. + let stored_hash: String = + sqlx::query_scalar("SELECT token_hash FROM admin_tokens WHERE id = $1") + .bind(token_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_ne!(stored_hash, raw_token); + assert_eq!(stored_hash, hash_admin_token(&raw_token)); + + // The list never carries secret material. + let response = router + .clone() + .oneshot(request_with_token( + "GET", + "/v1/admin/tokens", + &bootstrap_raw, + None, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let listed: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let items = listed["items"].as_array().expect("items array"); + assert!(items.iter().any(|item| item["id"] == created["id"])); + for item in items { + assert!(item.get("token").is_none(), "{item}"); + assert!(item.get("token_hash").is_none(), "{item}"); + } + + // Revoke: 204, and the digest stops authenticating immediately. + let response = router + .clone() + .oneshot(request_with_token( + "DELETE", + &format!("/v1/admin/tokens/{token_id}"), + &bootstrap_raw, + None, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + let active = find_active_by_hash(&pool, &hash_admin_token(&raw_token)) + .await + .unwrap(); + assert!(active.is_none()); + + // Unknown id is a 404, not a silent success. + let response = router + .oneshot(request_with_token( + "DELETE", + &format!("/v1/admin/tokens/{}", Uuid::now_v7()), + &bootstrap_raw, + None, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + for name in [&bootstrap_name, &created_name] { + sqlx::query("DELETE FROM admin_tokens WHERE name = $1") + .bind(name) + .execute(&pool) + .await + .expect("cleanup token row"); + } + } +} diff --git a/core/src/admin/mod.rs b/core/src/admin/mod.rs index 5d3e611..46de1db 100644 --- a/core/src/admin/mod.rs +++ b/core/src/admin/mod.rs @@ -1,9 +1,6 @@ -//! Admin API surface. TODO(Wave3): token-authenticated management routes -//! (admin users, billing rules, plan assignments). +//! Admin API surface: token lifecycle, billing rules, plan assignments, +//! and owner usage, authenticated by `X-Admin-Token` (see `auth::admin_auth`). -use axum::Router; +pub mod handlers; -/// Admin router — placeholder until Wave-3 adds routes. -pub fn admin_router() -> Router<()> { - Router::new() -} +pub use handlers::admin_router; From 125a14e24e2d3bb5e9da18a6c1e82d50bf8bf610 Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 30 Aug 2026 14:27:04 +0900 Subject: [PATCH 08/15] feat(public): public file serving with immutable caching; health endpoints --- core/src/health.rs | 23 +++++++++--- core/src/main.rs | 9 +++-- core/src/public.rs | 85 +++++++++++++++++++++++++++++++++++++++++++-- core/src/storage.rs | 11 ++++++ 4 files changed, 116 insertions(+), 12 deletions(-) diff --git a/core/src/health.rs b/core/src/health.rs index 2075bd0..fc24a93 100644 --- a/core/src/health.rs +++ b/core/src/health.rs @@ -1,12 +1,25 @@ //! Liveness/readiness endpoints. use axum::Router; +use axum::extract::State; +use axum::http::StatusCode; use axum::routing::get; -/// `/healthz` (liveness) and `/readyz` (readiness). Readiness always -/// reports ok until Wave-3 wires real dependency checks. -pub fn health_router() -> Router<()> { +use crate::state::AppState; + +/// `/healthz` (liveness) and `/readyz` (dependency readiness). +pub fn health_router() -> Router { Router::new() - .route("/healthz", get(|| async { "ok" })) - .route("/readyz", get(|| async { "ok" })) + .route("/healthz", get(|| async { (StatusCode::OK, "ok") })) + .route("/readyz", get(ready)) +} + +async fn ready(State(state): State) -> (StatusCode, &'static str) { + let database_ready = sqlx::query("SELECT 1").execute(&state.pool).await.is_ok(); + let storage_ready = state.s3.check_ready().await.is_ok(); + if database_ready && storage_ready { + (StatusCode::OK, "ok") + } else { + (StatusCode::SERVICE_UNAVAILABLE, "not ready") + } } diff --git a/core/src/main.rs b/core/src/main.rs index 1d4fa21..b21a2c6 100644 --- a/core/src/main.rs +++ b/core/src/main.rs @@ -89,17 +89,16 @@ async fn run() { rate_limiters: Arc::new(RateLimiters::default()), }; - // Wave-1 mounts only stateless routes; state is baked in up front. - // Wave 2+ restructures as stateful routers land (axum 0.8 lacks a - // From> for Router conversion for late merges). + // Stateful routers merge first; state is baked in once at the end + // (axum 0.8 lacks a Router -> Router<()> late conversion). let app = axum::Router::::new() - .with_state(state) .merge(health::health_router()) .route("/openapi.json", get(|| async { Json(openapi_spec()) })) .merge( utoipa_swagger_ui::SwaggerUi::new("/swagger-ui") .url("/api-docs/openapi.json", openapi_spec()), - ); + ) + .with_state(state); let listener = tokio::net::TcpListener::bind(&listen_addr) .await diff --git a/core/src/public.rs b/core/src/public.rs index 6b644c0..bed158c 100644 --- a/core/src/public.rs +++ b/core/src/public.rs @@ -1,2 +1,83 @@ -//! Public (unauthenticated) download endpoints. TODO(Wave3): public_key -//! resolution and file streaming. +//! Public (unauthenticated) file download endpoint. + +use axum::Router; +use axum::body::Body; +use axum::extract::{Path, State}; +use axum::http::header::{CACHE_CONTROL, CONTENT_LENGTH, CONTENT_TYPE}; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; + +use crate::error::AppError; +use crate::model::OBJECT_KIND_ORIGINAL; +use crate::state::AppState; + +const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable"; + +#[derive(Debug, sqlx::FromRow)] +struct PublicFile { + id: uuid::Uuid, +} + +#[derive(Debug, sqlx::FromRow)] +struct OriginalObject { + storage_key: String, +} + +/// Build the unauthenticated public file router. +pub fn public_router() -> Router { + Router::new().route("/public/{key}", get(get_public_file)) +} + +async fn get_public_file( + State(state): State, + Path(key): Path, +) -> Result { + let file = sqlx::query_as::<_, PublicFile>( + "SELECT id FROM files WHERE public_key = $1 AND is_public = TRUE", + ) + .bind(&key) + .fetch_optional(&state.pool) + .await + .map_err(|err| AppError::Internal(format!("public file lookup failed: {err}")))? + .ok_or_else(|| AppError::NotFound(format!("public file {key}")))?; + + let object = sqlx::query_as::<_, OriginalObject>( + "SELECT storage_key FROM file_objects WHERE file_id = $1 AND object_kind = $2", + ) + .bind(file.id) + .bind(OBJECT_KIND_ORIGINAL) + .fetch_optional(&state.pool) + .await + .map_err(|err| AppError::Internal(format!("public object lookup failed: {err}")))? + .ok_or_else(|| AppError::NotFound(format!("original object for file {}", file.id)))?; + + let (content_type, content_length, stream) = + state.s3.get_streaming(&object.storage_key).await?; + let content_type = HeaderValue::from_str(&content_type) + .map_err(|err| AppError::Internal(format!("invalid object content type: {err}")))?; + let content_length = HeaderValue::from_str(&content_length.to_string()) + .map_err(|err| AppError::Internal(format!("invalid object content length: {err}")))?; + + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, content_type); + headers.insert(CONTENT_LENGTH, content_length); + headers.insert( + CACHE_CONTROL, + HeaderValue::from_static(IMMUTABLE_CACHE_CONTROL), + ); + Ok((StatusCode::OK, headers, Body::new(stream.into_inner())).into_response()) +} + +#[cfg(test)] +mod tests { + use super::IMMUTABLE_CACHE_CONTROL; + + #[test] + fn immutable_cache_control_is_public_and_one_year() { + assert_eq!( + IMMUTABLE_CACHE_CONTROL, + "public, max-age=31536000, immutable" + ); + } +} diff --git a/core/src/storage.rs b/core/src/storage.rs index 1534008..229be9e 100644 --- a/core/src/storage.rs +++ b/core/src/storage.rs @@ -107,6 +107,17 @@ impl Storage { } } + /// Check that the configured bucket is reachable without creating it. + pub async fn check_ready(&self) -> Result<(), AppError> { + self.client + .head_bucket() + .bucket(&self.bucket) + .send() + .await + .map(|_| ()) + .map_err(|err| AppError::Internal(format!("head bucket {}: {err}", self.bucket))) + } + /// Upload `body` to `key`. /// /// `content_length` is forwarded explicitly: the SDK needs a known From ca20c3bb082df6c05dee118fda9e54109f97d66b Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 30 Aug 2026 14:59:33 +0900 Subject: [PATCH 09/15] feat(api): router wiring, OpenAPI document, swagger UI, checked-in openapi.json --- core/src/admin/handlers.rs | 10 +++ core/src/api_doc.rs | 120 +++++++++++++++++++++++++++-- core/src/billing/mod.rs | 1 + core/src/billing/status_handler.rs | 49 ++++++++++++ core/src/config.rs | 8 ++ core/src/drive/files.rs | 14 ++-- core/src/drive/folders.rs | 10 +-- core/src/error.rs | 4 +- core/src/health.rs | 8 +- core/src/main.rs | 52 ++++++++++++- core/src/model.rs | 6 +- core/src/public.rs | 10 +++ openapi.json | 15 +--- 13 files changed, 267 insertions(+), 40 deletions(-) create mode 100644 core/src/billing/status_handler.rs diff --git a/core/src/admin/handlers.rs b/core/src/admin/handlers.rs index 886bd2a..1055cc4 100644 --- a/core/src/admin/handlers.rs +++ b/core/src/admin/handlers.rs @@ -148,6 +148,7 @@ fn parse_plan(value: &str) -> Result { post, path = "/v1/admin/tokens", tag = "admin", + security(("admin_token" = [])), request_body = CreateTokenRequest, responses( (status = 201, description = "Token created; the raw token is returned exactly once", body = CreatedAdminToken), @@ -185,6 +186,7 @@ async fn create_token( get, path = "/v1/admin/tokens", tag = "admin", + security(("admin_token" = [])), responses( (status = 200, description = "All admin tokens without secret material", body = AdminTokenList), (status = 401, description = "Missing, unknown, or revoked admin token"), @@ -218,6 +220,7 @@ async fn list_tokens( delete, path = "/v1/admin/tokens/{id}", tag = "admin", + security(("admin_token" = [])), params(("id" = Uuid, Path, description = "Admin token id")), responses( (status = 204, description = "Revoked; the token authenticates no longer"), @@ -246,6 +249,7 @@ async fn revoke_token( get, path = "/v1/admin/billing/rules", tag = "admin", + security(("admin_token" = [])), responses( (status = 200, description = "All billing rules, global layer first", body = BillingRuleList), (status = 401, description = "Missing, unknown, or revoked admin token"), @@ -267,6 +271,7 @@ async fn list_billing_rules( post, path = "/v1/admin/billing/rules", tag = "admin", + security(("admin_token" = [])), request_body = CreateRuleRequest, responses( (status = 200, description = "Rule inserted or updated", body = BillingRuleItem), @@ -295,6 +300,7 @@ async fn create_billing_rule( delete, path = "/v1/admin/billing/rules/{id}", tag = "admin", + security(("admin_token" = [])), params(("id" = Uuid, Path, description = "Billing rule id")), responses( (status = 204, description = "Rule deleted"), @@ -318,6 +324,7 @@ async fn delete_billing_rule( put, path = "/v1/admin/owners/{owner_type}/{owner_id}/plan", tag = "admin", + security(("admin_token" = [])), params( ("owner_type" = String, Path, description = "Owner type, e.g. account"), ("owner_id" = String, Path, description = "Owner id"), @@ -344,6 +351,7 @@ async fn set_plan( get, path = "/v1/admin/owners/{owner_type}/{owner_id}/plan", tag = "admin", + security(("admin_token" = [])), params( ("owner_type" = String, Path, description = "Owner type, e.g. account"), ("owner_id" = String, Path, description = "Owner id"), @@ -369,6 +377,7 @@ async fn get_plan( delete, path = "/v1/admin/owners/{owner_type}/{owner_id}/plan", tag = "admin", + security(("admin_token" = [])), params( ("owner_type" = String, Path, description = "Owner type, e.g. account"), ("owner_id" = String, Path, description = "Owner id"), @@ -399,6 +408,7 @@ async fn delete_plan( get, path = "/v1/admin/owners/{owner_type}/{owner_id}/usage", tag = "admin", + security(("admin_token" = [])), params( ("owner_type" = String, Path, description = "Owner type, e.g. account"), ("owner_id" = String, Path, description = "Owner id"), diff --git a/core/src/api_doc.rs b/core/src/api_doc.rs index cc4705c..bd553f8 100644 --- a/core/src/api_doc.rs +++ b/core/src/api_doc.rs @@ -1,13 +1,123 @@ //! OpenAPI document generation and Swagger UI wiring. -use utoipa::OpenApi; +use utoipa::openapi::security::{ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityScheme}; +use utoipa::{Modify, OpenApi}; #[derive(OpenApi)] -#[openapi(info(title = "Booskiff", version = "0.1.0"))] -struct ApiDoc {} +#[openapi( + info( + title = "Booskiff", + version = "0.1.0", + description = "Booskiff drive API: per-owner file and folder storage with \ + streaming uploads, publishing, and download URLs, built on the \ + billing policy foundation (plans, rules, quotas) that governs \ + storage limits.", + ), + paths( + crate::drive::files::upload_file, + crate::drive::files::list_files, + crate::drive::files::get_file, + crate::drive::files::delete_file, + crate::drive::files::download_url, + crate::drive::files::publish_file, + crate::drive::files::unpublish_file, + crate::drive::folders::create_folder, + crate::drive::folders::list_folders, + crate::drive::folders::get_folder, + crate::drive::folders::rename_folder, + crate::drive::folders::delete_folder, + crate::billing::status_handler::get_billing_status, + crate::admin::handlers::create_token, + crate::admin::handlers::list_tokens, + crate::admin::handlers::revoke_token, + crate::admin::handlers::list_billing_rules, + crate::admin::handlers::create_billing_rule, + crate::admin::handlers::delete_billing_rule, + crate::admin::handlers::set_plan, + crate::admin::handlers::get_plan, + crate::admin::handlers::delete_plan, + crate::admin::handlers::get_usage, + crate::public::get_public_file, + crate::health::healthz, + crate::health::ready, + ), + components( + schemas( + crate::drive::files::FileResponse, + crate::drive::files::FileListResponse, + crate::drive::files::UrlResponse, + crate::drive::folders::FolderResponse, + crate::drive::folders::FolderListResponse, + crate::drive::folders::CreateFolderRequest, + crate::drive::folders::RenameFolderRequest, + crate::billing::status_handler::BillingStatusResponse, + crate::admin::handlers::CreateTokenRequest, + crate::admin::handlers::CreatedAdminToken, + crate::admin::handlers::AdminTokenItem, + crate::admin::handlers::AdminTokenList, + crate::admin::handlers::CreateRuleRequest, + crate::admin::handlers::BillingRuleItem, + crate::admin::handlers::BillingRuleList, + crate::admin::handlers::SetPlanRequest, + crate::admin::handlers::PlanResponse, + crate::admin::handlers::UsageResponse, + crate::model::Owner, + crate::model::Limits, + crate::model::Plan, + crate::error::ErrorBody, + crate::error::ErrorDetail, + ), + ), + modifiers(&SecurityAddon), +)] +pub struct ApiDoc; -/// The OpenAPI document; endpoint annotations are attached to handlers in -/// later waves. +/// Registers the two authentication schemes referenced by the per-path +/// `security(...)` annotations. +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + let components = openapi.components.get_or_insert_with(Default::default); + components.add_security_scheme( + "bearer_auth", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .build(), + ), + ); + components.add_security_scheme( + "admin_token", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::with_description( + "x-admin-token", + "Admin API token created via /v1/admin/tokens", + ))), + ); + } +} + +/// The OpenAPI document. pub fn openapi_spec() -> utoipa::openapi::OpenApi { ApiDoc::openapi() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn checked_in_openapi_json_matches_generated_spec() { + // Given: the checked-in root spec and the freshly generated one. + let checked_in: serde_json::Value = + serde_json::from_str(include_str!("../../openapi.json")) + .expect("checked-in openapi.json must parse"); + let generated: serde_json::Value = + serde_json::from_str(&openapi_spec().to_json().expect("serialize generated spec")) + .expect("generated spec must parse"); + + // Then: they are equal regardless of key order. + assert_eq!(checked_in, generated); + } +} diff --git a/core/src/billing/mod.rs b/core/src/billing/mod.rs index 06dee87..226bbe6 100644 --- a/core/src/billing/mod.rs +++ b/core/src/billing/mod.rs @@ -6,4 +6,5 @@ pub mod plans; pub mod provider; pub mod resolve; pub mod rules; +pub mod status_handler; pub mod usage; diff --git a/core/src/billing/status_handler.rs b/core/src/billing/status_handler.rs new file mode 100644 index 0000000..c98d04e --- /dev/null +++ b/core/src/billing/status_handler.rs @@ -0,0 +1,49 @@ +//! Billing status endpoint: effective limits plus metered usage. + +use axum::Json; +use axum::Router; +use axum::extract::State; +use axum::routing::get; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::auth::extractor::AccountContext; +use crate::billing::usage::used_bytes; +use crate::error::AppError; +use crate::state::AppState; + +/// Storage usage and effective limits of the authenticated owner. +#[derive(Debug, Serialize, ToSchema)] +pub struct BillingStatusResponse { + pub used_bytes: i64, + pub storage_quota_bytes: i64, + pub max_file_bytes: i64, + pub rate_limit_rpm: u32, +} + +/// Builds the authenticated billing status router. +pub fn billing_status_router() -> Router { + Router::new().route("/v1/billing/status", get(get_billing_status)) +} + +#[utoipa::path( + get, + path = "/v1/billing/status", + tag = "billing", + security(("bearer_auth" = [])), + responses( + (status = 200, description = "Usage and effective limits for the authenticated owner", body = BillingStatusResponse), + ) +)] +async fn get_billing_status( + ctx: AccountContext, + State(state): State, +) -> Result, AppError> { + let used_bytes = used_bytes(&state.pool, &ctx.owner).await?; + Ok(Json(BillingStatusResponse { + used_bytes, + storage_quota_bytes: ctx.limits.storage_quota_bytes, + max_file_bytes: ctx.limits.max_file_bytes, + rate_limit_rpm: ctx.limits.rate_limit_rpm, + })) +} diff --git a/core/src/config.rs b/core/src/config.rs index 66eaa70..aa60059 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -62,6 +62,9 @@ pub struct Config { pub plan_premium_rate_limit_rpm: u32, pub presigned_get_ttl_secs: u64, pub public_base_url: String, + /// Raw admin token seeded into `admin_tokens` (name `bootstrap`) on + /// startup when no active token with that hash exists yet. + pub admin_bootstrap_token: Option, } impl Default for Config { @@ -88,6 +91,7 @@ impl Default for Config { plan_premium_rate_limit_rpm: 300, presigned_get_ttl_secs: 900, public_base_url: "http://localhost:3000".into(), + admin_bootstrap_token: None, } } } @@ -171,6 +175,10 @@ impl Config { "presigned_get_ttl_secs", )?, public_base_url: override_str(d.public_base_url, "BOOSKIFF_PUBLIC_BASE_URL"), + admin_bootstrap_token: override_opt_str( + d.admin_bootstrap_token, + "BOOSKIFF_ADMIN_BOOTSTRAP_TOKEN", + ), }) } } diff --git a/core/src/drive/files.rs b/core/src/drive/files.rs index 571ec53..10e16d8 100644 --- a/core/src/drive/files.rs +++ b/core/src/drive/files.rs @@ -128,7 +128,7 @@ pub fn files_router(config: &Config) -> Router { ) } -#[utoipa::path(post, path = "/v1/files", tag = "files", responses((status = 201, body = FileResponse)))] +#[utoipa::path(post, path = "/v1/files", tag = "files", security(("bearer_auth" = [])), responses((status = 201, body = FileResponse)))] async fn upload_file( ctx: AccountContext, State(state): State, @@ -213,7 +213,7 @@ async fn upload_file( response.map(|file| (StatusCode::CREATED, Json(file))) } -#[utoipa::path(get, path = "/v1/files", tag = "files", responses((status = 200, body = FileListResponse)))] +#[utoipa::path(get, path = "/v1/files", tag = "files", security(("bearer_auth" = [])), responses((status = 200, body = FileListResponse)))] async fn list_files( ctx: AccountContext, State(state): State, @@ -250,7 +250,7 @@ async fn list_files( Ok(Json(FileListResponse { items })) } -#[utoipa::path(get, path = "/v1/files/{id}", tag = "files", params(("id" = Uuid, Path)), responses((status = 200, body = FileResponse), (status = 404)))] +#[utoipa::path(get, path = "/v1/files/{id}", tag = "files", security(("bearer_auth" = [])), params(("id" = Uuid, Path)), responses((status = 200, body = FileResponse), (status = 404)))] async fn get_file( ctx: AccountContext, State(state): State, @@ -262,7 +262,7 @@ async fn get_file( .map(Json) } -#[utoipa::path(delete, path = "/v1/files/{id}", tag = "files", params(("id" = Uuid, Path)), responses((status = 204), (status = 404)))] +#[utoipa::path(delete, path = "/v1/files/{id}", tag = "files", security(("bearer_auth" = [])), params(("id" = Uuid, Path)), responses((status = 204), (status = 404)))] async fn delete_file( ctx: AccountContext, State(state): State, @@ -293,7 +293,7 @@ async fn delete_file( Ok(StatusCode::NO_CONTENT) } -#[utoipa::path(get, path = "/v1/files/{id}/download-url", tag = "files", params(("id" = Uuid, Path)), responses((status = 200, body = UrlResponse), (status = 404)))] +#[utoipa::path(get, path = "/v1/files/{id}/download-url", tag = "files", security(("bearer_auth" = [])), params(("id" = Uuid, Path)), responses((status = 200, body = UrlResponse), (status = 404)))] async fn download_url( ctx: AccountContext, State(state): State, @@ -313,7 +313,7 @@ async fn download_url( Ok(Json(UrlResponse { url })) } -#[utoipa::path(post, path = "/v1/files/{id}/publish", tag = "files", params(("id" = Uuid, Path)), responses((status = 200, body = UrlResponse), (status = 404)))] +#[utoipa::path(post, path = "/v1/files/{id}/publish", tag = "files", security(("bearer_auth" = [])), params(("id" = Uuid, Path)), responses((status = 200, body = UrlResponse), (status = 404)))] async fn publish_file( ctx: AccountContext, State(state): State, @@ -349,7 +349,7 @@ async fn publish_file( })) } -#[utoipa::path(delete, path = "/v1/files/{id}/publish", tag = "files", params(("id" = Uuid, Path)), responses((status = 204), (status = 404)))] +#[utoipa::path(delete, path = "/v1/files/{id}/publish", tag = "files", security(("bearer_auth" = [])), params(("id" = Uuid, Path)), responses((status = 204), (status = 404)))] async fn unpublish_file( ctx: AccountContext, State(state): State, diff --git a/core/src/drive/folders.rs b/core/src/drive/folders.rs index fa0731e..cfce009 100644 --- a/core/src/drive/folders.rs +++ b/core/src/drive/folders.rs @@ -53,7 +53,7 @@ pub fn folders_router() -> Router { ) } -#[utoipa::path(post, path = "/v1/folders", tag = "folders")] +#[utoipa::path(post, path = "/v1/folders", tag = "folders", security(("bearer_auth" = [])))] async fn create_folder( context: AccountContext, State(state): State, @@ -73,7 +73,7 @@ async fn create_folder( Ok((StatusCode::CREATED, Json(folder_response(row)?))) } -#[utoipa::path(get, path = "/v1/folders", tag = "folders")] +#[utoipa::path(get, path = "/v1/folders", tag = "folders", security(("bearer_auth" = [])))] async fn list_folders( context: AccountContext, State(state): State, @@ -94,7 +94,7 @@ async fn list_folders( Ok(Json(FolderListResponse { items })) } -#[utoipa::path(get, path = "/v1/folders/{id}", tag = "folders")] +#[utoipa::path(get, path = "/v1/folders/{id}", tag = "folders", security(("bearer_auth" = [])))] async fn get_folder( context: AccountContext, State(state): State, @@ -104,7 +104,7 @@ async fn get_folder( Ok(Json(folder_response(row)?)) } -#[utoipa::path(patch, path = "/v1/folders/{id}", tag = "folders")] +#[utoipa::path(patch, path = "/v1/folders/{id}", tag = "folders", security(("bearer_auth" = [])))] async fn rename_folder( context: AccountContext, State(state): State, @@ -127,7 +127,7 @@ async fn rename_folder( Ok(Json(folder_response(row)?)) } -#[utoipa::path(delete, path = "/v1/folders/{id}", tag = "folders")] +#[utoipa::path(delete, path = "/v1/folders/{id}", tag = "folders", security(("bearer_auth" = [])))] async fn delete_folder( context: AccountContext, State(state): State, diff --git a/core/src/error.rs b/core/src/error.rs index f5b5701..d35a85d 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -31,12 +31,12 @@ pub enum AppError { } /// JSON body shape: `{"error":{"code":...,"message":...}}`. -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ErrorBody { pub error: ErrorDetail, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, utoipa::ToSchema)] pub struct ErrorDetail { pub code: &'static str, pub message: String, diff --git a/core/src/health.rs b/core/src/health.rs index fc24a93..ba6fa44 100644 --- a/core/src/health.rs +++ b/core/src/health.rs @@ -10,10 +10,16 @@ use crate::state::AppState; /// `/healthz` (liveness) and `/readyz` (dependency readiness). pub fn health_router() -> Router { Router::new() - .route("/healthz", get(|| async { (StatusCode::OK, "ok") })) + .route("/healthz", get(healthz)) .route("/readyz", get(ready)) } +#[utoipa::path(get, path = "/healthz", tag = "health", responses((status = 200)))] +async fn healthz() -> (StatusCode, &'static str) { + (StatusCode::OK, "ok") +} + +#[utoipa::path(get, path = "/readyz", tag = "health", responses((status = 200), (status = 503)))] async fn ready(State(state): State) -> (StatusCode, &'static str) { let database_ready = sqlx::query("SELECT 1").execute(&state.pool).await.is_ok(); let storage_ready = state.s3.check_ready().await.is_ok(); diff --git a/core/src/main.rs b/core/src/main.rs index b21a2c6..96a976d 100644 --- a/core/src/main.rs +++ b/core/src/main.rs @@ -1,11 +1,17 @@ use axum::Json; +use axum::response::Redirect; use axum::routing::get; use core::api_doc::openapi_spec; +use core::auth::admin_auth::{find_active_by_hash, hash_admin_token}; use core::auth::jwks::JwksCache; use core::config::Config; +use core::error::AppError; use core::state::{AppState, RateLimiters}; -use core::{health, storage::Storage}; +use core::{admin, billing, drive, health, public, storage::Storage}; use std::sync::Arc; +use std::time::Duration; +use tower_http::timeout::TimeoutLayer; +use tower_http::trace::TraceLayer; fn main() { // The process-wide aws-lc-rs provider must be installed before any @@ -15,8 +21,8 @@ fn main() { .ok(); if std::env::args().nth(1).as_deref() == Some("openapi") { - match serde_json::to_string_pretty(&openapi_spec()) { - Ok(spec) => println!("{spec}"), + match openapi_spec().to_json() { + Ok(spec) => print!("{spec}"), Err(err) => { eprintln!("failed to serialize openapi spec: {err}"); std::process::exit(1); @@ -67,6 +73,12 @@ async fn run() { eprintln!("failed to run database migrations: {err}"); std::process::exit(1); } + if let Some(token) = &config.admin_bootstrap_token + && let Err(err) = seed_bootstrap_admin_token(&pool, token).await + { + eprintln!("failed to seed bootstrap admin token: {err}"); + std::process::exit(1); + } let storage = match Storage::build(&config).await { Ok(storage) => storage, @@ -81,6 +93,7 @@ async fn run() { } let jwks_cache = JwksCache::new(config.jwt_trusted_issuers.clone()); + let files_routes = drive::files::files_router(&config); let state = AppState { pool, s3: storage, @@ -92,12 +105,23 @@ async fn run() { // Stateful routers merge first; state is baked in once at the end // (axum 0.8 lacks a Router -> Router<()> late conversion). let app = axum::Router::::new() + .merge(billing::status_handler::billing_status_router()) + .merge(files_routes) + .nest("/v1/folders", drive::folders::folders_router()) + .nest("/v1/admin", admin::admin_router()) + .merge(public::public_router()) .merge(health::health_router()) .route("/openapi.json", get(|| async { Json(openapi_spec()) })) + .route("/docs", get(docs_redirect)) .merge( utoipa_swagger_ui::SwaggerUi::new("/swagger-ui") .url("/api-docs/openapi.json", openapi_spec()), ) + .layer(TraceLayer::new_for_http()) + .layer(TimeoutLayer::with_status_code( + axum::http::StatusCode::REQUEST_TIMEOUT, + Duration::from_secs(120), + )) .with_state(state); let listener = tokio::net::TcpListener::bind(&listen_addr) @@ -112,3 +136,25 @@ async fn run() { std::process::exit(1); } } + +/// Seed `config.admin_bootstrap_token` as an active `admin_tokens` row +/// (name `bootstrap`) unless an active token with that hash already +/// exists, making restarts with the same environment idempotent. +async fn seed_bootstrap_admin_token(pool: &sqlx::PgPool, token: &str) -> Result<(), AppError> { + let token_hash = hash_admin_token(token); + if find_active_by_hash(pool, &token_hash).await?.is_some() { + tracing::info!("bootstrap admin token already seeded"); + return Ok(()); + } + sqlx::query("INSERT INTO admin_tokens (name, token_hash) VALUES ('bootstrap', $1) ON CONFLICT (name) DO UPDATE SET token_hash = EXCLUDED.token_hash, revoked_at = NULL") + .bind(&token_hash) + .execute(pool) + .await + .map_err(|err| AppError::Internal(format!("seed bootstrap admin token: {err}")))?; + tracing::info!("seeded bootstrap admin token"); + Ok(()) +} + +async fn docs_redirect() -> Redirect { + Redirect::to("/swagger-ui/") +} diff --git a/core/src/model.rs b/core/src/model.rs index 41320a6..9457454 100644 --- a/core/src/model.rs +++ b/core/src/model.rs @@ -1,7 +1,7 @@ //! Core domain models shared across modules. /// Polymorphic owner reference (`owner_type:owner_id`). -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, utoipa::ToSchema)] pub struct Owner { pub owner_type: String, pub owner_id: String, @@ -25,7 +25,7 @@ impl Owner { pub const OBJECT_KIND_ORIGINAL: &str = "original"; /// Billing plan tiers. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, utoipa::ToSchema)] pub enum Plan { Default, Premium, @@ -57,7 +57,7 @@ impl std::str::FromStr for Plan { } /// Effective limits for an owner after plan/billing resolution. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, utoipa::ToSchema)] pub struct Limits { pub storage_quota_bytes: i64, pub max_file_bytes: i64, diff --git a/core/src/public.rs b/core/src/public.rs index bed158c..925c4c1 100644 --- a/core/src/public.rs +++ b/core/src/public.rs @@ -29,6 +29,16 @@ pub fn public_router() -> Router { Router::new().route("/public/{key}", get(get_public_file)) } +#[utoipa::path( + get, + path = "/public/{key}", + tag = "public", + params(("key" = String, Path, description = "Public key of a published file")), + responses( + (status = 200, description = "The file content, served with immutable caching"), + (status = 404, description = "Unknown or unpublished key"), + ) +)] async fn get_public_file( State(state): State, Path(key): Path, diff --git a/openapi.json b/openapi.json index 255d4bc..118a56a 100644 --- a/openapi.json +++ b/openapi.json @@ -1,14 +1 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "Booskiff", - "description": "", - "license": { - "name": "MIT", - "identifier": "MIT" - }, - "version": "0.1.0" - }, - "paths": {}, - "components": {} -} +{"openapi":"3.1.0","info":{"title":"Booskiff","description":"Booskiff drive API: per-owner file and folder storage with streaming uploads, publishing, and download URLs, built on the billing policy foundation (plans, rules, quotas) that governs storage limits.","license":{"name":"MIT","identifier":"MIT"},"version":"0.1.0"},"paths":{"/healthz":{"get":{"tags":["health"],"operationId":"healthz","responses":{"200":{"description":""}}}},"/public/{key}":{"get":{"tags":["public"],"operationId":"get_public_file","parameters":[{"name":"key","in":"path","description":"Public key of a published file","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The file content, served with immutable caching"},"404":{"description":"Unknown or unpublished key"}}}},"/readyz":{"get":{"tags":["health"],"operationId":"ready","responses":{"200":{"description":""},"503":{"description":""}}}},"/v1/admin/billing/rules":{"get":{"tags":["admin"],"operationId":"list_billing_rules","responses":{"200":{"description":"All billing rules, global layer first","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingRuleList"}}}},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]},"post":{"tags":["admin"],"operationId":"create_billing_rule","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRuleRequest"}}},"required":true},"responses":{"200":{"description":"Rule inserted or updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingRuleItem"}}}},"400":{"description":"Partial owner scope, unknown key, or invalid value"},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]}},"/v1/admin/billing/rules/{id}":{"delete":{"tags":["admin"],"operationId":"delete_billing_rule","parameters":[{"name":"id","in":"path","description":"Billing rule id","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Rule deleted"},"401":{"description":"Missing, unknown, or revoked admin token"},"404":{"description":"Unknown rule id"}},"security":[{"admin_token":[]}]}},"/v1/admin/owners/{owner_type}/{owner_id}/plan":{"get":{"tags":["admin"],"operationId":"get_plan","parameters":[{"name":"owner_type","in":"path","description":"Owner type, e.g. account","required":true,"schema":{"type":"string"}},{"name":"owner_id","in":"path","description":"Owner id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Assigned plan; default when unassigned","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlanResponse"}}}},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]},"put":{"tags":["admin"],"operationId":"set_plan","parameters":[{"name":"owner_type","in":"path","description":"Owner type, e.g. account","required":true,"schema":{"type":"string"}},{"name":"owner_id","in":"path","description":"Owner id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetPlanRequest"}}},"required":true},"responses":{"204":{"description":"Plan assigned, replacing any previous assignment"},"400":{"description":"Unknown plan name"},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]},"delete":{"tags":["admin"],"operationId":"delete_plan","parameters":[{"name":"owner_type","in":"path","description":"Owner type, e.g. account","required":true,"schema":{"type":"string"}},{"name":"owner_id","in":"path","description":"Owner id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Assignment removed"},"401":{"description":"Missing, unknown, or revoked admin token"},"404":{"description":"No assignment for this owner"}},"security":[{"admin_token":[]}]}},"/v1/admin/owners/{owner_type}/{owner_id}/usage":{"get":{"tags":["admin"],"operationId":"get_usage","parameters":[{"name":"owner_type","in":"path","description":"Owner type, e.g. account","required":true,"schema":{"type":"string"}},{"name":"owner_id","in":"path","description":"Owner id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Metered received bytes; 0 when never uploaded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsageResponse"}}}},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]}},"/v1/admin/tokens":{"get":{"tags":["admin"],"operationId":"list_tokens","responses":{"200":{"description":"All admin tokens without secret material","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminTokenList"}}}},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]},"post":{"tags":["admin"],"operationId":"create_token","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTokenRequest"}}},"required":true},"responses":{"201":{"description":"Token created; the raw token is returned exactly once","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatedAdminToken"}}}},"401":{"description":"Missing, unknown, or revoked admin token"}},"security":[{"admin_token":[]}]}},"/v1/admin/tokens/{id}":{"delete":{"tags":["admin"],"operationId":"revoke_token","parameters":[{"name":"id","in":"path","description":"Admin token id","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Revoked; the token authenticates no longer"},"401":{"description":"Missing, unknown, or revoked admin token"},"404":{"description":"Unknown token id"}},"security":[{"admin_token":[]}]}},"/v1/billing/status":{"get":{"tags":["billing"],"operationId":"get_billing_status","responses":{"200":{"description":"Usage and effective limits for the authenticated owner","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingStatusResponse"}}}}},"security":[{"bearer_auth":[]}]}},"/v1/files":{"get":{"tags":["files"],"operationId":"list_files","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FileListResponse"}}}}},"security":[{"bearer_auth":[]}]},"post":{"tags":["files"],"operationId":"upload_file","responses":{"201":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FileResponse"}}}}},"security":[{"bearer_auth":[]}]}},"/v1/files/{id}":{"get":{"tags":["files"],"operationId":"get_file","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FileResponse"}}}},"404":{"description":""}},"security":[{"bearer_auth":[]}]},"delete":{"tags":["files"],"operationId":"delete_file","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""},"404":{"description":""}},"security":[{"bearer_auth":[]}]}},"/v1/files/{id}/download-url":{"get":{"tags":["files"],"operationId":"download_url","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UrlResponse"}}}},"404":{"description":""}},"security":[{"bearer_auth":[]}]}},"/v1/files/{id}/publish":{"post":{"tags":["files"],"operationId":"publish_file","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UrlResponse"}}}},"404":{"description":""}},"security":[{"bearer_auth":[]}]},"delete":{"tags":["files"],"operationId":"unpublish_file","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""},"404":{"description":""}},"security":[{"bearer_auth":[]}]}},"/v1/folders":{"get":{"tags":["folders"],"operationId":"list_folders","responses":{},"security":[{"bearer_auth":[]}]},"post":{"tags":["folders"],"operationId":"create_folder","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateFolderRequest"}}},"required":true},"responses":{},"security":[{"bearer_auth":[]}]}},"/v1/folders/{id}":{"get":{"tags":["folders"],"operationId":"get_folder","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{},"security":[{"bearer_auth":[]}]},"delete":{"tags":["folders"],"operationId":"delete_folder","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{},"security":[{"bearer_auth":[]}]},"patch":{"tags":["folders"],"operationId":"rename_folder","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenameFolderRequest"}}},"required":true},"responses":{},"security":[{"bearer_auth":[]}]}}},"components":{"schemas":{"AdminTokenItem":{"type":"object","description":"One row of `GET /v1/admin/tokens`; carries no secret material.","required":["id","name","created_at"],"properties":{"created_at":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"revoked_at":{"type":["string","null"]}}},"AdminTokenList":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/AdminTokenItem"}}}},"BillingRuleItem":{"type":"object","description":"One billing rule as served by the admin API.","required":["id","key","value","enabled","created_at"],"properties":{"created_at":{"type":"string"},"enabled":{"type":"boolean"},"id":{"type":"string","format":"uuid"},"key":{"type":"string"},"owner_id":{"type":["string","null"]},"owner_type":{"type":["string","null"]},"value":{}}},"BillingRuleList":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/BillingRuleItem"}}}},"BillingStatusResponse":{"type":"object","description":"Storage usage and effective limits of the authenticated owner.","required":["used_bytes","storage_quota_bytes","max_file_bytes","rate_limit_rpm"],"properties":{"max_file_bytes":{"type":"integer","format":"int64"},"rate_limit_rpm":{"type":"integer","format":"int32","minimum":0},"storage_quota_bytes":{"type":"integer","format":"int64"},"used_bytes":{"type":"integer","format":"int64"}}},"CreateFolderRequest":{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}},"CreateRuleRequest":{"type":"object","required":["key","value","enabled"],"properties":{"enabled":{"type":"boolean"},"key":{"type":"string"},"owner_id":{"type":["string","null"]},"owner_type":{"type":["string","null"]},"value":{}}},"CreateTokenRequest":{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}},"CreatedAdminToken":{"type":"object","description":"Response of `POST /v1/admin/tokens`. `token` is the raw secret, returned\nexactly once; only its SHA-256 digest is ever persisted.","required":["id","name","token","created_at"],"properties":{"created_at":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"token":{"type":"string"}}},"ErrorBody":{"type":"object","description":"JSON body shape: `{\"error\":{\"code\":...,\"message\":...}}`.","required":["error"],"properties":{"error":{"$ref":"#/components/schemas/ErrorDetail"}}},"ErrorDetail":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string"},"message":{"type":"string"}}},"FileListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/FileResponse"}}}},"FileResponse":{"type":"object","required":["id","name","mime_type","size_bytes","is_public","created_at"],"properties":{"created_at":{"type":"string"},"folder_id":{"type":["string","null"],"format":"uuid"},"id":{"type":"string","format":"uuid"},"is_public":{"type":"boolean"},"mime_type":{"type":"string"},"name":{"type":"string"},"size_bytes":{"type":"integer","format":"int64"}}},"FolderListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/FolderResponse"}}}},"FolderResponse":{"type":"object","required":["id","name","created_at"],"properties":{"created_at":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"Limits":{"type":"object","description":"Effective limits for an owner after plan/billing resolution.","required":["storage_quota_bytes","max_file_bytes","rate_limit_rpm"],"properties":{"max_file_bytes":{"type":"integer","format":"int64"},"rate_limit_rpm":{"type":"integer","format":"int32","minimum":0},"storage_quota_bytes":{"type":"integer","format":"int64"}}},"Owner":{"type":"object","description":"Polymorphic owner reference (`owner_type:owner_id`).","required":["owner_type","owner_id"],"properties":{"owner_id":{"type":"string"},"owner_type":{"type":"string"}}},"Plan":{"type":"string","description":"Billing plan tiers.","enum":["Default","Premium"]},"PlanResponse":{"type":"object","required":["plan"],"properties":{"plan":{"type":"string"}}},"RenameFolderRequest":{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}},"SetPlanRequest":{"type":"object","required":["plan"],"properties":{"plan":{"type":"string"}}},"UrlResponse":{"type":"object","required":["url"],"properties":{"url":{"type":"string"}}},"UsageResponse":{"type":"object","required":["used_bytes"],"properties":{"used_bytes":{"type":"integer","format":"int64"}}}},"securitySchemes":{"admin_token":{"type":"apiKey","in":"header","name":"x-admin-token","description":"Admin API token created via /v1/admin/tokens"},"bearer_auth":{"type":"http","scheme":"bearer","bearerFormat":"JWT"}}}} \ No newline at end of file From fe24035ac567aa876a07d78b391eac1295723a9c Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 30 Aug 2026 15:27:16 +0900 Subject: [PATCH 10/15] test(e2e): compose harness, JWKS test issuer, lifecycle scenarios --- core/tests/e2e.rs | 675 ++++++++++++++++++++++++++++++++++++++++++++++ e2e/run-e2e.sh | 51 +++- 2 files changed, 725 insertions(+), 1 deletion(-) create mode 100644 core/tests/e2e.rs diff --git a/core/tests/e2e.rs b/core/tests/e2e.rs new file mode 100644 index 0000000..fe67620 --- /dev/null +++ b/core/tests/e2e.rs @@ -0,0 +1,675 @@ +// allow: SIZE_OK — the requested single-server S1-S4 executable specification is intentionally +// kept in one integration-test target so its sequential state and lifecycle remain explicit. + +use std::fs::OpenOptions; +use std::net::TcpListener as StdTcpListener; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use axum::Router; +use axum::routing::get; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; +use reqwest::{Client, Response, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio::time::{Instant, sleep}; +use uuid::Uuid; + +const DATABASE_URL: &str = "postgres://booskiff:booskiff@127.0.0.1:5432/booskiff"; +const JWKS: &str = include_str!("fixtures/test_only_jwks.json"); +const PRIVATE_KEY: &[u8] = include_bytes!("fixtures/test_only_rsa_private.pem"); +const SERVER_LOG: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../e2e/core-server.log"); + +#[derive(Serialize)] +struct Claims<'a> { + iss: &'a str, + sub: &'a str, + exp: u64, + owner_type: &'a str, +} + +#[derive(Deserialize)] +struct FileResponse { + id: Uuid, +} + +#[derive(Deserialize)] +struct FileListResponse { + items: Vec, +} + +#[derive(Deserialize)] +struct UrlResponse { + url: String, +} + +#[derive(Deserialize)] +struct BillingStatus { + used_bytes: i64, + storage_quota_bytes: i64, +} + +#[derive(Deserialize)] +struct ErrorBody { + error: ErrorDetail, +} + +#[derive(Deserialize)] +struct ErrorDetail { + code: String, +} + +#[derive(Deserialize)] +struct CreatedAdminToken { + id: Uuid, + token: String, +} + +struct JwksServer { + issuer: String, + task: JoinHandle<()>, +} + +impl Drop for JwksServer { + fn drop(&mut self) { + self.task.abort(); + } +} + +struct CoreServer { + base_url: String, + issuer: String, + bootstrap_token: String, + child: Child, + _jwks: JwksServer, +} + +impl Drop for CoreServer { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl CoreServer { + async fn start(client: &Client) -> Self { + let jwks = start_jwks_server().await; + let port = reserve_port(); + let base_url = format!("http://127.0.0.1:{port}"); + let bootstrap_token = format!("e2e-bootstrap-{}", Uuid::now_v7()); + let log = OpenOptions::new() + .create(true) + .append(true) + .open(SERVER_LOG) + .expect("open E2E server log"); + let stderr = log.try_clone().expect("clone E2E server log"); + let child = Command::new(env!("CARGO_BIN_EXE_core")) + .env("DATABASE_URL", DATABASE_URL) + .env("BOOSKIFF_S3_ENDPOINT", "http://127.0.0.1:9000") + .env("BOOSKIFF_S3_REGION", "us-east-1") + .env("BOOSKIFF_S3_BUCKET", "booskiff-default") + .env("BOOSKIFF_S3_ACCESS_KEY", "booskiff") + .env("BOOSKIFF_S3_SECRET_KEY", "booskiff-secret") + .env("BOOSKIFF_JWT_TRUSTED_ISSUERS", &jwks.issuer) + .env("BOOSKIFF_ADMIN_BOOTSTRAP_TOKEN", &bootstrap_token) + .env("BOOSKIFF_LISTEN_ADDR", format!("127.0.0.1:{port}")) + .env("BOOSKIFF_PUBLIC_BASE_URL", &base_url) + .env("BOOSKIFF_PREMIUM_MODE", "mirror") + .stdout(Stdio::from(log)) + .stderr(Stdio::from(stderr)) + .spawn() + .expect("spawn core binary"); + + let server = Self { + base_url, + issuer: jwks.issuer.clone(), + bootstrap_token, + child, + _jwks: jwks, + }; + server.wait_ready(client).await; + server + } + + async fn wait_ready(&self, client: &Client) { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + if let Ok(response) = client.get(format!("{}/readyz", self.base_url)).send().await + && response.status() == StatusCode::OK + { + return; + } + assert!( + Instant::now() < deadline, + "core did not become ready within 30s" + ); + sleep(Duration::from_millis(100)).await; + } + } + + fn jwt(&self, owner_type: &str, owner_id: &str) -> String { + let exp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after epoch") + .as_secs() + + 300; + let header = Header { + alg: Algorithm::RS256, + kid: Some("test-only-key-1".to_owned()), + ..Header::default() + }; + jsonwebtoken::encode( + &header, + &Claims { + iss: &self.issuer, + sub: owner_id, + exp, + owner_type, + }, + &EncodingKey::from_rsa_pem(PRIVATE_KEY).expect("parse test RSA key"), + ) + .expect("sign test JWT") + } +} + +async fn start_jwks_server() -> JwksServer { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind JWKS listener"); + let addr = listener.local_addr().expect("JWKS listener address"); + let app = Router::new().route( + "/.well-known/jwks.json", + get(|| async { ([("content-type", "application/json")], JWKS) }), + ); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve JWKS"); + }); + JwksServer { + issuer: format!("http://{addr}"), + task, + } +} + +fn reserve_port() -> u16 { + StdTcpListener::bind("127.0.0.1:0") + .expect("reserve core port") + .local_addr() + .expect("reserved core address") + .port() +} + +async fn expect_status(response: Response, expected: StatusCode, scenario: &str) -> Response { + let actual = response.status(); + assert_eq!(actual, expected, "{scenario}: unexpected HTTP status"); + response +} + +async fn expect_error_code(response: Response, expected: StatusCode, code: &str, scenario: &str) { + let response = expect_status(response, expected, scenario).await; + let body = response + .json::() + .await + .expect("parse error body"); + assert_eq!(body.error.code, code, "{scenario}: unexpected error code"); +} + +async fn billing_status(client: &Client, server: &CoreServer, jwt: &str) -> BillingStatus { + expect_status( + client + .get(format!("{}/v1/billing/status", server.base_url)) + .bearer_auth(jwt) + .send() + .await + .expect("request billing status"), + StatusCode::OK, + "billing status", + ) + .await + .json() + .await + .expect("parse billing status") +} + +async fn create_owner_rule( + client: &Client, + server: &CoreServer, + owner_type: &str, + owner_id: &str, + key: &str, + value: i64, +) { + expect_status( + client + .post(format!("{}/v1/admin/billing/rules", server.base_url)) + .header("X-Admin-Token", &server.bootstrap_token) + .json(&json!({ + "owner_type": owner_type, + "owner_id": owner_id, + "key": key, + "value": value, + "enabled": true + })) + .send() + .await + .expect("create owner billing rule"), + StatusCode::OK, + "admin create owner rule", + ) + .await; +} + +async fn upload( + client: &Client, + server: &CoreServer, + jwt: &str, + name: &str, + bytes: &[u8], +) -> Response { + client + .post(format!( + "{}/v1/files?name={name}&mime=application/octet-stream", + server.base_url + )) + .bearer_auth(jwt) + .header(reqwest::header::CONTENT_LENGTH, bytes.len()) + .body(bytes.to_vec()) + .send() + .await + .expect("upload file") +} + +async fn scenario_s1(client: &Client, server: &CoreServer) { + let owner_id = Uuid::now_v7().to_string(); + let jwt = server.jwt("e2e-s1", &owner_id); + let mut uploaded = vec![0_u8; 1024 * 1024]; + rand::fill(uploaded.as_mut_slice()); + + let file = expect_status( + upload(client, server, &jwt, "s1.bin", &uploaded).await, + StatusCode::CREATED, + "S1 upload", + ) + .await + .json::() + .await + .expect("S1 parse upload response"); + let status = billing_status(client, server, &jwt).await; + assert_eq!(status.used_bytes, 1_048_576, "S1 metered bytes"); + + let download = expect_status( + client + .get(format!( + "{}/v1/files/{}/download-url", + server.base_url, file.id + )) + .bearer_auth(&jwt) + .send() + .await + .expect("S1 request download URL"), + StatusCode::OK, + "S1 download URL", + ) + .await + .json::() + .await + .expect("S1 parse download URL"); + let downloaded = expect_status( + client + .get(download.url) + .send() + .await + .expect("S1 download object"), + StatusCode::OK, + "S1 presigned download", + ) + .await + .bytes() + .await + .expect("S1 read downloaded bytes"); + assert_eq!( + Sha256::digest(&downloaded), + Sha256::digest(&uploaded), + "S1 SHA-256" + ); +} + +async fn scenario_s2(client: &Client, server: &CoreServer) { + let owner_type = "e2e-s2"; + let owner_id = Uuid::now_v7().to_string(); + let jwt = server.jwt(owner_type, &owner_id); + create_owner_rule( + client, + server, + owner_type, + &owner_id, + "storage_quota_bytes", + 2_048, + ) + .await; + create_owner_rule( + client, + server, + owner_type, + &owner_id, + "max_file_bytes", + 4_096, + ) + .await; + let initial = billing_status(client, server, &jwt).await.used_bytes; + + let oversized = vec![1_u8; 4_097]; + expect_error_code( + upload(client, server, &jwt, "oversized.bin", &oversized).await, + StatusCode::PAYLOAD_TOO_LARGE, + "payload_too_large", + "S2 maximum file size", + ) + .await; + assert_eq!( + billing_status(client, server, &jwt).await.used_bytes, + initial, + "S2 oversize usage unchanged" + ); + + let over_quota = vec![2_u8; 3_072]; + expect_error_code( + upload(client, server, &jwt, "over-quota.bin", &over_quota).await, + StatusCode::INSUFFICIENT_STORAGE, + "insufficient_storage", + "S2 storage quota", + ) + .await; + assert_eq!( + billing_status(client, server, &jwt).await.used_bytes, + initial, + "S2 quota usage unchanged" + ); + let files = expect_status( + client + .get(format!("{}/v1/files", server.base_url)) + .bearer_auth(&jwt) + .send() + .await + .expect("S2 list files"), + StatusCode::OK, + "S2 list after rejected uploads", + ) + .await + .json::() + .await + .expect("S2 parse file list"); + assert!( + files.items.is_empty(), + "S2 rejected uploads must not create file rows" + ); + + create_owner_rule(client, server, owner_type, &owner_id, "rate_limit_rpm", 3).await; + let mut rate_limited = false; + for _ in 0..4 { + let response = client + .get(format!("{}/v1/files", server.base_url)) + .bearer_auth(&jwt) + .send() + .await + .expect("S2 rate-limit request"); + if response.status() == StatusCode::TOO_MANY_REQUESTS { + let body = response + .json::() + .await + .expect("S2 parse rate-limit body"); + assert_eq!(body.error.code, "rate_limited", "S2 rate-limit code"); + rate_limited = true; + } + } + assert!(rate_limited, "S2 burst must produce at least one 429"); +} + +async fn scenario_s3(client: &Client, server: &CoreServer) { + let owner_id = Uuid::now_v7().to_string(); + let jwt = server.jwt("e2e-s3", &owner_id); + let bytes = b"S3 publish lifecycle bytes".to_vec(); + let before = billing_status(client, server, &jwt).await.used_bytes; + let file = expect_status( + upload(client, server, &jwt, "published.bin", &bytes).await, + StatusCode::CREATED, + "S3 upload", + ) + .await + .json::() + .await + .expect("S3 parse upload response"); + let published = expect_status( + client + .post(format!("{}/v1/files/{}/publish", server.base_url, file.id)) + .bearer_auth(&jwt) + .send() + .await + .expect("S3 publish"), + StatusCode::OK, + "S3 publish", + ) + .await + .json::() + .await + .expect("S3 parse public URL"); + let public = expect_status( + client + .get(&published.url) + .send() + .await + .expect("S3 public GET"), + StatusCode::OK, + "S3 public GET", + ) + .await; + assert_eq!( + public + .headers() + .get(reqwest::header::CACHE_CONTROL) + .expect("S3 cache header"), + "public, max-age=31536000, immutable", + "S3 immutable cache control" + ); + assert_eq!( + public.bytes().await.expect("S3 public bytes").as_ref(), + bytes, + "S3 public content" + ); + + expect_status( + client + .delete(format!("{}/v1/files/{}/publish", server.base_url, file.id)) + .bearer_auth(&jwt) + .send() + .await + .expect("S3 unpublish"), + StatusCode::NO_CONTENT, + "S3 unpublish", + ) + .await; + expect_error_code( + client + .get(&published.url) + .send() + .await + .expect("S3 unpublished GET"), + StatusCode::NOT_FOUND, + "not_found", + "S3 unpublished public URL", + ) + .await; + expect_status( + client + .delete(format!("{}/v1/files/{}", server.base_url, file.id)) + .bearer_auth(&jwt) + .send() + .await + .expect("S3 delete file"), + StatusCode::NO_CONTENT, + "S3 delete file", + ) + .await; + let after = billing_status(client, server, &jwt).await.used_bytes; + assert_eq!( + after, before, + "S3 deletion must subtract exactly the file size" + ); +} + +fn contains_matching_secret_field(value: &Value, raw_token: &str) -> bool { + match value { + Value::Object(fields) => fields.iter().any(|(name, value)| { + ((name == "token" || name == "token_hash") && value.as_str() == Some(raw_token)) + || contains_matching_secret_field(value, raw_token) + }), + Value::Array(items) => items + .iter() + .any(|item| contains_matching_secret_field(item, raw_token)), + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => false, + } +} + +async fn scenario_s4(client: &Client, server: &CoreServer) { + let token_name = format!("e2e-{}", Uuid::now_v7()); + let created = expect_status( + client + .post(format!("{}/v1/admin/tokens", server.base_url)) + .header("X-Admin-Token", &server.bootstrap_token) + .json(&json!({"name": token_name})) + .send() + .await + .expect("S4 create admin token"), + StatusCode::CREATED, + "S4 create admin token", + ) + .await + .json::() + .await + .expect("S4 parse created admin token"); + let listed = expect_status( + client + .get(format!("{}/v1/admin/tokens", server.base_url)) + .header("X-Admin-Token", &server.bootstrap_token) + .send() + .await + .expect("S4 list admin tokens"), + StatusCode::OK, + "S4 list admin tokens", + ) + .await + .json::() + .await + .expect("S4 parse admin token list"); + assert!( + !contains_matching_secret_field(&listed, &created.token), + "S4 token list leaked raw secret material" + ); + + expect_status( + client + .get(format!("{}/v1/admin/tokens", server.base_url)) + .header("X-Admin-Token", &created.token) + .send() + .await + .expect("S4 use new admin token"), + StatusCode::OK, + "S4 new admin token auth", + ) + .await; + expect_status( + client + .delete(format!( + "{}/v1/admin/tokens/{}", + server.base_url, created.id + )) + .header("X-Admin-Token", &server.bootstrap_token) + .send() + .await + .expect("S4 revoke admin token"), + StatusCode::NO_CONTENT, + "S4 revoke admin token", + ) + .await; + expect_error_code( + client + .get(format!("{}/v1/admin/tokens", server.base_url)) + .header("X-Admin-Token", &created.token) + .send() + .await + .expect("S4 use revoked admin token"), + StatusCode::UNAUTHORIZED, + "unauthorized", + "S4 revoked admin token", + ) + .await; + + let owner_type = "e2e-s4"; + let owner_id = Uuid::now_v7().to_string(); + let jwt = server.jwt(owner_type, &owner_id); + expect_status( + client + .put(format!( + "{}/v1/admin/owners/{owner_type}/{owner_id}/plan", + server.base_url + )) + .header("X-Admin-Token", &server.bootstrap_token) + .json(&json!({"plan": "premium"})) + .send() + .await + .expect("S4 assign premium plan"), + StatusCode::NO_CONTENT, + "S4 assign premium plan", + ) + .await; + assert_eq!( + billing_status(client, server, &jwt) + .await + .storage_quota_bytes, + 10_737_418_240, + "S4 premium quota" + ); + + let override_quota = 12_345_678_i64; + create_owner_rule( + client, + server, + owner_type, + &owner_id, + "storage_quota_bytes", + override_quota, + ) + .await; + assert_eq!( + billing_status(client, server, &jwt) + .await + .storage_quota_bytes, + override_quota, + "S4 owner rule overrides plan" + ); +} + +#[ignore] +#[test] +fn e2e_suite() { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(4) + .enable_all() + .build() + .expect("build E2E Tokio runtime"); + runtime.block_on(async { + let client = Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(30)) + .build() + .expect("build E2E HTTP client"); + let server = CoreServer::start(&client).await; + + scenario_s1(&client, &server).await; + scenario_s2(&client, &server).await; + scenario_s3(&client, &server).await; + scenario_s4(&client, &server).await; + }); +} diff --git a/e2e/run-e2e.sh b/e2e/run-e2e.sh index c9eb528..7383922 100755 --- a/e2e/run-e2e.sh +++ b/e2e/run-e2e.sh @@ -1,3 +1,52 @@ #!/usr/bin/env bash set -euo pipefail -echo "e2e harness installed by Wave 5" + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SERVER_LOG="${ROOT_DIR}/e2e/core-server.log" +HEALTH_TIMEOUT_SECONDS=90 + +cleanup() { + local status=$? + if (( status != 0 )); then + echo "E2E failed (exit ${status}); core server log follows:" >&2 + if [[ -f "${SERVER_LOG}" ]]; then + tail -n 200 "${SERVER_LOG}" >&2 + else + echo "(core server log was not created)" >&2 + fi + fi + if [[ "${BOOSKIFF_E2E_TEARDOWN:-0}" == "1" ]]; then + docker compose -f "${ROOT_DIR}/compose.yml" down -v + fi + exit "${status}" +} +trap cleanup EXIT + +cd "${ROOT_DIR}" +: >"${SERVER_LOG}" + +docker compose up -d + +deadline=$((SECONDS + HEALTH_TIMEOUT_SECONDS)) +while true; do + postgres_id="$(docker compose ps -q postgres)" + minio_id="$(docker compose ps -q minio)" + mc_init_id="$(docker compose ps -aq mc-init)" + + postgres_health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${postgres_id}" 2>/dev/null || true)" + minio_health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${minio_id}" 2>/dev/null || true)" + mc_init_state="$(docker inspect --format '{{.State.Status}}:{{.State.ExitCode}}' "${mc_init_id}" 2>/dev/null || true)" + + if [[ "${postgres_health}" == "healthy" && "${minio_health}" == "healthy" && "${mc_init_state}" == "exited:0" ]]; then + break + fi + if (( SECONDS >= deadline )); then + echo "Timed out waiting for compose services: postgres=${postgres_health}, minio=${minio_health}, mc-init=${mc_init_state}" >&2 + docker compose logs --tail=200 >&2 + exit 1 + fi + sleep 1 +done + +cargo build -p core +cargo test -p core --test e2e -- --ignored --test-threads=1 From b7374c29c7ca30307c3d90edf3e769d37d813b4a Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 30 Aug 2026 15:27:16 +0900 Subject: [PATCH 11/15] feat(deploy): self-hosting compose with Caddy, Containerfile, env template --- deploy/self-hosting/.env.example | 90 ++++++++++++++ deploy/self-hosting/Caddyfile | 6 + deploy/self-hosting/Containerfile | 37 ++++++ .../self-hosting/Containerfile.dockerignore | 9 ++ deploy/self-hosting/compose.yml | 112 ++++++++++++++++++ 5 files changed, 254 insertions(+) create mode 100644 deploy/self-hosting/.env.example create mode 100644 deploy/self-hosting/Caddyfile create mode 100644 deploy/self-hosting/Containerfile create mode 100644 deploy/self-hosting/Containerfile.dockerignore create mode 100644 deploy/self-hosting/compose.yml diff --git a/deploy/self-hosting/.env.example b/deploy/self-hosting/.env.example new file mode 100644 index 0000000..960af1d --- /dev/null +++ b/deploy/self-hosting/.env.example @@ -0,0 +1,90 @@ +# Booskiff self-hosting stack configuration. +# Copy to `.env` next to compose.yml (`cp .env.example .env`) and edit. +# Every variable is optional: values left empty or unset fall back to the +# defaults shown, which produce a working internal-only stack. +# +# Variable names and semantics mirror core/src/config.rs. + +# ---------------------------------------------------------------- database -- +# Postgres credentials used by the postgres service. Keep them in sync with +# BOOSKIFF_DATABASE_URL below. +POSTGRES_USER=booskiff +POSTGRES_PASSWORD=changeme +POSTGRES_DB=booskiff + +# Connection string core uses. The host must be the compose service name +# (`postgres`), not localhost. `DATABASE_URL` is accepted as a fallback name. +BOOSKIFF_DATABASE_URL=postgres://booskiff:changeme@postgres:5432/booskiff + +# ------------------------------------------------------------------ server -- +# Address core binds inside its container. Keep port 8080: it matches EXPOSE +# in the Containerfile and the compose healthcheck target. +BOOSKIFF_LISTEN_ADDR=0.0.0.0:8080 + +# Base URL used when core builds externally reachable absolute links. +# 18080 is the host port Caddy publishes on 127.0.0.1. +BOOSKIFF_PUBLIC_BASE_URL=http://127.0.0.1:18080 + +# Optional tracing filter (RUST_LOG syntax), e.g. `info` or `debug`. +RUST_LOG=info + +# ------------------------------------------------------------- object store -- +# MinIO root credentials (minio service). Keep them in sync with +# BOOSKIFF_S3_ACCESS_KEY / BOOSKIFF_S3_SECRET_KEY below. +MINIO_ROOT_USER=booskiff +MINIO_ROOT_PASSWORD=booskiff-secret + +# S3 endpoint core talks to. Must be the compose service name (`minio`). +BOOSKIFF_S3_ENDPOINT=http://minio:9000 + +# Endpoint embedded into presigned URLs handed to clients. Leave empty to +# reuse BOOSKIFF_S3_ENDPOINT (correct while the stack stays internal-only; +# set e.g. http://127.0.0.1:18080/s3 only if you later expose MinIO publicly). +BOOSKIFF_S3_ENDPOINT_PUBLIC= + +BOOSKIFF_S3_REGION=us-east-1 +BOOSKIFF_S3_BUCKET=booskiff-default +BOOSKIFF_S3_ACCESS_KEY=booskiff +BOOSKIFF_S3_SECRET_KEY=booskiff-secret + +# --------------------------------------------------------------------- JWT -- +# Trusted JWT issuers, comma-separated. Each entry is `issuer` or +# `issuer|jwks_url`; an empty `|jwks_url` part defaults to +# `{issuer}/.well-known/jwks.json`. Example: +# BOOSKIFF_JWT_TRUSTED_ISSUERS=https://auth.example.com|https://keys.example.com/jwks.json,https://other.example.com +# Empty = no trusted issuers (authenticated endpoints stay unusable until set). +BOOSKIFF_JWT_TRUSTED_ISSUERS= + +# Optional expected `aud` claim. Empty = audience not checked. +BOOSKIFF_JWT_AUDIENCE= + +# Claim carrying the owner type in access tokens. +BOOSKIFF_JWT_OWNER_TYPE_CLAIM=owner_type + +# -------------------------------------------------------------------- plans -- +# How premium limits are assigned: `everyone` (all owners get premium limits) +# or `mirror` (only owners with a premium plan assignment). +BOOSKIFF_PREMIUM_MODE=everyone + +# Payment provider wiring. Only `none` / `disabled` exist today. +BOOSKIFF_PAYMENT_PROVIDER=disabled + +# Default plan limits. +BOOSKIFF_PLAN_DEFAULT_STORAGE_QUOTA_BYTES=1073741824 +BOOSKIFF_PLAN_DEFAULT_MAX_FILE_BYTES=104857600 +BOOSKIFF_PLAN_DEFAULT_RATE_LIMIT_RPM=100 + +# Premium plan limits (applied per BOOSKIFF_PREMIUM_MODE). +BOOSKIFF_PLAN_PREMIUM_STORAGE_QUOTA_BYTES=10737418240 +BOOSKIFF_PLAN_PREMIUM_MAX_FILE_BYTES=524288000 +BOOSKIFF_PLAN_PREMIUM_RATE_LIMIT_RPM=300 + +# TTL for presigned download URLs, in seconds. +BOOSKIFF_PRESIGNED_GET_TTL_SECS=900 + +# -------------------------------------------------------------------- admin -- +# Set to seed the first admin token; rotate by changing value and restarting. +# The token is seeded into `admin_tokens` (name `bootstrap`) on startup when +# no active token with that hash exists yet; restarts with the same value are +# idempotent. Empty = no bootstrap token is seeded. +BOOSKIFF_ADMIN_BOOTSTRAP_TOKEN= diff --git a/deploy/self-hosting/Caddyfile b/deploy/self-hosting/Caddyfile new file mode 100644 index 0000000..ab770d9 --- /dev/null +++ b/deploy/self-hosting/Caddyfile @@ -0,0 +1,6 @@ +# Reverse proxy for the self-hosting stack: Caddy listens on :80 inside the +# compose network and is published as 127.0.0.1:18080 on the host. +# reverse_proxy preserves the original Host header by default. +:80 { + reverse_proxy core:8080 +} diff --git a/deploy/self-hosting/Containerfile b/deploy/self-hosting/Containerfile new file mode 100644 index 0000000..c4f560b --- /dev/null +++ b/deploy/self-hosting/Containerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 +# Multi-stage build for the Booskiff core binary. +# Builder installs cmake+perl because aws-lc-rs (used by sqlx/rustls/jsonwebtoken +# TLS) compiles its bundled C sources from source; build-essential provides the +# C toolchain, pkg-config is probed by some transitive build scripts. + +# -bookworm: the plain 1.97-slim tag is trixie-based; the binary must link +# against the bookworm glibc that the runtime stage below ships. +FROM rust:1.97-slim-bookworm AS builder +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential cmake perl pkg-config \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +# Workspace inputs only: manifests, lockfile, toolchain pin, the core crate and +# the checked-in OpenAPI spec. The heavy target/ dir stays out of the build +# context via Containerfile.dockerignore. +COPY Cargo.toml Cargo.lock rust-toolchain.toml openapi.json ./ +COPY core ./core +# Cache mounts persist downloaded crates across builds. Deliberately NO +# cache mount on target/: a cached binary survives base-image changes that +# cargo's fingerprints cannot see, which shipped a glibc-incompatible binary +# once already. A full recompile is cheap (~1 minute). +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + cargo build --release --locked -p core + +FROM debian:bookworm-slim +# ca-certificates: JWKS fetch and presigned URLs against HTTPS endpoints. +# curl: container healthcheck (/readyz). +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --uid 10001 --user-group --shell /usr/sbin/nologin booskiff +COPY --from=builder /app/target/release/core /usr/local/bin/core +USER booskiff +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/core"] diff --git a/deploy/self-hosting/Containerfile.dockerignore b/deploy/self-hosting/Containerfile.dockerignore new file mode 100644 index 0000000..74db20d --- /dev/null +++ b/deploy/self-hosting/Containerfile.dockerignore @@ -0,0 +1,9 @@ +# Build-context exclusions for deploy/self-hosting/Containerfile only +# (BuildKit per-Dockerfile ignore file; leaves the repo-root .dockerignore +# untouched). Keeps the 6.5G target/ dir and unrelated files out of the +# context sent to the daemon. +target/ +.git +.codegraph +.omo/ +e2e/ diff --git a/deploy/self-hosting/compose.yml b/deploy/self-hosting/compose.yml new file mode 100644 index 0000000..e96259c --- /dev/null +++ b/deploy/self-hosting/compose.yml @@ -0,0 +1,112 @@ +# Self-hosting stack: core + Postgres + MinIO + Caddy reverse proxy. +# +# Everything is on the internal compose network; the only published port is +# Caddy on 127.0.0.1:18080. The dev stack in the repo-root compose.yml owns +# host ports 5432/9000, so this project publishes nothing else (project name +# booskiff-sh also keeps volumes/images separate from the dev stack). +# +# Configuration: copy .env.example to .env next to this file and edit. +# Every value below has a working default, so the stack also runs with no +# .env at all. `${VAR:-default}` means empty values in .env fall back to the +# default, matching core's own empty-means-unset handling. +name: booskiff-sh + +services: + core: + build: + context: ../.. + dockerfile: deploy/self-hosting/Containerfile + environment: + # Keep POSTGRES_* below and the credentials in this URL in sync. + BOOSKIFF_DATABASE_URL: ${BOOSKIFF_DATABASE_URL:-postgres://booskiff:changeme@postgres:5432/booskiff} + # Keep 8080 to match EXPOSE and the healthcheck target. + BOOSKIFF_LISTEN_ADDR: ${BOOSKIFF_LISTEN_ADDR:-0.0.0.0:8080} + BOOSKIFF_S3_ENDPOINT: ${BOOSKIFF_S3_ENDPOINT:-http://minio:9000} + # Empty = build presigned URLs from BOOSKIFF_S3_ENDPOINT (internal-only). + BOOSKIFF_S3_ENDPOINT_PUBLIC: ${BOOSKIFF_S3_ENDPOINT_PUBLIC:-} + BOOSKIFF_S3_REGION: ${BOOSKIFF_S3_REGION:-us-east-1} + BOOSKIFF_S3_BUCKET: ${BOOSKIFF_S3_BUCKET:-booskiff-default} + # Keep MINIO_ROOT_* below and these credentials in sync. + BOOSKIFF_S3_ACCESS_KEY: ${BOOSKIFF_S3_ACCESS_KEY:-booskiff} + BOOSKIFF_S3_SECRET_KEY: ${BOOSKIFF_S3_SECRET_KEY:-booskiff-secret} + BOOSKIFF_JWT_TRUSTED_ISSUERS: ${BOOSKIFF_JWT_TRUSTED_ISSUERS:-} + BOOSKIFF_JWT_AUDIENCE: ${BOOSKIFF_JWT_AUDIENCE:-} + BOOSKIFF_JWT_OWNER_TYPE_CLAIM: ${BOOSKIFF_JWT_OWNER_TYPE_CLAIM:-owner_type} + BOOSKIFF_PREMIUM_MODE: ${BOOSKIFF_PREMIUM_MODE:-everyone} + BOOSKIFF_PAYMENT_PROVIDER: ${BOOSKIFF_PAYMENT_PROVIDER:-disabled} + BOOSKIFF_PLAN_DEFAULT_STORAGE_QUOTA_BYTES: ${BOOSKIFF_PLAN_DEFAULT_STORAGE_QUOTA_BYTES:-1073741824} + BOOSKIFF_PLAN_DEFAULT_MAX_FILE_BYTES: ${BOOSKIFF_PLAN_DEFAULT_MAX_FILE_BYTES:-104857600} + BOOSKIFF_PLAN_DEFAULT_RATE_LIMIT_RPM: ${BOOSKIFF_PLAN_DEFAULT_RATE_LIMIT_RPM:-100} + BOOSKIFF_PLAN_PREMIUM_STORAGE_QUOTA_BYTES: ${BOOSKIFF_PLAN_PREMIUM_STORAGE_QUOTA_BYTES:-10737418240} + BOOSKIFF_PLAN_PREMIUM_MAX_FILE_BYTES: ${BOOSKIFF_PLAN_PREMIUM_MAX_FILE_BYTES:-524288000} + BOOSKIFF_PLAN_PREMIUM_RATE_LIMIT_RPM: ${BOOSKIFF_PLAN_PREMIUM_RATE_LIMIT_RPM:-300} + BOOSKIFF_PRESIGNED_GET_TTL_SECS: ${BOOSKIFF_PRESIGNED_GET_TTL_SECS:-900} + BOOSKIFF_PUBLIC_BASE_URL: ${BOOSKIFF_PUBLIC_BASE_URL:-http://127.0.0.1:18080} + BOOSKIFF_ADMIN_BOOTSTRAP_TOKEN: ${BOOSKIFF_ADMIN_BOOTSTRAP_TOKEN:-} + RUST_LOG: ${RUST_LOG:-info} + depends_on: + postgres: + condition: service_healthy + minio: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/readyz || exit 1"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 15s + restart: unless-stopped + + postgres: + image: postgres:16 + environment: + POSTGRES_USER: ${POSTGRES_USER:-booskiff} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme} + POSTGRES_DB: ${POSTGRES_DB:-booskiff} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-booskiff} -d ${POSTGRES_DB:-booskiff}"] + interval: 2s + timeout: 3s + retries: 30 + restart: unless-stopped + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-booskiff} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-booskiff-secret} + volumes: + - miniodata:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 2s + timeout: 3s + retries: 30 + restart: unless-stopped + + # One-shot bucket bootstrap, mirroring the dev compose.yml init approach. + # Always exits 0; core also self-creates the bucket on startup, so the + # ordering between this and core does not matter. + mc-init: + image: minio/mc + depends_on: + minio: + condition: service_healthy + entrypoint: ["/bin/sh", "-c", "sleep 2 && mc alias set local http://minio:9000 ${MINIO_ROOT_USER:-booskiff} ${MINIO_ROOT_PASSWORD:-booskiff-secret} && (mc mb -p local/${BOOSKIFF_S3_BUCKET:-booskiff-default} || true)"] + + caddy: + image: caddy:2 + ports: + - "127.0.0.1:18080:80" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + depends_on: + - core + restart: unless-stopped + +volumes: + pgdata: + miniodata: From bc7fe47f7e027d30e56c47b05332f3ba5e45f580 Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 30 Aug 2026 15:27:17 +0900 Subject: [PATCH 12/15] ci: lint, test, openapi drift, e2e, image build workflows --- .github/workflows/ci.yml | 82 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5426fd2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI + +on: + push: + branches: + - main + - claude/** + tags: + - v* + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --check + - run: cargo clippy --all-targets -- -D warnings + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + - uses: Swatinem/rust-cache@v2 + - run: cargo check --all-targets + - run: cargo test + + openapi-drift: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + - uses: Swatinem/rust-cache@v2 + - run: cargo run -p core -- openapi > openapi.json + - run: git diff --exit-code openapi.json + + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + - run: bash e2e/run-e2e.sh + + image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - name: Log in to GitHub Container Registry + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push release image + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/self-hosting/Containerfile + push: true + tags: | + ghcr.io/${{ github.repository }}:${{ github.ref_name }} + ghcr.io/${{ github.repository }}:latest + - name: Build image + if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))" + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/self-hosting/Containerfile + push: false From 91a44b6f70ad989e55731b7646d0291ec015f8e1 Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 30 Aug 2026 15:58:35 +0900 Subject: [PATCH 13/15] fix(ci): pass pinned toolchain channel to dtolnay/rust-toolchain --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5426fd2..0278e30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,9 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.97.1" + components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 - run: cargo fmt --check - run: cargo clippy --all-targets -- -D warnings @@ -29,6 +32,8 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.97.1" - uses: Swatinem/rust-cache@v2 - run: cargo check --all-targets - run: cargo test @@ -38,6 +43,8 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.97.1" - uses: Swatinem/rust-cache@v2 - run: cargo run -p core -- openapi > openapi.json - run: git diff --exit-code openapi.json @@ -47,6 +54,9 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.97.1" + - run: docker compose version - run: bash e2e/run-e2e.sh image: From d5901a9ce67b3fae74f11837c369a2aff343241d Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 30 Aug 2026 16:21:23 +0900 Subject: [PATCH 14/15] fix(core): delete integrity guards, config-driven public/presigned URLs, timeout-exempt upload route --- core/src/drive/files.rs | 41 +++++++++++++++++++++++------------------ core/src/main.rs | 37 ++++++++++++++++++++++--------------- 2 files changed, 45 insertions(+), 33 deletions(-) diff --git a/core/src/drive/files.rs b/core/src/drive/files.rs index 10e16d8..6faa215 100644 --- a/core/src/drive/files.rs +++ b/core/src/drive/files.rs @@ -8,7 +8,7 @@ use std::time::Duration; use aws_smithy_types::body::SdkBody; use axum::body::Body; use axum::extract::{DefaultBodyLimit, Path, Query, State}; -use axum::http::{HeaderMap, StatusCode, Uri, header}; +use axum::http::{HeaderMap, StatusCode, header}; use axum::routing::{get, post}; use axum::{Json, Router}; use base64::Engine as _; @@ -30,7 +30,6 @@ use super::upload_body::CountingBody; const DEFAULT_LIST_LIMIT: i64 = 50; const MAX_LIST_LIMIT: i64 = 200; const BODY_LIMIT_SLACK: i64 = 1024 * 1024; -const DOWNLOAD_URL_TTL: Duration = Duration::from_secs(15 * 60); #[derive(Debug, Serialize, utoipa::ToSchema)] pub struct FileResponse { @@ -109,16 +108,19 @@ impl FileRow { } } -pub fn files_router(config: &Config) -> Router { +pub fn upload_router(config: &Config) -> Router { let body_limit_i64 = config .plan_premium_max_file_bytes .saturating_add(BODY_LIMIT_SLACK); let body_limit = usize::try_from(body_limit_i64).unwrap_or(usize::MAX); + Router::new().route( + "/v1/files", + post(upload_file).route_layer(DefaultBodyLimit::max(body_limit)), + ) +} + +pub fn files_router() -> Router { Router::new() - .route( - "/v1/files", - post(upload_file).route_layer(DefaultBodyLimit::max(body_limit)), - ) .route("/v1/files", get(list_files)) .route("/v1/files/{id}", get(get_file).delete(delete_file)) .route("/v1/files/{id}/download-url", get(download_url)) @@ -274,16 +276,24 @@ async fn delete_file( .begin() .await .map_err(|err| AppError::Internal(format!("begin delete file: {err}")))?; - sqlx::query("DELETE FROM file_objects WHERE file_id = $1") + let objects = sqlx::query("DELETE FROM file_objects WHERE file_id = $1") .bind(id) .execute(&mut *tx) .await .map_err(|err| AppError::Internal(format!("delete file objects: {err}")))?; - sqlx::query("DELETE FROM files WHERE id = $1") + if objects.rows_affected() == 0 { + return Err(AppError::NotFound(format!("file {id}"))); + } + let row_delete = sqlx::query("DELETE FROM files WHERE id = $1") .bind(id) .execute(&mut *tx) .await .map_err(|err| AppError::Internal(format!("delete file row: {err}")))?; + // A loser of a concurrent delete unblocks with zero rows; this guard + // prevents a double quota refund (commit-dropped tx rolls back). + if row_delete.rows_affected() == 0 { + return Err(AppError::NotFound(format!("file {id}"))); + } subtract_used_bytes(&mut tx, &ctx.owner, row.size_bytes).await?; tx.commit() .await @@ -309,7 +319,8 @@ async fn download_url( .await .map_err(|err| AppError::Internal(format!("load original object: {err}")))?; let key = key.ok_or_else(|| AppError::NotFound(format!("file object {id}")))?; - let url = state.s3.presign_get(&key, DOWNLOAD_URL_TTL).await?; + let ttl = Duration::from_secs(state.config.presigned_get_ttl_secs); + let url = state.s3.presign_get(&key, ttl).await?; Ok(Json(UrlResponse { url })) } @@ -318,8 +329,6 @@ async fn publish_file( ctx: AccountContext, State(state): State, Path(id): Path, - headers: HeaderMap, - uri: Uri, ) -> Result, AppError> { load_owned_file(&state.pool, &ctx.owner, id).await?; let mut bytes = [0_u8; 32]; @@ -339,13 +348,9 @@ async fn publish_file( if result.rows_affected() == 0 { return Err(AppError::NotFound(format!("file {id}"))); } - let scheme = uri.scheme_str().unwrap_or("http"); - let host = headers - .get(header::HOST) - .and_then(|value| value.to_str().ok()) - .ok_or_else(|| AppError::Validation("host header is required".into()))?; + let base = state.config.public_base_url.trim_end_matches('/'); Ok(Json(UrlResponse { - url: format!("{scheme}://{host}/public/{key}"), + url: format!("{base}/public/{key}"), })) } diff --git a/core/src/main.rs b/core/src/main.rs index 96a976d..ae245bf 100644 --- a/core/src/main.rs +++ b/core/src/main.rs @@ -93,7 +93,8 @@ async fn run() { } let jwks_cache = JwksCache::new(config.jwt_trusted_issuers.clone()); - let files_routes = drive::files::files_router(&config); + let upload_routes = drive::files::upload_router(&config); + let files_routes = drive::files::files_router(); let state = AppState { pool, s3: storage, @@ -104,24 +105,30 @@ async fn run() { // Stateful routers merge first; state is baked in once at the end // (axum 0.8 lacks a Router -> Router<()> late conversion). + // `upload_routes` is merged one level up so the 120 s request timeout + // never kills a large, slow upload; everything else stays capped. let app = axum::Router::::new() - .merge(billing::status_handler::billing_status_router()) - .merge(files_routes) - .nest("/v1/folders", drive::folders::folders_router()) - .nest("/v1/admin", admin::admin_router()) - .merge(public::public_router()) - .merge(health::health_router()) - .route("/openapi.json", get(|| async { Json(openapi_spec()) })) - .route("/docs", get(docs_redirect)) + .merge(upload_routes) .merge( - utoipa_swagger_ui::SwaggerUi::new("/swagger-ui") - .url("/api-docs/openapi.json", openapi_spec()), + axum::Router::::new() + .merge(billing::status_handler::billing_status_router()) + .merge(files_routes) + .nest("/v1/folders", drive::folders::folders_router()) + .nest("/v1/admin", admin::admin_router()) + .merge(public::public_router()) + .merge(health::health_router()) + .route("/openapi.json", get(|| async { Json(openapi_spec()) })) + .route("/docs", get(docs_redirect)) + .merge( + utoipa_swagger_ui::SwaggerUi::new("/swagger-ui") + .url("/api-docs/openapi.json", openapi_spec()), + ) + .layer(TimeoutLayer::with_status_code( + axum::http::StatusCode::REQUEST_TIMEOUT, + Duration::from_secs(120), + )), ) .layer(TraceLayer::new_for_http()) - .layer(TimeoutLayer::with_status_code( - axum::http::StatusCode::REQUEST_TIMEOUT, - Duration::from_secs(120), - )) .with_state(state); let listener = tokio::net::TcpListener::bind(&listen_addr) From b935277c2c32a462594bdcfd1a42d9565e9b32f8 Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 30 Aug 2026 16:28:38 +0900 Subject: [PATCH 15/15] test(e2e): folder-chain lifecycle scenario (S5) --- core/tests/e2e.rs | 196 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 194 insertions(+), 2 deletions(-) diff --git a/core/tests/e2e.rs b/core/tests/e2e.rs index fe67620..ebc5e7d 100644 --- a/core/tests/e2e.rs +++ b/core/tests/e2e.rs @@ -1,4 +1,4 @@ -// allow: SIZE_OK — the requested single-server S1-S4 executable specification is intentionally +// allow: SIZE_OK — the requested single-server S1-S5 executable specification is intentionally // kept in one integration-test target so its sequential state and lifecycle remain explicit. use std::fs::OpenOptions; @@ -34,6 +34,12 @@ struct Claims<'a> { #[derive(Deserialize)] struct FileResponse { id: Uuid, + folder_id: Option, +} + +#[derive(Deserialize)] +struct FolderResponse { + id: Uuid, } #[derive(Deserialize)] @@ -268,9 +274,23 @@ async fn upload( name: &str, bytes: &[u8], ) -> Response { + upload_into_folder(client, server, jwt, name, bytes, None).await +} + +async fn upload_into_folder( + client: &Client, + server: &CoreServer, + jwt: &str, + name: &str, + bytes: &[u8], + folder_id: Option, +) -> Response { + let folder_query = folder_id + .map(|id| format!("&folder_id={id}")) + .unwrap_or_default(); client .post(format!( - "{}/v1/files?name={name}&mime=application/octet-stream", + "{}/v1/files?name={name}&mime=application/octet-stream{folder_query}", server.base_url )) .bearer_auth(jwt) @@ -651,6 +671,177 @@ async fn scenario_s4(client: &Client, server: &CoreServer) { ); } +async fn scenario_s5(client: &Client, server: &CoreServer) { + let owner_id = Uuid::now_v7().to_string(); + let jwt = server.jwt("e2e-s5", &owner_id); + let bytes = b"S5 folder chain bytes".to_vec(); + let before = billing_status(client, server, &jwt).await.used_bytes; + + let folder = expect_status( + client + .post(format!("{}/v1/folders", server.base_url)) + .bearer_auth(&jwt) + .json(&json!({"name": "s5-folder"})) + .send() + .await + .expect("S5 create folder"), + StatusCode::CREATED, + "S5 create folder", + ) + .await + .json::() + .await + .expect("S5 parse folder response"); + + expect_error_code( + client + .post(format!("{}/v1/folders", server.base_url)) + .bearer_auth(&jwt) + .json(&json!({"name": "s5-folder"})) + .send() + .await + .expect("S5 duplicate folder request"), + StatusCode::CONFLICT, + "conflict", + "S5 duplicate folder name", + ) + .await; + + let file = expect_status( + upload_into_folder( + client, + server, + &jwt, + "s5-in-folder.bin", + &bytes, + Some(folder.id), + ) + .await, + StatusCode::CREATED, + "S5 upload into folder", + ) + .await + .json::() + .await + .expect("S5 parse upload response"); + assert_eq!( + file.folder_id, + Some(folder.id), + "S5 upload response must reference the target folder" + ); + + let in_folder = expect_status( + client + .get(format!( + "{}/v1/files?folder_id={}", + server.base_url, folder.id + )) + .bearer_auth(&jwt) + .send() + .await + .expect("S5 list files in folder"), + StatusCode::OK, + "S5 list files in folder", + ) + .await + .json::() + .await + .expect("S5 parse folder file list"); + assert!( + in_folder.items.iter().any(|item| item.id == file.id), + "S5 folder listing must contain the uploaded file" + ); + + let all_files = expect_status( + client + .get(format!("{}/v1/files", server.base_url)) + .bearer_auth(&jwt) + .send() + .await + .expect("S5 list all files"), + StatusCode::OK, + "S5 list all files", + ) + .await + .json::() + .await + .expect("S5 parse unfiltered file list"); + assert!( + all_files.items.iter().any(|item| item.id == file.id), + "S5 unfiltered listing must contain the uploaded file" + ); + + expect_status( + client + .delete(format!("{}/v1/folders/{}", server.base_url, folder.id)) + .bearer_auth(&jwt) + .send() + .await + .expect("S5 delete folder"), + StatusCode::NO_CONTENT, + "S5 delete folder", + ) + .await; + + let after_delete = expect_status( + client + .get(format!( + "{}/v1/files?folder_id={}", + server.base_url, folder.id + )) + .bearer_auth(&jwt) + .send() + .await + .expect("S5 list files after folder delete"), + StatusCode::OK, + "S5 list files after folder delete", + ) + .await + .json::() + .await + .expect("S5 parse folder file list after delete"); + assert!( + !after_delete.items.iter().any(|item| item.id == file.id), + "S5 deleted folder must not list its former files" + ); + + let unlinked = expect_status( + client + .get(format!("{}/v1/files/{}", server.base_url, file.id)) + .bearer_auth(&jwt) + .send() + .await + .expect("S5 get unlinked file"), + StatusCode::OK, + "S5 get unlinked file", + ) + .await + .json::() + .await + .expect("S5 parse unlinked file"); + assert!( + unlinked.folder_id.is_none(), + "S5 folder deletion must set the file folder_id to NULL" + ); + + expect_status( + client + .delete(format!("{}/v1/files/{}", server.base_url, file.id)) + .bearer_auth(&jwt) + .send() + .await + .expect("S5 delete file"), + StatusCode::NO_CONTENT, + "S5 delete file", + ) + .await; + let after = billing_status(client, server, &jwt).await.used_bytes; + assert_eq!( + after, before, + "S5 deletion must subtract exactly the file size" + ); +} + #[ignore] #[test] fn e2e_suite() { @@ -671,5 +862,6 @@ fn e2e_suite() { scenario_s2(&client, &server).await; scenario_s3(&client, &server).await; scenario_s4(&client, &server).await; + scenario_s5(&client, &server).await; }); }