From c0792f2e1d220c691dd2c8c00ebf94f3586ef1ab Mon Sep 17 00:00:00 2001 From: Vlad Durnea Date: Wed, 11 Mar 2026 22:23:16 +0200 Subject: [PATCH] added initial roadmap and implementation --- .dockerignore | 20 + .env | 6 + .env.example | 5 + .gitignore | 4 + Cargo.lock | 5316 +++++++++++++++++ Cargo.toml | 43 + Dockerfile | 11 + README.md | 134 +- ROADMAP.md | 146 + SPECIFICATIONS.md | 317 + auth/Cargo.toml | 23 + auth/src/handlers.rs | 249 + auth/src/lib.rs | 19 + auth/src/middleware.rs | 122 + auth/src/models.rs | 64 + auth/src/oauth.rs | 307 + auth/src/utils.rs | 118 + common/Cargo.toml | 15 + common/src/config.rs | 60 + common/src/db.rs | 10 + common/src/lib.rs | 5 + control_plane/Cargo.toml | 20 + control_plane/src/lib.rs | 251 + data_api/Cargo.toml | 18 + data_api/src/handlers.rs | 893 +++ data_api/src/lib.rs | 20 + data_api/src/parser.rs | 276 + docker-compose.yml | 113 + gateway/Cargo.toml | 27 + gateway/src/main.rs | 220 + gateway/src/middleware.rs | 133 + gateway/src/state.rs | 10 + migrations/20240101000000_init_auth.sql | 23 + migrations/20240101000001_refresh_tokens.sql | 14 + migrations/20240101000002_storage_schema.sql | 72 + migrations/20240101000003_control_plane.sql | 30 + .../20240101000004_realtime_triggers.sql | 49 + .../20260311000000_realtime_history.sql | 71 + ...20260311000001_fix_storage_permissions.sql | 35 + .../results.json | 1 + prometheus.yml | 7 + realtime/Cargo.toml | 22 + realtime/src/lib.rs | 34 + realtime/src/replication.rs | 35 + realtime/src/ws.rs | 223 + restore_trigger.sql | 54 + src/main.rs | 3 + storage/Cargo.toml | 26 + storage/src/handlers.rs | 427 ++ storage/src/lib.rs | 60 + test_multitenancy.sh | 113 + tests/integration/auth.test.ts | 42 + tests/integration/db.test.ts | 82 + tests/integration/package-lock.json | 1646 +++++ tests/integration/package.json | 18 + tests/integration/realtime.test.ts | 38 + tests/integration/run_tests.sh | 12 + tests/integration/setup.ts | 31 + tests/integration/setup_db.sql | 53 + tests/integration/storage.test.ts | 39 + trigger.sql | 7 + web/index.html | 169 + 62 files changed, 12410 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .env create mode 100644 .env.example create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 ROADMAP.md create mode 100644 SPECIFICATIONS.md create mode 100644 auth/Cargo.toml create mode 100644 auth/src/handlers.rs create mode 100644 auth/src/lib.rs create mode 100644 auth/src/middleware.rs create mode 100644 auth/src/models.rs create mode 100644 auth/src/oauth.rs create mode 100644 auth/src/utils.rs create mode 100644 common/Cargo.toml create mode 100644 common/src/config.rs create mode 100644 common/src/db.rs create mode 100644 common/src/lib.rs create mode 100644 control_plane/Cargo.toml create mode 100644 control_plane/src/lib.rs create mode 100644 data_api/Cargo.toml create mode 100644 data_api/src/handlers.rs create mode 100644 data_api/src/lib.rs create mode 100644 data_api/src/parser.rs create mode 100644 docker-compose.yml create mode 100644 gateway/Cargo.toml create mode 100644 gateway/src/main.rs create mode 100644 gateway/src/middleware.rs create mode 100644 gateway/src/state.rs create mode 100644 migrations/20240101000000_init_auth.sql create mode 100644 migrations/20240101000001_refresh_tokens.sql create mode 100644 migrations/20240101000002_storage_schema.sql create mode 100644 migrations/20240101000003_control_plane.sql create mode 100644 migrations/20240101000004_realtime_triggers.sql create mode 100644 migrations/20260311000000_realtime_history.sql create mode 100644 migrations/20260311000001_fix_storage_permissions.sql create mode 100644 node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json create mode 100644 prometheus.yml create mode 100644 realtime/Cargo.toml create mode 100644 realtime/src/lib.rs create mode 100644 realtime/src/replication.rs create mode 100644 realtime/src/ws.rs create mode 100644 restore_trigger.sql create mode 100644 src/main.rs create mode 100644 storage/Cargo.toml create mode 100644 storage/src/handlers.rs create mode 100644 storage/src/lib.rs create mode 100755 test_multitenancy.sh create mode 100644 tests/integration/auth.test.ts create mode 100644 tests/integration/db.test.ts create mode 100644 tests/integration/package-lock.json create mode 100644 tests/integration/package.json create mode 100644 tests/integration/realtime.test.ts create mode 100755 tests/integration/run_tests.sh create mode 100644 tests/integration/setup.ts create mode 100644 tests/integration/setup_db.sql create mode 100644 tests/integration/storage.test.ts create mode 100644 trigger.sql create mode 100644 web/index.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..aa92212a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.git +.github +.idea +.vscode + +**/target +**/target/** + +**/node_modules +**/node_modules/** + +**/.next +**/.next/** +**/dist +**/dist/** +**/build +**/build/** + +**/*.log +.DS_Store diff --git a/.env b/.env new file mode 100644 index 00000000..a4c0f05c --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +DATABASE_URL=postgres://admin:admin_password@localhost:5433/madbase_control +PORT=8001 +HOST=0.0.0.0 +JWT_SECRET=supersecret +DEFAULT_TENANT_DB_URL=postgres://postgres:postgres@localhost:5432/postgres +RATE_LIMIT_PER_SECOND=100 diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..13268e71 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +DATABASE_URL=postgres://admin:admin_password@localhost:5433/madbase_control +DEFAULT_TENANT_DB_URL=postgres://postgres:postgres@localhost:5432/postgres +PORT=8001 +HOST=0.0.0.0 +JWT_SECRET=supersecret diff --git a/.gitignore b/.gitignore index 0b188bc2..c0fbb3be 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,7 @@ target/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Integration Tests +tests/integration/node_modules/ +tests/integration/.env diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..a16779e4 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,5316 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "auth" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "axum", + "chrono", + "common", + "jsonwebtoken", + "oauth2", + "rand 0.8.5", + "reqwest 0.13.2", + "serde", + "serde_json", + "sha2", + "sqlx", + "tokio", + "tracing", + "uuid", + "validator", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-config" +version = "1.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11493b0bad143270fb8ad284a096dd529ba91924c5409adeac856cc1bf047dbc" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http 0.63.6", + "aws-smithy-json 0.62.5", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.0", + "sha1", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-runtime" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc0651c57e384202e47153c1260b84a9936e19803d747615edf199dc3b98d17" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http 0.63.6", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.119.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d65fddc3844f902dfe1864acb8494db5f9342015ee3ab7890270d36fbd2e01c" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http 0.62.6", + "aws-smithy-json 0.61.9", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "lru", + "percent-encoding", + "regex-lite", + "sha2", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.96.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64a6eded248c6b453966e915d32aeddb48ea63ad17932682774eb026fbef5b1" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http 0.63.6", + "aws-smithy-json 0.62.5", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db96d720d3c622fcbe08bae1c4b04a72ce6257d8b0584cb5418da00ae20a344f" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http 0.63.6", + "aws-smithy-json 0.62.5", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.100.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fafbdda43b93f57f699c5dfe8328db590b967b8a820a13ccdd6687355dfcc7ca" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http 0.63.6", + "aws-smithy-json 0.62.5", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b660013a6683ab23797778e21f1f854744fdf05f68204b4cca4c8c04b5d1f4" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http 0.63.6", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "crypto-bigint 0.5.5", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "p256", + "percent-encoding", + "ring", + "sha2", + "subtle", + "time", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.63.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87294a084b43d649d967efe58aa1f9e0adc260e13a6938eb904c0ae9b45824ae" +dependencies = [ + "aws-smithy-http 0.62.6", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 0.2.12", + "http-body 0.4.6", + "md-5", + "pin-project-lite", + "sha1", + "sha2", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf09d74e5e32f76b8762da505a3cd59303e367a664ca67295387baa8c1d7548" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.62.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826141069295752372f8203c17f28e30c464d22899a43a0c9fd9c458d469c88b" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.13", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.8.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.7", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower 0.5.3", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.61.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49fa1213db31ac95288d981476f78d05d9cbb0353d22cdf3472cc05bb02f6551" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "028999056d2d2fd58a697232f9eec4a643cf73a71cf327690a7edad1d2af2110" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http 0.63.6", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876ab3c9c29791ba4ba02b780a3049e21ec63dabda09268b175272c3733a79e6" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2b1117b3b2bbe166d11199b540ceed0d0f7676e36e7b962b5a437a9971eac75" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c8323699dd9b3c8d5b3c13051ae9cdef58fd179957c882f8374dd8725962d9" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.22.1", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-prometheus" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b683cbc43010e9a3d72c2f31ca464155ff4f95819e88a32924b0f47a43898978" +dependencies = [ + "axum", + "bytes", + "futures", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "matchit", + "metrics", + "metrics-exporter-prometheus", + "once_cell", + "pin-project", + "tokio", + "tower 0.4.13", + "tower-http 0.5.2", +] + +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[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 = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[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 = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[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.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "common" +version = "0.1.0" +dependencies = [ + "anyhow", + "config", + "dotenvy", + "serde", + "serde_json", + "sqlx", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23738e11972c7643e4ec947840fc463b6a571afcd3e735bdfce7d03c7a784aca" +dependencies = [ + "async-trait", + "json5", + "lazy_static", + "nom", + "pathdiff", + "ron", + "rust-ini", + "serde", + "serde_json", + "toml", + "yaml-rust", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "control_plane" +version = "0.1.0" +dependencies = [ + "anyhow", + "auth", + "axum", + "base64 0.21.7", + "chrono", + "common", + "jsonwebtoken", + "rand 0.8.5", + "serde", + "serde_json", + "sqlx", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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 = "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.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc-fast" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ddc2d09feefeee8bd78101665bd8645637828fa9317f9f292496dbbd8c65ff3" +dependencies = [ + "crc", + "digest", + "rand 0.9.2", + "regex", + "rustversion", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "data_api" +version = "0.1.0" +dependencies = [ + "auth", + "axum", + "chrono", + "common", + "futures", + "regex", + "serde", + "serde_json", + "sqlx", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlv-list" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" + +[[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.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der 0.6.1", + "elliptic-curve", + "rfc6979", + "signature 1.6.4", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +dependencies = [ + "base16ct", + "crypto-bigint 0.4.9", + "der 0.6.1", + "digest", + "ff", + "generic-array", + "group", + "pkcs8 0.9.0", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[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.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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 = "forwarded-header-value" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" +dependencies = [ + "nonempty", + "thiserror 1.0.69", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gateway" +version = "0.1.0" +dependencies = [ + "anyhow", + "auth", + "axum", + "axum-prometheus", + "common", + "control_plane", + "data_api", + "dotenvy", + "moka", + "realtime", + "serde", + "serde_json", + "sqlx", + "storage", + "tokio", + "tower-http 0.6.8", + "tower_governor", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[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 0.11.1+wasi-snapshot-preview1", + "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "governor" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b" +dependencies = [ + "cfg-if", + "dashmap", + "futures", + "futures-timer", + "no-std-compat", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.8.5", + "smallvec", + "spinning_top", +] + +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +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.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +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.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[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 = "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.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper 1.8.1", + "hyper-util", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", + "webpki-roots 1.0.6", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "bitflags 2.11.0", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[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.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "metrics" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d05972e8cbac2671e85aa9d04d9160d193f8bebd1a5c1a2f4542c62e65d1d0" +dependencies = [ + "ahash 0.8.12", + "portable-atomic", +] + +[[package]] +name = "metrics-exporter-prometheus" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bf4e7146e30ad172c42c39b3246864bd2d3c6396780711a1baf749cfe423e21" +dependencies = [ + "base64 0.21.7", + "hyper 0.14.32", + "indexmap", + "ipnet", + "metrics", + "metrics-util", + "quanta", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "metrics-util" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b07a5eb561b8cbc16be2d216faf7757f9baf3bfb94dbb0fae3df8387a5bb47f" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.14.5", + "metrics", + "num_cpus", + "quanta", + "sketches-ddsketch", +] + +[[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 = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.4.0", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nonempty" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http 1.4.0", + "rand 0.8.5", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-multimap" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" +dependencies = [ + "dlv-list", + "hashbrown 0.12.3", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "p256" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +dependencies = [ + "ecdsa", + "elliptic-curve", + "sha2", +] + +[[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 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "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 = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der 0.6.1", + "spki 0.6.0", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "postgres-protocol" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee9dd5fe15055d2b6806f4736aa0c9637217074e224bbec46d4041b91bb9491" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator", + "hmac", + "md-5", + "memchr", + "rand 0.9.2", + "sha2", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b858f82211e84682fecd373f68e1ceae642d8d751a1ebd13f33de6257b3e20" +dependencies = [ + "bytes", + "fallible-iterator", + "postgres-protocol", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +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 = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +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 0.11.1+wasi-snapshot-preview1", + "web-sys", + "winapi", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.37", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls 0.23.37", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +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.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[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 = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "realtime" +version = "0.1.0" +dependencies = [ + "anyhow", + "auth", + "axum", + "bytes", + "chrono", + "common", + "futures", + "jsonwebtoken", + "postgres-protocol", + "serde", + "serde_json", + "sqlx", + "tokio", + "tokio-postgres", + "tracing", + "uuid", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls 0.27.7", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.37", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tower 0.5.3", + "tower-http 0.6.8", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.6", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls 0.27.7", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.37", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tower 0.5.3", + "tower-http 0.6.8", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +dependencies = [ + "crypto-bigint 0.4.9", + "hmac", + "zeroize", +] + +[[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", + "windows-sys 0.52.0", +] + +[[package]] +name = "ron" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88073939a61e5b7680558e6be56b419e208420c2adb92be54921fa6b72283f1a" +dependencies = [ + "base64 0.13.1", + "bitflags 1.3.2", + "serde", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "signature 2.2.0", + "spki 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-ini" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[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.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.9", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.9", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[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", +] + +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct", + "der 0.6.1", + "generic-array", + "pkcs8 0.9.0", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "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.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[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 = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[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 2.0.18", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "sketches-ddsketch" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[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.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der 0.6.1", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls 0.23.37", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.11.0", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami 1.6.1", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.11.0", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami 1.6.1", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "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 = "storage" +version = "0.1.0" +dependencies = [ + "anyhow", + "auth", + "aws-config", + "aws-sdk-s3", + "aws-types", + "axum", + "bytes", + "chrono", + "common", + "futures", + "http-body-util", + "serde", + "serde_json", + "sqlx", + "tokio", + "tower 0.4.13", + "tower-http 0.5.2", + "tracing", + "uuid", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +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", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +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.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcea47c8f71744367793f16c2db1f11cb859d28f436bdb4ca9193eb1f787ee42" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.9.2", + "socket2 0.6.3", + "tokio", + "tokio-util", + "whoami 2.1.1", +] + +[[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.37", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "tower-layer", + "tower-service", + "tracing", +] + +[[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.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower 0.5.3", + "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 = "tower_governor" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aea939ea6cfa7c4880f3e7422616624f97a567c16df67b53b11f0d03917a8e46" +dependencies = [ + "axum", + "forwarded-header-value", + "governor", + "http 1.4.0", + "pin-project", + "thiserror 1.0.69", + "tower 0.5.3", + "tracing", +] + +[[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", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[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 = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[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", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[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 = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +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-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite 0.1.0", +] + +[[package]] +name = "whoami" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6a5b12f9df4f978d2cfdb1bd3bac52433f44393342d7ee9c25f5a1c14c0f45d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite 1.0.2", + "web-sys", +] + +[[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-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..48bd5336 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,43 @@ +[workspace] +resolver = "2" +members = [ + "common", + "gateway", + "auth", + "data_api", + "control_plane", + "realtime", + "storage", +] + +[workspace.dependencies] +tokio = { version = "1.36", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +axum = "0.7" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "migrate"] } +uuid = { version = "1.7", features = ["v4", "serde"] } +thiserror = "1.0" +dotenvy = "0.15" +config = "0.13" +chrono = { version = "0.4", features = ["serde"] } +anyhow = "1.0" +argon2 = "0.5" +jsonwebtoken = "9.2" +rand = "0.8" +regex = "1.10" +futures = "0.3" +sha2 = "0.10" +aws-sdk-s3 = "1.15.0" +aws-config = "1.1.2" +aws-types = "1.1.2" + +# Local dependencies +common = { path = "common" } +auth = { path = "auth" } +data_api = { path = "data_api" } +control_plane = { path = "control_plane" } +realtime = { path = "realtime" } +storage = { path = "storage" } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..4727014f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM rust:latest AS builder +WORKDIR /app +COPY . . +RUN cargo build --release --bin gateway + +FROM debian:trixie-slim +WORKDIR /app +RUN apt-get update && apt-get install -y libssl-dev ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/target/release/gateway . +COPY web ./web +CMD ["./gateway"] diff --git a/README.md b/README.md index 1c7c1a28..741e5a83 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,134 @@ -# madbase +# MadBase +**MadBase** is an open-source, high-performance Backend-as-a-Service (BaaS) written in Rust. It serves as a lightweight alternative to Supabase, providing a comprehensive suite of tools for building modern web and mobile applications. + +## 🚀 Features + +MadBase consolidates the following services into a single, efficient binary: + +* **🔐 Authentication (`/auth/v1`)** + * User Signup & Login (Email/Password). + * JWT-based Session Management. + * Row Level Security (RLS) integration with PostgreSQL. +* **💾 Data API (`/rest/v1`)** + * Auto-generated REST API for your Postgres tables. + * CRUD operations (Select, Insert, Update, Delete). + * Filtering, Pagination, and Ordering. + * Stored Procedure (RPC) calls. +* **⚡ Realtime (`/realtime/v1`)** + * WebSocket-based event streaming. + * Listen to database changes via Postgres `LISTEN/NOTIFY`. +* **📦 Storage (`/storage/v1`)** + * S3-compatible object storage (backed by MinIO). + * File Upload, Download, and Management. + * Integrated RLS permissions for buckets and objects. +* **🎛️ Control Plane (`/platform/v1`)** + * Project Management. + * Automatic API Key Generation (`anon` and `service_role`). + +## 🛠️ Architecture + +MadBase is built as a modular monolith in **Rust**, utilizing the **Axum** web framework for high performance and low latency. + +* **Gateway**: The central entry point that routes requests to appropriate internal modules. +* **PostgreSQL**: The primary database for data, auth, and system state. +* **MinIO**: S3-compatible object storage. + +## 🏁 Getting Started + +### Prerequisites + +* **Rust** (latest stable) +* **Docker** & **Docker Compose** (for DB and MinIO) +* **PostgreSQL Client** (optional, for debugging) + +### Installation + +1. **Clone the repository:** + ```bash + git clone https://github.com/yourusername/madbase.git + cd madbase + ``` + +2. **Start Infrastructure:** + Spin up PostgreSQL and MinIO using Docker Compose: + ```bash + docker-compose up -d + ``` + +3. **Run Migrations:** + Initialize the database schema: + ```bash + sqlx migrate run + ``` + *(Note: You may need to install sqlx-cli: `cargo install sqlx-cli`)* + +4. **Start the Gateway:** + Run the main server: + ```bash + cargo run -p gateway + ``` + The server will start at `http://0.0.0.0:8000`. + +## 📖 Usage Guide + +### 1. Create a Project +Use the Control Plane to initialize a project and get your API keys. + +```bash +curl -X POST http://localhost:8000/platform/v1/projects \ + -H "Content-Type: application/json" \ + -d '{"name": "my-awesome-app"}' +``` + +**Response:** +```json +{ + "id": "...", + "anon_key": "eyJ...", + "service_role_key": "eyJ...", + ... +} +``` +*Save the `anon_key` and `service_role_key`!* + +### 2. Authentication +Sign up a new user: + +```bash +curl -X POST http://localhost:8000/auth/v1/signup \ + -H "apikey: " \ + -H "Content-Type: application/json" \ + -d '{"email": "user@example.com", "password": "securepassword"}' +``` + +### 3. Data Operations +Query a table (e.g., `users`): + +```bash +curl -X GET "http://localhost:8000/rest/v1/users?select=*" \ + -H "apikey: " \ + -H "Authorization: Bearer " +``` + +### 4. Realtime +Connect via WebSocket: +`ws://localhost:8000/realtime/v1` + +### 5. Storage +Upload a file: + +```bash +curl -X POST http://localhost:8000/storage/v1/object/my-bucket/image.png \ + -H "apikey: " \ + -H "Authorization: Bearer " \ + -F "file=@./local-image.png" +``` + +## 🗺️ Roadmap + +See [ROADMAP.md](./ROADMAP.md) for detailed progress and future plans. + +## 📄 License + +MIT diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..892ca7e1 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,146 @@ +# MadBase Development Roadmap + +This document outlines the development plan for **MadBase**, a high-performance, resource-efficient, Supabase-compatible API layer written in Rust. The roadmap is derived from the requirements specified in [SPECIFICATIONS.md](./SPECIFICATIONS.md). + +## Phase 1: Foundation & Core APIs (MVP) +**Goal:** Establish the single-binary architecture and deliver functional Auth and Data APIs for a single project context. + +### 1.1 Project Scaffolding & Architecture +- [x] Initialize Rust workspace with modular crate structure (`gateway`, `auth`, `data_api`, `common`, `control_plane`). +- [x] Implement configuration management (Environment variables + .env). +- [x] Set up basic HTTP server (Axum/Actix) acting as the **Gateway**. +- [x] Implement connection pooling for PostgreSQL (SQLx or similar). +- [x] Create `docker-compose.yml` for dev database (compatible with Podman). + +### 1.2 Authentication Service (`/auth/v1`) +- [x] Implement User model & schema (compatible with GoTrue/Supabase). +- [x] **Sign Up**: Email/password registration with Argon2 hashing. +- [x] **Sign In**: Email/password login returning JWTs. +- [x] **Token Management**: + - [x] Issue Access Tokens (JWT) with required claims (`sub`, `role`, `iss`, `iat`, `exp`) and optional (`aud`, `email`). + - [x] Issue Refresh Tokens and implement rotation logic. +- [x] **Session**: `/user` endpoint to retrieve current session. + +### 1.3 Data API (PostgREST-lite) (`/rest/v1`) +- [x] **Query Parser**: Parse URL parameters for filtering, ordering, and pagination. + - [x] Filters: `eq`, `neq`, `lt`, `gt`, `in`, `is`. + - [x] Ordering: `order=col.asc|desc`. + - [x] Pagination: `limit`, `offset`. +- [x] **CRUD Operations**: + - [x] `GET`: Select rows (basic `select=*`). + - [x] `POST`: Insert rows. + - [x] `PATCH`: Update rows. + - [x] `DELETE`: Delete rows. +- [x] **RPC**: `POST /rpc/` support for calling Postgres functions. +- [x] **RLS Enforcement**: + - [x] Implement transaction wrapping. + - [x] Inject claims via `SET LOCAL request.jwt.claims`. + - [x] Switch roles (`anon` vs `authenticated` vs `service_role`). + +### 1.9 Podman Compose Deployment +Single `docker-compose.yml` (compatible with `podman-compose`) deploys: +- [x] **PostgreSQL**: Database for Auth and Data storage. +- [x] **MinIO**: Object storage for file uploads. +- [x] **Control Plane DB**: Stores project-specific config and secrets. + +--- + +## Phase 2: Realtime & Storage +**Goal:** Enable real-time data subscriptions and object storage capabilities. + +### 2.1 Realtime Service (`/realtime/v1`) +- [x] **WebSocket Server**: Implement using `axum` + `tungstenite`. +- [x] **Replication Consumer**: + - [x] Connect to Postgres via LISTEN/NOTIFY (fallback path). + - [ ] Connect to Postgres replication slot (`pgoutput`) via `tokio-postgres` or `sqlx` (Defer to Phase 5: Advanced Realtime). + - [x] Broadcast row changes (INSERT/UPDATE/DELETE) to connected clients. +- [x] **Subscription Management**: + - [x] Handle `Join` messages to subscribe to specific tables/rows. + - [x] Filter events based on client subscriptions. + +### 2.2 Storage Service (`/storage/v1`) +- [x] **S3 Proxy**: + - [x] List Buckets (`GET /bucket`). + - [x] List Objects (`GET /object/:bucket_id`). + - [x] Upload/Download (`POST/GET /object/:bucket_id/:filename`). +- [x] **Permissions**: + - [x] RLS-like policies for buckets/objects (storage.buckets, storage.objects tables). + - [x] Public vs Private buckets. + +## Phase 3: Control Plane & Management +**Goal**: Build the administrative layer to manage projects and configurations. + +### 3.1 Project Management (`/v1/projects`) +- [x] **Projects Table**: Store project metadata (name, owner, status). +- [x] **Provisioning**: (Mocked for MVP) Simulate creating resources for a new project. +- [x] **API Keys**: Generate and validate Service Keys (anon/service_role). + +### 3.2 Secrets Management (`/v1/secrets`) +- [x] **JWT Generation**: Automatically generate secure JWT secrets and keys for new projects. + +- [x] **Project Resolution**: + - [x] Resolve project context via `x-project-ref` header. +- [x] **Dynamic Configuration**: + - [x] Load project-specific config (DB URL, JWT secret, API keys) from Control Plane DB. +- [x] **Isolation**: Ensure strict separation of connections and caches between projects. + +--- + +## Phase 4: Admin UI & Observability +**Goal:** Provide a management interface and production-grade monitoring. + +### 4.1 Admin API (`/admin/v1`) +- [x] **Project Management**: Create, Update, Soft-delete projects. +- [x] **User Management**: Admin-level user CRUD. +- [x] **Config Management**: Key rotation and setting updates. + +### 4.2 Management UI +- [x] **Dashboard**: React/Web-based UI for managing projects. +- [x] **Features**: + - [x] DB Connection tester. + - [x] Storage bucket browser (Basic). + - [x] Realtime connection stats (Basic). + - [x] Logs viewer (Basic). + +### 4.3 Observability Stack +- [x] **Metrics**: Expose Prometheus-compatible metrics (Request latency, DB pool stats, Active WS connections). +- [x] **Logs**: Structured JSON logging with correlation IDs. +- [x] **Infrastructure**: + - [x] Configure **VictoriaMetrics** for metric storage. + - [x] Configure **Loki** for log aggregation. + - [x] Configure **Grafana** with pre-built dashboards. +- [x] **Docker Compose**: Finalize the all-in-one `docker-compose.yml`. + +--- + +## Phase 5: Polish, Security & Extensions +**Goal:** Harden the system for production use and expand compatibility. + +### 5.1 Advanced Features +- [x] **Auth**: OAuth provider integration (Google, GitHub, etc.). +- [ ] **Data API**: + - [x] Basic column selection (`?select=col1,col2`). + - [x] Nested selects (joins) (`?select=col,relation(col)`). + - [x] Complex boolean logic (`or`, `and`). + - [x] Bulk operations optimization (Bulk Insert). +- [x] **Realtime**: Resume from LSN/ID support for reliability (via History Table). + +### 5.2 Security & Performance +- [x] **Hardening**: + - [x] Rate limiting (per IP/Project). + - [x] CORS configuration. + - [x] Input validation strictness. +- [x] **Performance**: + - [x] Query caching where appropriate. + - [x] WS fanout optimization. +- [x] **Testing**: + - [x] Integration tests using the official `@supabase/supabase-js` client. + - [ ] Load testing. + +--- + +## Milestone Summary +1. **MVP**: Auth + Data API (Phase 1). +2. **Beta**: + Realtime + Storage (Phase 2). +3. **RC**: + Functions + Multi-tenancy (Phase 3). +4. **v1.0**: + Admin UI + Observability + Production Ready (Phase 4 & 5). diff --git a/SPECIFICATIONS.md b/SPECIFICATIONS.md new file mode 100644 index 00000000..85911db4 --- /dev/null +++ b/SPECIFICATIONS.md @@ -0,0 +1,317 @@ +### MadBase (Supabase-Compatible Rust API Layer) — Functional & Non-Functional Specification (Updated) + +This document specifies **MadBase**, a **Supabase API-compatible** platform implemented primarily in **Rust**, designed to be a drop-in replacement for Supabase’s hosted API surface while supporting **Bring Your Own PostgreSQL**. Primary goals: **low resource usage**, **simplicity**, and **operability** (Docker Compose-first). + +## 0. Key Decisions (Chosen for Resource Usage, Simplicity, Operability) + +1. **Single Rust “gateway” binary** (one container) exposes Supabase-compatible endpoints: + - `/auth/v1/*`, `/rest/v1/*`, `/rpc/v1/*` (or `/rest/v1/rpc/*` depending on compatibility requirements), `/storage/v1/*`, `/realtime/v1/*` (WS), `/functions/v1/*`, `/admin/v1/*` +2. **Custom “PostgREST-lite” Rust data API** (instead of embedding PostgREST) to reduce runtime overhead and simplify deployment. +3. **Realtime via PostgreSQL logical replication (pgoutput)** implemented in Rust (no external Debezium/Elixir stack). +4. **Edge Functions executed as WASM via Wasmtime** (small, fast startup, strong sandbox). (Optionally support “Deno compatibility mode” later if required.) +5. **Storage API is an S3 proxy** with MinIO default in podman-compose and support for external S3/R2/etc. in production. +6. **Monitoring: VictoriaMetrics + Loki + Grafana** with Prometheus-format metrics endpoints and JSON stdout logs. + +--- + +## 1. Functional Requirements + +### 1.1 Supabase Client Compatibility (Hard Requirement) +**Goal:** `@supabase/supabase-js` works unchanged. Users only swap the URL. + +#### 1.1.1 API Surface Compatibility +MadBase must mimic Supabase’s public endpoints, including: +- Path structure (e.g., `/auth/v1`, `/rest/v1`, `/storage/v1`, `/realtime/v1`, `/functions/v1`) +- Required headers: + - `apikey: ` + - `Authorization: Bearer ` +- Status codes and error format consistency (enough to not break supabase-js). + +#### 1.1.2 Key Types +- **Anon key**: public, used by clients for most operations. +- **Service role key**: privileged, bypasses RLS (admin operations). +- Key rotation supported per project. + +--- + +### 1.2 Multi-Tenant Projects (Control Plane) +MadBase is multi-tenant and supports many “projects,” each mapping to: +- A PostgreSQL connection string (BYO DB/cluster) +- JWT secret / signing policy +- Storage backend configuration +- Function runtime configuration +- Realtime replication configuration + +#### 1.2.1 Project Identification +Projects are identified via one of: +- Subdomain-based routing: `https://.` +- Or header-based routing: `x-project-ref: ` (for internal/admin use) + +#### 1.2.2 Project Lifecycle +- Create project +- Update project settings (DB URL, keys, storage config) +- Disable / delete project (with soft-delete option) +- View usage/health info per project + +--- + +### 1.3 Auth (GoTrue-Compatible-ish) +MadBase provides auth flows expected by supabase-js. + +#### 1.3.1 Supported Flows +- Email/password signup +- Email/password login +- Token refresh (refresh token → new access token) +- Session retrieval (`/user`) +- Password reset (email-based) — optional for MVP, but spec’d +- Email confirmation — optional for MVP, but spec’d +- OAuth providers — **out of MVP**, future extension + +#### 1.3.2 User Model +For each project: +- Users are isolated by `project_id` +- Store: + - `id (uuid)` + - `email` + - `password_hash (argon2)` + - `created_at` + - `confirmed_at` (optional) + - `user_metadata` (jsonb) + - `app_metadata` (jsonb, e.g., provider) + +#### 1.3.3 Token Model +- Access token: JWT, short-lived +- Refresh token: opaque random string (stored hashed) or JWT-like; must support rotation +- Required JWT claims to align with RLS expectations: + - `sub` + - `role` (e.g., `authenticated` / `anon`) + - `aud` + - `exp` + - plus project scoping claim (e.g., `project_ref`) + +--- + +### 1.4 Data API (CRUD, Filtering, Ordering, Pagination) +MadBase provides PostgREST-like behavior. + +#### 1.4.1 CRUD +- `GET /rest/v1/` +- `POST /rest/v1/
` +- `PATCH /rest/v1/
` +- `DELETE /rest/v1/
` + +#### 1.4.2 Query Features +- `select=*` and nested selects (MVP: basic selects; advanced nested relations phased) +- Filters: + - `eq`, `neq`, `lt`, `lte`, `gt`, `gte` + - `like`, `ilike` + - `in` + - `is` (null checks) + - `or` boolean expression support (MVP: limited; expand later) +- Ordering: `order=col.asc|desc` +- Pagination: + - `limit`, `offset` + - Range headers compatibility (nice-to-have if required by supabase-js usage patterns) +- Count support (`Prefer: count=exact|planned|estimated`) as feasible + +#### 1.4.3 RPC +- Postgres function invocation endpoint compatible with Supabase usage patterns: + - `POST /rest/v1/rpc/` (common supabase pattern) +- Input via JSON body; output as JSON. + +#### 1.4.4 RLS Enforcement (Hard Requirement) +MadBase must rely on **native PostgreSQL RLS**. + +For each request within a transaction: +- Validate JWT (or anon) +- Set request-local variables: + - `SET LOCAL request.jwt.claims = ''` OR granular `SET LOCAL request.jwt.claim.` +- Set role appropriately: + - anon → restricted + - authenticated → restricted + - service role → bypass policies (by using privileged DB role / bypass behavior) + +**Outcome:** Existing Supabase-style RLS policies work unchanged. + +--- + +### 1.5 Realtime (WebSocket Subscriptions) +MadBase supports Supabase-js subscriptions. + +#### 1.5.1 WebSocket Protocol Compatibility +- `supabase.channel(...).on('postgres_changes', ...)` works unchanged (protocol-level compatibility target) +- Support channel join/leave, heartbeats, and authorization. + +#### 1.5.2 Change Capture +- Use PostgreSQL logical replication (`pgoutput`) +- Per-project replication slot management +- Resume from last confirmed LSN after restart + +#### 1.5.3 Filtering + RLS Semantics +- Enforce subscription filters (table, schema, event types, optional column filters) +- **RLS-correctness goal**: only stream rows the user is allowed to see. + - MVP approach: re-check row visibility by executing a parameterized query under the user’s claims (heavier but correct) + - Future: optimize with precomputed policies / projection strategies + +--- + +### 1.6 Object Storage (Supabase Storage-Compatible) +MadBase provides: +- Bucket CRUD +- Object upload/download/delete +- Signed URL generation (optional) +- Public/private bucket behavior + +#### 1.6.1 Storage Backend +- Default: MinIO in Podman Compose +- Production: external S3-compatible endpoint +- Metadata stored per project in control DB (or optionally tenant DB) + +#### 1.6.2 Authorization +- Requires JWT or anon access depending on bucket policy +- Service role bypass supported + +--- + +### 1.7 Edge Functions (Full Support, WASM Runtime) +MadBase supports: +- `POST /functions/v1/` invocation +- Deploy/update function artifacts per project + +#### 1.7.1 Runtime +- Wasmtime sandbox execution +- Per-invocation limits: + - max CPU time + - max memory + - max request body size +- Environment variables injection: + - project ref + - supabase URL + - anon key (optional) + - secrets (encrypted at rest) + +#### 1.7.2 Function Packaging +- MVP: accept WASM modules + manifest +- Optional toolchain support for TS/JS→WASM documented, but not required in-core. + +--- + +### 1.8 Management UI (Full Project Management) +UI provides: +- Project creation/config +- DB connection configuration test +- Key management + rotation +- User management (admin) +- Storage bucket management +- Edge function deployment + logs +- Realtime connection stats +- RLS policy helpers (optional, best-effort) +- Health checks + +UI talks to `/admin/v1/*` endpoints. + +--- + +### 1.9 Podman Compose Deployment +Single `docker-compose.yml` (compatible with `podman-compose`) deploys: +- MadBase API container +- Control plane Postgres (for project/user/config) +- MinIO (default storage) +- Grafana +- Loki +- VictoriaMetrics + +BYO tenant Postgres DBs are external (not necessarily in compose). + +--- + +## 2. Non-Functional Requirements + +### 2.1 Resource Usage Targets +- **Single-node dev deployment** (compose): + - MadBase (idle) memory target: ~100–300MB depending on active WS connections + - Minimal CPU when idle +- Monitoring stack runs within reasonable bounds for small installs. + +### 2.2 Performance +- REST overhead target: low single-digit milliseconds excluding DB time +- WebSocket fanout efficiency: + - handle thousands to tens of thousands connections per node (depending on hardware) +- Streaming uploads/downloads without buffering entire objects in memory. + +### 2.3 Operability (Primary Goal) +- **Few moving parts**: one Rust service container + standard off-the-shelf observability components. +- Stateless API layer: + - can scale horizontally behind a load balancer +- Configuration via environment variables + control DB records. +- Smooth upgrades: + - migrations for control plane DB + - backward compatible API where possible + +### 2.4 Reliability & Availability +- Graceful shutdown: + - drain in-flight requests + - close WS connections cleanly +- Realtime resume: + - store last confirmed LSN per project +- Retry policies for transient DB/storage failures +- Backpressure handling for WS broadcasts and replication consumption + +### 2.5 Security +- JWT validation strictness (issuer/audience configurable per project) +- Password hashing: Argon2 +- Secrets encrypted at rest in control DB +- RBAC for admin UI +- Edge runtime sandboxing: + - no filesystem by default + - no network by default (or allowlist) +- Defense-in-depth: + - rate limiting per project/IP + - request size limits + - CORS configuration + +### 2.6 Observability +- Metrics: + - request counts/latency by endpoint and project + - DB pool stats + - replication lag + - WS connections + - function invocation duration/errors + - storage bytes in/out +- Logs: + - JSON structured logs to stdout + - correlation IDs per request +- Dashboards: + - Grafana dashboards included for the above + +### 2.7 Maintainability +- Modular internal crate structure: + - `gateway`, `auth`, `data_api`, `realtime`, `storage`, `functions`, `admin`, `control_plane` +- Clear compatibility test suite using supabase-js integration tests +- Versioned API surface for `/admin/v1` + +--- + +## 3. Constraints & Assumptions + +- MadBase is **API layer only**; it does not provision tenant databases (though it can optionally assist with templates). +- PostgreSQL is assumed to be standard and supports: + - RLS + - logical replication (for realtime) +- Supabase-js compatibility is the north star; where full fidelity is expensive: + - MVP supports the most common subset, with explicit “compatibility gaps” tracked. + +--- + +## 4. Acceptance Criteria (Definition of Done) + +1. A standard supabase-js app can: + - sign up, log in, refresh tokens + - perform CRUD with filters/order/pagination + - call RPC functions + - upload/download storage objects + - receive realtime row change events + - invoke an edge function +2. Two projects pointing at two different external Postgres instances are fully isolated. +3. RLS policies behave identically to Supabase for supported query patterns. +4. Entire stack deploys via podman-compose and exposes Grafana dashboards with logs+metrics. \ No newline at end of file diff --git a/auth/Cargo.toml b/auth/Cargo.toml new file mode 100644 index 00000000..25ae9856 --- /dev/null +++ b/auth/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "auth" +version = "0.1.0" +edition = "2021" + +[dependencies] +common = { workspace = true } +tokio = { workspace = true } +axum = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } +argon2 = { workspace = true } +jsonwebtoken = { workspace = true } +rand = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +anyhow = { workspace = true } +sha2 = { workspace = true } +oauth2 = "5.0.0" +reqwest = { version = "0.13.2", features = ["json"] } +validator = { version = "0.20.0", features = ["derive"] } diff --git a/auth/src/handlers.rs b/auth/src/handlers.rs new file mode 100644 index 00000000..a622ceba --- /dev/null +++ b/auth/src/handlers.rs @@ -0,0 +1,249 @@ +use crate::middleware::AuthContext; +use crate::models::{AuthResponse, SignInRequest, SignUpRequest, User}; +use crate::utils::{ + generate_refresh_token, generate_token, hash_password, hash_refresh_token, issue_refresh_token, verify_password, +}; +use axum::{ + extract::{Extension, Query, State}, + http::StatusCode, + Json, +}; +use common::Config; +use common::ProjectContext; +use serde::Deserialize; +use serde_json::Value; +use sqlx::{Executor, PgPool, Postgres}; +use std::collections::HashMap; +use uuid::Uuid; +use validator::Validate; + +#[derive(Clone)] +pub struct AuthState { + pub db: PgPool, + pub config: Config, +} + +#[derive(Deserialize)] +struct RefreshTokenGrant { + refresh_token: String, +} + +pub async fn signup( + State(state): State, + db: Option>, + project_ctx: Option>, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + payload.validate().map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone()); + // Check if user exists + let user_exists = sqlx::query("SELECT id FROM users WHERE email = $1") + .bind(&payload.email) + .fetch_optional(&db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if user_exists.is_some() { + return Err((StatusCode::BAD_REQUEST, "User already exists".to_string())); + } + + let hashed_password = hash_password(&payload.password) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let user = sqlx::query_as::<_, User>( + r#" + INSERT INTO users (email, encrypted_password, raw_user_meta_data) + VALUES ($1, $2, $3) + RETURNING * + "#, + ) + .bind(&payload.email) + .bind(hashed_password) + .bind(payload.data.unwrap_or(serde_json::json!({}))) + .fetch_one(&db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let jwt_secret = if let Some(Extension(ctx)) = project_ctx.as_ref() { + ctx.jwt_secret.as_str() + } else { + state.config.jwt_secret.as_str() + }; + + let (token, expires_in) = generate_token(user.id, &user.email, "authenticated", jwt_secret) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let refresh_token = issue_refresh_token(&db, user.id, Uuid::new_v4(), None).await?; + Ok(Json(AuthResponse { + access_token: token, + token_type: "bearer".to_string(), + expires_in, + refresh_token, + user, + })) +} + +pub async fn login( + State(state): State, + db: Option>, + project_ctx: Option>, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone()); + let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1") + .bind(&payload.email) + .fetch_optional(&db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or(( + StatusCode::UNAUTHORIZED, + "Invalid email or password".to_string(), + ))?; + + if !verify_password(&payload.password, &user.encrypted_password) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + { + return Err(( + StatusCode::UNAUTHORIZED, + "Invalid email or password".to_string(), + )); + } + + let jwt_secret = if let Some(Extension(ctx)) = project_ctx.as_ref() { + ctx.jwt_secret.as_str() + } else { + state.config.jwt_secret.as_str() + }; + + let (token, expires_in) = generate_token(user.id, &user.email, "authenticated", jwt_secret) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let refresh_token = issue_refresh_token(&db, user.id, Uuid::new_v4(), None).await?; + Ok(Json(AuthResponse { + access_token: token, + token_type: "bearer".to_string(), + expires_in, + refresh_token, + user, + })) +} + +pub async fn get_user( + State(state): State, + db: Option>, + Extension(auth_ctx): Extension, +) -> Result, (StatusCode, String)> { + let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone()); + let claims = auth_ctx + .claims + .ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?; + + let user_id = Uuid::parse_str(&claims.sub) + .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID".to_string()))?; + + let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(user_id) + .fetch_optional(&db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?; + + Ok(Json(user)) +} + +pub async fn token( + State(state): State, + db: Option>, + project_ctx: Option>, + Query(params): Query>, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone()); + let grant_type = params + .get("grant_type") + .map(|s| s.as_str()) + .unwrap_or("password"); + + match grant_type { + "password" => { + let req: SignInRequest = serde_json::from_value(payload) + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + req.validate().map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + login(State(state), Some(Extension(db)), project_ctx, Json(req)).await + } + "refresh_token" => { + let req: RefreshTokenGrant = serde_json::from_value(payload) + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + + let token_hash = hash_refresh_token(&req.refresh_token); + let mut tx = db + .begin() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let (revoked_token_hash, user_id, session_id) = + sqlx::query_as::<_, (String, Uuid, Option)>( + r#" + UPDATE refresh_tokens + SET revoked = true, updated_at = now() + WHERE token = $1 AND revoked = false + RETURNING token, user_id, session_id + "#, + ) + .bind(&token_hash) + .fetch_optional(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or(( + StatusCode::UNAUTHORIZED, + "Invalid refresh token".to_string(), + ))?; + + let session_id = session_id.ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + "Missing session".to_string(), + ))?; + + let new_refresh_token = issue_refresh_token( + &mut *tx, + user_id, + session_id, + Some(revoked_token_hash.as_str()), + ) + .await?; + + tx.commit() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(user_id) + .fetch_optional(&db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?; + + let jwt_secret = if let Some(Extension(ctx)) = project_ctx.as_ref() { + ctx.jwt_secret.as_str() + } else { + state.config.jwt_secret.as_str() + }; + + let (access_token, expires_in) = + generate_token(user.id, &user.email, "authenticated", jwt_secret) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(AuthResponse { + access_token, + token_type: "bearer".to_string(), + expires_in, + refresh_token: new_refresh_token, + user, + })) + } + _ => Err(( + StatusCode::BAD_REQUEST, + "Unsupported grant_type".to_string(), + )), + } +} diff --git a/auth/src/lib.rs b/auth/src/lib.rs new file mode 100644 index 00000000..00555b00 --- /dev/null +++ b/auth/src/lib.rs @@ -0,0 +1,19 @@ +pub mod handlers; +pub mod middleware; +pub mod models; +pub mod oauth; +pub mod utils; + +use axum::routing::{get, post}; +pub use axum::Router; +pub use handlers::AuthState; +pub use middleware::{auth_middleware, AuthContext, AuthMiddlewareState}; + +pub fn router() -> Router { + Router::new() + .route("/signup", post(handlers::signup)) + .route("/token", post(handlers::token)) + .route("/authorize", get(oauth::authorize)) + .route("/callback/:provider", get(oauth::callback)) + .route("/user", get(handlers::get_user)) +} diff --git a/auth/src/middleware.rs b/auth/src/middleware.rs new file mode 100644 index 00000000..087b2395 --- /dev/null +++ b/auth/src/middleware.rs @@ -0,0 +1,122 @@ +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::Response, +}; +use common::{Config, ProjectContext}; +use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone)] +pub struct AuthMiddlewareState { + pub config: Config, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Claims { + pub sub: String, + pub email: Option, + pub role: String, + pub exp: usize, + pub iss: String, + pub aud: Option, +} + +#[derive(Clone)] +pub struct AuthContext { + pub claims: Option, + pub role: String, +} + +pub async fn auth_middleware( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + // 1. Try to get ProjectContext (if available) + // If we are running in multi-tenant mode, ProjectContext should be present. + // If not, we fall back to global config (legacy/single-tenant). + let project_ctx = req.extensions().get::().cloned(); + + // Allow public OAuth routes + let path = req.uri().path(); + if path.contains("/authorize") || path.contains("/callback") { + return Ok(next.run(req).await); + } + + // Determine the secret to use + let jwt_secret = if let Some(ctx) = &project_ctx { + ctx.jwt_secret.clone() + } else { + state.config.jwt_secret.clone() + }; + + let auth_header = req + .headers() + .get("Authorization") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()); + + let apikey_header = req + .headers() + .get("apikey") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()); + + // Logic: + // 1. Bearer Token takes precedence for identity (Claims). + // 2. API Key is checked if no Bearer token, OR it acts as the "Client Key" (anon/service). + // Usually Supabase requires 'apikey' header ALWAYS, and Authorization header OPTIONAL (for user context). + + let token = if let Some(auth) = auth_header { + auth.strip_prefix("Bearer ").map(|t| t.to_string()) + } else { + // If no Auth header, check apikey header as fallback (e.g. for anon requests) + apikey_header.clone() + }; + + if let Some(token) = token { + let mut validation = Validation::new(Algorithm::HS256); + validation.validate_exp = true; + validation.validate_aud = false; + // validation.set_audience(&["authenticated"]); // If we used audience + + match decode::( + &token, + &DecodingKey::from_secret(jwt_secret.as_bytes()), + &validation, + ) { + Ok(token_data) => { + let claims = token_data.claims; + let role = claims.role.clone(); + + let ctx = AuthContext { + claims: Some(claims), + role, + }; + req.extensions_mut().insert(ctx); + return Ok(next.run(req).await); + } + Err(_) => { + // Invalid token + return Err(StatusCode::UNAUTHORIZED); + } + } + } + + // No valid token found. + // Assign "anon" role if apikey is valid anon key? + // Or just default to "anon" role without claims? + // Supabase usually requires a valid JWT even for anon. The 'anon key' IS a JWT with role='anon'. + + // So if decoding failed above, we returned Unauthorized. + // If no header provided at all? + // We should allow public routes to proceed? + // But this middleware is applied to ALL routes in /rest, /auth etc. + // /auth/v1/signup needs to be accessible. + // But wait, even signup requires the 'anon' key in Supabase. + + // So: strict check. + Err(StatusCode::UNAUTHORIZED) +} diff --git a/auth/src/models.rs b/auth/src/models.rs new file mode 100644 index 00000000..a7d4aeee --- /dev/null +++ b/auth/src/models.rs @@ -0,0 +1,64 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; +use validator::Validate; + +#[derive(Debug, Serialize, Deserialize, FromRow, Clone)] +pub struct User { + pub id: Uuid, + pub email: String, + #[serde(skip)] + pub encrypted_password: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub last_sign_in_at: Option>, + pub raw_app_meta_data: serde_json::Value, + pub raw_user_meta_data: serde_json::Value, + pub is_super_admin: Option, + pub confirmed_at: Option>, + pub email_confirmed_at: Option>, + pub phone: Option, + pub phone_confirmed_at: Option>, + pub confirmation_token: Option, + pub recovery_token: Option, + pub email_change_token_new: Option, + pub email_change: Option, +} + +#[derive(Debug, Deserialize, Validate)] +pub struct SignUpRequest { + #[validate(email)] + pub email: String, + #[validate(length(min = 6, message = "Password must be at least 6 characters"))] + pub password: String, + pub data: Option, +} + +#[derive(Debug, Deserialize, Validate)] +pub struct SignInRequest { + #[validate(email)] + pub email: String, + pub password: String, +} + +#[derive(Debug, Serialize)] +pub struct AuthResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: i64, + pub refresh_token: String, + pub user: User, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct RefreshToken { + pub id: i64, // BigSerial + pub token: String, + pub user_id: Uuid, + pub revoked: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub parent: Option, + pub session_id: Option, +} diff --git a/auth/src/oauth.rs b/auth/src/oauth.rs new file mode 100644 index 00000000..c71badc5 --- /dev/null +++ b/auth/src/oauth.rs @@ -0,0 +1,307 @@ +use crate::utils::{generate_token, issue_refresh_token}; +use crate::AuthState; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Redirect}, + Json, + extract::Extension, +}; +use common::{Config, ProjectContext}; +use oauth2::{ + basic::{BasicErrorResponseType, BasicTokenType}, + AuthUrl, AuthorizationCode, Client, ClientId, ClientSecret, CsrfToken, + EmptyExtraTokenFields, EndpointNotSet, EndpointSet, HttpRequest, HttpResponse, + RedirectUrl, RevocationErrorResponseType, Scope, StandardErrorResponse, + StandardRevocableToken, StandardTokenIntrospectionResponse, StandardTokenResponse, + TokenResponse, TokenUrl, +}; +use reqwest::Client as ReqwestClient; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +pub struct OAuthRequest { + pub provider: String, + pub redirect_to: Option, +} + +#[derive(Debug, Deserialize)] +pub struct OAuthCallback { + pub code: String, + pub state: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct UserProfile { + email: String, + name: Option, + avatar_url: Option, + provider_id: String, +} + +#[derive(Debug)] +pub struct OAuthHttpError(String); +impl std::fmt::Display for OAuthHttpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "OAuth HTTP Error: {}", self.0) + } +} +impl std::error::Error for OAuthHttpError {} + +// Define the client type that matches our usage (AuthUrl + TokenUrl set) +type OAuthClient = Client< + StandardErrorResponse, + StandardTokenResponse, + StandardTokenIntrospectionResponse, + StandardRevocableToken, + StandardErrorResponse, + EndpointSet, // HasAuthUrl + EndpointNotSet, + EndpointNotSet, + EndpointNotSet, + EndpointSet, // HasTokenUrl +>; + +pub async fn async_http_client( + request: HttpRequest, +) -> Result { + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| OAuthHttpError(e.to_string()))?; + + let mut request_builder = client + .request(request.method().clone(), request.uri().to_string()); + + for (name, value) in request.headers() { + request_builder = request_builder.header(name, value); + } + + request_builder = request_builder.body(request.into_body()); + + let response = request_builder.send().await.map_err(|e| OAuthHttpError(e.to_string()))?; + + let mut builder = axum::http::Response::builder() + .status(response.status()); + + for (name, value) in response.headers() { + builder = builder.header(name, value); + } + + builder + .body(response.bytes().await.map_err(|e| OAuthHttpError(e.to_string()))?.to_vec()) + .map_err(|e| OAuthHttpError(e.to_string())) +} + +fn get_client(provider: &str, config: &Config) -> Result { + let (client_id, client_secret, auth_url, token_url) = match provider { + "google" => ( + config.google_client_id.clone().ok_or("Google Client ID not set")?, + config.google_client_secret.clone().ok_or("Google Client Secret not set")?, + "https://accounts.google.com/o/oauth2/v2/auth", + "https://oauth2.googleapis.com/token", + ), + "github" => ( + config.github_client_id.clone().ok_or("GitHub Client ID not set")?, + config.github_client_secret.clone().ok_or("GitHub Client Secret not set")?, + "https://github.com/login/oauth/authorize", + "https://github.com/login/oauth/access_token", + ), + _ => return Err(format!("Unknown provider: {}", provider)), + }; + + let redirect_uri = if config.redirect_uri.ends_with('/') { + format!("{}{}", config.redirect_uri, provider) + } else { + format!("{}/{}", config.redirect_uri, provider) + }; + + let client = Client::new(ClientId::new(client_id)) + .set_client_secret(ClientSecret::new(client_secret)) + .set_auth_uri(AuthUrl::new(auth_url.to_string()).map_err(|e| e.to_string())?) + .set_token_uri(TokenUrl::new(token_url.to_string()).map_err(|e| e.to_string())?) + .set_redirect_uri(RedirectUrl::new(redirect_uri).map_err(|e| e.to_string())?); + + Ok(client) +} + +pub async fn authorize( + State(state): State, + Query(query): Query, +) -> Result { + let client = get_client(&query.provider, &state.config) + .map_err(|e| (StatusCode::BAD_REQUEST, e))?; + + let mut auth_request = client.authorize_url(CsrfToken::new_random); + + match query.provider.as_str() { + "google" => { + auth_request = auth_request + .add_scope(Scope::new("email".to_string())) + .add_scope(Scope::new("profile".to_string())); + } + "github" => { + auth_request = auth_request + .add_scope(Scope::new("user:email".to_string())); + } + _ => {} + } + + let (auth_url, _csrf_token) = auth_request.url(); + + // TODO: Store csrf_token in cookie/session for validation + + Ok(Redirect::to(auth_url.as_str())) +} + +pub async fn callback( + State(state): State, + db: Option>, + project_ctx: Option>, + Path(provider): Path, + Query(query): Query, +) -> Result { + let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone()); + let client = get_client(&provider, &state.config) + .map_err(|e| (StatusCode::BAD_REQUEST, e))?; + + let token_result = client + .exchange_code(AuthorizationCode::new(query.code)) + .request_async(&async_http_client) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Token exchange failed: {}", e)))?; + + let access_token = token_result.access_token().secret(); + + let user_profile = fetch_user_profile(&provider, access_token).await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + + // Check if user exists by email + let existing_user = sqlx::query_as::<_, crate::models::User>("SELECT * FROM users WHERE email = $1") + .bind(&user_profile.email) + .fetch_optional(&db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let user = if let Some(u) = existing_user { + // Update user meta data if needed? For now, just return existing user. + // We might want to record that they logged in with this provider. + u + } else { + // Create new user + let raw_meta = json!({ + "name": user_profile.name, + "avatar_url": user_profile.avatar_url, + "provider": provider, + "provider_id": user_profile.provider_id + }); + + sqlx::query_as::<_, crate::models::User>( + r#" + INSERT INTO users (email, encrypted_password, raw_user_meta_data) + VALUES ($1, $2, $3) + RETURNING * + "#, + ) + .bind(&user_profile.email) + .bind("oauth_user_no_password") // Placeholder + .bind(raw_meta) + .fetch_one(&db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + }; + + let jwt_secret = if let Some(Extension(ctx)) = project_ctx.as_ref() { + ctx.jwt_secret.as_str() + } else { + state.config.jwt_secret.as_str() + }; + + let (token, expires_in) = generate_token(user.id, &user.email, "authenticated", jwt_secret) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let refresh_token: String = issue_refresh_token(&db, user.id, Uuid::new_v4(), None) + .await + .map_err(|(code, msg)| (StatusCode::from_u16(code.as_u16()).unwrap(), msg))?; + + Ok(Json(json!({ + "access_token": token, + "token_type": "bearer", + "expires_in": expires_in, + "refresh_token": refresh_token, + "user": user + }))) +} + +async fn fetch_user_profile(provider: &str, token: &str) -> Result { + let client = ReqwestClient::new(); + match provider { + "google" => { + let resp = client.get("https://www.googleapis.com/oauth2/v2/userinfo") + .bearer_auth(token) + .send() + .await + .map_err(|e| e.to_string())? + .json::() + .await + .map_err(|e| e.to_string())?; + + let email = resp["email"].as_str().ok_or("No email found")?.to_string(); + let name = resp["name"].as_str().map(|s| s.to_string()); + let avatar_url = resp["picture"].as_str().map(|s| s.to_string()); + let provider_id = resp["id"].as_str().ok_or("No ID found")?.to_string(); + + Ok(UserProfile { + email, + name, + avatar_url, + provider_id, + }) + }, + "github" => { + let resp = client.get("https://api.github.com/user") + .bearer_auth(token) + .header("User-Agent", "madbase") + .send() + .await + .map_err(|e| e.to_string())? + .json::() + .await + .map_err(|e| e.to_string())?; + + let email = if let Some(e) = resp["email"].as_str() { + e.to_string() + } else { + // Fetch private emails + let emails = client.get("https://api.github.com/user/emails") + .bearer_auth(token) + .header("User-Agent", "madbase") + .send() + .await + .map_err(|e| e.to_string())? + .json::>() + .await + .map_err(|e| e.to_string())?; + + let primary = emails.iter().find(|e| e["primary"].as_bool().unwrap_or(false)) + .ok_or("No primary email found")?; + + primary["email"].as_str().ok_or("No email found")?.to_string() + }; + + let name = resp["name"].as_str().map(|s| s.to_string()); + let avatar_url = resp["avatar_url"].as_str().map(|s| s.to_string()); + let provider_id = resp["id"].as_i64().map(|id| id.to_string()).ok_or("No ID found")?.to_string(); + + Ok(UserProfile { + email, + name, + avatar_url, + provider_id, + }) + }, + _ => Err("Unknown provider".to_string()) + } +} diff --git a/auth/src/utils.rs b/auth/src/utils.rs new file mode 100644 index 00000000..2303708b --- /dev/null +++ b/auth/src/utils.rs @@ -0,0 +1,118 @@ +use argon2::{ + password_hash::{ + rand_core::{OsRng, RngCore}, + PasswordHash, PasswordHasher, PasswordVerifier, SaltString, + }, + Argon2, +}; +use chrono::{Duration, Utc}; +use jsonwebtoken::{encode, EncodingKey, Header}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Claims { + pub sub: String, + pub email: Option, + pub role: String, + pub exp: usize, + pub iss: String, + pub aud: Option, + pub iat: usize, +} + +pub fn hash_password(password: &str) -> anyhow::Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| anyhow::anyhow!(e))? + .to_string(); + Ok(password_hash) +} + +pub fn verify_password(password: &str, password_hash: &str) -> anyhow::Result { + let parsed_hash = PasswordHash::new(password_hash).map_err(|e| anyhow::anyhow!(e))?; + Ok(Argon2::default() + .verify_password(password.as_bytes(), &parsed_hash) + .is_ok()) +} + +pub fn generate_refresh_token() -> String { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + hex_encode(&bytes) +} + +pub fn hash_refresh_token(raw: &str) -> String { + let digest = Sha256::digest(raw.as_bytes()); + hex_encode(&digest) +} + +pub fn generate_token( + user_id: Uuid, + email: &str, + role: &str, + jwt_secret: &str, +) -> anyhow::Result<(String, i64)> { + let now = Utc::now(); + let expiration = now + .checked_add_signed(Duration::seconds(3600)) // 1 hour + .expect("valid timestamp") + .timestamp(); + + let claims = Claims { + sub: user_id.to_string(), + email: Some(email.to_string()), + role: role.to_string(), + exp: expiration as usize, + iss: "madbase".to_string(), + aud: Some("authenticated".to_string()), + iat: now.timestamp() as usize, + }; + + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(jwt_secret.as_bytes()), + )?; + + Ok((token, 3600)) +} + +fn hex_encode(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + use std::fmt::Write; + let _ = write!(&mut out, "{:02x}", b); + } + out +} + +pub async fn issue_refresh_token( + executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + user_id: Uuid, + session_id: Uuid, + parent: Option<&str>, +) -> Result { + let token = generate_refresh_token(); + let token_hash = hash_refresh_token(&token); + + sqlx::query( + r#" + INSERT INTO refresh_tokens (token, user_id, session_id, parent) + VALUES ($1, $2, $3, $4) + "#, + ) + .bind(&token_hash) + .bind(user_id) + .bind(session_id) + .bind(parent) + .execute(executor) + .await + .map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(token) +} + diff --git a/common/Cargo.toml b/common/Cargo.toml new file mode 100644 index 00000000..1a8376f2 --- /dev/null +++ b/common/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "common" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +sqlx = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +config = { workspace = true } +dotenvy = { workspace = true } diff --git a/common/src/config.rs b/common/src/config.rs new file mode 100644 index 00000000..0b1ebf22 --- /dev/null +++ b/common/src/config.rs @@ -0,0 +1,60 @@ +use serde::{Deserialize, Serialize}; +use std::env; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Config { + pub database_url: String, + pub jwt_secret: String, + pub port: u16, + pub google_client_id: Option, + pub google_client_secret: Option, + pub github_client_id: Option, + pub github_client_secret: Option, + pub redirect_uri: String, + pub rate_limit_per_second: u64, +} + +impl Config { + pub fn new() -> Result { + let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let jwt_secret = + env::var("JWT_SECRET").unwrap_or_else(|_| "super-secret-key-please-change".to_string()); + let port = env::var("PORT") + .unwrap_or_else(|_| "8000".to_string()) + .parse() + .unwrap_or(8000); + let rate_limit_per_second = env::var("RATE_LIMIT_PER_SECOND") + .unwrap_or_else(|_| "10".to_string()) + .parse() + .unwrap_or(10); + + let google_client_id = env::var("GOOGLE_CLIENT_ID").ok(); + let google_client_secret = env::var("GOOGLE_CLIENT_SECRET").ok(); + let github_client_id = env::var("GITHUB_CLIENT_ID").ok(); + let github_client_secret = env::var("GITHUB_CLIENT_SECRET").ok(); + let redirect_uri = env::var("REDIRECT_URI") + .unwrap_or_else(|_| "http://localhost:8000/auth/v1/callback".to_string()); + + Ok(Config { + database_url, + jwt_secret, + port, + google_client_id, + google_client_secret, + github_client_id, + github_client_secret, + redirect_uri, + rate_limit_per_second, + }) + } +} + +// New struct for Project Context +#[derive(Clone, Debug)] +pub struct ProjectContext { + pub project_ref: String, + pub db_url: String, + pub jwt_secret: String, + pub anon_key: Option, + pub service_role_key: Option, +} diff --git a/common/src/db.rs b/common/src/db.rs new file mode 100644 index 00000000..4831343a --- /dev/null +++ b/common/src/db.rs @@ -0,0 +1,10 @@ +use sqlx::postgres::{PgPool, PgPoolOptions}; +use std::time::Duration; + +pub async fn init_pool(database_url: &str) -> Result { + PgPoolOptions::new() + .max_connections(20) + .acquire_timeout(Duration::from_secs(3)) + .connect(database_url) + .await +} diff --git a/common/src/lib.rs b/common/src/lib.rs new file mode 100644 index 00000000..f6a02c55 --- /dev/null +++ b/common/src/lib.rs @@ -0,0 +1,5 @@ +pub mod config; +pub mod db; + +pub use config::{Config, ProjectContext}; +pub use db::init_pool; diff --git a/control_plane/Cargo.toml b/control_plane/Cargo.toml new file mode 100644 index 00000000..6743eee7 --- /dev/null +++ b/control_plane/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "control_plane" +version = "0.1.0" +edition = "2021" + +[dependencies] +common = { workspace = true } +auth = { workspace = true } +tokio = { workspace = true } +axum = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } +rand = { workspace = true } +base64 = "0.21" +jsonwebtoken = { workspace = true } +chrono = { workspace = true } +anyhow = { workspace = true } diff --git a/control_plane/src/lib.rs b/control_plane/src/lib.rs new file mode 100644 index 00000000..ff27332f --- /dev/null +++ b/control_plane/src/lib.rs @@ -0,0 +1,251 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + routing::{delete, get, put}, + Json, Router, +}; +use jsonwebtoken::{encode, EncodingKey, Header}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Clone)] +pub struct ControlPlaneState { + pub db: PgPool, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct Project { + pub id: Uuid, + pub name: String, + pub owner_id: Option, + pub status: String, + pub db_url: String, + pub jwt_secret: String, + pub anon_key: Option, + pub service_role_key: Option, + pub created_at: Option>, +} + +#[derive(Deserialize)] +pub struct CreateProjectRequest { + pub name: String, + pub owner_id: Option, +} + +#[derive(Serialize, Deserialize)] +struct Claims { + role: String, + iss: String, + iat: usize, + exp: usize, + sub: String, +} + +pub async fn list_projects( + State(state): State, +) -> Result>, (StatusCode, String)> { + let projects = sqlx::query_as::<_, Project>("SELECT * FROM projects") + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(projects)) +} + +pub async fn create_project( + State(state): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + // 1. Generate JWT Secret + let jwt_secret: String = rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(40) + .map(char::from) + .collect(); + + // 2. Generate Keys (JWTs) + let anon_key = generate_jwt(&jwt_secret, "anon") + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let service_role_key = generate_jwt(&jwt_secret, "service_role") + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let default_db_url = std::env::var("DEFAULT_TENANT_DB_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_default(); + + let project = sqlx::query_as::<_, Project>( + r#" + INSERT INTO projects (name, owner_id, status, db_url, jwt_secret, anon_key, service_role_key) + VALUES ($1, $2, 'active', $3, $4, $5, $6) + RETURNING * + "# + ) + .bind(&payload.name) + .bind(payload.owner_id) + .bind(default_db_url) + .bind(&jwt_secret) + .bind(&anon_key) + .bind(&service_role_key) + .fetch_one(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(project)) +} + +pub async fn delete_project( + State(state): State, + Path(id): Path, +) -> Result { + // Soft delete + let result = sqlx::query("UPDATE projects SET status = 'deleted' WHERE id = $1") + .bind(id) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "Project not found".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +pub struct RotateKeyRequest { + pub new_secret: Option, +} + +pub async fn rotate_keys( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let jwt_secret = payload.new_secret.unwrap_or_else(|| { + rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(40) + .map(char::from) + .collect() + }); + + let anon_key = generate_jwt(&jwt_secret, "anon") + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let service_role_key = generate_jwt(&jwt_secret, "service_role") + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let project = sqlx::query_as::<_, Project>( + r#" + UPDATE projects + SET jwt_secret = $1, anon_key = $2, service_role_key = $3 + WHERE id = $4 + RETURNING * + "#, + ) + .bind(&jwt_secret) + .bind(&anon_key) + .bind(&service_role_key) + .bind(id) + .fetch_optional(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Project not found".to_string()))?; + + Ok(Json(project)) +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct AdminUser { + pub id: Uuid, + pub email: String, + pub created_at: chrono::DateTime, +} + +pub async fn list_users( + State(state): State, +) -> Result>, (StatusCode, String)> { + let users = sqlx::query_as::<_, AdminUser>("SELECT id, email, created_at FROM users") + .fetch_all(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(users)) +} + +pub async fn delete_user( + State(state): State, + Path(id): Path, +) -> Result { + let result = sqlx::query("DELETE FROM users WHERE id = $1") + .bind(id) + .execute(&state.db) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "User not found".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} + +fn generate_jwt(secret: &str, role: &str) -> anyhow::Result { + let now = chrono::Utc::now(); + let claims = Claims { + role: role.to_string(), + iss: "madbase".to_string(), + iat: now.timestamp() as usize, + exp: (now + chrono::Duration::days(365 * 10)).timestamp() as usize, // 10 years + sub: role.to_string(), // Use role as sub + }; + + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + )?; + + Ok(token) +} + +pub fn router(state: ControlPlaneState) -> Router { + Router::new() + .route("/projects", get(list_projects).post(create_project)) + .route("/projects/:id", delete(delete_project)) + .route("/projects/:id/keys", put(rotate_keys)) + .route("/users", get(list_users)) + .route("/users/:id", delete(delete_user)) + .with_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; + + #[test] + fn test_jwt_generation() { + let secret = "test-secret-123"; + let role = "anon"; + + let token = generate_jwt(secret, role).expect("Failed to generate JWT"); + + let mut validation = Validation::new(Algorithm::HS256); + validation.validate_exp = true; + + let token_data = decode::( + &token, + &DecodingKey::from_secret(secret.as_bytes()), + &validation, + ) + .expect("Failed to decode JWT"); + + assert_eq!(token_data.claims.role, "anon"); + assert_eq!(token_data.claims.sub, "anon"); + assert_eq!(token_data.claims.iss, "madbase"); + } +} diff --git a/data_api/Cargo.toml b/data_api/Cargo.toml new file mode 100644 index 00000000..742ce796 --- /dev/null +++ b/data_api/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "data_api" +version = "0.1.0" +edition = "2021" + +[dependencies] +common = { workspace = true } +auth = { workspace = true } +tokio = { workspace = true } +axum = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } +regex = { workspace = true } +futures = { workspace = true } +uuid = { workspace = true, features = ["serde"] } +chrono = { workspace = true, features = ["serde"] } diff --git a/data_api/src/handlers.rs b/data_api/src/handlers.rs new file mode 100644 index 00000000..c217ef2a --- /dev/null +++ b/data_api/src/handlers.rs @@ -0,0 +1,893 @@ +use crate::parser::{Operator, QueryParams, SelectNode, FilterNode}; +use auth::AuthContext; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Json}, + Extension, +}; +use common::Config; +use futures::future::BoxFuture; +use serde_json::{json, Value}; +use sqlx::{Column, PgPool, Row, TypeInfo}; +use std::collections::HashMap; +use uuid::Uuid; + +#[derive(Clone)] +pub struct DataState { + pub db: PgPool, + pub config: Config, +} + +enum SqlValue { + String(String), + Int(i64), + Float(f64), + Bool(bool), + Uuid(Uuid), + Json(Value), + Null, +} + +fn json_value_to_sql_value(v: Value) -> SqlValue { + match v { + Value::String(s) => { + if let Ok(u) = Uuid::parse_str(&s) { + SqlValue::Uuid(u) + } else { + SqlValue::String(s) + } + }, + Value::Number(n) => { + if let Some(i) = n.as_i64() { + SqlValue::Int(i) + } else if let Some(f) = n.as_f64() { + SqlValue::Float(f) + } else { + SqlValue::String(n.to_string()) + } + }, + Value::Bool(b) => SqlValue::Bool(b), + Value::Object(_) | Value::Array(_) => SqlValue::Json(v), + Value::Null => SqlValue::Null, + } +} + +pub async fn get_rows( + State(state): State, + db: Option>, + Extension(auth_ctx): Extension, + Path(table): Path, + Query(params): Query>, +) -> Result { + let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone()); + let query_params = QueryParams::parse(params); + + if !is_valid_identifier(&table) { + return Err((StatusCode::BAD_REQUEST, "Invalid table name".to_string())); + } + + // Start transaction for RLS + let mut tx = db + .begin() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Set RLS variables + let role_query = format!("SET LOCAL role = '{}'", auth_ctx.role); + sqlx::query(&role_query) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set role: {}", e), + ) + })?; + + if let Some(claims) = &auth_ctx.claims { + let sub_query = "SELECT set_config('request.jwt.claim.sub', $1, true)"; + sqlx::query(sub_query) + .bind(&claims.sub) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set claims: {}", e), + ) + })?; + + if let Some(email) = &claims.email { + let email_query = "SELECT set_config('request.jwt.claim.email', $1, true)"; + sqlx::query(email_query) + .bind(email) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set claims: {}", e), + ) + })?; + } + } + + // --- Construct Query --- + // Use pool for schema introspection to avoid borrowing tx + let select_clause = build_select_clause(&query_params.select, &table, &db).await?; + + let mut sql = format!("SELECT {} FROM {}", select_clause, table); + let mut values: Vec = Vec::new(); + let mut param_index = 1; + + if !query_params.filters.is_empty() { + sql.push_str(" WHERE "); + let conditions: Vec = query_params + .filters + .iter() + .map(|f| build_filter_clause(f, &mut param_index, &mut values)) + .collect(); + sql.push_str(&conditions.join(" AND ")); + } + + if let Some(order) = query_params.order { + if is_valid_identifier(&order.column) { + let dir = match order.direction { + crate::parser::Direction::Asc => "ASC", + crate::parser::Direction::Desc => "DESC", + }; + sql.push_str(&format!(" ORDER BY {} {}", order.column, dir)); + } + } + + if let Some(limit) = query_params.limit { + sql.push_str(&format!(" LIMIT {}", limit)); + } + + if let Some(offset) = query_params.offset { + sql.push_str(&format!(" OFFSET {}", offset)); + } + + let mut query = sqlx::query(&sql); + for v in values { + match v { + SqlValue::String(s) => query = query.bind(s), + SqlValue::Int(n) => query = query.bind(n), + SqlValue::Float(f) => query = query.bind(f), + SqlValue::Bool(b) => query = query.bind(b), + SqlValue::Uuid(u) => query = query.bind(u), + SqlValue::Json(j) => query = query.bind(j), + SqlValue::Null => query = query.bind(Option::::None), + }; + } + + let rows = query + .fetch_all(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + tx.commit() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let json_rows = rows_to_json(rows); + Ok(Json(json_rows)) +} + +fn build_filter_clause( + node: &FilterNode, + param_index: &mut usize, + values: &mut Vec, +) -> String { + match node { + FilterNode::Condition { column, operator, value } => { + if !is_valid_identifier(column) { + return "false".to_string(); + } + let clause = match operator { + Operator::In => { + format!("{} {} (${})", column, operator.to_sql(), param_index) + } + _ => format!("{} {} ${}", column, operator.to_sql(), param_index), + }; + + let val = if let Ok(i) = value.parse::() { + SqlValue::Int(i) + } else if let Ok(f) = value.parse::() { + SqlValue::Float(f) + } else if let Ok(b) = value.parse::() { + SqlValue::Bool(b) + } else if let Ok(u) = Uuid::parse_str(value) { + SqlValue::Uuid(u) + } else { + SqlValue::String(value.clone()) + }; + + values.push(val); + *param_index += 1; + clause + } + FilterNode::Or(nodes) => { + let clauses: Vec = nodes + .iter() + .map(|n| build_filter_clause(n, param_index, values)) + .collect(); + if clauses.is_empty() { + "false".to_string() + } else { + format!("({})", clauses.join(" OR ")) + } + } + FilterNode::And(nodes) => { + let clauses: Vec = nodes + .iter() + .map(|n| build_filter_clause(n, param_index, values)) + .collect(); + if clauses.is_empty() { + "true".to_string() + } else { + format!("({})", clauses.join(" AND ")) + } + } + } +} + + +fn build_select_clause<'a>( + nodes: &'a [SelectNode], + table: &'a str, + pool: &'a PgPool, +) -> BoxFuture<'a, Result> { + Box::pin(async move { + if nodes.is_empty() { + return Ok("*".to_string()); + } + + let mut clauses = Vec::new(); + for node in nodes { + match node { + SelectNode::Column(c) => { + if c == "*" { + clauses.push("*".to_string()); + } else if is_valid_identifier(c) { + clauses.push(format!("\"{}\"", c)); + } + } + SelectNode::Relation(rel, inner) => { + let fk_info = find_foreign_key(table, rel, pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + + if let Some((local_col, foreign_table, foreign_col)) = fk_info { + let inner_select = if inner.is_empty() { + "*".to_string() + } else { + build_select_clause(inner, &foreign_table, pool).await? + }; + + let subquery = if foreign_col.starts_with("REV:") { + let actual_foreign_col = &foreign_col[4..]; + format!( + "(SELECT json_agg(t) FROM (SELECT {} FROM {} WHERE {} = {}.{}) t) as \"{}\"", + inner_select, foreign_table, actual_foreign_col, table, local_col, rel + ) + } else { + format!( + "(SELECT row_to_json(t) FROM (SELECT {} FROM {} WHERE {} = {}.{}) t) as \"{}\"", + inner_select, foreign_table, foreign_col, table, local_col, rel + ) + }; + clauses.push(subquery); + } + } + } + } + + if clauses.is_empty() { + return Err((StatusCode::BAD_REQUEST, "No valid columns selected".to_string())); + } + + Ok(clauses.join(", ")) + }) +} + + +async fn find_foreign_key( + table: &str, + relation: &str, + pool: &PgPool, +) -> Result, String> { + // Basic introspection to find FK. + // We look for a table named `relation` or a column named `relation_id`. + // PostgREST logic is complex, here's a simplified version: + // 1. Check if `relation` is a table name. + // 2. Find FK between `table` and `relation`. + + let query = r#" + SELECT + kcu.column_name as local_col, + ccu.table_name as foreign_table, + ccu.column_name as foreign_col + FROM + information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage AS ccu + ON ccu.constraint_name = tc.constraint_name + AND ccu.table_schema = tc.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_name = $1 + AND ccu.table_name = $2; + "#; + + let row = sqlx::query_as::<_, (String, String, String)>(query) + .bind(table) + .bind(relation) + .fetch_optional(pool) + .await + .map_err(|e| e.to_string())?; + + if let Some(r) = row { + return Ok(Some(r)); + } + + // Try reverse (many-to-one): relation table has FK to our table + let reverse_query = r#" + SELECT + ccu.column_name as local_col, + tc.table_name as foreign_table, + kcu.column_name as foreign_col + FROM + information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage AS ccu + ON ccu.constraint_name = tc.constraint_name + AND ccu.table_schema = tc.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_name = $2 + AND ccu.table_name = $1; + "#; + + let row = sqlx::query_as::<_, (String, String, String)>(reverse_query) + .bind(table) + .bind(relation) + .fetch_optional(pool) + .await + .map_err(|e| e.to_string())?; + + if let Some(r) = row { + // For reverse relations (one-to-many), we want to aggregate them. + // Returning a tuple that signifies reverse relation might be tricky with the same signature. + // Let's hack it: return foreign_col as "REV:foreign_col". + return Ok(Some((r.0, r.1, format!("REV:{}", r.2)))); + } + + Ok(None) +} + + +fn rows_to_json(rows: Vec) -> Vec { + let mut json_rows = Vec::new(); + for row in rows { + let mut obj = serde_json::Map::new(); + for col in row.columns() { + let name = col.name(); + let type_info = col.type_info(); + let type_name = type_info.name(); + + tracing::info!("Column: {}, Type: {}", name, type_name); + + let val: Value = if type_name == "BOOL" { + json!(row.try_get::(name).unwrap_or(false)) + } else if type_name == "INT2" { + json!(row.try_get::(name).unwrap_or(0)) + } else if type_name == "INT4" { + json!(row.try_get::(name).unwrap_or(0)) + } else if type_name == "INT8" { + json!(row.try_get::(name).unwrap_or(0)) + } else if ["FLOAT4", "FLOAT8"].contains(&type_name) { + json!(row.try_get::(name).unwrap_or(0.0)) + } else if ["JSON", "JSONB"].contains(&type_name) { + row.try_get::(name).unwrap_or(Value::Null) + } else if type_name == "UUID" { + if let Ok(u) = row.try_get::(name) { + json!(u.to_string()) + } else { + Value::Null + } + } else if type_name == "TIMESTAMPTZ" { + if let Ok(ts) = row.try_get::, _>(name) { + json!(ts) + } else { + Value::Null + } + } else if type_name == "TIMESTAMP" { + if let Ok(ts) = row.try_get::(name) { + json!(ts.to_string()) + } else { + Value::Null + } + } else { + // Fallback for types that can't be directly read as String + match row.try_get::(name) { + Ok(s) => json!(s), + Err(_) => match row.try_get::(name) { + Ok(v) => v, + Err(_) => Value::Null, + }, + } + }; + + obj.insert(name.to_string(), val); + } + json_rows.push(Value::Object(obj)); + } + json_rows +} + +pub async fn insert_row( + State(state): State, + db: Option>, + Extension(auth_ctx): Extension, + Path(table): Path, + Json(payload): Json, +) -> Result { + let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone()); + if !is_valid_identifier(&table) { + return Err((StatusCode::BAD_REQUEST, "Invalid table name".to_string())); + } + + // Start transaction for RLS + let mut tx = db + .begin() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Set RLS variables + let role_query = format!("SET LOCAL role = '{}'", auth_ctx.role); + sqlx::query(&role_query) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set role: {}", e), + ) + })?; + + if let Some(claims) = &auth_ctx.claims { + let sub_query = "SELECT set_config('request.jwt.claim.sub', $1, true)"; + sqlx::query(sub_query) + .bind(&claims.sub) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set claims: {}", e), + ) + })?; + + if let Some(email) = &claims.email { + let email_query = "SELECT set_config('request.jwt.claim.email', $1, true)"; + sqlx::query(email_query) + .bind(email) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set claims: {}", e), + ) + })?; + } + } + + let rows_to_insert = match payload { + Value::Array(arr) => arr, + Value::Object(obj) => vec![Value::Object(obj)], + _ => return Err((StatusCode::BAD_REQUEST, "Payload must be a JSON object or array".to_string())), + }; + + if rows_to_insert.is_empty() { + return Err((StatusCode::BAD_REQUEST, "Payload empty".to_string())); + } + + // Use keys from the first row as the columns + let first_row = rows_to_insert[0].as_object().ok_or((StatusCode::BAD_REQUEST, "Rows must be objects".to_string()))?; + let columns: Vec = first_row.keys().cloned().collect(); + + if columns.is_empty() { + return Err((StatusCode::BAD_REQUEST, "No columns to insert".to_string())); + } + + let col_str = columns + .iter() + .map(|c| format!("\"{}\"", c)) + .collect::>() + .join(", "); + + let mut values_sql = Vec::new(); + let mut bind_values: Vec = Vec::new(); + let mut param_index = 1; + + for row in rows_to_insert { + let obj = row.as_object().ok_or((StatusCode::BAD_REQUEST, "Rows must be objects".to_string()))?; + let mut row_placeholders = Vec::new(); + + for col in &columns { + row_placeholders.push(format!("${}", param_index)); + param_index += 1; + + // Get value or Null + let val = obj.get(col).cloned().unwrap_or(Value::Null); + bind_values.push(json_value_to_sql_value(val)); + } + values_sql.push(format!("({})", row_placeholders.join(", "))); + } + + let sql = format!( + "INSERT INTO {} ({}) VALUES {} RETURNING *", + table, col_str, values_sql.join(", ") + ); + + let mut query = sqlx::query(&sql); + + for v in bind_values { + match v { + SqlValue::String(s) => query = query.bind(s), + SqlValue::Int(n) => query = query.bind(n), + SqlValue::Float(f) => query = query.bind(f), + SqlValue::Bool(b) => query = query.bind(b), + SqlValue::Uuid(u) => query = query.bind(u), + SqlValue::Json(j) => query = query.bind(j), + SqlValue::Null => query = query.bind(Option::::None), + }; + } + + let rows = query + .fetch_all(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + tx.commit() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let json_rows = rows_to_json(rows); + Ok((StatusCode::CREATED, Json(json_rows))) +} + + +pub async fn delete_rows( + State(state): State, + db: Option>, + Extension(auth_ctx): Extension, + Path(table): Path, + Query(params): Query>, +) -> Result { + let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone()); + let query_params = QueryParams::parse(params); + + if !is_valid_identifier(&table) { + return Err((StatusCode::BAD_REQUEST, "Invalid table name".to_string())); + } + + let mut tx = db + .begin() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let role_query = format!("SET LOCAL role = '{}'", auth_ctx.role); + sqlx::query(&role_query) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set role: {}", e), + ) + })?; + + if let Some(claims) = &auth_ctx.claims { + let sub_query = "SELECT set_config('request.jwt.claim.sub', $1, true)"; + sqlx::query(sub_query) + .bind(&claims.sub) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set claims: {}", e), + ) + })?; + + if let Some(email) = &claims.email { + let email_query = "SELECT set_config('request.jwt.claim.email', $1, true)"; + sqlx::query(email_query) + .bind(email) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set claims: {}", e), + ) + })?; + } + } + + let mut sql = format!("DELETE FROM {}", table); + let mut values: Vec = Vec::new(); + let mut param_index = 1; + + if !query_params.filters.is_empty() { + sql.push_str(" WHERE "); + let conditions: Vec = query_params + .filters + .iter() + .map(|f| build_filter_clause(f, &mut param_index, &mut values)) + .collect(); + sql.push_str(&conditions.join(" AND ")); + } + + let mut query = sqlx::query(&sql); + for v in values { + match v { + SqlValue::String(s) => query = query.bind(s), + SqlValue::Int(n) => query = query.bind(n), + SqlValue::Float(f) => query = query.bind(f), + SqlValue::Bool(b) => query = query.bind(b), + SqlValue::Uuid(u) => query = query.bind(u), + SqlValue::Json(j) => query = query.bind(j), + SqlValue::Null => query = query.bind(Option::::None), + }; + } + + query + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Delete Rows error: SQL={}, Error={:?}", sql, e); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) + })?; + + tx.commit() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn update_rows( + State(state): State, + db: Option>, + Extension(auth_ctx): Extension, + Path(table): Path, + Query(params): Query>, + Json(payload): Json, +) -> Result { + let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone()); + if !is_valid_identifier(&table) { + return Err((StatusCode::BAD_REQUEST, "Invalid table name".to_string())); + } + + let query_params = QueryParams::parse(params); + + let mut tx = db + .begin() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let role_query = format!("SET LOCAL role = '{}'", auth_ctx.role); + sqlx::query(&role_query) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set role: {}", e), + ) + })?; + + if let Some(claims) = &auth_ctx.claims { + let sub_query = "SELECT set_config('request.jwt.claim.sub', $1, true)"; + sqlx::query(sub_query) + .bind(&claims.sub) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set claims: {}", e), + ) + })?; + + if let Some(email) = &claims.email { + let email_query = "SELECT set_config('request.jwt.claim.email', $1, true)"; + sqlx::query(email_query) + .bind(email) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set claims: {}", e), + ) + })?; + } + } + + let obj = payload.as_object().ok_or(( + StatusCode::BAD_REQUEST, + "Payload must be a JSON object".to_string(), + ))?; + if obj.is_empty() { + return Err((StatusCode::BAD_REQUEST, "Payload empty".to_string())); + } + + let mut final_sql = format!("UPDATE {} SET ", table); + let mut final_values: Vec = Vec::new(); + let mut p_idx = 1; + + let mut sets = Vec::new(); + for (k, v) in obj { + sets.push(format!("\"{}\" = ${}", k, p_idx)); + final_values.push(json_value_to_sql_value(v.clone())); + p_idx += 1; + } + final_sql.push_str(&sets.join(", ")); + + if !query_params.filters.is_empty() { + final_sql.push_str(" WHERE "); + let mut conds = Vec::new(); + + for f in &query_params.filters { + conds.push(build_filter_clause(f, &mut p_idx, &mut final_values)); + } + final_sql.push_str(&conds.join(" AND ")); + } + + let mut query = sqlx::query(&final_sql); + + for v in final_values { + match v { + SqlValue::String(s) => query = query.bind(s), + SqlValue::Int(n) => query = query.bind(n), + SqlValue::Float(f) => query = query.bind(f), + SqlValue::Bool(b) => query = query.bind(b), + SqlValue::Uuid(u) => query = query.bind(u), + SqlValue::Json(j) => query = query.bind(j), + SqlValue::Null => query = query.bind(Option::::None), + }; + } + + query + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Update Rows error: SQL={}, Error={:?}", final_sql, e); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) + })?; + + tx.commit() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn rpc( + State(state): State, + db: Option>, + Extension(auth_ctx): Extension, + Path(function): Path, + Json(payload): Json, +) -> Result { + let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone()); + if !is_valid_identifier(&function) { + return Err((StatusCode::BAD_REQUEST, "Invalid function name".to_string())); + } + + let mut tx = db + .begin() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let role_query = format!("SET LOCAL role = '{}'", auth_ctx.role); + sqlx::query(&role_query) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set role: {}", e), + ) + })?; + + if let Some(claims) = &auth_ctx.claims { + let sub_query = "SELECT set_config('request.jwt.claim.sub', $1, true)"; + sqlx::query(sub_query) + .bind(&claims.sub) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set claims: {}", e), + ) + })?; + + if let Some(email) = &claims.email { + let email_query = "SELECT set_config('request.jwt.claim.email', $1, true)"; + sqlx::query(email_query) + .bind(email) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set claims: {}", e), + ) + })?; + } + } + + let obj = payload.as_object().ok_or(( + StatusCode::BAD_REQUEST, + "Payload must be a JSON object".to_string(), + ))?; + + let mut args = Vec::new(); + let mut values: Vec = Vec::new(); + let mut p_idx = 1; + + for (k, v) in obj { + if !is_valid_identifier(k) { + return Err((StatusCode::BAD_REQUEST, "Invalid argument name".to_string())); + } + args.push(format!("{} => ${}", k, p_idx)); + values.push(json_value_to_sql_value(v.clone())); + p_idx += 1; + } + + let sql = if args.is_empty() { + format!("SELECT * FROM {}()", function) + } else { + format!("SELECT * FROM {}({})", function, args.join(", ")) + }; + + let mut query = sqlx::query(&sql); + + for v in values { + match v { + SqlValue::String(s) => query = query.bind(s), + SqlValue::Int(n) => query = query.bind(n), + SqlValue::Float(f) => query = query.bind(f), + SqlValue::Bool(b) => query = query.bind(b), + SqlValue::Uuid(u) => query = query.bind(u), + SqlValue::Json(j) => query = query.bind(j), + SqlValue::Null => query = query.bind(Option::::None), + }; + } + + let rows = query + .fetch_all(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + tx.commit() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let json_rows = rows_to_json(rows); + Ok(Json(json_rows)) +} + +fn is_valid_identifier(s: &str) -> bool { + s.chars().all(|c| c.is_alphanumeric() || c == '_') && !s.is_empty() +} diff --git a/data_api/src/lib.rs b/data_api/src/lib.rs new file mode 100644 index 00000000..3f7c344f --- /dev/null +++ b/data_api/src/lib.rs @@ -0,0 +1,20 @@ +pub mod handlers; +pub mod parser; + +use axum::{ + routing::{get, post}, + Router, +}; +use handlers::DataState; + +pub fn router() -> Router { + Router::new() + .route("/rpc/:function", post(handlers::rpc)) + .route( + "/:table", + get(handlers::get_rows) + .post(handlers::insert_row) + .patch(handlers::update_rows) + .delete(handlers::delete_rows), + ) +} diff --git a/data_api/src/parser.rs b/data_api/src/parser.rs new file mode 100644 index 00000000..fa9e1b56 --- /dev/null +++ b/data_api/src/parser.rs @@ -0,0 +1,276 @@ +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq)] +pub enum Operator { + Eq, + Neq, + Gt, + Gte, + Lt, + Lte, + Like, + Ilike, + In, + Is, +} + +impl Operator { + pub fn parse(s: &str) -> Option { + match s { + "eq" => Some(Operator::Eq), + "neq" => Some(Operator::Neq), + "gt" => Some(Operator::Gt), + "gte" => Some(Operator::Gte), + "lt" => Some(Operator::Lt), + "lte" => Some(Operator::Lte), + "like" => Some(Operator::Like), + "ilike" => Some(Operator::Ilike), + "in" => Some(Operator::In), + "is" => Some(Operator::Is), + _ => None, + } + } + + pub fn to_sql(&self) -> &'static str { + match self { + Operator::Eq => "=", + Operator::Neq => "!=", + Operator::Gt => ">", + Operator::Gte => ">=", + Operator::Lt => "<", + Operator::Lte => "<=", + Operator::Like => "LIKE", + Operator::Ilike => "ILIKE", + Operator::In => "IN", + Operator::Is => "IS", + } + } +} + +#[derive(Debug, Clone)] +pub struct Order { + pub column: String, + pub direction: Direction, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Direction { + Asc, + Desc, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SelectNode { + Column(String), + Relation(String, Vec), +} + +impl SelectNode { + pub fn parse(input: &str) -> Vec { + let mut nodes = Vec::new(); + let mut buffer = String::new(); + let mut depth = 0; + + for c in input.chars() { + match c { + '(' => { + depth += 1; + buffer.push(c); + } + ')' => { + depth -= 1; + buffer.push(c); + } + ',' => { + if depth == 0 { + nodes.push(Self::parse_single(&buffer)); + buffer.clear(); + } else { + buffer.push(c); + } + } + _ => buffer.push(c), + } + } + if !buffer.is_empty() { + nodes.push(Self::parse_single(&buffer)); + } + nodes + } + + fn parse_single(s: &str) -> Self { + let s = s.trim(); + if let Some(idx) = s.find('(') { + if s.ends_with(')') { + let relation = &s[..idx]; + let inner = &s[idx + 1..s.len() - 1]; + return SelectNode::Relation(relation.to_string(), Self::parse(inner)); + } + } + SelectNode::Column(s.to_string()) + } +} + +#[derive(Debug, Clone)] +pub enum FilterNode { + Condition { + column: String, + operator: Operator, + value: String, + }, + Or(Vec), + And(Vec), +} + +impl FilterNode { + pub fn parse(key: &str, value: &str) -> Option { + if key == "or" || key == "and" { + let content = value.trim_start_matches('(').trim_end_matches(')'); + let parts = split_respecting_parens(content); + let mut nodes = Vec::new(); + + for part in parts { + // Try to find first dot to split col.op.val + // But handle nested logic: or(...) + if let Some(idx) = part.find('(') { + // It might be logic operator like or(...) + let k = &part[..idx]; + let v = &part[idx..]; + if let Some(node) = FilterNode::parse(k, v) { + nodes.push(node); + continue; + } + } + + // Normal case: col.op.val + if let Some(dot_idx) = part.find('.') { + let k = &part[..dot_idx]; + let v = &part[dot_idx + 1..]; + if let Some(node) = FilterNode::parse(k, v) { + nodes.push(node); + } + } + } + + if key == "or" { + Some(FilterNode::Or(nodes)) + } else { + Some(FilterNode::And(nodes)) + } + } else { + // Check for filters: column=operator.value or column=value (eq implicit) + let parts: Vec<&str> = value.splitn(2, '.').collect(); + if parts.len() == 2 { + if let Some(op) = Operator::parse(parts[0]) { + return Some(FilterNode::Condition { + column: key.to_string(), + operator: op, + value: parts[1].to_string(), + }); + } + } + // Default to eq + Some(FilterNode::Condition { + column: key.to_string(), + operator: Operator::Eq, + value: value.to_string(), + }) + } + } +} + +fn split_respecting_parens(input: &str) -> Vec { + let mut parts = Vec::new(); + let mut buffer = String::new(); + let mut depth = 0; + + for c in input.chars() { + match c { + '(' => { + depth += 1; + buffer.push(c); + } + ')' => { + depth -= 1; + buffer.push(c); + } + ',' => { + if depth == 0 { + parts.push(buffer.trim().to_string()); + buffer.clear(); + } else { + buffer.push(c); + } + } + _ => buffer.push(c), + } + } + if !buffer.is_empty() { + parts.push(buffer.trim().to_string()); + } + parts +} + +#[derive(Debug, Clone)] +pub struct QueryParams { + pub select: Vec, + pub filters: Vec, + pub order: Option, + pub limit: Option, + pub offset: Option, +} + +impl QueryParams { + pub fn parse(params: HashMap) -> Self { + let mut filters = Vec::new(); + let mut select = Vec::new(); + let mut order = None; + let mut limit = None; + let mut offset = None; + + for (key, value) in params { + match key.as_str() { + "select" => { + select = SelectNode::parse(&value); + } + "order" => { + // format: column.asc or column.desc + let parts: Vec<&str> = value.split('.').collect(); + if parts.len() == 2 { + let direction = match parts[1] { + "desc" => Direction::Desc, + _ => Direction::Asc, + }; + order = Some(Order { + column: parts[0].to_string(), + direction, + }); + } + } + "limit" => { + if let Ok(l) = value.parse::() { + limit = Some(l); + } + } + "offset" => { + if let Ok(o) = value.parse::() { + offset = Some(o); + } + } + _ => { + if let Some(node) = FilterNode::parse(&key, &value) { + filters.push(node); + } + } + } + } + + QueryParams { + select, + filters, + order, + limit, + offset, + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..08127152 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,113 @@ +services: + # Tenant Database (User Data) + db: + image: postgres:15-alpine + container_name: madbase_db + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + # Enable logical replication for Realtime + POSTGRES_HOST_AUTH_METHOD: trust + command: ["postgres", "-c", "wal_level=logical"] + ports: + - "5432:5432" + volumes: + - madbase_db_data:/var/lib/postgresql/data + + # Control Plane Database (Project Config, Secrets) + control_db: + image: postgres:15-alpine + container_name: madbase_control_db + restart: unless-stopped + environment: + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin_password + POSTGRES_DB: madbase_control + ports: + - "5433:5432" + volumes: + - madbase_control_db_data:/var/lib/postgresql/data + + # Object Storage (S3 Compatible) + minio: + image: minio/minio + container_name: madbase_minio + restart: unless-stopped + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + command: server /data --console-address ":9001" + ports: + - "9000:9000" + - "9001:9001" + volumes: + - madbase_minio_data:/data + + # Observability Stack + victoriametrics: + image: victoriametrics/victoria-metrics:v1.93.0 + container_name: madbase_vm + ports: + - "8428:8428" + volumes: + - madbase_vm_data:/victoria-metrics-data + - ./prometheus.yml:/etc/prometheus/prometheus.yml + command: + - "--storageDataPath=/victoria-metrics-data" + - "--httpListenAddr=:8428" + - "--promscrape.config=/etc/prometheus/prometheus.yml" + extra_hosts: + - "host.docker.internal:host-gateway" + + loki: + image: grafana/loki:2.9.2 + container_name: madbase_loki + ports: + - "3100:3100" + command: -config.file=/etc/loki/local-config.yaml + volumes: + - madbase_loki_data:/loki + + grafana: + image: grafana/grafana:10.2.0 + container_name: madbase_grafana + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - madbase_grafana_data:/var/lib/grafana + depends_on: + - victoriametrics + - loki + + gateway: + build: . + container_name: madbase_gateway + restart: unless-stopped + ports: + - "8000:8000" + environment: + - DATABASE_URL=postgres://admin:admin_password@control_db:5432/madbase_control + - DEFAULT_TENANT_DB_URL=postgres://postgres:postgres@db:5432/postgres + - S3_ENDPOINT=http://minio:9000 + - JWT_SECRET=supersecret + - PORT=8000 + - RUST_LOG=debug + - LOG_FORMAT=json + - RATE_LIMIT_PER_SECOND=1000 + depends_on: + - db + - control_db + - victoriametrics + - loki + +volumes: + madbase_db_data: + madbase_control_db_data: + madbase_minio_data: + madbase_vm_data: + madbase_loki_data: + madbase_grafana_data: diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml new file mode 100644 index 00000000..ce2069cc --- /dev/null +++ b/gateway/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "gateway" +version = "0.1.0" +edition = "2021" + +[dependencies] +common = { workspace = true } +auth = { workspace = true } +data_api = { workspace = true } +control_plane = { workspace = true } +realtime = { workspace = true } +storage = { workspace = true } + +tokio = { workspace = true } +axum = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sqlx = { workspace = true } +dotenvy = { workspace = true } +anyhow = { workspace = true } +axum-prometheus = "0.6" +tower_governor = "0.4.2" +tower-http = { version = "0.6.8", features = ["cors", "trace"] } +moka = { version = "0.12.14", features = ["future"] } + diff --git a/gateway/src/main.rs b/gateway/src/main.rs new file mode 100644 index 00000000..f7a70e52 --- /dev/null +++ b/gateway/src/main.rs @@ -0,0 +1,220 @@ +mod middleware; +mod state; + +use axum::{ + extract::Request, + middleware::{from_fn, from_fn_with_state, Next}, + response::Response, + routing::get, + Router, +}; +use axum_prometheus::PrometheusMetricLayer; +use common::{init_pool, Config}; +use state::AppState; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::RwLock; +use tower_governor::{governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor, GovernorLayer}; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::trace::TraceLayer; +use moka::future::Cache; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +async fn log_headers(req: Request, next: Next) -> Response { + tracing::debug!("Request Headers: {:?}", req.headers()); + next.run(req).await +} + +async fn dashboard_handler() -> axum::response::Html<&'static str> { + axum::response::Html(include_str!("../../web/index.html")) +} + +async fn wait_for_db(db_url: &str) -> sqlx::PgPool { + loop { + match init_pool(db_url).await { + Ok(pool) => return pool, + Err(e) => { + tracing::warn!("Database not ready yet, retrying in 2s: {}", e); + tokio::time::sleep(Duration::from_secs(2)).await; + } + } + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Load configuration + dotenvy::dotenv().ok(); + let config = Config::new().expect("Failed to load configuration"); + + // Initialize tracing + let rust_log = std::env::var("RUST_LOG").unwrap_or_else(|_| "debug".into()); + + if std::env::var("LOG_FORMAT").ok().as_deref() == Some("json") { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new(&rust_log)) + .with(tracing_subscriber::fmt::layer().json()) + .init(); + } else { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new(&rust_log)) + .with(tracing_subscriber::fmt::layer()) + .init(); + } + + tracing::info!("Starting MadBase Gateway..."); + + // Initialize Database (Control Plane / Main DB) + tracing::info!("Connecting to database at {}...", config.database_url); + let pool = wait_for_db(&config.database_url).await; + tracing::info!("Database connected successfully."); + + // Run Migrations + tracing::info!("Running database migrations..."); + sqlx::migrate!("../migrations") + .run(&pool) + .await + .expect("Failed to run migrations"); + tracing::info!("Migrations applied successfully."); + + let app_state = AppState { + control_db: pool.clone(), + tenant_pools: Arc::new(RwLock::new(HashMap::new())), + }; + + // Auth State (Legacy/Fallback) + let auth_state = auth::AuthState { + db: pool.clone(), + config: config.clone(), + }; + + let data_state = data_api::handlers::DataState { + db: pool.clone(), + config: config.clone(), + }; + + let control_state = control_plane::ControlPlaneState { db: pool.clone() }; + + // Initialize Tenant Database (for Realtime) + let default_tenant_db_url = std::env::var("DEFAULT_TENANT_DB_URL") + .expect("DEFAULT_TENANT_DB_URL must be set"); + tracing::info!("Connecting to default tenant database at {}...", default_tenant_db_url); + let tenant_pool = wait_for_db(&default_tenant_db_url).await; + tracing::info!("Tenant Database connected successfully."); + + let mut tenant_config = config.clone(); + tenant_config.database_url = default_tenant_db_url; + + // Realtime Init + let (realtime_router, realtime_state) = realtime::init(tenant_pool.clone(), tenant_config.clone()); + + // Start Replication Listener + let repl_config = tenant_config.clone(); + let repl_tx = realtime_state.broadcast_tx.clone(); + tokio::spawn(async move { + if let Err(e) = realtime::replication::start_replication_listener(repl_config, repl_tx).await { + tracing::error!("Replication listener failed: {}", e); + } + }); + + // Storage Init + let storage_router = storage::init(pool.clone(), config.clone()).await; + + // Auth Middleware State + let auth_middleware_state = auth::AuthMiddlewareState { + config: config.clone(), + }; + + // Project Middleware State + let project_middleware_state = middleware::ProjectMiddlewareState { + control_db: app_state.control_db.clone(), + tenant_pools: app_state.tenant_pools.clone(), + project_cache: Cache::new(100), + }; + + // Construct App + // We apply `resolve_project` middleware to /auth, /rest, /storage, /realtime + // But NOT /platform (admin) + + let tenant_routes = Router::new() + .nest( + "/auth/v1", + auth::router() + .layer(from_fn_with_state( + auth_middleware_state.clone(), + auth::auth_middleware, + )) + .with_state(auth_state), + ) + .nest( + "/rest/v1", + data_api::router() + .layer(from_fn_with_state( + auth_middleware_state.clone(), + auth::auth_middleware, + )) + .with_state(data_state), + ) + .nest("/realtime/v1", realtime_router) + .nest( + "/storage/v1", + storage_router.layer(from_fn_with_state( + auth_middleware_state.clone(), + auth::auth_middleware, + )), + ) + .layer(from_fn_with_state( + project_middleware_state.clone(), + middleware::inject_tenant_pool, + )) + .layer(from_fn_with_state( + project_middleware_state, + middleware::resolve_project, + )); + + // Metrics + let (prometheus_layer, metric_handle) = PrometheusMetricLayer::pair(); + + // Rate Limiting Configuration + let governor_conf = Arc::new( + GovernorConfigBuilder::default() + .per_second(config.rate_limit_per_second) + .burst_size(config.rate_limit_per_second as u32 * 2) + .key_extractor(SmartIpKeyExtractor) + .finish() + .unwrap(), + ); + + let app = Router::new() + .route("/", get(|| async { "Hello, MadBase!" })) + .route("/metrics", get(|| async move { metric_handle.render() })) + .route("/dashboard", get(dashboard_handler)) + .nest("/", tenant_routes) // Apply project resolution to these + .nest( + "/platform/v1", // Admin/Control Plane API (No project resolution needed) + control_plane::router(control_state), + ) + .layer(GovernorLayer { + config: governor_conf, + }) + .layer( + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any), + ) + .layer(TraceLayer::new_for_http()) + .layer(from_fn(log_headers)) + .layer(prometheus_layer); + + // Run it + let addr = SocketAddr::from(([0, 0, 0, 0], config.port)); + tracing::info!("Listening on {}", addr); + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app.into_make_service_with_connect_info::()).await?; + + Ok(()) +} diff --git a/gateway/src/middleware.rs b/gateway/src/middleware.rs new file mode 100644 index 00000000..721c73db --- /dev/null +++ b/gateway/src/middleware.rs @@ -0,0 +1,133 @@ +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::Response, +}; +use common::init_pool; +use common::ProjectContext; +use moka::future::Cache; +use sqlx::PgPool; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; +use tracing::warn; + +#[derive(Clone)] +pub struct ProjectMiddlewareState { + pub control_db: PgPool, + pub tenant_pools: Arc>>, + pub project_cache: Cache, +} + +pub async fn resolve_project( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + // 1. Extract Project Ref from Header or Subdomain + let project_ref = if let Some(val) = req.headers().get("x-project-ref") { + val.to_str() + .map_err(|_| StatusCode::BAD_REQUEST)? + .to_string() + } else { + "default".to_string() + }; + + // 2. Check Cache + if let Some(ctx) = state.project_cache.get(&project_ref).await { + req.extensions_mut().insert(ctx); + return Ok(next.run(req).await); + } + + // 3. Fetch Project Config from DB + // Use a common Record struct or map manually to avoid anonymous struct type mismatch in if/else + + #[derive(sqlx::FromRow)] + struct ProjectRecord { + db_url: String, + jwt_secret: String, + anon_key: Option, + service_role_key: Option, + } + + let record = if project_ref == "default" { + sqlx::query_as::<_, ProjectRecord>( + "SELECT db_url, jwt_secret, anon_key, service_role_key FROM projects LIMIT 1", + ) + .fetch_optional(&state.control_db) + .await + .map_err(|e| { + warn!("DB Error: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })? + } else { + sqlx::query_as::<_, ProjectRecord>( + "SELECT db_url, jwt_secret, anon_key, service_role_key FROM projects WHERE name = $1", + ) + .bind(&project_ref) + .fetch_optional(&state.control_db) + .await + .map_err(|e| { + warn!("DB Error: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })? + }; + + if record.is_none() { + warn!("Project not found: {}", project_ref); + return Err(StatusCode::NOT_FOUND); + } + let project = record.unwrap(); + + // 4. Construct ProjectContext + let ctx = ProjectContext { + project_ref: project_ref.clone(), + db_url: project.db_url, + jwt_secret: project.jwt_secret, + anon_key: project.anon_key, + service_role_key: project.service_role_key, + }; + + // 5. Update Cache + state.project_cache.insert(project_ref.clone(), ctx.clone()).await; + + // 6. Inject into Request + req.extensions_mut().insert(ctx); + + Ok(next.run(req).await) +} + +pub async fn inject_tenant_pool( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let project_ctx = req + .extensions() + .get::() + .cloned() + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + + let db_url = project_ctx.db_url.clone(); + + let existing = { state.tenant_pools.read().await.get(&db_url).cloned() }; + + let pool = if let Some(p) = existing { + p + } else { + let new_pool = init_pool(&db_url) + .await + .map_err(|e| { + warn!("Failed to init tenant pool for {}: {}", db_url, e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let mut w = state.tenant_pools.write().await; + let entry = w.entry(db_url).or_insert_with(|| new_pool.clone()); + entry.clone() + }; + + req.extensions_mut().insert(pool); + Ok(next.run(req).await) +} diff --git a/gateway/src/state.rs b/gateway/src/state.rs new file mode 100644 index 00000000..6a882f9b --- /dev/null +++ b/gateway/src/state.rs @@ -0,0 +1,10 @@ +use sqlx::PgPool; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Clone)] +pub struct AppState { + pub control_db: PgPool, + pub tenant_pools: Arc>>, +} diff --git a/migrations/20240101000000_init_auth.sql b/migrations/20240101000000_init_auth.sql new file mode 100644 index 00000000..769e2fdc --- /dev/null +++ b/migrations/20240101000000_init_auth.sql @@ -0,0 +1,23 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email TEXT UNIQUE NOT NULL, + encrypted_password TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_sign_in_at TIMESTAMPTZ, + raw_app_meta_data JSONB DEFAULT '{}'::jsonb, + raw_user_meta_data JSONB DEFAULT '{}'::jsonb, + is_super_admin BOOLEAN DEFAULT false, + confirmed_at TIMESTAMPTZ, + email_confirmed_at TIMESTAMPTZ, + phone TEXT, + phone_confirmed_at TIMESTAMPTZ, + confirmation_token TEXT, + recovery_token TEXT, + email_change_token_new TEXT, + email_change TEXT +); + +CREATE INDEX users_email_idx ON users (email); diff --git a/migrations/20240101000001_refresh_tokens.sql b/migrations/20240101000001_refresh_tokens.sql new file mode 100644 index 00000000..5eff1f8d --- /dev/null +++ b/migrations/20240101000001_refresh_tokens.sql @@ -0,0 +1,14 @@ + +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id BIGSERIAL PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + revoked BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + parent TEXT, + session_id UUID +); + +CREATE INDEX IF NOT EXISTS refresh_tokens_token_idx ON refresh_tokens(token); +CREATE INDEX IF NOT EXISTS refresh_tokens_user_id_idx ON refresh_tokens(user_id); diff --git a/migrations/20240101000002_storage_schema.sql b/migrations/20240101000002_storage_schema.sql new file mode 100644 index 00000000..994f839d --- /dev/null +++ b/migrations/20240101000002_storage_schema.sql @@ -0,0 +1,72 @@ + +-- Create roles if they don't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'authenticated') THEN + CREATE ROLE authenticated NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'anon') THEN + CREATE ROLE anon NOLOGIN; + END IF; +END +$$; + +CREATE SCHEMA IF NOT EXISTS storage; + +-- Grant usage +GRANT USAGE ON SCHEMA storage TO authenticated, anon; +GRANT USAGE ON SCHEMA public TO authenticated, anon; + +CREATE TABLE IF NOT EXISTS storage.buckets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + public BOOLEAN DEFAULT false, + owner UUID REFERENCES public.users(id), + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS storage.objects ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + bucket_id TEXT REFERENCES storage.buckets(id), + name TEXT NOT NULL, + owner UUID REFERENCES public.users(id), + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + last_accessed_at TIMESTAMPTZ DEFAULT now(), + metadata JSONB, + UNIQUE (bucket_id, name) +); + +-- Grant table access (RLS will filter rows) +GRANT ALL ON TABLE storage.buckets TO authenticated, anon; +GRANT ALL ON TABLE storage.objects TO authenticated, anon; + +ALTER TABLE storage.buckets ENABLE ROW LEVEL SECURITY; +ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY; + +-- Helper to allow public access to public buckets +CREATE POLICY "Public Buckets are viewable by everyone" +ON storage.buckets FOR SELECT +USING ( public = true ); + +-- Helper to allow authenticated users to view their own buckets +CREATE POLICY "Users can view their own buckets" +ON storage.buckets FOR SELECT +TO authenticated +USING ( owner = current_setting('request.jwt.claim.sub', true)::uuid ); + +-- Objects policies depend on bucket public status or object owner +CREATE POLICY "Public Objects are viewable by everyone" +ON storage.objects FOR SELECT +USING ( bucket_id IN (SELECT id FROM storage.buckets WHERE public = true) ); + +CREATE POLICY "Users can view their own objects" +ON storage.objects FOR SELECT +TO authenticated +USING ( owner = current_setting('request.jwt.claim.sub', true)::uuid ); + +CREATE POLICY "Users can insert their own objects" +ON storage.objects FOR INSERT +TO authenticated +WITH CHECK ( owner = current_setting('request.jwt.claim.sub', true)::uuid ); diff --git a/migrations/20240101000003_control_plane.sql b/migrations/20240101000003_control_plane.sql new file mode 100644 index 00000000..d4a1e819 --- /dev/null +++ b/migrations/20240101000003_control_plane.sql @@ -0,0 +1,30 @@ + +-- This migration runs on the CONTROL PLANE database (port 5433), not the tenant DB. +-- We need to ensure we migrate the correct DB. +-- For MVP, if we only have one migration pipeline, we might mix them? +-- Ideally we use `sqlx migrate run --database-url ...` for this specific migration. +-- Or we just put this table in the main DB for the MVP to avoid infrastructure complexity? +-- The `docker-compose.yml` has `control_db`. +-- Let's try to use the main DB for everything in MVP to reduce friction, +-- OR use a separate folder for control plane migrations. + +-- Let's put `projects` in the `public` schema of the main DB for simplicity of the "Single Tenant / Self Hosted" mode. +-- In a real SaaS, this would be separate. + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS projects ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + name TEXT NOT NULL, + owner_id UUID, -- No FK to users strictly required if users are in tenant DB, but here they are same DB. + status TEXT DEFAULT 'active', + db_url TEXT NOT NULL, + jwt_secret TEXT NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'), + anon_key TEXT, + service_role_key TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Trigger to generate keys on insert? Or handle in code. +-- Let's handle in code for keys. diff --git a/migrations/20240101000004_realtime_triggers.sql b/migrations/20240101000004_realtime_triggers.sql new file mode 100644 index 00000000..2f775598 --- /dev/null +++ b/migrations/20240101000004_realtime_triggers.sql @@ -0,0 +1,49 @@ + +-- Realtime schema +CREATE SCHEMA IF NOT EXISTS madbase_realtime; + +-- Generic Trigger Function +CREATE OR REPLACE FUNCTION madbase_realtime.broadcast_changes() +RETURNS trigger AS $$ +DECLARE + payload jsonb; + topic text; +BEGIN + -- Construct payload + payload = jsonb_build_object( + 'schema', TG_TABLE_SCHEMA, + 'table', TG_TABLE_NAME, + 'type', TG_OP, + 'timestamp', now() + ); + + IF (TG_OP = 'INSERT') THEN + payload = payload || jsonb_build_object('record', row_to_json(NEW)::jsonb); + ELSIF (TG_OP = 'UPDATE') THEN + payload = payload || jsonb_build_object( + 'record', row_to_json(NEW)::jsonb, + 'old_record', row_to_json(OLD)::jsonb + ); + ELSIF (TG_OP = 'DELETE') THEN + payload = payload || jsonb_build_object('old_record', row_to_json(OLD)::jsonb); + END IF; + + -- Send notification + -- Payload limit is 8000 bytes. Larger payloads will fail or need truncation. + -- For MVP, we assume it fits. + PERFORM pg_notify('madbase_realtime', payload::text); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Example: Enable for public.users (if it exists) +-- DO $$ +-- BEGIN +-- IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'users') THEN +-- CREATE TRIGGER realtime_users_changes +-- AFTER INSERT OR UPDATE OR DELETE ON public.users +-- FOR EACH ROW EXECUTE FUNCTION madbase_realtime.broadcast_changes(); +-- END IF; +-- END +-- $$; diff --git a/migrations/20260311000000_realtime_history.sql b/migrations/20260311000000_realtime_history.sql new file mode 100644 index 00000000..4f518c4f --- /dev/null +++ b/migrations/20260311000000_realtime_history.sql @@ -0,0 +1,71 @@ +-- Create History Table +CREATE TABLE IF NOT EXISTS madbase_realtime.messages ( + id bigserial PRIMARY KEY, + topic text NOT NULL, -- schema:table + payload jsonb NOT NULL, + created_at timestamptz DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_realtime_messages_topic_id ON madbase_realtime.messages (topic, id); + +-- Update Trigger Function +CREATE OR REPLACE FUNCTION madbase_realtime.broadcast_changes() +RETURNS trigger AS $$ +DECLARE + base_payload jsonb; + final_payload jsonb; + topic text; + msg_id bigint; +BEGIN + -- Construct topic + topic = TG_TABLE_SCHEMA || ':' || TG_TABLE_NAME; + + -- Construct base payload + base_payload = jsonb_build_object( + 'schema', TG_TABLE_SCHEMA, + 'table', TG_TABLE_NAME, + 'type', TG_OP, + 'timestamp', now() + ); + + IF (TG_OP = 'INSERT') THEN + base_payload = base_payload || jsonb_build_object('record', row_to_json(NEW)::jsonb); + ELSIF (TG_OP = 'UPDATE') THEN + base_payload = base_payload || jsonb_build_object( + 'record', row_to_json(NEW)::jsonb, + 'old_record', row_to_json(OLD)::jsonb + ); + ELSIF (TG_OP = 'DELETE') THEN + base_payload = base_payload || jsonb_build_object('old_record', row_to_json(OLD)::jsonb); + END IF; + + -- Insert into history + INSERT INTO madbase_realtime.messages (topic, payload) + VALUES (topic, base_payload) + RETURNING id INTO msg_id; + + -- Add ID to payload + final_payload = base_payload || jsonb_build_object('id', msg_id); + + -- Send notification + -- Payload limit is 8000 bytes. Larger payloads will fail or need truncation. + -- If payload is too large, we can send a "payload too large" message with ID, + -- and client can fetch it from history. + -- For MVP, we assume it fits or fail silently on notify (but insert succeeds). + BEGIN + PERFORM pg_notify('madbase_realtime', final_payload::text); + EXCEPTION WHEN string_data_right_truncation OR others THEN + -- If notification fails, client can still rely on history if they poll or reconnect. + -- We could notify just the ID. + PERFORM pg_notify('madbase_realtime', jsonb_build_object( + 'id', msg_id, + 'schema', TG_TABLE_SCHEMA, + 'table', TG_TABLE_NAME, + 'type', TG_OP, + 'truncated', true + )::text); + END; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/migrations/20260311000001_fix_storage_permissions.sql b/migrations/20260311000001_fix_storage_permissions.sql new file mode 100644 index 00000000..7696f9dc --- /dev/null +++ b/migrations/20260311000001_fix_storage_permissions.sql @@ -0,0 +1,35 @@ +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'service_role') THEN + CREATE ROLE service_role NOLOGIN; + END IF; +END +$$; + +ALTER ROLE service_role WITH BYPASSRLS; + +GRANT USAGE ON SCHEMA storage TO service_role; +GRANT ALL ON ALL TABLES IN SCHEMA storage TO service_role; +GRANT ALL ON ALL SEQUENCES IN SCHEMA storage TO service_role; +GRANT ALL ON ALL FUNCTIONS IN SCHEMA storage TO service_role; + +-- Policies for service_role +CREATE POLICY "Service role can do anything on buckets" +ON storage.buckets +FOR ALL +TO service_role +USING (true) +WITH CHECK (true); + +CREATE POLICY "Service role can do anything on objects" +ON storage.objects +FOR ALL +TO service_role +USING (true) +WITH CHECK (true); + +-- Also grant usage on public schema just in case +GRANT USAGE ON SCHEMA public TO service_role; +GRANT ALL ON ALL TABLES IN SCHEMA public TO service_role; +GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO service_role; +GRANT ALL ON ALL FUNCTIONS IN SCHEMA public TO service_role; diff --git a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json new file mode 100644 index 00000000..e035d9bf --- /dev/null +++ b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json @@ -0,0 +1 @@ +{"version":"4.0.18","results":[[":tests/integration/realtime.test.ts",{"duration":5003.304583999998,"failed":true}],[":tests/integration/db.test.ts",{"duration":131.38708399999996,"failed":false}],[":tests/integration/storage.test.ts",{"duration":106.0102910000005,"failed":false}],[":tests/integration/auth.test.ts",{"duration":144.21875,"failed":false}]]} \ No newline at end of file diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 00000000..fcae2772 --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,7 @@ +global: + scrape_interval: 5s + +scrape_configs: + - job_name: 'madbase_gateway' + static_configs: + - targets: ['host.docker.internal:8000'] diff --git a/realtime/Cargo.toml b/realtime/Cargo.toml new file mode 100644 index 00000000..f59100d4 --- /dev/null +++ b/realtime/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "realtime" +version = "0.1.0" +edition = "2021" + +[dependencies] +common = { workspace = true } +auth = { workspace = true } +tokio = { workspace = true } +axum = { workspace = true, features = ["ws"] } +serde = { workspace = true } +serde_json = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } +futures = { workspace = true } +uuid = { workspace = true } +tokio-postgres = "0.7" +postgres-protocol = "0.6" +anyhow = { workspace = true } +bytes = "1.0" +jsonwebtoken = { workspace = true } +chrono.workspace = true diff --git a/realtime/src/lib.rs b/realtime/src/lib.rs new file mode 100644 index 00000000..4ae9dd35 --- /dev/null +++ b/realtime/src/lib.rs @@ -0,0 +1,34 @@ +pub mod replication; +pub mod ws; + +use axum::Router; +use common::Config; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::PgPool; +use tokio::sync::broadcast; +pub use ws::{router, RealtimeState}; + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct PostgresPayload { + pub schema: String, + pub table: String, + pub r#type: String, + #[serde(default)] + pub record: Option, + #[serde(default)] + pub old_record: Option, + #[serde(default)] + pub id: Option, +} + +pub fn init(db: PgPool, config: Config) -> (Router, RealtimeState) { + let (tx, _) = broadcast::channel(100); + let state = RealtimeState { + db, + config, + broadcast_tx: tx, + }; + + (ws::router(state.clone()), state) +} diff --git a/realtime/src/replication.rs b/realtime/src/replication.rs new file mode 100644 index 00000000..52367db1 --- /dev/null +++ b/realtime/src/replication.rs @@ -0,0 +1,35 @@ +use common::Config; +use tokio::sync::broadcast; +use std::sync::Arc; +use crate::PostgresPayload; + +// Fallback listener using LISTEN/NOTIFY +pub async fn start_replication_listener( + config: Config, + broadcast_tx: broadcast::Sender>, +) -> anyhow::Result<()> { + let mut listener = sqlx::postgres::PgListener::connect(&config.database_url).await?; + listener.listen("madbase_realtime").await?; + tracing::info!("Listening on channel 'madbase_realtime'"); + + loop { + match listener.recv().await { + Ok(notification) => { + let payload = notification.payload(); + tracing::debug!("Received notification: {}", payload); + match serde_json::from_str::(payload) { + Ok(pg_payload) => { + let _ = broadcast_tx.send(Arc::new(pg_payload)); + } + Err(e) => { + tracing::error!("Failed to parse notification payload: {}", e); + } + } + } + Err(e) => { + tracing::error!("Replication listener error: {}", e); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + } + } +} diff --git a/realtime/src/ws.rs b/realtime/src/ws.rs new file mode 100644 index 00000000..50515dcf --- /dev/null +++ b/realtime/src/ws.rs @@ -0,0 +1,223 @@ +use crate::PostgresPayload; +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + Request, State, + }, + middleware::{from_fn, Next}, + response::{IntoResponse, Response}, + routing::get, + Extension, Router, +}; +use common::{Config, ProjectContext}; +use futures::{sink::SinkExt, stream::StreamExt}; +use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::PgPool; +use std::collections::HashSet; +use std::sync::Arc; +use tokio::sync::{broadcast, mpsc}; + +#[derive(Clone)] +pub struct RealtimeState { + pub db: PgPool, + pub config: Config, + pub broadcast_tx: broadcast::Sender>, +} + +#[derive(Debug, Serialize, Deserialize)] +struct Claims { + sub: String, + role: String, + exp: usize, +} + +pub async fn ws_handler( + ws: WebSocketUpgrade, + State(state): State, + Extension(project_ctx): Extension, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_socket(socket, state, project_ctx)) +} + +async fn handle_socket(socket: WebSocket, state: RealtimeState, project_ctx: ProjectContext) { + let (mut ws_sender, mut ws_receiver) = socket.split(); + + // Channel for internal tasks to send messages to the websocket client + // We send raw JSON string to avoid struct complexity + let (tx_internal, mut rx_internal) = mpsc::channel::(100); + + let mut rx_broadcast = state.broadcast_tx.subscribe(); + + let mut subscriptions = HashSet::::new(); + + // We might store the user's role/claims if they authenticate + let mut _user_claims: Option = None; + + loop { + tokio::select! { + // 1. Handle incoming broadcast messages from Postgres + res = rx_broadcast.recv() => { + match res { + Ok(msg_arc) => { + let pg_payload = msg_arc.as_ref(); + tracing::debug!("Received broadcast for {}.{}", pg_payload.schema, pg_payload.table); + let topic = format!("realtime:{}:{}", pg_payload.schema, pg_payload.table); + let wildcard_topic = format!("realtime:{}:*", pg_payload.schema); + let global_topic = "realtime:*".to_string(); + + if subscriptions.contains(&topic) || subscriptions.contains(&wildcard_topic) || subscriptions.contains(&global_topic) { + tracing::debug!("Match found for topic: {}", topic); + // Map to Supabase Realtime V2 format + let payload = serde_json::json!({ + "schema": pg_payload.schema, + "table": pg_payload.table, + "commit_timestamp": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + "type": pg_payload.r#type.to_uppercase(), + "event": pg_payload.r#type.to_uppercase(), // For Supabase client fallback + "new": pg_payload.record, + "old": pg_payload.old_record, + "errors": Option::::None + }); + + // Phoenix V2 Message: [null, null, topic, "postgres_changes", payload] + let msg_arr = serde_json::json!([ + Value::Null, + Value::Null, + topic, + "postgres_changes", + payload + ]); + + if let Ok(json) = serde_json::to_string(&msg_arr) { + tracing::debug!("Sending to client: {}", json); + if ws_sender.send(Message::Text(json)).await.is_err() { + break; + } + } + } + } + Err(broadcast::error::RecvError::Lagged(_)) => { + tracing::warn!("Realtime broadcast lagged"); + continue; + } + Err(broadcast::error::RecvError::Closed) => { + break; + } + } + } + + // 2. Handle internal messages + msg = rx_internal.recv() => { + match msg { + Some(msg) => { + if ws_sender.send(Message::Text(msg)).await.is_err() { + break; + } + } + None => break, // Channel closed + } + } + + // 3. Handle incoming messages from Client + result = ws_receiver.next() => { + match result { + Some(Ok(Message::Text(text))) => { + // Parse Phoenix V2 Array + if let Ok(arr) = serde_json::from_str::>(&text) { + if arr.len() >= 4 { + let join_ref = arr.get(0).and_then(|v| v.as_str()).map(|s| s.to_string()); + let r#ref = arr.get(1).and_then(|v| v.as_str()).map(|s| s.to_string()); + let topic = arr.get(2).and_then(|v| v.as_str()).unwrap_or("").to_string(); + let event = arr.get(3).and_then(|v| v.as_str()).unwrap_or("").to_string(); + let payload = arr.get(4).cloned().unwrap_or(Value::Null); + + match event.as_str() { + "phx_join" => { + // Auth Check + let token = payload.get("access_token").and_then(|v| v.as_str()); + if let Some(jwt) = token { + let validation = Validation::new(Algorithm::HS256); + match decode::(jwt, &DecodingKey::from_secret(project_ctx.jwt_secret.as_bytes()), &validation) { + Ok(data) => { + _user_claims = Some(data.claims); + }, + Err(_) => { + tracing::warn!("Invalid JWT in join"); + } + } + } + + tracing::debug!("Client joined: {}", topic); + subscriptions.insert(topic.clone()); + + // Send Ack: [join_ref, ref, topic, "phx_reply", {status: "ok", response: {}}] + let reply = serde_json::json!([ + join_ref, + r#ref, + topic, + "phx_reply", + { "status": "ok", "response": {} } + ]); + if let Ok(reply_str) = serde_json::to_string(&reply) { + let _ = tx_internal.send(reply_str).await; + } + }, + "phx_leave" => { + tracing::debug!("Client left: {}", topic); + subscriptions.remove(&topic); + + let reply = serde_json::json!([ + join_ref, + r#ref, + topic, + "phx_reply", + { "status": "ok", "response": {} } + ]); + if let Ok(reply_str) = serde_json::to_string(&reply) { + let _ = tx_internal.send(reply_str).await; + } + }, + "heartbeat" => { + let reply = serde_json::json!([ + Value::Null, + r#ref, + "phoenix", + "phx_reply", + { "status": "ok", "response": {} } + ]); + if let Ok(reply_str) = serde_json::to_string(&reply) { + let _ = tx_internal.send(reply_str).await; + } + }, + _ => { + tracing::debug!("Unknown event: {}", event); + } + } + } + } else { + tracing::warn!("Failed to deserialize client message: {}", text); + } + }, + Some(Ok(Message::Close(_))) => break, + Some(Err(_)) => break, + None => break, // Stream closed + _ => {} + } + } + } + } +} + +async fn log_realtime(req: Request, next: Next) -> Response { + tracing::info!("Realtime router reached: {}", req.uri()); + next.run(req).await +} + +pub fn router(state: RealtimeState) -> Router { + Router::new() + .route("/websocket", get(ws_handler)) + .layer(from_fn(log_realtime)) + .with_state(state) +} diff --git a/restore_trigger.sql b/restore_trigger.sql new file mode 100644 index 00000000..9952b57f --- /dev/null +++ b/restore_trigger.sql @@ -0,0 +1,54 @@ +CREATE OR REPLACE FUNCTION madbase_realtime.broadcast_changes() +RETURNS trigger AS $$ +DECLARE + base_payload jsonb; + final_payload jsonb; + topic text; + msg_id bigint; +BEGIN + -- Construct topic + topic = TG_TABLE_SCHEMA || ':' || TG_TABLE_NAME; + + -- Construct base payload + base_payload = jsonb_build_object( + 'schema', TG_TABLE_SCHEMA, + 'table', TG_TABLE_NAME, + 'type', TG_OP, + 'timestamp', now() + ); + + IF (TG_OP = 'INSERT') THEN + base_payload = base_payload || jsonb_build_object('record', row_to_json(NEW)::jsonb); + ELSIF (TG_OP = 'UPDATE') THEN + base_payload = base_payload || jsonb_build_object( + 'record', row_to_json(NEW)::jsonb, + 'old_record', row_to_json(OLD)::jsonb + ); + ELSIF (TG_OP = 'DELETE') THEN + base_payload = base_payload || jsonb_build_object('old_record', row_to_json(OLD)::jsonb); + END IF; + + -- Insert into history + INSERT INTO madbase_realtime.messages (topic, payload) + VALUES (topic, base_payload) + RETURNING id INTO msg_id; + + -- Add ID to payload + final_payload = base_payload || jsonb_build_object('id', msg_id); + + -- Send notification + BEGIN + PERFORM pg_notify('madbase_realtime', final_payload::text); + EXCEPTION WHEN string_data_right_truncation OR others THEN + PERFORM pg_notify('madbase_realtime', jsonb_build_object( + 'id', msg_id, + 'schema', TG_TABLE_SCHEMA, + 'table', TG_TABLE_NAME, + 'type', TG_OP, + 'truncated', true + )::text); + END; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 00000000..e7a11a96 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} diff --git a/storage/Cargo.toml b/storage/Cargo.toml new file mode 100644 index 00000000..9d7dd369 --- /dev/null +++ b/storage/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "storage" +version = "0.1.0" +edition = "2021" + +[dependencies] +common = { workspace = true } +auth = { workspace = true } +tokio = { workspace = true } +axum = { workspace = true, features = ["multipart"] } +serde = { workspace = true } +serde_json = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } +futures = { workspace = true } +aws-sdk-s3 = { workspace = true } +aws-config = { workspace = true } +aws-types = { workspace = true } + +bytes = "1.0" +anyhow = { workspace = true } +tower = "0.4" +tower-http = { version = "0.5", features = ["fs", "trace"] } +uuid = { workspace = true } +chrono = { workspace = true } +http-body-util = "0.1.3" diff --git a/storage/src/handlers.rs b/storage/src/handlers.rs new file mode 100644 index 00000000..a1f66e11 --- /dev/null +++ b/storage/src/handlers.rs @@ -0,0 +1,427 @@ +use auth::AuthContext; +use aws_sdk_s3::{primitives::ByteStream, Client}; +use axum::{ + body::{Body, Bytes}, + extract::{FromRequest, Multipart, Path, Request, State}, + http::{header::{self, CONTENT_TYPE}, HeaderMap, StatusCode}, + response::{IntoResponse, Json}, + Extension, +}; +use common::{Config, ProjectContext}; +use futures::stream::StreamExt; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::{PgPool, Row}; +use std::sync::Arc; +use uuid::Uuid; +use http_body_util::BodyExt; // For collect() + +#[derive(Clone)] +pub struct StorageState { + pub db: PgPool, + pub s3_client: Client, + pub config: Config, + pub bucket_name: String, // Global S3 Bucket Name +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct FileObject { + pub name: String, + pub id: Option, + pub updated_at: Option>, + pub created_at: Option>, + pub last_accessed_at: Option>, + pub metadata: Option, +} + +pub async fn list_buckets( + State(state): State, + db: Option>, + Extension(auth_ctx): Extension, + Extension(_project_ctx): Extension, +) -> Result>, (StatusCode, String)> { + // Query storage.buckets with RLS + let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone()); + let mut tx = db + .begin() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let role_query = format!("SET LOCAL role = '{}'", auth_ctx.role); + sqlx::query(&role_query) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set role: {}", e), + ) + })?; + + if let Some(claims) = &auth_ctx.claims { + let sub_query = "SELECT set_config('request.jwt.claim.sub', $1, true)"; + sqlx::query(sub_query) + .bind(&claims.sub) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set claims: {}", e), + ) + })?; + } + + // In a real system, `storage.buckets` table would have a `project_id` column? + // OR we just use the single DB (which is shared in MVP) but RLS handles ownership? + // Wait, the DB tables are shared across all tenants in this MVP architecture? + // Yes, we only have one Postgres instance. + // So we need to filter by tenant/project if we had a project_id column. + // But `storage.buckets` schema (from Supabase) usually doesn't have project_id if it's per-tenant DB. + // Since we share the DB, we must add a way to segregate. + // BUT, for MVP, let's assume `buckets` are global within the DB? + // No, that leaks data. + + // Simplification: We prefix bucket IDs with `project_ref` in the DB? + // Or we just rely on RLS. + // If we rely on RLS, we need to know WHICH buckets belong to WHICH project. + // `storage.buckets` has an `owner` column (User UUID). + // Users are unique per project? No, we share `auth.users` too in MVP? + // Actually, `auth.users` is global in this MVP implementation (single table). + // So users from Project A and Project B are all in the same table. + // If a user creates a bucket, they own it. + // So `list_buckets` will show buckets owned by the user. + // This is "User Multitenancy", not "Project Multitenancy". + + // If we want "Project Multitenancy", we need to filter by Project Context. + // Let's assume for now we just list what RLS allows. + + let buckets: Vec = sqlx::query_scalar("SELECT id FROM storage.buckets") + .fetch_all(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Filter buckets that start with project_ref? + // Or just return all visible. + // Let's filter by prefix to enforce project isolation if we adopt a naming convention. + // Convention: "{project_ref}_{bucket_name}" + // But user sends "bucket_name". + + // Let's assume we return "bucket_name" by stripping prefix? + // Too complex for MVP. + // Let's just return what RLS gives us. + + Ok(Json(buckets)) +} + +pub async fn list_objects( + State(state): State, + db: Option>, + Extension(auth_ctx): Extension, + Extension(_project_ctx): Extension, + Path(bucket_id): Path, +) -> Result>, (StatusCode, String)> { + tracing::info!("Starting list_objects for bucket: {}", bucket_id); + let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone()); + let mut tx = db + .begin() + .await + .map_err(|e| { + tracing::error!("Failed to begin transaction: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) + })?; + + let role_query = format!("SET LOCAL role = '{}'", auth_ctx.role); + sqlx::query(&role_query) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Failed to set role: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set role: {}", e), + ) + })?; + + if let Some(claims) = &auth_ctx.claims { + let sub_query = "SELECT set_config('request.jwt.claim.sub', $1, true)"; + sqlx::query(sub_query) + .bind(&claims.sub) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set claims: {}", e), + ) + })?; + } + + // Ensure we are accessing a bucket that belongs to this project? + // We can check if `bucket_id` matches expected pattern or if we use a project_id column. + // For MVP, we trust RLS on the `storage.buckets` table. + + let bucket_exists: Option = + sqlx::query_scalar("SELECT id FROM storage.buckets WHERE id = $1") + .bind(&bucket_id) + .fetch_optional(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if bucket_exists.is_none() { + return Err((StatusCode::NOT_FOUND, "Bucket not found".to_string())); + } + + let objects = sqlx::query_as::<_, FileObject>( + r#" + SELECT name, id, updated_at, created_at, last_accessed_at, metadata + FROM storage.objects + WHERE bucket_id = $1 + "#, + ) + .bind(&bucket_id) + .fetch_all(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(objects)) +} + +pub async fn upload_object( + State(state): State, + db: Option>, + Extension(auth_ctx): Extension, + Extension(project_ctx): Extension, + Path((bucket_id, filename)): Path<(String, String)>, + request: Request, +) -> Result { + tracing::info!("Starting upload_object for bucket: {}, filename: {}", bucket_id, filename); + + let content_type = request.headers().get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let data = if content_type.starts_with("multipart/form-data") { + let mut multipart = Multipart::from_request(request, &state).await + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + + let mut file_data = None; + while let Ok(Some(field)) = multipart.next_field().await { + if field.name() == Some("file") || field.name() == Some("") { + let bytes = field.bytes().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + file_data = Some(bytes); + break; + } + } + file_data.ok_or((StatusCode::BAD_REQUEST, "No file found in multipart".to_string()))? + } else { + // Raw body + let body = request.into_body(); + body.collect().await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .to_bytes() + }; + + let size = data.len(); + tracing::info!("File size: {} bytes", size); + + let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone()); + let mut tx = db + .begin() + .await + .map_err(|e| { + tracing::error!("Failed to begin transaction: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) + })?; + + let role_query = format!("SET LOCAL role = '{}'", auth_ctx.role); + sqlx::query(&role_query) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Failed to set role: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set role: {}", e), + ) + })?; + + if let Some(claims) = &auth_ctx.claims { + let sub_query = "SELECT set_config('request.jwt.claim.sub', $1, true)"; + sqlx::query(sub_query) + .bind(&claims.sub) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Failed to set claims: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set claims: {}", e), + ) + })?; + } + + let bucket_exists: Option = + sqlx::query_scalar("SELECT id FROM storage.buckets WHERE id = $1") + .bind(&bucket_id) + .fetch_optional(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Failed to check bucket existence: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) + })?; + + if bucket_exists.is_none() { + tracing::warn!("Bucket not found: {}", bucket_id); + return Err((StatusCode::NOT_FOUND, "Bucket not found".to_string())); + } + + let key = format!("{}/{}/{}", project_ctx.project_ref, bucket_id, filename); + tracing::info!("Uploading to S3 with key: {}", key); + + state + .s3_client + .put_object() + .bucket(&state.bucket_name) + .key(&key) + .body(ByteStream::from(data)) + .send() + .await + .map_err(|e| { + tracing::error!("S3 PutObject error: {:?}", e); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) + })?; + + tracing::info!("S3 upload successful"); + + let user_id = auth_ctx + .claims + .as_ref() + .and_then(|c| Uuid::parse_str(&c.sub).ok()); + + tracing::info!("Inserting metadata into DB"); + + let file_object = sqlx::query_as::<_, FileObject>( + r#" + INSERT INTO storage.objects (bucket_id, name, owner, metadata) + VALUES ($1, $2, $3, $4) + ON CONFLICT (bucket_id, name) + DO UPDATE SET updated_at = now(), metadata = $4 + RETURNING name, id, updated_at, created_at, last_accessed_at, metadata + "#, + ) + .bind(&bucket_id) + .bind(&filename) + .bind(user_id) + .bind(serde_json::json!({ "size": size, "mimetype": "application/octet-stream" })) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + tracing::error!("DB Insert Object error: {:?}", e); + (StatusCode::FORBIDDEN, format!("Permission denied: {}", e)) + })?; + + tx.commit() + .await + .map_err(|e| { + tracing::error!("Commit error: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) + })?; + + Ok((StatusCode::CREATED, Json(file_object))) +} + +pub async fn download_object( + State(state): State, + db: Option>, + Extension(auth_ctx): Extension, + Extension(project_ctx): Extension, + Path((bucket_id, filename)): Path<(String, String)>, +) -> Result { + let db = db.map(|Extension(p)| p).unwrap_or_else(|| state.db.clone()); + let mut tx = db + .begin() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let role_query = format!("SET LOCAL role = '{}'", auth_ctx.role); + sqlx::query(&role_query) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set role: {}", e), + ) + })?; + + if let Some(claims) = &auth_ctx.claims { + let sub_query = "SELECT set_config('request.jwt.claim.sub', $1, true)"; + sqlx::query(sub_query) + .bind(&claims.sub) + .execute(&mut *tx) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to set claims: {}", e), + ) + })?; + } + + let object_exists: Option = + sqlx::query_scalar("SELECT id FROM storage.objects WHERE bucket_id = $1 AND name = $2") + .bind(&bucket_id) + .bind(&filename) + .fetch_optional(&mut *tx) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if object_exists.is_none() { + return Err(( + StatusCode::NOT_FOUND, + "File not found or access denied".to_string(), + )); + } + + // S3 Key Namespacing: {project_ref}/{bucket_id}/{filename} + let key = format!("{}/{}/{}", project_ctx.project_ref, bucket_id, filename); + + let resp = state + .s3_client + .get_object() + .bucket(&state.bucket_name) + .key(&key) + .send() + .await + .map_err(|_e| { + ( + StatusCode::NOT_FOUND, + "File content not found in storage".to_string(), + ) + })?; + + let mut headers = HeaderMap::new(); + if let Some(ct) = resp.content_type() { + if let Ok(val) = ct.parse() { + headers.insert("Content-Type", val); + } + } + + let body_bytes = resp + .body + .collect() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .into_bytes(); + + if let Ok(s) = std::str::from_utf8(&body_bytes) { + tracing::info!("Downloaded content (utf8): {}", s); + } else { + tracing::info!("Downloaded content (binary): {} bytes", body_bytes.len()); + } + + let body = Body::from(body_bytes); + + Ok((headers, body)) +} diff --git a/storage/src/lib.rs b/storage/src/lib.rs new file mode 100644 index 00000000..c148d9f6 --- /dev/null +++ b/storage/src/lib.rs @@ -0,0 +1,60 @@ +pub mod handlers; + +use aws_config::BehaviorVersion; +use aws_sdk_s3::config::Credentials; +use aws_sdk_s3::{config::Region, Client}; +use axum::{extract::DefaultBodyLimit, routing::{get, post}, Router}; +use common::Config; +use handlers::StorageState; +use sqlx::PgPool; + +pub async fn init(db: PgPool, config: Config) -> Router { + // Initialize S3 Client (MinIO) + let s3_endpoint = + std::env::var("S3_ENDPOINT").unwrap_or_else(|_| "http://localhost:9000".to_string()); + let s3_access_key = + std::env::var("MINIO_ROOT_USER").unwrap_or_else(|_| "minioadmin".to_string()); + let s3_secret_key = + std::env::var("MINIO_ROOT_PASSWORD").unwrap_or_else(|_| "minioadmin".to_string()); + let s3_bucket = std::env::var("S3_BUCKET").unwrap_or_else(|_| "madbase".to_string()); + + let aws_config = aws_config::defaults(BehaviorVersion::latest()) + .region(Region::new("us-east-1")) + .endpoint_url(&s3_endpoint) + .credentials_provider(Credentials::new( + s3_access_key, + s3_secret_key, + None, + None, + "static", + )) + .load() + .await; + + let s3_config = aws_sdk_s3::config::Builder::from(&aws_config) + .endpoint_url(&s3_endpoint) + .force_path_style(true) + .build(); + + let s3_client = Client::from_conf(s3_config); + + // Create bucket if not exists + let _ = s3_client.create_bucket().bucket(&s3_bucket).send().await; + + let state = StorageState { + db, + s3_client, + config, + bucket_name: s3_bucket, + }; + + Router::new() + .route("/bucket", get(handlers::list_buckets)) + .route("/object/list/:bucket_id", post(handlers::list_objects)) + .route( + "/object/:bucket_id/:filename", + get(handlers::download_object).post(handlers::upload_object), + ) + .layer(DefaultBodyLimit::max(10 * 1024 * 1024)) // 10MB limit + .with_state(state) +} diff --git a/test_multitenancy.sh b/test_multitenancy.sh new file mode 100755 index 00000000..dc425a94 --- /dev/null +++ b/test_multitenancy.sh @@ -0,0 +1,113 @@ + +#!/bin/bash + +# Configuration +GATEWAY_URL="${GATEWAY_URL:-http://localhost:8000}" +PROJECT_NAME="test-project-$(date +%s)" +USER_EMAIL="user-$(date +%s)@example.com" +USER_PASSWORD="securepassword123" + +echo "🧪 Starting Multi-tenancy E2E Test..." +echo "-------------------------------------" + +# 1. Create Project +echo "1. Creating Project '$PROJECT_NAME'..." +RESPONSE=$(curl -s -X POST "$GATEWAY_URL/platform/v1/projects" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"$PROJECT_NAME\"}") + +# Extract keys using grep/sed/awk since jq might not be installed +ANON_KEY=$(echo $RESPONSE | grep -o '"anon_key":"[^"]*' | cut -d'"' -f4) +SERVICE_KEY=$(echo $RESPONSE | grep -o '"service_role_key":"[^"]*' | cut -d'"' -f4) +PROJECT_ID=$(echo $RESPONSE | grep -o '"id":"[^"]*' | cut -d'"' -f4) + +if [ -z "$ANON_KEY" ]; then + echo "❌ Failed to create project. Response: $RESPONSE" + exit 1 +fi + +echo "✅ Project Created!" +echo " ID: $PROJECT_ID" +# echo " Anon Key: $ANON_KEY" +echo " (Keys received)" + +# 2. Signup User (Project Context) +echo "" +echo "2. Signing up user '$USER_EMAIL' in project context..." +SIGNUP_RES=$(curl -v -X POST "$GATEWAY_URL/auth/v1/signup" \ + -H "Content-Type: application/json" \ + -H "apikey: $ANON_KEY" \ + -H "x-project-ref: $PROJECT_NAME" \ + -d "{\"email\": \"$USER_EMAIL\", \"password\": \"$USER_PASSWORD\"}") + +# Check for success (access_token present) +ACCESS_TOKEN=$(echo $SIGNUP_RES | grep -o '"access_token":"[^"]*' | cut -d'"' -f4) + +if [ -z "$ACCESS_TOKEN" ]; then + # Maybe user already exists or error? + echo "⚠️ Signup response: $SIGNUP_RES" + # Try login instead if signup failed (e.g. if we re-ran script quickly) + echo " Attempting login..." + LOGIN_RES=$(curl -v -X POST "$GATEWAY_URL/auth/v1/token?grant_type=password" \ + -H "Content-Type: application/json" \ + -H "apikey: $ANON_KEY" \ + -H "x-project-ref: $PROJECT_NAME" \ + -d "{\"email\": \"$USER_EMAIL\", \"password\": \"$USER_PASSWORD\"}") + + ACCESS_TOKEN=$(echo $LOGIN_RES | grep -o '"access_token":"[^"]*' | cut -d'"' -f4) +fi + +if [ -z "$ACCESS_TOKEN" ]; then + echo "❌ Authentication failed." + exit 1 +fi + +echo "✅ Authenticated! Token received." + +# 3. Create Data (Insert into public.users? Or create a table first?) +# Since we don't have a 'create table' API exposed (except maybe via RPC or direct SQL which we don't expose via REST), +# we can only insert into EXISTING tables. +# The only table guaranteed to exist is `auth.users` (which we just inserted into via Signup) +# and `storage.buckets`. +# Let's try to list buckets using the new user token. + +echo "" +echo "3. Testing Data/Storage Access (List Buckets)..." +BUCKETS_RAW=$(curl -sS -X GET "$GATEWAY_URL/storage/v1/bucket" \ + -H "apikey: $ANON_KEY" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "x-project-ref: $PROJECT_NAME" \ + -w "\nHTTP_STATUS:%{http_code}\n") + +BUCKETS_STATUS=$(echo "$BUCKETS_RAW" | tail -n 1 | sed 's/HTTP_STATUS://') +BUCKETS_RES=$(echo "$BUCKETS_RAW" | sed '$d') + +echo " Status: $BUCKETS_STATUS" +echo " Response: $BUCKETS_RES" + +if [[ $BUCKETS_STATUS == 2* ]] && [[ $BUCKETS_RES == *"["* ]]; then + echo "✅ Storage API Accessed Successfully!" +else + echo "❌ Storage API Failed." + exit 1 +fi + +# 4. Verify Project Isolation (Optional - try with wrong key) +echo "" +echo "4. Verifying Isolation (Access with wrong project ref)..." +WRONG_RES=$(curl -s -X GET "$GATEWAY_URL/storage/v1/bucket" \ + -H "apikey: $ANON_KEY" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "x-project-ref: non-existent-project") + +if [[ $WRONG_RES == *"Not Found"* ]] || [[ $WRONG_RES == *"404"* ]] || [[ -z "$WRONG_RES" ]]; then + echo "✅ Isolation Verified (Request failed as expected)." +else + # Note: Middleware returns 404 if project not found. + # curl -s might return empty if 404? No, it returns body. + # Let's check status code in real script, but here simple grep. + echo " Response: $WRONG_RES" +fi + +echo "" +echo "🎉 E2E Test Completed Successfully!" diff --git a/tests/integration/auth.test.ts b/tests/integration/auth.test.ts new file mode 100644 index 00000000..42e4aedd --- /dev/null +++ b/tests/integration/auth.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { createAnonClient } from './setup.ts'; + +const client = createAnonClient(); +const email = `test-${Date.now()}@example.com`; +const password = 'password123'; + +describe('Authentication', () => { + it('should sign up a new user', async () => { + const { data, error } = await client.auth.signUp({ + email, + password, + }); + + expect(error).toBeNull(); + expect(data.user).toBeDefined(); + expect(data.user?.email).toBe(email); + expect(data.session).toBeDefined(); // Assuming auto-sign-in on signup + }); + + it('should sign in an existing user', async () => { + const { data, error } = await client.auth.signInWithPassword({ + email, + password, + }); + + expect(error).toBeNull(); + expect(data.session).toBeDefined(); + expect(data.user).toBeDefined(); + expect(data.user?.email).toBe(email); + }); + + it('should fail with incorrect password', async () => { + const { data, error } = await client.auth.signInWithPassword({ + email, + password: 'wrongpassword', + }); + + expect(error).toBeDefined(); + expect(data.session).toBeNull(); + }); +}); diff --git a/tests/integration/db.test.ts b/tests/integration/db.test.ts new file mode 100644 index 00000000..729170d6 --- /dev/null +++ b/tests/integration/db.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { createAnonClient, createServiceRoleClient } from './setup.ts'; + +const client = createAnonClient(); +const adminClient = createServiceRoleClient(); + +describe('Data API (PostgREST-lite)', () => { + const todoTitle = `Task ${Date.now()}`; + + it('should create a todo', async () => { + const { data: rows, error } = await client + .from('todos') + .insert({ title: todoTitle, completed: false }) + .select(); + + expect(error).toBeNull(); + expect(rows).toBeDefined(); + expect(rows?.length).toBe(1); + + const data = rows![0]; + expect(data.title).toBe(todoTitle); + expect(data.completed).toBe(false); + }); + + it('should list todos', async () => { + const { data, error } = await client.from('todos').select('*'); + + expect(error).toBeNull(); + expect(data).toBeDefined(); + expect(Array.isArray(data)).toBe(true); + expect(data?.length).toBeGreaterThan(0); + expect(data?.some((t) => t.title === todoTitle)).toBe(true); + }); + + it('should update a todo', async () => { + // First get the todo + const { data: todos } = await client + .from('todos') + .select('id') + .eq('title', todoTitle) + .limit(1); + + expect(todos).toBeDefined(); + if (!todos || todos.length === 0) throw new Error('Todo not found'); + const id = todos[0].id; + + const { error } = await client + .from('todos') + .update({ completed: true }) + .eq('id', id); + + expect(error).toBeNull(); + + // Verify update + const { data: rows } = await client.from('todos').select('*').eq('id', id); + expect(rows).toBeDefined(); + expect(rows?.length).toBe(1); + const updated = rows![0]; + expect(updated.completed).toBe(true); + }); + + it('should delete a todo', async () => { + // First get the todo + const { data: todos } = await client + .from('todos') + .select('id') + .eq('title', todoTitle) + .limit(1); + + expect(todos).toBeDefined(); + if (!todos || todos.length === 0) throw new Error('Todo not found'); + const id = todos[0].id; + + const { error } = await client.from('todos').delete().eq('id', id); + + expect(error).toBeNull(); + + // Verify deletion + const { data } = await client.from('todos').select('*').eq('id', id); + expect(data?.length).toBe(0); + }); +}); diff --git a/tests/integration/package-lock.json b/tests/integration/package-lock.json new file mode 100644 index 00000000..6043fc9e --- /dev/null +++ b/tests/integration/package-lock.json @@ -0,0 +1,1646 @@ +{ + "name": "integration", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "integration", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@supabase/supabase-js": "^2.49.1", + "dotenv": "^16.4.7", + "vitest": "^3.0.7" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@supabase/auth-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.99.1.tgz", + "integrity": "sha512-x7lKKTvKjABJt/FYcRSPiTT01Xhm2FF8RhfL8+RHMkmlwmRQ88/lREupIHKwFPW0W6pTCJqkZb7Yhpw/EZ+fNw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.99.1.tgz", + "integrity": "sha512-WQE62W5geYImCO4jzFxCk/avnK7JmOdtqu2eiPz3zOaNiIJajNRSAwMMDgEGd2EMs+sUVYj1LfBjfmW3EzHgIA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.99.1.tgz", + "integrity": "sha512-gtw2ibJrADvfqrpUWXGNlrYUvxttF4WVWfPpTFKOb2IRj7B6YRWMDgcrYqIuD4ZEabK4m6YKQCCGy6clgf1lPA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.99.1.tgz", + "integrity": "sha512-9EDdy/5wOseGFqxW88ShV9JMRhm7f+9JGY5x+LqT8c7R0X1CTLwg5qie8FiBWcXTZ+68yYxVWunI+7W4FhkWOg==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.99.1.tgz", + "integrity": "sha512-mf7zPfqofI62SOoyQJeNUVxe72E4rQsbWim6lTDPeLu3lHija/cP5utlQADGrjeTgOUN6znx/rWn7SjrETP1dw==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.99.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.99.1.tgz", + "integrity": "sha512-5MRoYD9ffXq8F6a036dm65YoSHisC3by/d22mauKE99Vrwf792KxYIIr/iqCX7E4hkuugbPZ5EGYHTB7MKy6Vg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.99.1", + "@supabase/functions-js": "2.99.1", + "@supabase/postgrest-js": "2.99.1", + "@supabase/realtime-js": "2.99.1", + "@supabase/storage-js": "2.99.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz", + "integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/tests/integration/package.json b/tests/integration/package.json new file mode 100644 index 00000000..ae81e0a7 --- /dev/null +++ b/tests/integration/package.json @@ -0,0 +1,18 @@ +{ + "name": "integration", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "vitest run" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "module", + "dependencies": { + "@supabase/supabase-js": "^2.49.1", + "dotenv": "^16.4.7", + "vitest": "^3.0.7" + } +} diff --git a/tests/integration/realtime.test.ts b/tests/integration/realtime.test.ts new file mode 100644 index 00000000..a39df0f5 --- /dev/null +++ b/tests/integration/realtime.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { createAnonClient } from './setup.ts'; + +const client = createAnonClient(); + +describe('Realtime', () => { + it('should receive insert events', async () => { + return new Promise((resolve, reject) => { + const channel = client + .channel('public:todos') + .on( + 'postgres_changes', + { event: 'INSERT', schema: 'public', table: 'todos' }, + (payload) => { + console.log('Received INSERT event:', payload); + expect(payload.new).toBeDefined(); + expect(payload.new.title).toBe('Realtime Test'); + client.removeChannel(channel).then(() => resolve()); + } + ) + .subscribe(async (status) => { + if (status === 'SUBSCRIBED') { + // Trigger an insert + const { error } = await client + .from('todos') + .insert({ title: 'Realtime Test', completed: false }); + + if (error) reject(error); + } + }); + + // Timeout if no event received + setTimeout(() => { + reject(new Error('Timeout waiting for Realtime event')); + }, 10000); + }); + }, 10000); +}); diff --git a/tests/integration/run_tests.sh b/tests/integration/run_tests.sh new file mode 100755 index 00000000..6e0481db --- /dev/null +++ b/tests/integration/run_tests.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e + +# Setup Database +echo "Setting up test database (applying migrations)..." +# Concatenate all migrations and setup script +cat migrations/*.sql tests/integration/setup_db.sql | podman exec -i madbase_db psql -U postgres -d postgres + +# Run Tests +echo "Running integration tests..." +cd tests/integration +npm test diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts new file mode 100644 index 00000000..dc9347e9 --- /dev/null +++ b/tests/integration/setup.ts @@ -0,0 +1,31 @@ +import { createClient, SupabaseClient } from '@supabase/supabase-js'; +import dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +const SUPABASE_URL = process.env.MADBASE_URL || 'http://localhost:8000'; +const SUPABASE_ANON_KEY = process.env.MADBASE_ANON_KEY || ''; +const SUPABASE_SERVICE_ROLE_KEY = process.env.MADBASE_SERVICE_ROLE_KEY || ''; + +if (!SUPABASE_ANON_KEY || !SUPABASE_SERVICE_ROLE_KEY) { + throw new Error('Missing MADBASE_ANON_KEY or MADBASE_SERVICE_ROLE_KEY'); +} + +export const createAnonClient = (): SupabaseClient => { + return createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + auth: { + persistSession: false, + autoRefreshToken: false, + }, + }); +}; + +export const createServiceRoleClient = (): SupabaseClient => { + return createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { + persistSession: false, + autoRefreshToken: false, + }, + }); +}; diff --git a/tests/integration/setup_db.sql b/tests/integration/setup_db.sql new file mode 100644 index 00000000..5d919523 --- /dev/null +++ b/tests/integration/setup_db.sql @@ -0,0 +1,53 @@ +DROP TABLE IF EXISTS public.todos; +CREATE TABLE public.todos ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + title text NOT NULL, + completed boolean DEFAULT false, + user_id uuid, -- For RLS testing later + created_at timestamptz DEFAULT now() +); + +ALTER TABLE public.todos ENABLE ROW LEVEL SECURITY; + +-- Grants for public +GRANT ALL ON public.todos TO anon, authenticated; + +-- Grants for Realtime schema +GRANT USAGE ON SCHEMA madbase_realtime TO anon, authenticated; +GRANT ALL ON ALL TABLES IN SCHEMA madbase_realtime TO anon, authenticated; +GRANT ALL ON ALL SEQUENCES IN SCHEMA madbase_realtime TO anon, authenticated; +GRANT ALL ON ALL FUNCTIONS IN SCHEMA madbase_realtime TO anon, authenticated; + +-- Allow everything for anon for now to test basic CRUD +CREATE POLICY "Allow anon select" ON public.todos FOR SELECT TO anon USING (true); +CREATE POLICY "Allow anon insert" ON public.todos FOR INSERT TO anon WITH CHECK (true); +CREATE POLICY "Allow anon update" ON public.todos FOR UPDATE TO anon USING (true); +CREATE POLICY "Allow anon delete" ON public.todos FOR DELETE TO anon USING (true); + +-- Allow authenticated users +CREATE POLICY "Allow auth select" ON public.todos FOR SELECT TO authenticated USING (true); +CREATE POLICY "Allow auth insert" ON public.todos FOR INSERT TO authenticated WITH CHECK (true); +CREATE POLICY "Allow auth update" ON public.todos FOR UPDATE TO authenticated USING (true); +CREATE POLICY "Allow auth delete" ON public.todos FOR DELETE TO authenticated USING (true); + +-- Enable Realtime +CREATE TRIGGER realtime_todos +AFTER INSERT OR UPDATE OR DELETE ON public.todos +FOR EACH ROW EXECUTE FUNCTION madbase_realtime.broadcast_changes(); + +-- Storage Setup +INSERT INTO storage.buckets (id, name, public) VALUES ('test-bucket', 'test-bucket', true) ON CONFLICT DO NOTHING; + +-- Allow anon to upload to test-bucket +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT FROM pg_policies WHERE tablename = 'objects' AND policyname = 'Anon can insert into test-bucket' + ) THEN + CREATE POLICY "Anon can insert into test-bucket" + ON storage.objects FOR INSERT + TO anon + WITH CHECK ( bucket_id = 'test-bucket' ); + END IF; +END +$$; diff --git a/tests/integration/storage.test.ts b/tests/integration/storage.test.ts new file mode 100644 index 00000000..6ed395de --- /dev/null +++ b/tests/integration/storage.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { createAnonClient, createServiceRoleClient } from './setup.ts'; + +const client = createAnonClient(); +const admin = createServiceRoleClient(); +const bucket = 'test-bucket'; + +describe('Storage', () => { + it('should upload a file', async () => { + // Use Buffer for Node environment reliability + const file = Buffer.from('Hello, MadBase!'); + // Use admin to bypass RLS/Permission issues for now to verify S3 connectivity + const { data, error } = await admin.storage + .from(bucket) + .upload('hello.txt', file, { upsert: true }); + + if (error) console.error('Upload error:', error); + expect(error).toBeNull(); + expect(data).toBeDefined(); + expect(data?.path).toBe('hello.txt'); + }); + + it('should list files', async () => { + const { data, error } = await client.storage.from(bucket).list(); + + expect(error).toBeNull(); + expect(data).toBeDefined(); + expect(data?.some((f) => f.name === 'hello.txt')).toBe(true); + }); + + it('should download a file', async () => { + const { data, error } = await client.storage.from(bucket).download('hello.txt'); + + expect(error).toBeNull(); + expect(data).toBeDefined(); + const text = await data?.text(); + expect(text).toBe('Hello, MadBase!'); + }); +}); diff --git a/trigger.sql b/trigger.sql new file mode 100644 index 00000000..10936444 --- /dev/null +++ b/trigger.sql @@ -0,0 +1,7 @@ +CREATE OR REPLACE FUNCTION madbase_realtime.broadcast_changes() RETURNS trigger AS $$ +BEGIN + RAISE WARNING 'Trigger Fired by %', current_user; + PERFORM pg_notify('madbase_realtime', '{"test": "trigger"}'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/web/index.html b/web/index.html new file mode 100644 index 00000000..74d99dc8 --- /dev/null +++ b/web/index.html @@ -0,0 +1,169 @@ + + + + + + MadBase Admin Dashboard + + + +

MadBase Admin Dashboard

+ +
+

Projects

+
+ + +
IDNameStatusAction
+
+ + +
+ + +
+

Features

+ + +
+
+ +
+

Users (Global)

+ + + +
IDEmailCreated AtAction
+
+ +
+

System Metrics

+
Loading...
+
+ + + +