diff --git a/.gitignore b/.gitignore index 392d20e..cc50778 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -backend-codebase.txt -frontend-codebase.txt -.env \ No newline at end of file +.env + +/target diff --git a/API Design.md b/API Design.md deleted file mode 100644 index 3527b46..0000000 --- a/API Design.md +++ /dev/null @@ -1,165 +0,0 @@ -# **Thoughts \- API Design (Version 1\)** - -## **1\. Overview** - -This document specifies the RESTful API for the Thoughts platform. - -* **Base URL:** /api/v1 -* **Data Format:** All requests and responses will be in JSON format. -* **Authentication:** The API uses two primary methods for authentication: - 1. **JWT (JSON Web Tokens):** For the official web client. The POST /api/v1/auth/login endpoint returns a short-lived JWT. This token must be included in the Authorization: Bearer \ header for all subsequent authenticated requests. - 2. **API Keys:** For third-party applications. Users can generate long-lived API keys. These keys must be included in the Authorization: ApiKey \ header. - -## **2\. API Endpoints** - -### **Auth Endpoints** - -**POST /auth/register** - -* **Description:** Creates a new user account. -* **Authentication:** Public. -* **Request Body:** - { - "username": "frutiger", - "email": "aero@example.com", - "password": "strongpassword123" - } - -* **Success Response:** 201 Created with the new User object (password omitted). -* **Error Responses:** 400 Bad Request (invalid input), 409 Conflict (username or email already exists). - -**POST /auth/login** - -* **Description:** Authenticates a user and returns a JWT. -* **Authentication:** Public. -* **Request Body:** - { - "username": "frutiger", - "password": "strongpassword123" - } - -* **Success Response:** 200 OK with a JWT. - { - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - } - -* **Error Responses:** 400 Bad Request, 401 Unauthorized. - -### **User & Profile Endpoints** - -**GET /users/{username}** - -* **Description:** Retrieves the public profile of a user. -* **Authentication:** Public. -* **Success Response:** 200 OK with a public User object. - -**GET /users/me** - -* **Description:** Retrieves the full profile of the currently authenticated user (including private details like email). -* **Authentication:** Required (JWT). -* **Success Response:** 200 OK with the full User object. - -**PUT /users/me** - -* **Description:** Updates the profile of the currently authenticated user. -* **Authentication:** Required (JWT). -* **Request Body:** - { - "displayName": "Frutiger Aero Fan", - "bio": "Est. 2004", - "avatarUrl": "https://...", - "headerUrl": "https://...", - "customCss": "body { background: blue; }", - "topFriends": \["username1", "username2"\] - } - -* **Success Response:** 200 OK with the updated User object. -* **Error Responses:** 400 Bad Request. - -### **Thoughts (Posts) Endpoints** - -**POST /thoughts** - -* **Description:** Creates a new thought. -* **Authentication:** Required (JWT or API Key). -* **Request Body:** - { - "content": "This is my first thought\! \#welcome" - } - -* **Success Response:** 201 Created with the new Thought object. -* **Error Responses:** 400 Bad Request (e.g., content \> 128 chars). - -**GET /users/{username}/thoughts** - -* **Description:** Retrieves all thoughts for a specific user, paginated. -* **Authentication:** Public. -* **Success Response:** 200 OK with an array of Thought objects. - -**DELETE /thoughts/{id}** - -* **Description:** Deletes a thought. The user must be the author. -* **Authentication:** Required (JWT or API Key). -* **Success Response:** 204 No Content. -* **Error Responses:** 403 Forbidden, 404 Not Found. - -### **Social Endpoints** - -**POST /users/{username}/follow** - -* **Description:** Follows a user. -* **Authentication:** Required (JWT). -* **Success Response:** 204 No Content. -* **Error Responses:** 404 Not Found, 409 Conflict (already following). - -**DELETE /users/{username}/follow** - -* **Description:** Unfollows a user. -* **Authentication:** Required (JWT). -* **Success Response:** 204 No Content. -* **Error Responses:** 404 Not Found. - -**GET /feed** - -* **Description:** Retrieves the main feed for the authenticated user, paginated. -* **Authentication:** Required (JWT). -* **Success Response:** 200 OK with an array of Thought objects from followed users. - -### **Discovery Endpoints** - -**GET /tags/popular** - -* **Description:** Retrieves a list of currently popular tags. -* **Authentication:** Public. -* **Success Response:** 200 OK with an array of tag strings. - -**GET /tags/{tagName}** - -* **Description:** Retrieves a feed of all thoughts with a specific tag, paginated. -* **Authentication:** Public. -* **Success Response:** 200 OK with an array of Thought objects. - -## **3\. Data Models** - -**User Object (Public)** - -{ - "username": "frutiger", - "displayName": "Frutiger Aero Fan", - "bio": "Est. 2004", - "avatarUrl": "https://...", - "headerUrl": "https://...", - "customCss": "body { background: blue; }", - "topFriends": \["username1", "username2"\], - "joinedAt": "2024-01-01T12:00:00Z" -} - -**Thought Object** - -{ - "id": "uuid-v4-string", - "authorUsername": "frutiger", - "content": "This is my first thought\! \#welcome", - "tags": \["welcome"\], - "createdAt": "2024-01-01T12:01:00Z" -} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5376a79 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4840 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "activitypub" +version = "0.1.0" +dependencies = [ + "activitypub-base", + "anyhow", + "async-trait", + "chrono", + "domain", + "serde", + "serde_json", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "activitypub-base" +version = "0.1.0" +dependencies = [ + "activitypub_federation", + "anyhow", + "async-trait", + "axum", + "chrono", + "domain", + "enum_delegate", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "activitypub_federation" +version = "0.7.0-beta.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20222f29f14358d3baeb0ffdec08f99bd0f56b6b59504f33556d97db720de748" +dependencies = [ + "activitystreams-kinds", + "actix-web", + "async-trait", + "axum", + "base64", + "bytes", + "chrono", + "derive_builder", + "dyn-clone", + "either", + "enum_delegate", + "futures", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-signature-normalization", + "http-signature-normalization-reqwest", + "httpdate", + "itertools", + "moka", + "pin-project-lite", + "rand 0.8.6", + "regex", + "reqwest", + "reqwest-middleware", + "rsa", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tower", + "tracing", + "url", +] + +[[package]] +name = "activitystreams-kinds" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e97dfe76efd8c0b113cc3580a6b5f4acba47662e3cfbbfcce081c9ac89798990" +dependencies = [ + "serde", + "url", +] + +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-http" +version = "3.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93acb4a42f64936f9b8cae4a433b237599dd6eb6ed06124eb67132ef8cc90662" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "bitflags", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "foldhash 0.1.5", + "futures-core", + "http 0.2.12", + "httparse", + "httpdate", + "itoa", + "language-tags", + "mime", + "percent-encoding", + "pin-project-lite", + "smallvec", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-router" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.12", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" +dependencies = [ + "actix-codec", + "actix-http", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "bytes", + "bytestring", + "cfg-if", + "derive_more", + "encoding_rs", + "foldhash 0.1.5", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.6.3", + "time", + "tracing", + "url", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[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 = "api-types" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "utoipa", + "uuid", +] + +[[package]] +name = "application" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "domain", + "hex", + "sha2", + "thiserror 2.0.18", + "tokio", + "uuid", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[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-nats" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76433c4de73442daedb3a59e991d94e85c14ebfc33db53dfcd347a21cd6ef4f8" +dependencies = [ + "base64", + "bytes", + "futures", + "memchr", + "nkeys", + "nuid", + "once_cell", + "pin-project", + "portable-atomic", + "rand 0.8.6", + "regex", + "ring", + "rustls-native-certs 0.7.3", + "rustls-pemfile", + "rustls-webpki 0.102.8", + "serde", + "serde_json", + "serde_nanos", + "serde_repr", + "thiserror 1.0.69", + "time", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "tryhard", + "url", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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 2.0.117", +] + +[[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 = [ + "argon2", + "async-trait", + "chrono", + "domain", + "jsonwebtoken", + "rand 0.8.6", + "serde", + "thiserror 2.0.18", + "tokio", + "uuid", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +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 = "bootstrap" +version = "0.1.0" +dependencies = [ + "activitypub", + "activitypub-base", + "async-nats", + "async-trait", + "auth", + "axum", + "domain", + "dotenvy", + "event-transport", + "http 1.4.0", + "nats", + "postgres", + "postgres-federation", + "postgres-search", + "presentation", + "sqlx", + "tokio", + "tower-http", + "tower_governor", + "tracing", + "tracing-subscriber", +] + +[[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" +dependencies = [ + "serde", +] + +[[package]] +name = "bytestring" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" +dependencies = [ + "bytes", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.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.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +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 = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[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-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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 2.0.117", +] + +[[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 2.0.117", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[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", + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[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 2.0.117", +] + +[[package]] +name = "domain" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "futures", + "serde", + "thiserror 2.0.18", + "tokio", + "url", + "uuid", +] + +[[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 = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2", + "signature", + "subtle", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[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 = "enum_delegate" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8ea75f31022cba043afe037940d73684327e915f88f62478e778c3de914cd0a" +dependencies = [ + "enum_delegate_lib", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "enum_delegate_lib" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1f6c3800b304a6be0012039e2a45a322a093539c45ab818d9e6895a39c90fe" +dependencies = [ + "proc-macro2", + "quote", + "rand 0.8.6", + "syn 1.0.109", +] + +[[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 = "event-payload" +version = "0.1.0" +dependencies = [ + "domain", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "event-transport" +version = "0.1.0" +dependencies = [ + "async-trait", + "domain", + "event-payload", + "futures", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "miniz_oxide", + "zlib-rs", +] + +[[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 = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "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 2.0.117", +] + +[[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 = "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", + "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.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.4", + "smallvec", + "spinning_top", + "web-time", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[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 = "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 = "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", + "pin-project-lite", +] + +[[package]] +name = "http-signature-normalization" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95e3149194de5f3f9d5225bcc6a8677979f8ff8ce39c85654730ad4824f101e" +dependencies = [ + "httpdate", +] + +[[package]] +name = "http-signature-normalization-reqwest" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2441a67ea8984d46c95099b4a9be83dc5bed2c254b8443dc2554edaeaa7d0b" +dependencies = [ + "async-trait", + "base64", + "http-signature-normalization", + "httpdate", + "reqwest", + "reqwest-middleware", + "sha2", + "tokio", +] + +[[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 = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http 1.4.0", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body", + "hyper", + "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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "impl-more" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.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 = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[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.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[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.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[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.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[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 = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + +[[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-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.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 = "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 = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "nats" +version = "0.1.0" +dependencies = [ + "async-nats", + "async-stream", + "async-trait", + "domain", + "event-payload", + "event-transport", + "futures", + "serde_json", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "nkeys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf" +dependencies = [ + "data-encoding", + "ed25519", + "ed25519-dalek", + "getrandom 0.2.17", + "log", + "rand 0.8.6", + "signatory", +] + +[[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 = "nuid" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83" +dependencies = [ + "rand 0.8.6", +] + +[[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.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[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 = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[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 = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[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" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "domain", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "postgres-federation" +version = "0.1.0" +dependencies = [ + "activitypub-base", + "anyhow", + "async-trait", + "chrono", + "sqlx", + "tokio", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "postgres-search" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "domain", + "postgres", + "sqlx", + "tokio", + "uuid", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +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 = "presentation" +version = "0.1.0" +dependencies = [ + "api-types", + "application", + "async-trait", + "axum", + "chrono", + "domain", + "hex", + "http-body-util", + "serde", + "serde_json", + "sha2", + "tokio", + "tower", + "tower-http", + "tracing", + "url", + "utoipa", + "utoipa-scalar", + "utoipa-swagger-ui", + "uuid", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[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", + "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", + "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.4", + "ring", + "rustc-hash", + "rustls", + "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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +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", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags", +] + +[[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.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "reqwest-middleware" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199dda04a536b532d0cc04d7979e39b1c763ea749bf91507017069c00b96056f" +dependencies = [ + "anyhow", + "async-trait", + "http 1.4.0", + "reqwest", + "thiserror 2.0.18", + "tower-service", +] + +[[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 = "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", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.117", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[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.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs 0.8.3", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.13", + "security-framework 3.7.0", + "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.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +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 = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "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.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_nanos" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93142f0367a4cc53ae0fead1bcda39e85beccfad3dcd717656cacab94b12985" +dependencies = [ + "serde", +] + +[[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_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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 = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[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 = "signatory" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" +dependencies = [ + "pkcs8", + "rand_core 0.6.4", + "signature", + "zeroize", +] + +[[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 = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[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 = "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.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[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", + "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", + "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 2.0.117", +] + +[[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 2.0.117", + "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", + "bitflags", + "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.6", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "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.6", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[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 = "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 = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[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 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "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 2.0.117", +] + +[[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 2.0.117", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +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.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +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.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "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-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 = "tokio-websockets" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "http 1.4.0", + "httparse", + "rand 0.8.6", + "ring", + "rustls-native-certs 0.8.3", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tokio-util", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2 0.6.3", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "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", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.4.0", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[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.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6" +dependencies = [ + "axum", + "forwarded-header-value", + "governor", + "http 1.4.0", + "pin-project", + "thiserror 2.0.18", + "tonic", + "tower", + "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 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tryhard" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fe58ebd5edd976e0fe0f8a14d2a04b7c81ef153ea9a54eebc42e67c2c23b4e5" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[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-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[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 = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", + "uuid", +] + +[[package]] +name = "utoipa-scalar" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719" +dependencies = [ + "axum", + "serde", + "serde_json", + "utoipa", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" +dependencies = [ + "axum", + "base64", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "utoipa-swagger-ui-vendored", + "zip", +] + +[[package]] +name = "utoipa-swagger-ui-vendored" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[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 0.51.0", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +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 = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +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.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +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", +] + +[[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 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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.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.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.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.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.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.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.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.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.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" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[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 2.0.117", + "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 2.0.117", + "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", + "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 = "worker" +version = "0.1.0" +dependencies = [ + "activitypub", + "activitypub-base", + "application", + "async-nats", + "domain", + "dotenvy", + "event-transport", + "futures", + "nats", + "postgres", + "postgres-federation", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zip" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Database schema.md b/Database schema.md deleted file mode 100644 index 6685480..0000000 --- a/Database schema.md +++ /dev/null @@ -1,114 +0,0 @@ -# **Thoughts \- Database Schema (PostgreSQL)** - -## **1\. Overview** - -This document outlines the table structure for the Thoughts platform using PostgreSQL. The design uses UUIDs for primary keys to facilitate decentralization and prevent enumeration attacks. All timestamps are stored with time zones (TIMESTAMPTZ). - -## **2\. Schema Diagram (ERD)** - -\+-------------+ \+--------------+ \+--------------+ -| users |\<--+--| thoughts |---+--|\> thought\_tags | -\+-------------+ | \+--------------+ | \+--------------+ - | | | ^ - | | | | - | | \+--------------+ | \+--------------+ - \+--------+--+--|\> follows |\<--+-+--| tags | - | | \+--------------+ | \+--------------+ - | | | - v | | -\+-------------+ | | -| top\_friends |\<-+ | -\+-------------+ | - | | - v | -\+-------------+ | -| api\_keys |\<--------------------------+ -\+-------------+ - -*(Note: Arrows denote foreign key relationships)* - -## **3\. Table Definitions** - -### **users** - -Stores user account and profile information. - -| Column Name | Data Type | Constraints | Description | -| :---- | :---- | :---- | :---- | -| id | UUID | PRIMARY KEY, DEFAULT gen\_random\_uuid() | Unique identifier for the user. | -| username | VARCHAR(32) | NOT NULL, UNIQUE | The user's handle. | -| email | VARCHAR(255) | NOT NULL, UNIQUE | The user's email address. | -| password\_hash | TEXT | NOT NULL | Hashed password (using Argon2 or bcrypt). | -| display\_name | VARCHAR(50) | NULL | User's public display name. | -| bio | VARCHAR(160) | NULL | User's public biography. | -| avatar\_url | TEXT | NULL | URL to the user's avatar image. | -| header\_url | TEXT | NULL | URL to the user's header image. | -| custom\_css | TEXT | NULL | User's custom profile CSS. | -| created\_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | Timestamp of account creation. | -| updated\_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | Timestamp of the last profile update. | - -### **thoughts** - -Stores the content of each post. - -| Column Name | Data Type | Constraints | Description | -| :---- | :---- | :---- | :---- | -| id | UUID | PRIMARY KEY, DEFAULT gen\_random\_uuid() | Unique identifier for the thought. | -| user\_id | UUID | NOT NULL, REFERENCES users(id) | The ID of the authoring user. | -| content | VARCHAR(128) | NOT NULL | The text content of the thought. | -| created\_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | Timestamp of when the thought was posted. | - -### **follows** - -A join table representing the follower/following relationship. - -| Column Name | Data Type | Constraints | Description | -| :---- | :---- | :---- | :---- | -| follower\_id | UUID | NOT NULL, REFERENCES users(id) | The user who is initiating the follow. | -| following\_id | UUID | NOT NULL, REFERENCES users(id) | The user who is being followed. | -| | | PRIMARY KEY (follower\_id, following\_id) | Ensures a user can't follow someone twice. | - -### **top\_friends** - -Stores the ordered list of a user's "Top Friends". - -| Column Name | Data Type | Constraints | Description | -| :---- | :---- | :---- | :---- | -| user\_id | UUID | NOT NULL, REFERENCES users(id) | The owner of this "Top Friends" list. | -| friend\_id | UUID | NOT NULL, REFERENCES users(id) | The user being displayed as a friend. | -| position | SMALLINT | NOT NULL | The order (1-8) of the friend on the list. | -| | | PRIMARY KEY (user\_id, friend\_id) | Ensures a user can't be in the list twice. | -| | | UNIQUE (user\_id, position) | Ensures positions are not duplicated. | - -### **tags and thought\_tags (for hashtags)** - -* **tags**: Stores unique tag names. -* **thought\_tags**: A join table linking thoughts to tags. - -#### **tags** - -| Column Name | Data Type | Constraints | Description | -| :---- | :---- | :---- | :---- | -| id | SERIAL | PRIMARY KEY | Unique ID for the tag. | -| name | VARCHAR(50) | NOT NULL, UNIQUE | The tag name (e.g., "welcome"). | - -#### **thought\_tags** - -| Column Name | Data Type | Constraints | Description | -| :---- | :---- | :---- | :---- | -| thought\_id | UUID | NOT NULL, REFERENCES thoughts(id) | The ID of the thought. | -| tag\_id | INTEGER | NOT NULL, REFERENCES tags(id) | The ID of the tag. | -| | | PRIMARY KEY (thought\_id, tag\_id) | Prevents duplicate tags per post. | - -### **api\_keys** - -Stores hashed API keys for users. - -| Column Name | Data Type | Constraints | Description | -| :---- | :---- | :---- | :---- | -| id | UUID | PRIMARY KEY, DEFAULT gen\_random\_uuid() | Unique identifier for the API key. | -| user\_id | UUID | NOT NULL, REFERENCES users(id) | The user who owns this key. | -| key\_hash | TEXT | NOT NULL, UNIQUE | The hashed value of the API key. | -| name | VARCHAR(50) | NOT NULL | A user-provided name for the key. | -| created\_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | Timestamp of when the key was created. | - diff --git a/codebase-prompt.txt b/codebase-prompt.txt deleted file mode 100644 index a0514ba..0000000 --- a/codebase-prompt.txt +++ /dev/null @@ -1,2 +0,0 @@ -uvx files-to-prompt thoughts-backend -e toml -e rs -e md --ignore "*target" -o backend-codebase.txt -uvx files-to-prompt thoughts-frontend -o frontend-codebase.txt --ignore "*node_modules" --ignore "*.lock" \ No newline at end of file diff --git a/crates/adapters/activitypub/src/handler.rs b/crates/adapters/activitypub/src/handler.rs index b88317b..38748c3 100644 --- a/crates/adapters/activitypub/src/handler.rs +++ b/crates/adapters/activitypub/src/handler.rs @@ -1,14 +1,14 @@ -use std::sync::Arc; use anyhow::{anyhow, Result}; use async_trait::async_trait; use chrono::{DateTime, Utc}; +use std::sync::Arc; use url::Url; +use crate::note::ThoughtNote; +use crate::urls::ThoughtsUrls; use activitypub_base::ApObjectHandler; use domain::ports::ActivityPubRepository; use domain::value_objects::UserId; -use crate::note::ThoughtNote; -use crate::urls::ThoughtsUrls; pub struct ThoughtsObjectHandler { repo: Arc, @@ -17,7 +17,10 @@ pub struct ThoughtsObjectHandler { impl ThoughtsObjectHandler { pub fn new(repo: Arc, base_url: &str) -> Self { - Self { repo, urls: ThoughtsUrls::new(base_url) } + Self { + repo, + urls: ThoughtsUrls::new(base_url), + } } } @@ -28,21 +31,34 @@ impl ApObjectHandler for ThoughtsObjectHandler { user_id: uuid::Uuid, ) -> Result> { let uid = UserId::from_uuid(user_id); - let entries = self.repo.outbox_entries_for_actor(&uid).await + let entries = self + .repo + .outbox_entries_for_actor(&uid) + .await .map_err(|e| anyhow!("{e}"))?; - entries.into_iter().map(|e| { - let note_url = self.urls.thought_url(e.thought.id.as_uuid()); - let actor_url = self.urls.user_url(e.author_username.as_str()); - let followers = self.urls.user_followers(e.author_username.as_str()); - let in_reply_to = e.thought.in_reply_to_id.map(|id| self.urls.thought_url(id.as_uuid())); - let note = ThoughtNote::new_public( - note_url.clone(), actor_url, - e.thought.content.as_str().to_owned(), - e.thought.created_at, in_reply_to, - e.thought.sensitive, e.thought.content_warning, followers, - ); - Ok((note_url, serde_json::to_value(¬e)?)) - }).collect() + entries + .into_iter() + .map(|e| { + let note_url = self.urls.thought_url(e.thought.id.as_uuid()); + let actor_url = self.urls.user_url(e.author_username.as_str()); + let followers = self.urls.user_followers(e.author_username.as_str()); + let in_reply_to = e + .thought + .in_reply_to_id + .map(|id| self.urls.thought_url(id.as_uuid())); + let note = ThoughtNote::new_public( + note_url.clone(), + actor_url, + e.thought.content.as_str().to_owned(), + e.thought.created_at, + in_reply_to, + e.thought.sensitive, + e.thought.content_warning, + followers, + ); + Ok((note_url, serde_json::to_value(¬e)?)) + }) + .collect() } async fn get_local_objects_page( @@ -52,22 +68,35 @@ impl ApObjectHandler for ThoughtsObjectHandler { limit: usize, ) -> Result)>> { let uid = UserId::from_uuid(user_id); - let entries = self.repo.outbox_page_for_actor(&uid, before, limit).await + let entries = self + .repo + .outbox_page_for_actor(&uid, before, limit) + .await .map_err(|e| anyhow!("{e}"))?; - entries.into_iter().map(|e| { - let created_at = e.thought.created_at; - let note_url = self.urls.thought_url(e.thought.id.as_uuid()); - let actor_url = self.urls.user_url(e.author_username.as_str()); - let followers = self.urls.user_followers(e.author_username.as_str()); - let in_reply_to = e.thought.in_reply_to_id.map(|id| self.urls.thought_url(id.as_uuid())); - let note = ThoughtNote::new_public( - note_url.clone(), actor_url, - e.thought.content.as_str().to_owned(), - created_at, in_reply_to, - e.thought.sensitive, e.thought.content_warning, followers, - ); - Ok((note_url, serde_json::to_value(¬e)?, created_at)) - }).collect() + entries + .into_iter() + .map(|e| { + let created_at = e.thought.created_at; + let note_url = self.urls.thought_url(e.thought.id.as_uuid()); + let actor_url = self.urls.user_url(e.author_username.as_str()); + let followers = self.urls.user_followers(e.author_username.as_str()); + let in_reply_to = e + .thought + .in_reply_to_id + .map(|id| self.urls.thought_url(id.as_uuid())); + let note = ThoughtNote::new_public( + note_url.clone(), + actor_url, + e.thought.content.as_str().to_owned(), + created_at, + in_reply_to, + e.thought.sensitive, + e.thought.content_warning, + followers, + ); + Ok((note_url, serde_json::to_value(¬e)?, created_at)) + }) + .collect() } async fn on_create( @@ -77,15 +106,22 @@ impl ApObjectHandler for ThoughtsObjectHandler { object: serde_json::Value, ) -> Result<()> { let note: ThoughtNote = serde_json::from_value(object)?; - let author_id = self.repo.intern_remote_actor(actor_url).await + let author_id = self + .repo + .intern_remote_actor(actor_url) + .await .map_err(|e| anyhow!("{e}"))?; - self.repo.accept_note( - ap_id, &author_id, - ¬e.content, - note.published, - note.sensitive, - note.summary, - ).await.map_err(|e| anyhow!("{e}")) + self.repo + .accept_note( + ap_id, + &author_id, + ¬e.content, + note.published, + note.sensitive, + note.summary, + ) + .await + .map_err(|e| anyhow!("{e}")) } async fn on_update( @@ -95,19 +131,30 @@ impl ApObjectHandler for ThoughtsObjectHandler { object: serde_json::Value, ) -> Result<()> { let note: ThoughtNote = serde_json::from_value(object)?; - self.repo.apply_note_update(ap_id, ¬e.content).await + self.repo + .apply_note_update(ap_id, ¬e.content) + .await .map_err(|e| anyhow!("{e}")) } async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> { - self.repo.retract_note(ap_id).await.map_err(|e| anyhow!("{e}")) + self.repo + .retract_note(ap_id) + .await + .map_err(|e| anyhow!("{e}")) } async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> { - self.repo.retract_actor_notes(actor_url).await.map_err(|e| anyhow!("{e}")) + self.repo + .retract_actor_notes(actor_url) + .await + .map_err(|e| anyhow!("{e}")) } async fn count_local_posts(&self) -> Result { - self.repo.count_local_notes().await.map_err(|e| anyhow!("{e}")) + self.repo + .count_local_notes() + .await + .map_err(|e| anyhow!("{e}")) } } diff --git a/crates/adapters/activitypub/src/note.rs b/crates/adapters/activitypub/src/note.rs index 6194b7d..8411ef2 100644 --- a/crates/adapters/activitypub/src/note.rs +++ b/crates/adapters/activitypub/src/note.rs @@ -1,5 +1,5 @@ -use activitypub_base::AS_PUBLIC; use activitypub_base::NoteType; +use activitypub_base::AS_PUBLIC; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use url::Url; @@ -27,16 +27,26 @@ pub struct ThoughtNote { impl ThoughtNote { pub fn new_public( - id: Url, actor_url: Url, content: String, published: DateTime, - in_reply_to: Option, sensitive: bool, summary: Option, + id: Url, + actor_url: Url, + content: String, + published: DateTime, + in_reply_to: Option, + sensitive: bool, + summary: Option, followers_url: Url, ) -> Self { Self { kind: Default::default(), - id, attributed_to: actor_url, content, published, + id, + attributed_to: actor_url, + content, + published, to: vec![AS_PUBLIC.to_string()], cc: vec![followers_url.to_string()], - in_reply_to, sensitive, summary, + in_reply_to, + sensitive, + summary, } } } @@ -52,7 +62,9 @@ mod tests { "https://example.com/users/alice".parse().unwrap(), "Hello world".to_string(), chrono::Utc::now(), - None, false, None, + None, + false, + None, "https://example.com/users/alice/followers".parse().unwrap(), ); let json = serde_json::to_string(¬e).unwrap(); diff --git a/crates/adapters/activitypub/src/urls.rs b/crates/adapters/activitypub/src/urls.rs index 5f7bf82..f15f95a 100644 --- a/crates/adapters/activitypub/src/urls.rs +++ b/crates/adapters/activitypub/src/urls.rs @@ -6,7 +6,9 @@ pub struct ThoughtsUrls { impl ThoughtsUrls { pub fn new(base_url: &str) -> Self { - Self { base_url: base_url.trim_end_matches('/').to_string() } + Self { + base_url: base_url.trim_end_matches('/').to_string(), + } } pub fn user_url(&self, username: &str) -> Url { @@ -37,13 +39,19 @@ mod tests { #[test] fn user_url_format() { let urls = ThoughtsUrls::new("https://example.com"); - assert_eq!(urls.user_url("alice").as_str(), "https://example.com/users/alice"); + assert_eq!( + urls.user_url("alice").as_str(), + "https://example.com/users/alice" + ); } #[test] fn thought_url_format() { let urls = ThoughtsUrls::new("https://example.com"); let id = uuid::Uuid::nil(); - assert!(urls.thought_url(id).as_str().starts_with("https://example.com/thoughts/")); + assert!(urls + .thought_url(id) + .as_str() + .starts_with("https://example.com/thoughts/")); } } diff --git a/crates/adapters/auth/src/lib.rs b/crates/adapters/auth/src/lib.rs index 53088b9..2a9a0c3 100644 --- a/crates/adapters/auth/src/lib.rs +++ b/crates/adapters/auth/src/lib.rs @@ -1,12 +1,12 @@ use async_trait::async_trait; use chrono::{Duration, Utc}; -use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; -use serde::{Deserialize, Serialize}; use domain::{ errors::DomainError, ports::{AuthService, GeneratedToken, PasswordHasher}, value_objects::{PasswordHash, UserId}, }; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] struct Claims { @@ -21,7 +21,10 @@ pub struct JwtAuthService { impl JwtAuthService { pub fn new(secret: String, ttl_seconds: i64) -> Self { - Self { secret, ttl_seconds } + Self { + secret, + ttl_seconds, + } } } @@ -51,8 +54,8 @@ impl AuthService for JwtAuthService { &Validation::default(), ) .map_err(|_| DomainError::Unauthorized)?; - let uuid = uuid::Uuid::parse_str(&data.claims.sub) - .map_err(|_| DomainError::Unauthorized)?; + let uuid = + uuid::Uuid::parse_str(&data.claims.sub).map_err(|_| DomainError::Unauthorized)?; Ok(UserId::from_uuid(uuid)) } } @@ -62,10 +65,7 @@ pub struct Argon2PasswordHasher; #[async_trait] impl PasswordHasher for Argon2PasswordHasher { async fn hash(&self, plain: &str) -> Result { - use argon2::{ - password_hash::SaltString, - Argon2, PasswordHasher as _, - }; + use argon2::{password_hash::SaltString, Argon2, PasswordHasher as _}; use rand::rngs::OsRng; let salt = SaltString::generate(OsRng); let hash = Argon2::default() @@ -77,8 +77,7 @@ impl PasswordHasher for Argon2PasswordHasher { async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result { use argon2::{password_hash::PasswordHash as ArgonHash, Argon2, PasswordVerifier}; - let parsed = ArgonHash::new(&hash.0) - .map_err(|e| DomainError::Internal(e.to_string()))?; + let parsed = ArgonHash::new(&hash.0).map_err(|e| DomainError::Internal(e.to_string()))?; Ok(Argon2::default() .verify_password(plain.as_bytes(), &parsed) .is_ok()) diff --git a/crates/adapters/event-payload/src/lib.rs b/crates/adapters/event-payload/src/lib.rs index 0a8c617..98bd2ed 100644 --- a/crates/adapters/event-payload/src/lib.rs +++ b/crates/adapters/event-payload/src/lib.rs @@ -74,20 +74,20 @@ impl EventPayload { /// Returns the NATS subject for this event. pub fn subject(&self) -> &'static str { match self { - Self::ThoughtCreated { .. } => "thoughts.created", - Self::ThoughtDeleted { .. } => "thoughts.deleted", - Self::ThoughtUpdated { .. } => "thoughts.updated", - Self::LikeAdded { .. } => "likes.added", - Self::LikeRemoved { .. } => "likes.removed", - Self::BoostAdded { .. } => "boosts.added", - Self::BoostRemoved { .. } => "boosts.removed", + Self::ThoughtCreated { .. } => "thoughts.created", + Self::ThoughtDeleted { .. } => "thoughts.deleted", + Self::ThoughtUpdated { .. } => "thoughts.updated", + Self::LikeAdded { .. } => "likes.added", + Self::LikeRemoved { .. } => "likes.removed", + Self::BoostAdded { .. } => "boosts.added", + Self::BoostRemoved { .. } => "boosts.removed", Self::FollowRequested { .. } => "follows.requested", - Self::FollowAccepted { .. } => "follows.accepted", - Self::FollowRejected { .. } => "follows.rejected", - Self::Unfollowed { .. } => "follows.removed", - Self::UserBlocked { .. } => "users.blocked", - Self::UserUnblocked { .. } => "users.unblocked", - Self::UserRegistered { .. } => "users.registered", + Self::FollowAccepted { .. } => "follows.accepted", + Self::FollowRejected { .. } => "follows.rejected", + Self::Unfollowed { .. } => "follows.removed", + Self::UserBlocked { .. } => "users.blocked", + Self::UserUnblocked { .. } => "users.unblocked", + Self::UserRegistered { .. } => "users.registered", } } } @@ -97,46 +97,102 @@ impl EventPayload { impl From<&DomainEvent> for EventPayload { fn from(e: &DomainEvent) -> Self { match e { - DomainEvent::ThoughtCreated { thought_id, user_id, in_reply_to_id } => Self::ThoughtCreated { + DomainEvent::ThoughtCreated { + thought_id, + user_id, + in_reply_to_id, + } => Self::ThoughtCreated { thought_id: thought_id.to_string(), user_id: user_id.to_string(), in_reply_to_id: in_reply_to_id.as_ref().map(|x| x.to_string()), }, - DomainEvent::ThoughtDeleted { thought_id, user_id } => Self::ThoughtDeleted { - thought_id: thought_id.to_string(), user_id: user_id.to_string(), + DomainEvent::ThoughtDeleted { + thought_id, + user_id, + } => Self::ThoughtDeleted { + thought_id: thought_id.to_string(), + user_id: user_id.to_string(), }, - DomainEvent::ThoughtUpdated { thought_id, user_id } => Self::ThoughtUpdated { - thought_id: thought_id.to_string(), user_id: user_id.to_string(), + DomainEvent::ThoughtUpdated { + thought_id, + user_id, + } => Self::ThoughtUpdated { + thought_id: thought_id.to_string(), + user_id: user_id.to_string(), }, - DomainEvent::LikeAdded { like_id, user_id, thought_id } => Self::LikeAdded { - like_id: like_id.to_string(), user_id: user_id.to_string(), thought_id: thought_id.to_string(), + DomainEvent::LikeAdded { + like_id, + user_id, + thought_id, + } => Self::LikeAdded { + like_id: like_id.to_string(), + user_id: user_id.to_string(), + thought_id: thought_id.to_string(), }, - DomainEvent::LikeRemoved { user_id, thought_id } => Self::LikeRemoved { - user_id: user_id.to_string(), thought_id: thought_id.to_string(), + DomainEvent::LikeRemoved { + user_id, + thought_id, + } => Self::LikeRemoved { + user_id: user_id.to_string(), + thought_id: thought_id.to_string(), }, - DomainEvent::BoostAdded { boost_id, user_id, thought_id } => Self::BoostAdded { - boost_id: boost_id.to_string(), user_id: user_id.to_string(), thought_id: thought_id.to_string(), + DomainEvent::BoostAdded { + boost_id, + user_id, + thought_id, + } => Self::BoostAdded { + boost_id: boost_id.to_string(), + user_id: user_id.to_string(), + thought_id: thought_id.to_string(), }, - DomainEvent::BoostRemoved { user_id, thought_id } => Self::BoostRemoved { - user_id: user_id.to_string(), thought_id: thought_id.to_string(), + DomainEvent::BoostRemoved { + user_id, + thought_id, + } => Self::BoostRemoved { + user_id: user_id.to_string(), + thought_id: thought_id.to_string(), }, - DomainEvent::FollowRequested { follower_id, following_id } => Self::FollowRequested { - follower_id: follower_id.to_string(), following_id: following_id.to_string(), + DomainEvent::FollowRequested { + follower_id, + following_id, + } => Self::FollowRequested { + follower_id: follower_id.to_string(), + following_id: following_id.to_string(), }, - DomainEvent::FollowAccepted { follower_id, following_id } => Self::FollowAccepted { - follower_id: follower_id.to_string(), following_id: following_id.to_string(), + DomainEvent::FollowAccepted { + follower_id, + following_id, + } => Self::FollowAccepted { + follower_id: follower_id.to_string(), + following_id: following_id.to_string(), }, - DomainEvent::FollowRejected { follower_id, following_id } => Self::FollowRejected { - follower_id: follower_id.to_string(), following_id: following_id.to_string(), + DomainEvent::FollowRejected { + follower_id, + following_id, + } => Self::FollowRejected { + follower_id: follower_id.to_string(), + following_id: following_id.to_string(), }, - DomainEvent::Unfollowed { follower_id, following_id } => Self::Unfollowed { - follower_id: follower_id.to_string(), following_id: following_id.to_string(), + DomainEvent::Unfollowed { + follower_id, + following_id, + } => Self::Unfollowed { + follower_id: follower_id.to_string(), + following_id: following_id.to_string(), }, - DomainEvent::UserBlocked { blocker_id, blocked_id } => Self::UserBlocked { - blocker_id: blocker_id.to_string(), blocked_id: blocked_id.to_string(), + DomainEvent::UserBlocked { + blocker_id, + blocked_id, + } => Self::UserBlocked { + blocker_id: blocker_id.to_string(), + blocked_id: blocked_id.to_string(), }, - DomainEvent::UserUnblocked { blocker_id, blocked_id } => Self::UserUnblocked { - blocker_id: blocker_id.to_string(), blocked_id: blocked_id.to_string(), + DomainEvent::UserUnblocked { + blocker_id, + blocked_id, + } => Self::UserUnblocked { + blocker_id: blocker_id.to_string(), + blocked_id: blocked_id.to_string(), }, DomainEvent::UserRegistered { user_id } => Self::UserRegistered { user_id: user_id.to_string(), @@ -157,60 +213,102 @@ impl TryFrom for DomainEvent { fn try_from(p: EventPayload) -> Result { Ok(match p { - EventPayload::ThoughtCreated { thought_id, user_id, in_reply_to_id } => DomainEvent::ThoughtCreated { + EventPayload::ThoughtCreated { + thought_id, + user_id, + in_reply_to_id, + } => DomainEvent::ThoughtCreated { thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), in_reply_to_id: in_reply_to_id .map(|s| parse_uuid(&s, "in_reply_to_id").map(ThoughtId::from_uuid)) .transpose()?, }, - EventPayload::ThoughtDeleted { thought_id, user_id } => DomainEvent::ThoughtDeleted { + EventPayload::ThoughtDeleted { + thought_id, + user_id, + } => DomainEvent::ThoughtDeleted { thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), }, - EventPayload::ThoughtUpdated { thought_id, user_id } => DomainEvent::ThoughtUpdated { + EventPayload::ThoughtUpdated { + thought_id, + user_id, + } => DomainEvent::ThoughtUpdated { thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), }, - EventPayload::LikeAdded { like_id, user_id, thought_id } => DomainEvent::LikeAdded { + EventPayload::LikeAdded { + like_id, + user_id, + thought_id, + } => DomainEvent::LikeAdded { like_id: LikeId::from_uuid(parse_uuid(&like_id, "like_id")?), user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), }, - EventPayload::LikeRemoved { user_id, thought_id } => DomainEvent::LikeRemoved { + EventPayload::LikeRemoved { + user_id, + thought_id, + } => DomainEvent::LikeRemoved { user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), }, - EventPayload::BoostAdded { boost_id, user_id, thought_id } => DomainEvent::BoostAdded { + EventPayload::BoostAdded { + boost_id, + user_id, + thought_id, + } => DomainEvent::BoostAdded { boost_id: BoostId::from_uuid(parse_uuid(&boost_id, "boost_id")?), user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), }, - EventPayload::BoostRemoved { user_id, thought_id } => DomainEvent::BoostRemoved { + EventPayload::BoostRemoved { + user_id, + thought_id, + } => DomainEvent::BoostRemoved { user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), }, - EventPayload::FollowRequested { follower_id, following_id } => DomainEvent::FollowRequested { + EventPayload::FollowRequested { + follower_id, + following_id, + } => DomainEvent::FollowRequested { follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?), following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?), }, - EventPayload::FollowAccepted { follower_id, following_id } => DomainEvent::FollowAccepted { + EventPayload::FollowAccepted { + follower_id, + following_id, + } => DomainEvent::FollowAccepted { follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?), following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?), }, - EventPayload::FollowRejected { follower_id, following_id } => DomainEvent::FollowRejected { + EventPayload::FollowRejected { + follower_id, + following_id, + } => DomainEvent::FollowRejected { follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?), following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?), }, - EventPayload::Unfollowed { follower_id, following_id } => DomainEvent::Unfollowed { + EventPayload::Unfollowed { + follower_id, + following_id, + } => DomainEvent::Unfollowed { follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?), following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?), }, - EventPayload::UserBlocked { blocker_id, blocked_id } => DomainEvent::UserBlocked { + EventPayload::UserBlocked { + blocker_id, + blocked_id, + } => DomainEvent::UserBlocked { blocker_id: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?), blocked_id: UserId::from_uuid(parse_uuid(&blocked_id, "blocked_id")?), }, - EventPayload::UserUnblocked { blocker_id, blocked_id } => DomainEvent::UserUnblocked { + EventPayload::UserUnblocked { + blocker_id, + blocked_id, + } => DomainEvent::UserUnblocked { blocker_id: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?), blocked_id: UserId::from_uuid(parse_uuid(&blocked_id, "blocked_id")?), }, @@ -240,22 +338,65 @@ mod tests { #[test] fn all_subjects_are_unique() { let samples: &[EventPayload] = &[ - EventPayload::ThoughtCreated { thought_id: "a".into(), user_id: "b".into(), in_reply_to_id: None }, - EventPayload::ThoughtDeleted { thought_id: "a".into(), user_id: "b".into() }, - EventPayload::ThoughtUpdated { thought_id: "a".into(), user_id: "b".into() }, - EventPayload::LikeAdded { like_id: "a".into(), user_id: "b".into(), thought_id: "c".into() }, - EventPayload::LikeRemoved { user_id: "b".into(), thought_id: "c".into() }, - EventPayload::BoostAdded { boost_id: "a".into(), user_id: "b".into(), thought_id: "c".into() }, - EventPayload::BoostRemoved { user_id: "b".into(), thought_id: "c".into() }, - EventPayload::FollowRequested { follower_id: "a".into(), following_id: "b".into() }, - EventPayload::FollowAccepted { follower_id: "a".into(), following_id: "b".into() }, - EventPayload::FollowRejected { follower_id: "a".into(), following_id: "b".into() }, - EventPayload::Unfollowed { follower_id: "a".into(), following_id: "b".into() }, - EventPayload::UserBlocked { blocker_id: "a".into(), blocked_id: "b".into() }, + EventPayload::ThoughtCreated { + thought_id: "a".into(), + user_id: "b".into(), + in_reply_to_id: None, + }, + EventPayload::ThoughtDeleted { + thought_id: "a".into(), + user_id: "b".into(), + }, + EventPayload::ThoughtUpdated { + thought_id: "a".into(), + user_id: "b".into(), + }, + EventPayload::LikeAdded { + like_id: "a".into(), + user_id: "b".into(), + thought_id: "c".into(), + }, + EventPayload::LikeRemoved { + user_id: "b".into(), + thought_id: "c".into(), + }, + EventPayload::BoostAdded { + boost_id: "a".into(), + user_id: "b".into(), + thought_id: "c".into(), + }, + EventPayload::BoostRemoved { + user_id: "b".into(), + thought_id: "c".into(), + }, + EventPayload::FollowRequested { + follower_id: "a".into(), + following_id: "b".into(), + }, + EventPayload::FollowAccepted { + follower_id: "a".into(), + following_id: "b".into(), + }, + EventPayload::FollowRejected { + follower_id: "a".into(), + following_id: "b".into(), + }, + EventPayload::Unfollowed { + follower_id: "a".into(), + following_id: "b".into(), + }, + EventPayload::UserBlocked { + blocker_id: "a".into(), + blocked_id: "b".into(), + }, ]; let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect(); subjects.sort(); subjects.dedup(); - assert_eq!(subjects.len(), samples.len(), "each event must have a unique subject"); + assert_eq!( + subjects.len(), + samples.len(), + "each event must have a unique subject" + ); } } diff --git a/crates/adapters/event-transport/src/lib.rs b/crates/adapters/event-transport/src/lib.rs index bc483d6..f0c63cc 100644 --- a/crates/adapters/event-transport/src/lib.rs +++ b/crates/adapters/event-transport/src/lib.rs @@ -1,5 +1,9 @@ use async_trait::async_trait; -use domain::{errors::DomainError, events::{DomainEvent, EventEnvelope}, ports::{EventConsumer, EventPublisher}}; +use domain::{ + errors::DomainError, + events::{DomainEvent, EventEnvelope}, + ports::{EventConsumer, EventPublisher}, +}; use event_payload::EventPayload; use futures::stream::BoxStream; @@ -31,8 +35,8 @@ impl EventPublisher for EventPublisherAdapter { async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { let payload = EventPayload::from(event); let subject = payload.subject(); - let bytes = serde_json::to_vec(&payload) - .map_err(|e| DomainError::Internal(e.to_string()))?; + let bytes = + serde_json::to_vec(&payload).map_err(|e| DomainError::Internal(e.to_string()))?; tracing::debug!(subject, "publishing event"); self.transport.publish_bytes(subject, &bytes).await } @@ -44,7 +48,7 @@ impl EventPublisher for EventPublisherAdapter { pub struct RawMessage { pub subject: String, pub payload: Vec, - pub ack: Box, + pub ack: Box, pub nack: Box, } @@ -60,7 +64,9 @@ pub struct EventConsumerAdapter { } impl EventConsumerAdapter { - pub fn new(source: S) -> Self { Self { source } } + pub fn new(source: S) -> Self { + Self { source } + } } impl EventConsumer for EventConsumerAdapter { @@ -90,7 +96,7 @@ impl EventConsumer for EventConsumerAdapter { }; Some(Ok(EventEnvelope { event, - ack: msg.ack, + ack: msg.ack, nack: msg.nack, })) } @@ -103,8 +109,8 @@ impl EventConsumer for EventConsumerAdapter { mod tests { use super::*; use async_trait::async_trait; - use std::sync::{Arc, Mutex}; use domain::value_objects::{ThoughtId, UserId}; + use std::sync::{Arc, Mutex}; struct SpyTransport { calls: Arc)>>>, @@ -112,13 +118,21 @@ mod tests { impl SpyTransport { fn new() -> (Self, Arc)>>>) { let calls = Arc::new(Mutex::new(vec![])); - (Self { calls: calls.clone() }, calls) + ( + Self { + calls: calls.clone(), + }, + calls, + ) } } #[async_trait] impl Transport for SpyTransport { async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> { - self.calls.lock().unwrap().push((subject.to_string(), bytes.to_vec())); + self.calls + .lock() + .unwrap() + .push((subject.to_string(), bytes.to_vec())); Ok(()) } } @@ -127,11 +141,14 @@ mod tests { async fn thought_created_routes_to_correct_subject() { let (spy, calls) = SpyTransport::new(); let publisher = EventPublisherAdapter::new(spy); - publisher.publish(&DomainEvent::ThoughtCreated { - thought_id: ThoughtId::new(), - user_id: UserId::new(), - in_reply_to_id: None, - }).await.unwrap(); + publisher + .publish(&DomainEvent::ThoughtCreated { + thought_id: ThoughtId::new(), + user_id: UserId::new(), + in_reply_to_id: None, + }) + .await + .unwrap(); let calls = calls.lock().unwrap(); assert_eq!(calls.len(), 1); assert_eq!(calls[0].0, "thoughts.created"); @@ -141,10 +158,13 @@ mod tests { async fn serialized_payload_is_valid_json() { let (spy, calls) = SpyTransport::new(); let publisher = EventPublisherAdapter::new(spy); - publisher.publish(&DomainEvent::UserBlocked { - blocker_id: UserId::new(), - blocked_id: UserId::new(), - }).await.unwrap(); + publisher + .publish(&DomainEvent::UserBlocked { + blocker_id: UserId::new(), + blocked_id: UserId::new(), + }) + .await + .unwrap(); let bytes = calls.lock().unwrap()[0].1.clone(); let json: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON"); assert_eq!(json["type"], "UserBlocked"); @@ -163,14 +183,16 @@ mod tests { let payload = EventPayload::from(&event); let bytes = serde_json::to_vec(&payload).unwrap(); - struct OneMessageSource { bytes: Vec } + struct OneMessageSource { + bytes: Vec, + } #[async_trait::async_trait] impl MessageSource for OneMessageSource { fn messages(&self) -> futures::stream::BoxStream<'_, Result> { let msg = RawMessage { subject: "thoughts.created".to_string(), payload: self.bytes.clone(), - ack: Box::new(|| {}), + ack: Box::new(|| {}), nack: Box::new(|| {}), }; Box::pin(futures::stream::once(async { Ok(msg) })) @@ -194,7 +216,7 @@ mod tests { let msg = RawMessage { subject: "bad".to_string(), payload: b"not valid json".to_vec(), - ack: Box::new(|| {}), + ack: Box::new(|| {}), nack: Box::new(|| {}), }; Box::pin(futures::stream::once(async { Ok(msg) })) diff --git a/crates/adapters/nats/src/lib.rs b/crates/adapters/nats/src/lib.rs index 0a14fd5..677ea89 100644 --- a/crates/adapters/nats/src/lib.rs +++ b/crates/adapters/nats/src/lib.rs @@ -10,7 +10,9 @@ pub struct NatsTransport { } impl NatsTransport { - pub fn new(client: async_nats::Client) -> Self { Self { client } } + pub fn new(client: async_nats::Client) -> Self { + Self { client } + } } #[async_trait] @@ -30,7 +32,9 @@ pub struct NatsMessageSource { } impl NatsMessageSource { - pub fn new(client: async_nats::Client) -> Self { Self { client } } + pub fn new(client: async_nats::Client) -> Self { + Self { client } + } } impl MessageSource for NatsMessageSource { @@ -61,7 +65,10 @@ impl MessageSource for NatsMessageSource { #[cfg(test)] mod tests { use super::*; - use domain::{events::DomainEvent, value_objects::{LikeId, ThoughtId, UserId}}; + use domain::{ + events::DomainEvent, + value_objects::{LikeId, ThoughtId, UserId}, + }; use event_payload::EventPayload; #[test] @@ -86,7 +93,12 @@ mod tests { }; let payload = EventPayload::from(&event); let back = DomainEvent::try_from(payload).unwrap(); - if let DomainEvent::LikeAdded { user_id, thought_id, .. } = back { + if let DomainEvent::LikeAdded { + user_id, + thought_id, + .. + } = back + { assert_eq!(user_id, uid); assert_eq!(thought_id, tid); } else { diff --git a/crates/adapters/postgres-federation/src/lib.rs b/crates/adapters/postgres-federation/src/lib.rs index 3cc21bd..7d37136 100644 --- a/crates/adapters/postgres-federation/src/lib.rs +++ b/crates/adapters/postgres-federation/src/lib.rs @@ -4,8 +4,8 @@ use chrono::{DateTime, Utc}; use sqlx::PgPool; use activitypub_base::{ - ApUser, ApUserRepository, - BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, + ApUser, ApUserRepository, BlockedDomain, FederationRepository, Follower, FollowerStatus, + FollowingStatus, RemoteActor, }; // ── PostgresFederationRepository ───────────────────────────────────────────── @@ -15,29 +15,54 @@ pub struct PostgresFederationRepository { } impl PostgresFederationRepository { - pub fn new(pool: PgPool) -> Self { Self { pool } } + pub fn new(pool: PgPool) -> Self { + Self { pool } + } } fn status_str(s: &FollowerStatus) -> &'static str { - match s { FollowerStatus::Pending => "pending", FollowerStatus::Accepted => "accepted", FollowerStatus::Rejected => "rejected" } + match s { + FollowerStatus::Pending => "pending", + FollowerStatus::Accepted => "accepted", + FollowerStatus::Rejected => "rejected", + } } fn str_status(s: &str) -> FollowerStatus { - match s { "accepted" => FollowerStatus::Accepted, "rejected" => FollowerStatus::Rejected, _ => FollowerStatus::Pending } + match s { + "accepted" => FollowerStatus::Accepted, + "rejected" => FollowerStatus::Rejected, + _ => FollowerStatus::Pending, + } } fn map_remote_actor( - url: String, handle: String, inbox_url: String, - shared_inbox_url: Option, display_name: Option, - avatar_url: Option, outbox_url: Option, + url: String, + handle: String, + inbox_url: String, + shared_inbox_url: Option, + display_name: Option, + avatar_url: Option, + outbox_url: Option, ) -> RemoteActor { - RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url } + RemoteActor { + url, + handle, + inbox_url, + shared_inbox_url, + display_name, + avatar_url, + outbox_url, + } } #[async_trait] impl FederationRepository for PostgresFederationRepository { async fn add_follower( - &self, local_user_id: uuid::Uuid, remote_actor_url: &str, - status: FollowerStatus, follow_activity_id: &str, + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + status: FollowerStatus, + follow_activity_id: &str, ) -> Result<()> { sqlx::query( "INSERT INTO federation_followers(local_user_id,remote_actor_url,status,follow_activity_id) @@ -50,22 +75,43 @@ impl FederationRepository for PostgresFederationRepository { } async fn get_follower_follow_activity_id( - &self, local_user_id: uuid::Uuid, remote_actor_url: &str, + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, ) -> Result> { sqlx::query_scalar::<_, String>( "SELECT follow_activity_id FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2" ).bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e)) } - async fn remove_follower(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<()> { - sqlx::query("DELETE FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2") - .bind(local_user_id).bind(remote_actor_url) - .execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + async fn remove_follower( + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + ) -> Result<()> { + sqlx::query( + "DELETE FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2", + ) + .bind(local_user_id) + .bind(remote_actor_url) + .execute(&self.pool) + .await + .map_err(|e| anyhow!(e)) + .map(|_| ()) } async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result> { #[derive(sqlx::FromRow)] - struct Row { remote_actor_url: String, status: String, handle: String, inbox_url: String, shared_inbox_url: Option, display_name: Option, avatar_url: Option, outbox_url: Option } + struct Row { + remote_actor_url: String, + status: String, + handle: String, + inbox_url: String, + shared_inbox_url: Option, + display_name: Option, + avatar_url: Option, + outbox_url: Option, + } sqlx::query_as::<_, Row>( "SELECT f.remote_actor_url, f.status, COALESCE(r.handle,'') AS handle, COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url @@ -79,10 +125,22 @@ impl FederationRepository for PostgresFederationRepository { } async fn get_followers_page( - &self, local_user_id: uuid::Uuid, offset: u32, limit: usize, + &self, + local_user_id: uuid::Uuid, + offset: u32, + limit: usize, ) -> Result> { #[derive(sqlx::FromRow)] - struct Row { remote_actor_url: String, status: String, handle: String, inbox_url: String, shared_inbox_url: Option, display_name: Option, avatar_url: Option, outbox_url: Option } + struct Row { + remote_actor_url: String, + status: String, + handle: String, + inbox_url: String, + shared_inbox_url: Option, + display_name: Option, + avatar_url: Option, + outbox_url: Option, + } sqlx::query_as::<_, Row>( "SELECT f.remote_actor_url, f.status, COALESCE(r.handle,'') AS handle, COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url @@ -105,7 +163,15 @@ impl FederationRepository for PostgresFederationRepository { async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result> { #[derive(sqlx::FromRow)] - struct Row { remote_actor_url: String, handle: String, inbox_url: String, shared_inbox_url: Option, display_name: Option, avatar_url: Option, outbox_url: Option } + struct Row { + remote_actor_url: String, + handle: String, + inbox_url: String, + shared_inbox_url: Option, + display_name: Option, + avatar_url: Option, + outbox_url: Option, + } sqlx::query_as::<_, Row>( "SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle, COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url @@ -118,7 +184,10 @@ impl FederationRepository for PostgresFederationRepository { } async fn update_follower_status( - &self, local_user_id: uuid::Uuid, remote_actor_url: &str, status: FollowerStatus, + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, + status: FollowerStatus, ) -> Result<()> { sqlx::query("UPDATE federation_followers SET status=$3 WHERE local_user_id=$1 AND remote_actor_url=$2") .bind(local_user_id).bind(remote_actor_url).bind(status_str(&status)) @@ -126,7 +195,10 @@ impl FederationRepository for PostgresFederationRepository { } async fn add_following( - &self, local_user_id: uuid::Uuid, actor: RemoteActor, follow_activity_id: &str, + &self, + local_user_id: uuid::Uuid, + actor: RemoteActor, + follow_activity_id: &str, ) -> Result<()> { self.upsert_remote_actor(actor.clone()).await?; sqlx::query( @@ -140,7 +212,9 @@ impl FederationRepository for PostgresFederationRepository { } async fn get_follow_activity_id( - &self, local_user_id: uuid::Uuid, remote_actor_url: &str, + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, ) -> Result> { sqlx::query_scalar::<_, String>( "SELECT follow_activity_id FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2" @@ -148,14 +222,28 @@ impl FederationRepository for PostgresFederationRepository { } async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { - sqlx::query("DELETE FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2") - .bind(local_user_id).bind(actor_url) - .execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + sqlx::query( + "DELETE FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2", + ) + .bind(local_user_id) + .bind(actor_url) + .execute(&self.pool) + .await + .map_err(|e| anyhow!(e)) + .map(|_| ()) } async fn get_following(&self, local_user_id: uuid::Uuid) -> Result> { #[derive(sqlx::FromRow)] - struct Row { remote_actor_url: String, handle: String, inbox_url: String, shared_inbox_url: Option, display_name: Option, avatar_url: Option, outbox_url: Option } + struct Row { + remote_actor_url: String, + handle: String, + inbox_url: String, + shared_inbox_url: Option, + display_name: Option, + avatar_url: Option, + outbox_url: Option, + } sqlx::query_as::<_, Row>( "SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle, COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url @@ -168,10 +256,21 @@ impl FederationRepository for PostgresFederationRepository { } async fn get_following_page( - &self, local_user_id: uuid::Uuid, offset: u32, limit: usize, + &self, + local_user_id: uuid::Uuid, + offset: u32, + limit: usize, ) -> Result> { #[derive(sqlx::FromRow)] - struct Row { remote_actor_url: String, handle: String, inbox_url: String, shared_inbox_url: Option, display_name: Option, avatar_url: Option, outbox_url: Option } + struct Row { + remote_actor_url: String, + handle: String, + inbox_url: String, + shared_inbox_url: Option, + display_name: Option, + avatar_url: Option, + outbox_url: Option, + } sqlx::query_as::<_, Row>( "SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle, COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url @@ -185,20 +284,28 @@ impl FederationRepository for PostgresFederationRepository { } async fn count_following(&self, local_user_id: uuid::Uuid) -> Result { - let n: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM federation_following WHERE local_user_id=$1" - ).bind(local_user_id).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?; + let n: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM federation_following WHERE local_user_id=$1") + .bind(local_user_id) + .fetch_one(&self.pool) + .await + .map_err(|e| anyhow!(e))?; Ok(n as usize) } async fn update_following_status( - &self, _local_user_id: uuid::Uuid, _remote_actor_url: &str, _status: FollowingStatus, + &self, + _local_user_id: uuid::Uuid, + _remote_actor_url: &str, + _status: FollowingStatus, ) -> Result<()> { Ok(()) } async fn get_following_outbox_url( - &self, local_user_id: uuid::Uuid, remote_actor_url: &str, + &self, + local_user_id: uuid::Uuid, + remote_actor_url: &str, ) -> Result> { sqlx::query_scalar::<_, String>( "SELECT outbox_url FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2" @@ -221,7 +328,15 @@ impl FederationRepository for PostgresFederationRepository { async fn get_remote_actor(&self, actor_url: &str) -> Result> { #[derive(sqlx::FromRow)] - struct Row { url: String, handle: String, inbox_url: String, shared_inbox_url: Option, display_name: Option, avatar_url: Option, outbox_url: Option } + struct Row { + url: String, + handle: String, + inbox_url: String, + shared_inbox_url: Option, + display_name: Option, + avatar_url: Option, + outbox_url: Option, + } sqlx::query_as::<_, Row>( "SELECT url,handle,inbox_url,shared_inbox_url,display_name,avatar_url,outbox_url FROM remote_actors WHERE url=$1" ).bind(actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e)).map(|o| o.map(|r| @@ -229,12 +344,22 @@ impl FederationRepository for PostgresFederationRepository { )) } - async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result> { + async fn get_local_actor_keypair( + &self, + user_id: uuid::Uuid, + ) -> Result> { #[derive(sqlx::FromRow)] - struct Row { public_key: Option, private_key: Option } + struct Row { + public_key: Option, + private_key: Option, + } let row = sqlx::query_as::<_, Row>( - "SELECT public_key, private_key FROM users WHERE id=$1 AND local=true" - ).bind(user_id).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?; + "SELECT public_key, private_key FROM users WHERE id=$1 AND local=true", + ) + .bind(user_id) + .fetch_optional(&self.pool) + .await + .map_err(|e| anyhow!(e))?; Ok(row.and_then(|r| match (r.public_key, r.private_key) { (Some(pub_k), Some(priv_k)) => Some((pub_k, priv_k)), _ => None, @@ -242,27 +367,49 @@ impl FederationRepository for PostgresFederationRepository { } async fn save_local_actor_keypair( - &self, user_id: uuid::Uuid, public_key: String, private_key: String, + &self, + user_id: uuid::Uuid, + public_key: String, + private_key: String, ) -> Result<()> { sqlx::query("UPDATE users SET public_key=$2, private_key=$3, updated_at=NOW() WHERE id=$1") - .bind(user_id).bind(&public_key).bind(&private_key) - .execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + .bind(user_id) + .bind(&public_key) + .bind(&private_key) + .execute(&self.pool) + .await + .map_err(|e| anyhow!(e)) + .map(|_| ()) } async fn add_announce( - &self, activity_id: &str, object_url: &str, actor_url: &str, announced_at: DateTime, + &self, + activity_id: &str, + object_url: &str, + actor_url: &str, + announced_at: DateTime, ) -> Result<()> { sqlx::query( "INSERT INTO federation_announces(activity_id,object_url,actor_url,announced_at) - VALUES($1,$2,$3,$4) ON CONFLICT(activity_id) DO NOTHING" - ).bind(activity_id).bind(object_url).bind(actor_url).bind(announced_at) - .execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + VALUES($1,$2,$3,$4) ON CONFLICT(activity_id) DO NOTHING", + ) + .bind(activity_id) + .bind(object_url) + .bind(actor_url) + .bind(announced_at) + .execute(&self.pool) + .await + .map_err(|e| anyhow!(e)) + .map(|_| ()) } async fn count_announces(&self, object_url: &str) -> Result { - let n: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM federation_announces WHERE object_url=$1" - ).bind(object_url).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?; + let n: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM federation_announces WHERE object_url=$1") + .bind(object_url) + .fetch_one(&self.pool) + .await + .map_err(|e| anyhow!(e))?; Ok(n as usize) } @@ -274,21 +421,44 @@ impl FederationRepository for PostgresFederationRepository { async fn remove_blocked_domain(&self, domain: &str) -> Result<()> { sqlx::query("DELETE FROM federation_blocked_domains WHERE domain=$1") - .bind(domain).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + .bind(domain) + .execute(&self.pool) + .await + .map_err(|e| anyhow!(e)) + .map(|_| ()) } async fn get_blocked_domains(&self) -> Result> { #[derive(sqlx::FromRow)] - struct Row { domain: String, reason: Option, blocked_at: DateTime } - sqlx::query_as::<_, Row>("SELECT domain,reason,blocked_at FROM federation_blocked_domains ORDER BY domain") - .fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r| - BlockedDomain { domain: r.domain, reason: r.reason, blocked_at: r.blocked_at.to_rfc3339() } - ).collect()) + struct Row { + domain: String, + reason: Option, + blocked_at: DateTime, + } + sqlx::query_as::<_, Row>( + "SELECT domain,reason,blocked_at FROM federation_blocked_domains ORDER BY domain", + ) + .fetch_all(&self.pool) + .await + .map_err(|e| anyhow!(e)) + .map(|rows| { + rows.into_iter() + .map(|r| BlockedDomain { + domain: r.domain, + reason: r.reason, + blocked_at: r.blocked_at.to_rfc3339(), + }) + .collect() + }) } async fn is_domain_blocked(&self, domain: &str) -> Result { - let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM federation_blocked_domains WHERE domain=$1") - .bind(domain).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?; + let n: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM federation_blocked_domains WHERE domain=$1") + .bind(domain) + .fetch_one(&self.pool) + .await + .map_err(|e| anyhow!(e))?; Ok(n > 0) } @@ -300,7 +470,12 @@ impl FederationRepository for PostgresFederationRepository { async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { sqlx::query("DELETE FROM federation_blocked_actors WHERE local_user_id=$1 AND actor_url=$2") - .bind(local_user_id).bind(actor_url).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) + .bind(local_user_id) + .bind(actor_url) + .execute(&self.pool) + .await + .map_err(|e| anyhow!(e)) + .map(|_| ()) } async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result> { @@ -325,12 +500,29 @@ pub struct PostgresApUserRepository { } impl PostgresApUserRepository { - pub fn new(pool: PgPool, base_url: String) -> Self { Self { pool, base_url } } + pub fn new(pool: PgPool, base_url: String) -> Self { + Self { pool, base_url } + } - fn row_to_ap_user(&self, id: uuid::Uuid, username: String, bio: Option, avatar_url: Option) -> ApUser { + fn row_to_ap_user( + &self, + id: uuid::Uuid, + username: String, + bio: Option, + avatar_url: Option, + ) -> ApUser { let profile_url = url::Url::parse(&format!("{}/users/{}", self.base_url, username)).ok(); let avatar_url = avatar_url.and_then(|u| url::Url::parse(&u).ok()); - ApUser { id, username, bio, avatar_url, banner_url: None, also_known_as: None, profile_url, attachment: vec![] } + ApUser { + id, + username, + bio, + avatar_url, + banner_url: None, + also_known_as: None, + profile_url, + attachment: vec![], + } } } @@ -338,25 +530,45 @@ impl PostgresApUserRepository { impl ApUserRepository for PostgresApUserRepository { async fn find_by_id(&self, id: uuid::Uuid) -> Result> { #[derive(sqlx::FromRow)] - struct Row { id: uuid::Uuid, username: String, bio: Option, avatar_url: Option } + struct Row { + id: uuid::Uuid, + username: String, + bio: Option, + avatar_url: Option, + } let row = sqlx::query_as::<_, Row>( - "SELECT id,username,bio,avatar_url FROM users WHERE id=$1 AND local=true" - ).bind(id).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?; + "SELECT id,username,bio,avatar_url FROM users WHERE id=$1 AND local=true", + ) + .bind(id) + .fetch_optional(&self.pool) + .await + .map_err(|e| anyhow!(e))?; Ok(row.map(|r| self.row_to_ap_user(r.id, r.username, r.bio, r.avatar_url))) } async fn find_by_username(&self, username: &str) -> Result> { #[derive(sqlx::FromRow)] - struct Row { id: uuid::Uuid, username: String, bio: Option, avatar_url: Option } + struct Row { + id: uuid::Uuid, + username: String, + bio: Option, + avatar_url: Option, + } let row = sqlx::query_as::<_, Row>( - "SELECT id,username,bio,avatar_url FROM users WHERE username=$1 AND local=true" - ).bind(username).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?; + "SELECT id,username,bio,avatar_url FROM users WHERE username=$1 AND local=true", + ) + .bind(username) + .fetch_optional(&self.pool) + .await + .map_err(|e| anyhow!(e))?; Ok(row.map(|r| self.row_to_ap_user(r.id, r.username, r.bio, r.avatar_url))) } async fn count_users(&self) -> Result { let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE local=true") - .fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?; + .fetch_one(&self.pool) + .await + .map_err(|e| anyhow!(e))?; Ok(n as usize) } } diff --git a/crates/adapters/postgres-search/src/lib.rs b/crates/adapters/postgres-search/src/lib.rs index 0a2f898..4b95701 100644 --- a/crates/adapters/postgres-search/src/lib.rs +++ b/crates/adapters/postgres-search/src/lib.rs @@ -1,6 +1,6 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; -use sqlx::PgPool; +use domain::models::thought::Visibility; use domain::{ errors::DomainError, models::{ @@ -11,10 +11,16 @@ use domain::{ ports::SearchPort, value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username}, }; -use domain::models::thought::Visibility; +use sqlx::PgPool; -pub struct PgSearchRepository { pool: PgPool } -impl PgSearchRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } +pub struct PgSearchRepository { + pool: PgPool, +} +impl PgSearchRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} #[derive(sqlx::FromRow)] struct FeedRow { @@ -87,13 +93,28 @@ fn row_to_entry(r: FeedRow) -> FeedEntry { username: Username::from_trusted(r.username), email: Email::from_trusted(r.email), password_hash: PasswordHash(r.password_hash), - display_name: r.display_name, bio: r.bio, - avatar_url: r.avatar_url, header_url: r.header_url, custom_css: r.custom_css, - local: r.author_local, ap_id: r.u_ap_id, inbox_url: r.inbox_url, - public_key: r.public_key, private_key: r.private_key, - created_at: r.author_created_at, updated_at: r.author_updated_at, + display_name: r.display_name, + bio: r.bio, + avatar_url: r.avatar_url, + header_url: r.header_url, + custom_css: r.custom_css, + local: r.author_local, + ap_id: r.u_ap_id, + inbox_url: r.inbox_url, + public_key: r.public_key, + private_key: r.private_key, + created_at: r.author_created_at, + updated_at: r.author_updated_at, }; - FeedEntry { thought, author, like_count: r.like_count, boost_count: r.boost_count, reply_count: r.reply_count, liked_by_viewer: false, boosted_by_viewer: false } + FeedEntry { + thought, + author, + like_count: r.like_count, + boost_count: r.boost_count, + reply_count: r.reply_count, + liked_by_viewer: false, + boosted_by_viewer: false, + } } #[derive(sqlx::FromRow)] @@ -123,11 +144,18 @@ impl From for User { username: Username::from_trusted(r.username), email: Email::from_trusted(r.email), password_hash: PasswordHash(r.password_hash), - display_name: r.display_name, bio: r.bio, - avatar_url: r.avatar_url, header_url: r.header_url, custom_css: r.custom_css, - local: r.local, ap_id: r.ap_id, inbox_url: r.inbox_url, - public_key: r.public_key, private_key: r.private_key, - created_at: r.created_at, updated_at: r.updated_at, + display_name: r.display_name, + bio: r.bio, + avatar_url: r.avatar_url, + header_url: r.header_url, + custom_css: r.custom_css, + local: r.local, + ap_id: r.ap_id, + inbox_url: r.inbox_url, + public_key: r.public_key, + private_key: r.private_key, + created_at: r.created_at, + updated_at: r.updated_at, } } } @@ -146,7 +174,7 @@ impl SearchPort for PgSearchRepository { ) -> Result, DomainError> { let total: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM thoughts t - WHERE t.content % $1 AND t.visibility='public'" + WHERE t.content % $1 AND t.visibility='public'", ) .bind(query) .fetch_one(&self.pool) @@ -182,7 +210,7 @@ impl SearchPort for PgSearchRepository { ) -> Result, DomainError> { let total: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM users u - WHERE u.local=true AND (u.username % $1 OR u.display_name % $1)" + WHERE u.local=true AND (u.username % $1 OR u.display_name % $1)", ) .bind(query) .fetch_one(&self.pool) @@ -216,7 +244,10 @@ impl SearchPort for PgSearchRepository { mod tests { use super::*; use domain::{ - models::{thought::{Thought, Visibility}, user::User}, + models::{ + thought::{Thought, Visibility}, + user::User, + }, ports::{SearchPort, ThoughtRepository, UserRepository}, value_objects::*, }; @@ -233,9 +264,13 @@ mod tests { ); urepo.save(&u).await.unwrap(); let t = Thought::new_local( - ThoughtId::new(), u.id.clone(), + ThoughtId::new(), + u.id.clone(), Content::new_local(content).unwrap(), - None, Visibility::Public, None, false, + None, + Visibility::Public, + None, + false, ); trepo.save(&t).await.unwrap(); (u, t) @@ -246,7 +281,17 @@ mod tests { seed_thought(&pool, "alice", "hello world").await; seed_thought(&pool, "bob", "goodbye universe").await; let repo = PgSearchRepository::new(pool); - let result = repo.search_thoughts("hello world", &PageParams { page: 1, per_page: 20 }, None).await.unwrap(); + let result = repo + .search_thoughts( + "hello world", + &PageParams { + page: 1, + per_page: 20, + }, + None, + ) + .await + .unwrap(); assert_eq!(result.total, 1); assert_eq!(result.items[0].thought.content.as_str(), "hello world"); } @@ -255,19 +300,46 @@ mod tests { async fn search_users_finds_by_username(pool: sqlx::PgPool) { use postgres::user::PgUserRepository; let urepo = PgUserRepository::new(pool.clone()); - let alice = User::new_local(UserId::new(), Username::new("alice_search").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into())); + let alice = User::new_local( + UserId::new(), + Username::new("alice_search").unwrap(), + Email::new("alice@ex.com").unwrap(), + PasswordHash("h".into()), + ); urepo.save(&alice).await.unwrap(); let repo = PgSearchRepository::new(pool); - let result = repo.search_users("alice", &PageParams { page: 1, per_page: 20 }).await.unwrap(); + let result = repo + .search_users( + "alice", + &PageParams { + page: 1, + per_page: 20, + }, + ) + .await + .unwrap(); assert!(!result.items.is_empty()); - assert!(result.items.iter().any(|u| u.username.as_str() == "alice_search")); + assert!(result + .items + .iter() + .any(|u| u.username.as_str() == "alice_search")); } #[sqlx::test(migrations = "../postgres/migrations")] async fn search_thoughts_returns_empty_for_no_match(pool: sqlx::PgPool) { seed_thought(&pool, "alice", "hello world").await; let repo = PgSearchRepository::new(pool); - let result = repo.search_thoughts("zzzzzzzzz", &PageParams { page: 1, per_page: 20 }, None).await.unwrap(); + let result = repo + .search_thoughts( + "zzzzzzzzz", + &PageParams { + page: 1, + per_page: 20, + }, + None, + ) + .await + .unwrap(); assert_eq!(result.total, 0); } } diff --git a/crates/adapters/postgres/src/activitypub.rs b/crates/adapters/postgres/src/activitypub.rs index 9d5e7d3..06538ae 100644 --- a/crates/adapters/postgres/src/activitypub.rs +++ b/crates/adapters/postgres/src/activitypub.rs @@ -22,7 +22,10 @@ impl PgActivityPubRepository { #[async_trait] impl ActivityPubRepository for PgActivityPubRepository { - async fn outbox_entries_for_actor(&self, user_id: &UserId) -> Result, DomainError> { + async fn outbox_entries_for_actor( + &self, + user_id: &UserId, + ) -> Result, DomainError> { #[derive(sqlx::FromRow)] struct Row { id: uuid::Uuid, @@ -134,7 +137,10 @@ impl ActivityPubRepository for PgActivityPubRepository { .collect()) } - async fn find_remote_actor_id(&self, actor_ap_url: &Url) -> Result, DomainError> { + async fn find_remote_actor_id( + &self, + actor_ap_url: &Url, + ) -> Result, DomainError> { sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE ap_id=$1") .bind(actor_ap_url.as_str()) .fetch_optional(&self.pool) @@ -148,7 +154,10 @@ impl ActivityPubRepository for PgActivityPubRepository { return Ok(id); } let new_id = uuid::Uuid::new_v4(); - let handle = actor_ap_url.path().trim_start_matches('/').replace('/', "_"); + let handle = actor_ap_url + .path() + .trim_start_matches('/') + .replace('/', "_"); sqlx::query( "INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at) VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING", @@ -163,7 +172,11 @@ impl ActivityPubRepository for PgActivityPubRepository { // Re-fetch to get whichever id won the race self.find_remote_actor_id(actor_ap_url) .await? - .ok_or_else(|| DomainError::Internal("intern_remote_actor: insert succeeded but row not found".into())) + .ok_or_else(|| { + DomainError::Internal( + "intern_remote_actor: insert succeeded but row not found".into(), + ) + }) } async fn accept_note( @@ -195,13 +208,15 @@ impl ActivityPubRepository for PgActivityPubRepository { async fn apply_note_update(&self, ap_id: &Url, new_content: &str) -> Result<(), DomainError> { let capped: String = new_content.chars().take(500).collect(); - sqlx::query("UPDATE thoughts SET content=$2,updated_at=NOW() WHERE ap_id=$1 AND local=false") - .bind(ap_id.as_str()) - .bind(&capped) - .execute(&self.pool) - .await - .map_err(|e| DomainError::Internal(e.to_string())) - .map(|_| ()) + sqlx::query( + "UPDATE thoughts SET content=$2,updated_at=NOW() WHERE ap_id=$1 AND local=false", + ) + .bind(ap_id.as_str()) + .bind(&capped) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string())) + .map(|_| ()) } async fn retract_note(&self, ap_id: &Url) -> Result<(), DomainError> { @@ -253,9 +268,16 @@ mod tests { let actor_url = url::Url::parse("https://remote.example/users/bob").unwrap(); let ap_id = url::Url::parse("https://remote.example/notes/1").unwrap(); let author = repo.intern_remote_actor(&actor_url).await.unwrap(); - repo.accept_note(&ap_id, &author, "hello from remote", chrono::Utc::now(), false, None) - .await - .unwrap(); + repo.accept_note( + &ap_id, + &author, + "hello from remote", + chrono::Utc::now(), + false, + None, + ) + .await + .unwrap(); repo.retract_note(&ap_id).await.unwrap(); } diff --git a/crates/adapters/postgres/src/api_key.rs b/crates/adapters/postgres/src/api_key.rs index df9054e..9ff3049 100644 --- a/crates/adapters/postgres/src/api_key.rs +++ b/crates/adapters/postgres/src/api_key.rs @@ -1,29 +1,75 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; +use domain::{ + errors::DomainError, + models::api_key::ApiKey, + ports::ApiKeyRepository, + value_objects::{ApiKeyId, UserId}, +}; use sqlx::PgPool; -use domain::{errors::DomainError, models::api_key::ApiKey, ports::ApiKeyRepository, value_objects::{ApiKeyId, UserId}}; -pub struct PgApiKeyRepository { pool: PgPool } -impl PgApiKeyRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } +pub struct PgApiKeyRepository { + pool: PgPool, +} +impl PgApiKeyRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} #[async_trait] impl ApiKeyRepository for PgApiKeyRepository { async fn save(&self, k: &ApiKey) -> Result<(), DomainError> { - sqlx::query("INSERT INTO api_keys(id,user_id,key_hash,name,created_at) VALUES($1,$2,$3,$4,$5)") - .bind(k.id.as_uuid()).bind(k.user_id.as_uuid()).bind(&k.key_hash).bind(&k.name).bind(k.created_at) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) + sqlx::query( + "INSERT INTO api_keys(id,user_id,key_hash,name,created_at) VALUES($1,$2,$3,$4,$5)", + ) + .bind(k.id.as_uuid()) + .bind(k.user_id.as_uuid()) + .bind(&k.key_hash) + .bind(&k.name) + .bind(k.created_at) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string())) + .map(|_| ()) } async fn find_by_hash(&self, hash: &str) -> Result, DomainError> { - #[derive(sqlx::FromRow)] struct Row { id: uuid::Uuid, user_id: uuid::Uuid, key_hash: String, name: String, created_at: DateTime } - sqlx::query_as::<_, Row>("SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE key_hash=$1") - .bind(hash).fetch_optional(&self.pool).await - .map_err(|e| DomainError::Internal(e.to_string())) - .map(|o| o.map(|r| ApiKey { id: ApiKeyId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), key_hash: r.key_hash, name: r.name, created_at: r.created_at })) + #[derive(sqlx::FromRow)] + struct Row { + id: uuid::Uuid, + user_id: uuid::Uuid, + key_hash: String, + name: String, + created_at: DateTime, + } + sqlx::query_as::<_, Row>( + "SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE key_hash=$1", + ) + .bind(hash) + .fetch_optional(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string())) + .map(|o| { + o.map(|r| ApiKey { + id: ApiKeyId::from_uuid(r.id), + user_id: UserId::from_uuid(r.user_id), + key_hash: r.key_hash, + name: r.name, + created_at: r.created_at, + }) + }) } async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError> { - #[derive(sqlx::FromRow)] struct Row { id: uuid::Uuid, user_id: uuid::Uuid, key_hash: String, name: String, created_at: DateTime } + #[derive(sqlx::FromRow)] + struct Row { + id: uuid::Uuid, + user_id: uuid::Uuid, + key_hash: String, + name: String, + created_at: DateTime, + } sqlx::query_as::<_, Row>("SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE user_id=$1 ORDER BY created_at DESC") .bind(user_id.as_uuid()).fetch_all(&self.pool).await .map_err(|e| DomainError::Internal(e.to_string())) @@ -32,30 +78,46 @@ impl ApiKeyRepository for PgApiKeyRepository { async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError> { sqlx::query("DELETE FROM api_keys WHERE id=$1 AND user_id=$2") - .bind(id.as_uuid()).bind(user_id.as_uuid()) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) + .bind(id.as_uuid()) + .bind(user_id.as_uuid()) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string())) + .map(|_| ()) } } #[cfg(test)] mod tests { use super::*; - use chrono::Utc; - use domain::{models::user::User, value_objects::*}; use crate::user::PgUserRepository; + use chrono::Utc; use domain::ports::UserRepository; + use domain::{models::user::User, value_objects::*}; async fn seed_user(pool: &sqlx::PgPool) -> User { let repo = PgUserRepository::new(pool.clone()); - let u = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into())); - repo.save(&u).await.unwrap(); u + let u = User::new_local( + UserId::new(), + Username::new("alice").unwrap(), + Email::new("alice@ex.com").unwrap(), + PasswordHash("h".into()), + ); + repo.save(&u).await.unwrap(); + u } #[sqlx::test(migrations = "./migrations")] async fn save_and_find_by_hash(pool: sqlx::PgPool) { let user = seed_user(&pool).await; let repo = PgApiKeyRepository::new(pool); - let key = ApiKey { id: ApiKeyId::new(), user_id: user.id.clone(), key_hash: "abc123".into(), name: "test".into(), created_at: Utc::now() }; + let key = ApiKey { + id: ApiKeyId::new(), + user_id: user.id.clone(), + key_hash: "abc123".into(), + name: "test".into(), + created_at: Utc::now(), + }; repo.save(&key).await.unwrap(); let found = repo.find_by_hash("abc123").await.unwrap().unwrap(); assert_eq!(found.name, "test"); @@ -65,7 +127,13 @@ mod tests { async fn delete_key(pool: sqlx::PgPool) { let user = seed_user(&pool).await; let repo = PgApiKeyRepository::new(pool); - let key = ApiKey { id: ApiKeyId::new(), user_id: user.id.clone(), key_hash: "def456".into(), name: "key2".into(), created_at: Utc::now() }; + let key = ApiKey { + id: ApiKeyId::new(), + user_id: user.id.clone(), + key_hash: "def456".into(), + name: "key2".into(), + created_at: Utc::now(), + }; repo.save(&key).await.unwrap(); repo.delete(&key.id, &user.id).await.unwrap(); assert!(repo.find_by_hash("def456").await.unwrap().is_none()); diff --git a/crates/adapters/postgres/src/block.rs b/crates/adapters/postgres/src/block.rs index 92b2006..9a2c434 100644 --- a/crates/adapters/postgres/src/block.rs +++ b/crates/adapters/postgres/src/block.rs @@ -1,9 +1,17 @@ use async_trait::async_trait; +use domain::{ + errors::DomainError, models::social::Block, ports::BlockRepository, value_objects::UserId, +}; use sqlx::PgPool; -use domain::{errors::DomainError, models::social::Block, ports::BlockRepository, value_objects::UserId}; -pub struct PgBlockRepository { pool: PgPool } -impl PgBlockRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } +pub struct PgBlockRepository { + pool: PgPool, +} +impl PgBlockRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} #[async_trait] impl BlockRepository for PgBlockRepository { @@ -31,14 +39,13 @@ impl BlockRepository for PgBlockRepository { } async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result { - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM blocks WHERE blocker_id=$1 AND blocked_id=$2" - ) - .bind(blocker_id.as_uuid()) - .bind(blocked_id.as_uuid()) - .fetch_one(&self.pool) - .await - .map_err(|e| DomainError::Internal(e.to_string()))?; + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM blocks WHERE blocker_id=$1 AND blocked_id=$2") + .bind(blocker_id.as_uuid()) + .bind(blocked_id.as_uuid()) + .fetch_one(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; Ok(count > 0) } } @@ -46,23 +53,33 @@ impl BlockRepository for PgBlockRepository { #[cfg(test)] mod tests { use super::*; - use chrono::Utc; - use domain::{models::user::User, value_objects::*}; use crate::user::PgUserRepository; + use chrono::Utc; use domain::ports::UserRepository; + use domain::{models::user::User, value_objects::*}; async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User { let repo = PgUserRepository::new(pool.clone()); - let u = User::new_local(UserId::new(), Username::new(username).unwrap(), Email::new(email).unwrap(), PasswordHash("h".into())); - repo.save(&u).await.unwrap(); u + let u = User::new_local( + UserId::new(), + Username::new(username).unwrap(), + Email::new(email).unwrap(), + PasswordHash("h".into()), + ); + repo.save(&u).await.unwrap(); + u } #[sqlx::test(migrations = "./migrations")] async fn block_exists(pool: sqlx::PgPool) { let alice = seed_user(&pool, "alice", "alice@ex.com").await; - let bob = seed_user(&pool, "bob", "bob@ex.com").await; + let bob = seed_user(&pool, "bob", "bob@ex.com").await; let repo = PgBlockRepository::new(pool); - let block = Block { blocker_id: alice.id.clone(), blocked_id: bob.id.clone(), created_at: Utc::now() }; + let block = Block { + blocker_id: alice.id.clone(), + blocked_id: bob.id.clone(), + created_at: Utc::now(), + }; repo.save(&block).await.unwrap(); assert!(repo.exists(&alice.id, &bob.id).await.unwrap()); assert!(!repo.exists(&bob.id, &alice.id).await.unwrap()); @@ -71,9 +88,13 @@ mod tests { #[sqlx::test(migrations = "./migrations")] async fn unblock(pool: sqlx::PgPool) { let alice = seed_user(&pool, "alice", "alice@ex.com").await; - let bob = seed_user(&pool, "bob", "bob@ex.com").await; + let bob = seed_user(&pool, "bob", "bob@ex.com").await; let repo = PgBlockRepository::new(pool); - let block = Block { blocker_id: alice.id.clone(), blocked_id: bob.id.clone(), created_at: Utc::now() }; + let block = Block { + blocker_id: alice.id.clone(), + blocked_id: bob.id.clone(), + created_at: Utc::now(), + }; repo.save(&block).await.unwrap(); repo.delete(&alice.id, &bob.id).await.unwrap(); assert!(!repo.exists(&alice.id, &bob.id).await.unwrap()); diff --git a/crates/adapters/postgres/src/boost.rs b/crates/adapters/postgres/src/boost.rs index a1f0431..20828a6 100644 --- a/crates/adapters/postgres/src/boost.rs +++ b/crates/adapters/postgres/src/boost.rs @@ -1,10 +1,21 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; +use domain::{ + errors::DomainError, + models::social::Boost, + ports::BoostRepository, + value_objects::{BoostId, ThoughtId, UserId}, +}; use sqlx::PgPool; -use domain::{errors::DomainError, models::social::Boost, ports::BoostRepository, value_objects::{BoostId, ThoughtId, UserId}}; -pub struct PgBoostRepository { pool: PgPool } -impl PgBoostRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } +pub struct PgBoostRepository { + pool: PgPool, +} +impl PgBoostRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} #[async_trait] impl BoostRepository for PgBoostRepository { @@ -18,15 +29,30 @@ impl BoostRepository for PgBoostRepository { async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { let r = sqlx::query("DELETE FROM boosts WHERE user_id=$1 AND thought_id=$2") - .bind(user_id.as_uuid()).bind(thought_id.as_uuid()) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - if r.rows_affected() == 0 { return Err(DomainError::NotFound); } + .bind(user_id.as_uuid()) + .bind(thought_id.as_uuid()) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + if r.rows_affected() == 0 { + return Err(DomainError::NotFound); + } Ok(()) } - async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result, DomainError> { + async fn find( + &self, + user_id: &UserId, + thought_id: &ThoughtId, + ) -> Result, DomainError> { #[derive(sqlx::FromRow)] - struct Row { id: uuid::Uuid, user_id: uuid::Uuid, thought_id: uuid::Uuid, ap_id: Option, created_at: DateTime } + struct Row { + id: uuid::Uuid, + user_id: uuid::Uuid, + thought_id: uuid::Uuid, + ap_id: Option, + created_at: DateTime, + } sqlx::query_as::<_, Row>("SELECT id,user_id,thought_id,ap_id,created_at FROM boosts WHERE user_id=$1 AND thought_id=$2") .bind(user_id.as_uuid()).bind(thought_id.as_uuid()) .fetch_optional(&self.pool).await @@ -36,7 +62,9 @@ impl BoostRepository for PgBoostRepository { async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result { sqlx::query_scalar("SELECT COUNT(*) FROM boosts WHERE thought_id=$1") - .bind(thought_id.as_uuid()).fetch_one(&self.pool).await + .bind(thought_id.as_uuid()) + .fetch_one(&self.pool) + .await .map_err(|e| DomainError::Internal(e.to_string())) } } @@ -44,17 +72,36 @@ impl BoostRepository for PgBoostRepository { #[cfg(test)] mod tests { use super::*; - use chrono::Utc; - use domain::{models::{thought::{Thought, Visibility}, user::User}, value_objects::*}; use crate::{thought::PgThoughtRepository, user::PgUserRepository}; + use chrono::Utc; use domain::ports::{ThoughtRepository, UserRepository}; + use domain::{ + models::{ + thought::{Thought, Visibility}, + user::User, + }, + value_objects::*, + }; async fn seed(pool: &sqlx::PgPool) -> (User, Thought) { let urepo = PgUserRepository::new(pool.clone()); let trepo = PgThoughtRepository::new(pool.clone()); - let u = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into())); + let u = User::new_local( + UserId::new(), + Username::new("alice").unwrap(), + Email::new("alice@ex.com").unwrap(), + PasswordHash("h".into()), + ); urepo.save(&u).await.unwrap(); - let t = Thought::new_local(ThoughtId::new(), u.id.clone(), Content::new_local("hi").unwrap(), None, Visibility::Public, None, false); + let t = Thought::new_local( + ThoughtId::new(), + u.id.clone(), + Content::new_local("hi").unwrap(), + None, + Visibility::Public, + None, + false, + ); trepo.save(&t).await.unwrap(); (u, t) } @@ -63,7 +110,13 @@ mod tests { async fn boost_and_count(pool: sqlx::PgPool) { let (user, thought) = seed(&pool).await; let repo = PgBoostRepository::new(pool); - let boost = Boost { id: BoostId::new(), user_id: user.id.clone(), thought_id: thought.id.clone(), ap_id: None, created_at: Utc::now() }; + let boost = Boost { + id: BoostId::new(), + user_id: user.id.clone(), + thought_id: thought.id.clone(), + ap_id: None, + created_at: Utc::now(), + }; repo.save(&boost).await.unwrap(); assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1); } @@ -72,7 +125,13 @@ mod tests { async fn unboost(pool: sqlx::PgPool) { let (user, thought) = seed(&pool).await; let repo = PgBoostRepository::new(pool); - let boost = Boost { id: BoostId::new(), user_id: user.id.clone(), thought_id: thought.id.clone(), ap_id: None, created_at: Utc::now() }; + let boost = Boost { + id: BoostId::new(), + user_id: user.id.clone(), + thought_id: thought.id.clone(), + ap_id: None, + created_at: Utc::now(), + }; repo.save(&boost).await.unwrap(); repo.delete(&user.id, &thought.id).await.unwrap(); assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0); diff --git a/crates/adapters/postgres/src/feed.rs b/crates/adapters/postgres/src/feed.rs index 99d47f7..5fd34e2 100644 --- a/crates/adapters/postgres/src/feed.rs +++ b/crates/adapters/postgres/src/feed.rs @@ -1,16 +1,26 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; -use sqlx::PgPool; +use domain::models::thought::Visibility; use domain::{ errors::DomainError, - models::{feed::{FeedEntry, PageParams, Paginated}, thought::Thought, user::User}, + models::{ + feed::{FeedEntry, PageParams, Paginated}, + thought::Thought, + user::User, + }, ports::FeedRepository, value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username}, }; -use domain::models::thought::Visibility; +use sqlx::PgPool; -pub struct PgFeedRepository { pool: PgPool } -impl PgFeedRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } +pub struct PgFeedRepository { + pool: PgPool, +} +impl PgFeedRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} #[derive(sqlx::FromRow)] struct FeedRow { @@ -57,7 +67,8 @@ fn feed_select(viewer: Option) -> String { ), None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(), }; - format!(" + format!( + " SELECT t.id AS thought_id, t.user_id AS t_user_id, t.content, t.in_reply_to_id, t.in_reply_to_url, t.ap_id AS t_ap_id, @@ -72,7 +83,8 @@ fn feed_select(viewer: Option) -> String { (SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count, (SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count, {viewer_checks} - FROM thoughts t JOIN users u ON u.id=t.user_id") + FROM thoughts t JOIN users u ON u.id=t.user_id" + ) } fn row_to_entry(r: FeedRow) -> FeedEntry { @@ -95,52 +107,105 @@ fn row_to_entry(r: FeedRow) -> FeedEntry { username: Username::from_trusted(r.username), email: Email::from_trusted(r.email), password_hash: PasswordHash(r.password_hash), - display_name: r.display_name, bio: r.bio, - avatar_url: r.avatar_url, header_url: r.header_url, custom_css: r.custom_css, - local: r.author_local, ap_id: r.u_ap_id, inbox_url: r.inbox_url, - public_key: r.public_key, private_key: r.private_key, - created_at: r.author_created_at, updated_at: r.author_updated_at, + display_name: r.display_name, + bio: r.bio, + avatar_url: r.avatar_url, + header_url: r.header_url, + custom_css: r.custom_css, + local: r.author_local, + ap_id: r.u_ap_id, + inbox_url: r.inbox_url, + public_key: r.public_key, + private_key: r.private_key, + created_at: r.author_created_at, + updated_at: r.author_updated_at, }; - FeedEntry { thought, author, like_count: r.like_count, boost_count: r.boost_count, reply_count: r.reply_count, liked_by_viewer: r.liked_by_viewer, boosted_by_viewer: r.boosted_by_viewer } + FeedEntry { + thought, + author, + like_count: r.like_count, + boost_count: r.boost_count, + reply_count: r.reply_count, + liked_by_viewer: r.liked_by_viewer, + boosted_by_viewer: r.boosted_by_viewer, + } } #[async_trait] impl FeedRepository for PgFeedRepository { - async fn home_feed(&self, following_ids: &[UserId], page: &PageParams, viewer_id: Option<&UserId>) -> Result, DomainError> { + async fn home_feed( + &self, + following_ids: &[UserId], + page: &PageParams, + viewer_id: Option<&UserId>, + ) -> Result, DomainError> { let ids: Vec = following_ids.iter().map(|id| id.as_uuid()).collect(); let viewer = viewer_id.map(|v| v.as_uuid()); let total: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM thoughts t WHERE t.user_id=ANY($1) AND t.visibility='public'" - ).bind(&ids).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; + "SELECT COUNT(*) FROM thoughts t WHERE t.user_id=ANY($1) AND t.visibility='public'", + ) + .bind(&ids) + .fetch_one(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; let sel = feed_select(viewer); let sql = format!("{sel} WHERE t.user_id=ANY($1) AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3"); let rows = sqlx::query_as::<_, FeedRow>(&sql) - .bind(&ids).bind(page.limit()).bind(page.offset()) - .fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; + .bind(&ids) + .bind(page.limit()) + .bind(page.offset()) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(Paginated { items: rows.into_iter().map(row_to_entry).collect(), total, page: page.page, per_page: page.per_page }) + Ok(Paginated { + items: rows.into_iter().map(row_to_entry).collect(), + total, + page: page.page, + per_page: page.per_page, + }) } - async fn public_feed(&self, page: &PageParams, viewer_id: Option<&UserId>) -> Result, DomainError> { + async fn public_feed( + &self, + page: &PageParams, + viewer_id: Option<&UserId>, + ) -> Result, DomainError> { let viewer = viewer_id.map(|v| v.as_uuid()); let total: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'" - ).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; + "SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'", + ) + .fetch_one(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; let sel = feed_select(viewer); let sql = format!("{sel} WHERE t.local=true AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $1 OFFSET $2"); let rows = sqlx::query_as::<_, FeedRow>(&sql) - .bind(page.limit()).bind(page.offset()) - .fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; + .bind(page.limit()) + .bind(page.offset()) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(Paginated { items: rows.into_iter().map(row_to_entry).collect(), total, page: page.page, per_page: page.per_page }) + Ok(Paginated { + items: rows.into_iter().map(row_to_entry).collect(), + total, + page: page.page, + per_page: page.per_page, + }) } - async fn search(&self, query: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result, DomainError> { + async fn search( + &self, + query: &str, + page: &PageParams, + viewer_id: Option<&UserId>, + ) -> Result, DomainError> { let viewer = viewer_id.map(|v| v.as_uuid()); let total: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'" + "SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'", ) .bind(query) .fetch_one(&self.pool) @@ -157,16 +222,26 @@ impl FeedRepository for PgFeedRepository { .await .map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(Paginated { items: rows.into_iter().map(row_to_entry).collect(), total, page: page.page, per_page: page.per_page }) + Ok(Paginated { + items: rows.into_iter().map(row_to_entry).collect(), + total, + page: page.page, + per_page: page.per_page, + }) } - async fn tag_feed(&self, tag_name: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result, DomainError> { + async fn tag_feed( + &self, + tag_name: &str, + page: &PageParams, + viewer_id: Option<&UserId>, + ) -> Result, DomainError> { let viewer = viewer_id.map(|v| v.as_uuid()); let total: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM thoughts t JOIN thought_tags tt ON tt.thought_id = t.id JOIN tags tg ON tg.id = tt.tag_id - WHERE tg.name = $1 AND t.visibility = 'public'" + WHERE tg.name = $1 AND t.visibility = 'public'", ) .bind(tag_name) .fetch_one(&self.pool) @@ -197,12 +272,17 @@ impl FeedRepository for PgFeedRepository { }) } - async fn user_feed(&self, user_id: &UserId, page: &PageParams, viewer_id: Option<&UserId>) -> Result, DomainError> { + async fn user_feed( + &self, + user_id: &UserId, + page: &PageParams, + viewer_id: Option<&UserId>, + ) -> Result, DomainError> { let viewer = viewer_id.map(|v| v.as_uuid()); let uid = user_id.as_uuid(); let total: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND t.visibility = 'public'" + "SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND t.visibility = 'public'", ) .bind(uid) .fetch_one(&self.pool) @@ -231,15 +311,35 @@ impl FeedRepository for PgFeedRepository { #[cfg(test)] mod tests { use super::*; - use domain::{models::{thought::{Thought, Visibility}, user::User}, ports::{ThoughtRepository, UserRepository}, value_objects::*}; use crate::{thought::PgThoughtRepository, user::PgUserRepository}; + use domain::{ + models::{ + thought::{Thought, Visibility}, + user::User, + }, + ports::{ThoughtRepository, UserRepository}, + value_objects::*, + }; async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) { let urepo = PgUserRepository::new(pool.clone()); let trepo = PgThoughtRepository::new(pool.clone()); - let u = User::new_local(UserId::new(), Username::new(username).unwrap(), Email::new(format!("{username}@ex.com")).unwrap(), PasswordHash("h".into())); + let u = User::new_local( + UserId::new(), + Username::new(username).unwrap(), + Email::new(format!("{username}@ex.com")).unwrap(), + PasswordHash("h".into()), + ); urepo.save(&u).await.unwrap(); - let t = Thought::new_local(ThoughtId::new(), u.id.clone(), Content::new_local(content).unwrap(), None, Visibility::Public, None, false); + let t = Thought::new_local( + ThoughtId::new(), + u.id.clone(), + Content::new_local(content).unwrap(), + None, + Visibility::Public, + None, + false, + ); trepo.save(&t).await.unwrap(); (u, t) } @@ -248,7 +348,16 @@ mod tests { async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) { let (_, _) = seed(&pool, "alice", "hello").await; let repo = PgFeedRepository::new(pool); - let result = repo.public_feed(&PageParams { page: 1, per_page: 20 }, None).await.unwrap(); + let result = repo + .public_feed( + &PageParams { + page: 1, + per_page: 20, + }, + None, + ) + .await + .unwrap(); assert_eq!(result.total, 1); assert_eq!(result.items[0].thought.content.as_str(), "hello"); } @@ -258,8 +367,21 @@ mod tests { let (_, _) = seed(&pool, "alice", "hello world").await; let (_, _) = seed(&pool, "bob", "goodbye world").await; let repo = PgFeedRepository::new(pool); - let result = repo.search("hello world", &PageParams { page: 1, per_page: 20 }, None).await.unwrap(); + let result = repo + .search( + "hello world", + &PageParams { + page: 1, + per_page: 20, + }, + None, + ) + .await + .unwrap(); assert!(result.total >= 1); - assert!(result.items.iter().any(|e| e.thought.content.as_str() == "hello world")); + assert!(result + .items + .iter() + .any(|e| e.thought.content.as_str() == "hello world")); } } diff --git a/crates/adapters/postgres/src/follow.rs b/crates/adapters/postgres/src/follow.rs index a752661..281e00e 100644 --- a/crates/adapters/postgres/src/follow.rs +++ b/crates/adapters/postgres/src/follow.rs @@ -1,15 +1,25 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; -use sqlx::PgPool; use domain::{ errors::DomainError, - models::{feed::{PageParams, Paginated}, social::{Follow, FollowState}, user::User}, + models::{ + feed::{PageParams, Paginated}, + social::{Follow, FollowState}, + user::User, + }, ports::FollowRepository, value_objects::UserId, }; +use sqlx::PgPool; -pub struct PgFollowRepository { pool: PgPool } -impl PgFollowRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } +pub struct PgFollowRepository { + pool: PgPool, +} +impl PgFollowRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} #[async_trait] impl FollowRepository for PgFollowRepository { @@ -37,13 +47,25 @@ impl FollowRepository for PgFollowRepository { .execute(&self.pool) .await .map_err(|e| DomainError::Internal(e.to_string()))?; - if r.rows_affected() == 0 { return Err(DomainError::NotFound); } + if r.rows_affected() == 0 { + return Err(DomainError::NotFound); + } Ok(()) } - async fn find(&self, follower_id: &UserId, following_id: &UserId) -> Result, DomainError> { + async fn find( + &self, + follower_id: &UserId, + following_id: &UserId, + ) -> Result, DomainError> { #[derive(sqlx::FromRow)] - struct Row { follower_id: uuid::Uuid, following_id: uuid::Uuid, state: String, ap_id: Option, created_at: DateTime } + struct Row { + follower_id: uuid::Uuid, + following_id: uuid::Uuid, + state: String, + ap_id: Option, + created_at: DateTime, + } sqlx::query_as::<_, Row>( "SELECT follower_id,following_id,state,ap_id,created_at FROM follows WHERE follower_id=$1 AND following_id=$2" ) @@ -61,7 +83,12 @@ impl FollowRepository for PgFollowRepository { })) } - async fn update_state(&self, follower_id: &UserId, following_id: &UserId, state: &FollowState) -> Result<(), DomainError> { + async fn update_state( + &self, + follower_id: &UserId, + following_id: &UserId, + state: &FollowState, + ) -> Result<(), DomainError> { sqlx::query("UPDATE follows SET state=$3 WHERE follower_id=$1 AND following_id=$2") .bind(follower_id.as_uuid()) .bind(following_id.as_uuid()) @@ -72,9 +99,13 @@ impl FollowRepository for PgFollowRepository { .map(|_| ()) } - async fn list_followers(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError> { + async fn list_followers( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, DomainError> { let total: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM follows WHERE following_id=$1 AND state='accepted'" + "SELECT COUNT(*) FROM follows WHERE following_id=$1 AND state='accepted'", ) .bind(user_id.as_uuid()) .fetch_one(&self.pool) @@ -102,9 +133,13 @@ impl FollowRepository for PgFollowRepository { }) } - async fn list_following(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError> { + async fn list_following( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, DomainError> { let total: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM follows WHERE follower_id=$1 AND state='accepted'" + "SELECT COUNT(*) FROM follows WHERE follower_id=$1 AND state='accepted'", ) .bind(user_id.as_uuid()) .fetch_one(&self.pool) @@ -132,9 +167,12 @@ impl FollowRepository for PgFollowRepository { }) } - async fn get_accepted_following_ids(&self, user_id: &UserId) -> Result, DomainError> { + async fn get_accepted_following_ids( + &self, + user_id: &UserId, + ) -> Result, DomainError> { let ids: Vec = sqlx::query_scalar( - "SELECT following_id FROM follows WHERE follower_id=$1 AND state='accepted'" + "SELECT following_id FROM follows WHERE follower_id=$1 AND state='accepted'", ) .bind(user_id.as_uuid()) .fetch_all(&self.pool) @@ -147,23 +185,35 @@ impl FollowRepository for PgFollowRepository { #[cfg(test)] mod tests { use super::*; - use chrono::Utc; - use domain::{models::user::User, value_objects::*}; use crate::user::PgUserRepository; + use chrono::Utc; use domain::ports::UserRepository; + use domain::{models::user::User, value_objects::*}; async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User { let repo = PgUserRepository::new(pool.clone()); - let u = User::new_local(UserId::new(), Username::new(username).unwrap(), Email::new(email).unwrap(), PasswordHash("h".into())); - repo.save(&u).await.unwrap(); u + let u = User::new_local( + UserId::new(), + Username::new(username).unwrap(), + Email::new(email).unwrap(), + PasswordHash("h".into()), + ); + repo.save(&u).await.unwrap(); + u } #[sqlx::test(migrations = "./migrations")] async fn save_and_find_follow(pool: sqlx::PgPool) { let alice = seed_user(&pool, "alice", "alice@ex.com").await; - let bob = seed_user(&pool, "bob", "bob@ex.com").await; + let bob = seed_user(&pool, "bob", "bob@ex.com").await; let repo = PgFollowRepository::new(pool); - let follow = Follow { follower_id: alice.id.clone(), following_id: bob.id.clone(), state: FollowState::Accepted, ap_id: None, created_at: Utc::now() }; + let follow = Follow { + follower_id: alice.id.clone(), + following_id: bob.id.clone(), + state: FollowState::Accepted, + ap_id: None, + created_at: Utc::now(), + }; repo.save(&follow).await.unwrap(); let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap(); assert_eq!(found.state, FollowState::Accepted); @@ -172,11 +222,19 @@ mod tests { #[sqlx::test(migrations = "./migrations")] async fn update_state(pool: sqlx::PgPool) { let alice = seed_user(&pool, "alice", "alice@ex.com").await; - let bob = seed_user(&pool, "bob", "bob@ex.com").await; + let bob = seed_user(&pool, "bob", "bob@ex.com").await; let repo = PgFollowRepository::new(pool); - let follow = Follow { follower_id: alice.id.clone(), following_id: bob.id.clone(), state: FollowState::Pending, ap_id: None, created_at: Utc::now() }; + let follow = Follow { + follower_id: alice.id.clone(), + following_id: bob.id.clone(), + state: FollowState::Pending, + ap_id: None, + created_at: Utc::now(), + }; repo.save(&follow).await.unwrap(); - repo.update_state(&alice.id, &bob.id, &FollowState::Accepted).await.unwrap(); + repo.update_state(&alice.id, &bob.id, &FollowState::Accepted) + .await + .unwrap(); let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap(); assert_eq!(found.state, FollowState::Accepted); } @@ -184,9 +242,15 @@ mod tests { #[sqlx::test(migrations = "./migrations")] async fn get_accepted_following_ids(pool: sqlx::PgPool) { let alice = seed_user(&pool, "alice", "alice@ex.com").await; - let bob = seed_user(&pool, "bob", "bob@ex.com").await; + let bob = seed_user(&pool, "bob", "bob@ex.com").await; let repo = PgFollowRepository::new(pool); - let follow = Follow { follower_id: alice.id.clone(), following_id: bob.id.clone(), state: FollowState::Accepted, ap_id: None, created_at: Utc::now() }; + let follow = Follow { + follower_id: alice.id.clone(), + following_id: bob.id.clone(), + state: FollowState::Accepted, + ap_id: None, + created_at: Utc::now(), + }; repo.save(&follow).await.unwrap(); let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap(); assert_eq!(ids, vec![bob.id]); diff --git a/crates/adapters/postgres/src/like.rs b/crates/adapters/postgres/src/like.rs index 556d486..edb8ac5 100644 --- a/crates/adapters/postgres/src/like.rs +++ b/crates/adapters/postgres/src/like.rs @@ -1,10 +1,21 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; +use domain::{ + errors::DomainError, + models::social::Like, + ports::LikeRepository, + value_objects::{LikeId, ThoughtId, UserId}, +}; use sqlx::PgPool; -use domain::{errors::DomainError, models::social::Like, ports::LikeRepository, value_objects::{LikeId, ThoughtId, UserId}}; -pub struct PgLikeRepository { pool: PgPool } -impl PgLikeRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } +pub struct PgLikeRepository { + pool: PgPool, +} +impl PgLikeRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} #[async_trait] impl LikeRepository for PgLikeRepository { @@ -18,15 +29,30 @@ impl LikeRepository for PgLikeRepository { async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { let r = sqlx::query("DELETE FROM likes WHERE user_id=$1 AND thought_id=$2") - .bind(user_id.as_uuid()).bind(thought_id.as_uuid()) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - if r.rows_affected() == 0 { return Err(DomainError::NotFound); } + .bind(user_id.as_uuid()) + .bind(thought_id.as_uuid()) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + if r.rows_affected() == 0 { + return Err(DomainError::NotFound); + } Ok(()) } - async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result, DomainError> { + async fn find( + &self, + user_id: &UserId, + thought_id: &ThoughtId, + ) -> Result, DomainError> { #[derive(sqlx::FromRow)] - struct Row { id: uuid::Uuid, user_id: uuid::Uuid, thought_id: uuid::Uuid, ap_id: Option, created_at: DateTime } + struct Row { + id: uuid::Uuid, + user_id: uuid::Uuid, + thought_id: uuid::Uuid, + ap_id: Option, + created_at: DateTime, + } sqlx::query_as::<_, Row>("SELECT id,user_id,thought_id,ap_id,created_at FROM likes WHERE user_id=$1 AND thought_id=$2") .bind(user_id.as_uuid()).bind(thought_id.as_uuid()) .fetch_optional(&self.pool).await @@ -36,7 +62,9 @@ impl LikeRepository for PgLikeRepository { async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result { sqlx::query_scalar("SELECT COUNT(*) FROM likes WHERE thought_id=$1") - .bind(thought_id.as_uuid()).fetch_one(&self.pool).await + .bind(thought_id.as_uuid()) + .fetch_one(&self.pool) + .await .map_err(|e| DomainError::Internal(e.to_string())) } } @@ -44,17 +72,36 @@ impl LikeRepository for PgLikeRepository { #[cfg(test)] mod tests { use super::*; - use chrono::Utc; - use domain::{models::{thought::{Thought, Visibility}, user::User}, value_objects::*}; use crate::{thought::PgThoughtRepository, user::PgUserRepository}; + use chrono::Utc; use domain::ports::{ThoughtRepository, UserRepository}; + use domain::{ + models::{ + thought::{Thought, Visibility}, + user::User, + }, + value_objects::*, + }; async fn seed(pool: &sqlx::PgPool) -> (User, Thought) { let urepo = PgUserRepository::new(pool.clone()); let trepo = PgThoughtRepository::new(pool.clone()); - let u = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into())); + let u = User::new_local( + UserId::new(), + Username::new("alice").unwrap(), + Email::new("alice@ex.com").unwrap(), + PasswordHash("h".into()), + ); urepo.save(&u).await.unwrap(); - let t = Thought::new_local(ThoughtId::new(), u.id.clone(), Content::new_local("hi").unwrap(), None, Visibility::Public, None, false); + let t = Thought::new_local( + ThoughtId::new(), + u.id.clone(), + Content::new_local("hi").unwrap(), + None, + Visibility::Public, + None, + false, + ); trepo.save(&t).await.unwrap(); (u, t) } @@ -63,7 +110,13 @@ mod tests { async fn like_and_count(pool: sqlx::PgPool) { let (user, thought) = seed(&pool).await; let repo = PgLikeRepository::new(pool); - let like = Like { id: LikeId::new(), user_id: user.id.clone(), thought_id: thought.id.clone(), ap_id: None, created_at: Utc::now() }; + let like = Like { + id: LikeId::new(), + user_id: user.id.clone(), + thought_id: thought.id.clone(), + ap_id: None, + created_at: Utc::now(), + }; repo.save(&like).await.unwrap(); assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1); } @@ -72,7 +125,13 @@ mod tests { async fn unlike(pool: sqlx::PgPool) { let (user, thought) = seed(&pool).await; let repo = PgLikeRepository::new(pool); - let like = Like { id: LikeId::new(), user_id: user.id.clone(), thought_id: thought.id.clone(), ap_id: None, created_at: Utc::now() }; + let like = Like { + id: LikeId::new(), + user_id: user.id.clone(), + thought_id: thought.id.clone(), + ap_id: None, + created_at: Utc::now(), + }; repo.save(&like).await.unwrap(); repo.delete(&user.id, &thought.id).await.unwrap(); assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0); diff --git a/crates/adapters/postgres/src/notification.rs b/crates/adapters/postgres/src/notification.rs index 4a13069..3330862 100644 --- a/crates/adapters/postgres/src/notification.rs +++ b/crates/adapters/postgres/src/notification.rs @@ -1,10 +1,24 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; +use domain::{ + errors::DomainError, + models::{ + feed::{PageParams, Paginated}, + notification::{Notification, NotificationType}, + }, + ports::NotificationRepository, + value_objects::{NotificationId, ThoughtId, UserId}, +}; use sqlx::PgPool; -use domain::{errors::DomainError, models::{feed::{PageParams, Paginated}, notification::{Notification, NotificationType}}, ports::NotificationRepository, value_objects::{NotificationId, ThoughtId, UserId}}; -pub struct PgNotificationRepository { pool: PgPool } -impl PgNotificationRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } +pub struct PgNotificationRepository { + pool: PgPool, +} +impl PgNotificationRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} #[async_trait] impl NotificationRepository for PgNotificationRepository { @@ -19,50 +33,91 @@ impl NotificationRepository for PgNotificationRepository { .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) } - async fn list_for_user(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError> { + async fn list_for_user( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, DomainError> { let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE user_id=$1") - .bind(user_id.as_uuid()).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; + .bind(user_id.as_uuid()) + .fetch_one(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; #[derive(sqlx::FromRow)] - struct Row { id: uuid::Uuid, user_id: uuid::Uuid, r#type: String, from_user_id: Option, thought_id: Option, read: bool, created_at: DateTime } + struct Row { + id: uuid::Uuid, + user_id: uuid::Uuid, + r#type: String, + from_user_id: Option, + thought_id: Option, + read: bool, + created_at: DateTime, + } let rows = sqlx::query_as::<_, Row>( "SELECT id,user_id,type,from_user_id,thought_id,read,created_at FROM notifications WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3" ).bind(user_id.as_uuid()).bind(page.limit()).bind(page.offset()) .fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - let items = rows.into_iter().map(|r| Notification { - id: NotificationId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), - notification_type: NotificationType::from_str(&r.r#type), - from_user_id: r.from_user_id.map(UserId::from_uuid), - thought_id: r.thought_id.map(ThoughtId::from_uuid), - read: r.read, created_at: r.created_at, - }).collect(); - Ok(Paginated { items, total, page: page.page, per_page: page.per_page }) + let items = rows + .into_iter() + .map(|r| Notification { + id: NotificationId::from_uuid(r.id), + user_id: UserId::from_uuid(r.user_id), + notification_type: NotificationType::from_str(&r.r#type), + from_user_id: r.from_user_id.map(UserId::from_uuid), + thought_id: r.thought_id.map(ThoughtId::from_uuid), + read: r.read, + created_at: r.created_at, + }) + .collect(); + Ok(Paginated { + items, + total, + page: page.page, + per_page: page.per_page, + }) } async fn mark_read(&self, id: &NotificationId, user_id: &UserId) -> Result<(), DomainError> { sqlx::query("UPDATE notifications SET read=true WHERE id=$1 AND user_id=$2") - .bind(id.as_uuid()).bind(user_id.as_uuid()) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) + .bind(id.as_uuid()) + .bind(user_id.as_uuid()) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string())) + .map(|_| ()) } async fn mark_all_read(&self, user_id: &UserId) -> Result<(), DomainError> { sqlx::query("UPDATE notifications SET read=true WHERE user_id=$1") .bind(user_id.as_uuid()) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string())) + .map(|_| ()) } } #[cfg(test)] mod tests { use super::*; - use chrono::Utc; - use domain::{models::{notification::NotificationType, user::User}, value_objects::*}; use crate::user::PgUserRepository; + use chrono::Utc; use domain::ports::UserRepository; + use domain::{ + models::{notification::NotificationType, user::User}, + value_objects::*, + }; async fn seed_user(pool: &sqlx::PgPool) -> User { let repo = PgUserRepository::new(pool.clone()); - let u = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into())); - repo.save(&u).await.unwrap(); u + let u = User::new_local( + UserId::new(), + Username::new("alice").unwrap(), + Email::new("alice@ex.com").unwrap(), + PasswordHash("h".into()), + ); + repo.save(&u).await.unwrap(); + u } #[sqlx::test(migrations = "./migrations")] @@ -70,9 +125,26 @@ mod tests { let user = seed_user(&pool).await; let repo = PgNotificationRepository::new(pool); use domain::models::feed::PageParams; - let n = Notification { id: NotificationId::new(), user_id: user.id.clone(), notification_type: NotificationType::Like, from_user_id: None, thought_id: None, read: false, created_at: Utc::now() }; + let n = Notification { + id: NotificationId::new(), + user_id: user.id.clone(), + notification_type: NotificationType::Like, + from_user_id: None, + thought_id: None, + read: false, + created_at: Utc::now(), + }; repo.save(&n).await.unwrap(); - let page = repo.list_for_user(&user.id, &PageParams { page: 1, per_page: 20 }).await.unwrap(); + let page = repo + .list_for_user( + &user.id, + &PageParams { + page: 1, + per_page: 20, + }, + ) + .await + .unwrap(); assert_eq!(page.total, 1); assert!(!page.items[0].read); } @@ -82,10 +154,27 @@ mod tests { let user = seed_user(&pool).await; let repo = PgNotificationRepository::new(pool); use domain::models::feed::PageParams; - let n = Notification { id: NotificationId::new(), user_id: user.id.clone(), notification_type: NotificationType::Follow, from_user_id: None, thought_id: None, read: false, created_at: Utc::now() }; + let n = Notification { + id: NotificationId::new(), + user_id: user.id.clone(), + notification_type: NotificationType::Follow, + from_user_id: None, + thought_id: None, + read: false, + created_at: Utc::now(), + }; repo.save(&n).await.unwrap(); repo.mark_all_read(&user.id).await.unwrap(); - let page = repo.list_for_user(&user.id, &PageParams { page: 1, per_page: 20 }).await.unwrap(); + let page = repo + .list_for_user( + &user.id, + &PageParams { + page: 1, + per_page: 20, + }, + ) + .await + .unwrap(); assert!(page.items[0].read); } } diff --git a/crates/adapters/postgres/src/remote_actor.rs b/crates/adapters/postgres/src/remote_actor.rs index 4fe912e..ab3bd22 100644 --- a/crates/adapters/postgres/src/remote_actor.rs +++ b/crates/adapters/postgres/src/remote_actor.rs @@ -1,10 +1,18 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; +use domain::{ + errors::DomainError, models::remote_actor::RemoteActor, ports::RemoteActorRepository, +}; use sqlx::PgPool; -use domain::{errors::DomainError, models::remote_actor::RemoteActor, ports::RemoteActorRepository}; -pub struct PgRemoteActorRepository { pool: PgPool } -impl PgRemoteActorRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } +pub struct PgRemoteActorRepository { + pool: PgPool, +} +impl PgRemoteActorRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} #[async_trait] impl RemoteActorRepository for PgRemoteActorRepository { @@ -23,7 +31,15 @@ impl RemoteActorRepository for PgRemoteActorRepository { async fn find_by_url(&self, url: &str) -> Result, DomainError> { #[derive(sqlx::FromRow)] - struct Row { url: String, handle: String, display_name: Option, inbox_url: String, shared_inbox_url: Option, public_key: String, last_fetched_at: DateTime } + struct Row { + url: String, + handle: String, + display_name: Option, + inbox_url: String, + shared_inbox_url: Option, + public_key: String, + last_fetched_at: DateTime, + } sqlx::query_as::<_, Row>( "SELECT url,handle,display_name,inbox_url,shared_inbox_url,public_key,last_fetched_at FROM remote_actors WHERE url=$1" ).bind(url).fetch_optional(&self.pool).await diff --git a/crates/adapters/postgres/src/tag.rs b/crates/adapters/postgres/src/tag.rs index b66e7ea..9363408 100644 --- a/crates/adapters/postgres/src/tag.rs +++ b/crates/adapters/postgres/src/tag.rs @@ -1,35 +1,81 @@ use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::{ + feed::{PageParams, Paginated}, + tag::Tag, + thought::Thought, + }, + ports::TagRepository, + value_objects::ThoughtId, +}; use sqlx::PgPool; -use domain::{errors::DomainError, models::{feed::{PageParams, Paginated}, tag::Tag, thought::Thought}, ports::TagRepository, value_objects::ThoughtId}; -pub struct PgTagRepository { pool: PgPool } -impl PgTagRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } +pub struct PgTagRepository { + pool: PgPool, +} +impl PgTagRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} #[async_trait] impl TagRepository for PgTagRepository { async fn find_or_create(&self, name: &str) -> Result { let name = name.to_lowercase(); sqlx::query("INSERT INTO tags(name) VALUES($1) ON CONFLICT(name) DO NOTHING") - .bind(&name).execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - #[derive(sqlx::FromRow)] struct Row { id: i32, name: String } - let row = sqlx::query_as::<_, Row>("SELECT id,name FROM tags WHERE name=$1").bind(&name) - .fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(Tag { id: row.id, name: row.name }) + .bind(&name) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + #[derive(sqlx::FromRow)] + struct Row { + id: i32, + name: String, + } + let row = sqlx::query_as::<_, Row>("SELECT id,name FROM tags WHERE name=$1") + .bind(&name) + .fetch_one(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + Ok(Tag { + id: row.id, + name: row.name, + }) } - async fn attach_to_thought(&self, thought_id: &ThoughtId, tag_id: i32) -> Result<(), DomainError> { - sqlx::query("INSERT INTO thought_tags(thought_id,tag_id) VALUES($1,$2) ON CONFLICT DO NOTHING") - .bind(thought_id.as_uuid()).bind(tag_id) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) + async fn attach_to_thought( + &self, + thought_id: &ThoughtId, + tag_id: i32, + ) -> Result<(), DomainError> { + sqlx::query( + "INSERT INTO thought_tags(thought_id,tag_id) VALUES($1,$2) ON CONFLICT DO NOTHING", + ) + .bind(thought_id.as_uuid()) + .bind(tag_id) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string())) + .map(|_| ()) } async fn detach_from_thought(&self, thought_id: &ThoughtId) -> Result<(), DomainError> { - sqlx::query("DELETE FROM thought_tags WHERE thought_id=$1").bind(thought_id.as_uuid()) - .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) + sqlx::query("DELETE FROM thought_tags WHERE thought_id=$1") + .bind(thought_id.as_uuid()) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string())) + .map(|_| ()) } async fn list_for_thought(&self, thought_id: &ThoughtId) -> Result, DomainError> { - #[derive(sqlx::FromRow)] struct Row { id: i32, name: String } + #[derive(sqlx::FromRow)] + struct Row { + id: i32, + name: String, + } sqlx::query_as::<_, Row>( "SELECT t.id,t.name FROM tags t JOIN thought_tags tt ON tt.tag_id=t.id WHERE tt.thought_id=$1" ).bind(thought_id.as_uuid()).fetch_all(&self.pool).await @@ -37,10 +83,18 @@ impl TagRepository for PgTagRepository { .map(|rows| rows.into_iter().map(|r| Tag { id: r.id, name: r.name }).collect()) } - async fn list_thoughts_by_tag(&self, tag_name: &str, page: &PageParams) -> Result, DomainError> { + async fn list_thoughts_by_tag( + &self, + tag_name: &str, + page: &PageParams, + ) -> Result, DomainError> { let total: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM thought_tags tt JOIN tags t ON t.id=tt.tag_id WHERE t.name=$1" - ).bind(tag_name).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; + "SELECT COUNT(*) FROM thought_tags tt JOIN tags t ON t.id=tt.tag_id WHERE t.name=$1", + ) + .bind(tag_name) + .fetch_one(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; let rows = sqlx::query_as::<_, crate::thought::ThoughtRow>( "SELECT th.id,th.user_id,th.content,th.in_reply_to_id,th.in_reply_to_url,th.ap_id,th.visibility,th.content_warning,th.sensitive,th.local,th.created_at,th.updated_at @@ -49,7 +103,12 @@ impl TagRepository for PgTagRepository { ).bind(tag_name).bind(page.limit()).bind(page.offset()) .fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(Paginated { items: rows.into_iter().map(Thought::from).collect(), total, page: page.page, per_page: page.per_page }) + Ok(Paginated { + items: rows.into_iter().map(Thought::from).collect(), + total, + page: page.page, + per_page: page.per_page, + }) } async fn popular_tags(&self, limit: usize) -> Result, DomainError> { @@ -59,7 +118,7 @@ impl TagRepository for PgTagRepository { JOIN thought_tags tt ON t.id = tt.tag_id GROUP BY t.id, t.name ORDER BY thought_count DESC - LIMIT $1" + LIMIT $1", ) .bind(limit as i64) .fetch_all(&self.pool) @@ -71,9 +130,15 @@ impl TagRepository for PgTagRepository { #[cfg(test)] mod tests { use super::*; - use domain::{models::{thought::{Thought, Visibility}, user::User}, value_objects::*}; use crate::{thought::PgThoughtRepository, user::PgUserRepository}; use domain::ports::{ThoughtRepository, UserRepository}; + use domain::{ + models::{ + thought::{Thought, Visibility}, + user::User, + }, + value_objects::*, + }; #[sqlx::test(migrations = "./migrations")] async fn find_or_create_tag(pool: sqlx::PgPool) { @@ -88,9 +153,22 @@ mod tests { async fn attach_and_list(pool: sqlx::PgPool) { let urepo = PgUserRepository::new(pool.clone()); let trepo = PgThoughtRepository::new(pool.clone()); - let u = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into())); + let u = User::new_local( + UserId::new(), + Username::new("alice").unwrap(), + Email::new("alice@ex.com").unwrap(), + PasswordHash("h".into()), + ); urepo.save(&u).await.unwrap(); - let t = Thought::new_local(ThoughtId::new(), u.id.clone(), Content::new_local("hi").unwrap(), None, Visibility::Public, None, false); + let t = Thought::new_local( + ThoughtId::new(), + u.id.clone(), + Content::new_local("hi").unwrap(), + None, + Visibility::Public, + None, + false, + ); trepo.save(&t).await.unwrap(); let repo = PgTagRepository::new(pool); let tag = repo.find_or_create("greetings").await.unwrap(); diff --git a/crates/adapters/postgres/src/thought.rs b/crates/adapters/postgres/src/thought.rs index 2ad60e9..b689a35 100644 --- a/crates/adapters/postgres/src/thought.rs +++ b/crates/adapters/postgres/src/thought.rs @@ -1,6 +1,5 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; -use sqlx::PgPool; use domain::{ errors::DomainError, models::{ @@ -10,9 +9,16 @@ use domain::{ ports::ThoughtRepository, value_objects::{Content, ThoughtId, UserId}, }; +use sqlx::PgPool; -pub struct PgThoughtRepository { pool: PgPool } -impl PgThoughtRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } +pub struct PgThoughtRepository { + pool: PgPool, +} +impl PgThoughtRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} #[derive(sqlx::FromRow)] pub(crate) struct ThoughtRow { @@ -93,7 +99,9 @@ impl ThoughtRepository for PgThoughtRepository { .execute(&self.pool) .await .map_err(|e| DomainError::Internal(e.to_string()))?; - if r.rows_affected() == 0 { return Err(DomainError::NotFound); } + if r.rows_affected() == 0 { + return Err(DomainError::NotFound); + } Ok(()) } @@ -108,9 +116,9 @@ impl ThoughtRepository for PgThoughtRepository { } async fn get_thread(&self, id: &ThoughtId) -> Result, DomainError> { - sqlx::query_as::<_, ThoughtRow>( - &format!("{THOUGHT_SELECT} WHERE id=$1 OR in_reply_to_id=$1 ORDER BY created_at ASC") - ) + sqlx::query_as::<_, ThoughtRow>(&format!( + "{THOUGHT_SELECT} WHERE id=$1 OR in_reply_to_id=$1 ORDER BY created_at ASC" + )) .bind(id.as_uuid()) .fetch_all(&self.pool) .await @@ -118,19 +126,21 @@ impl ThoughtRepository for PgThoughtRepository { .map(|rows| rows.into_iter().map(Thought::from).collect()) } - async fn list_by_user(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError> { + async fn list_by_user( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, DomainError> { let uid = user_id.as_uuid(); - let total: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM thoughts WHERE user_id = $1" - ) - .bind(uid) - .fetch_one(&self.pool) - .await - .map_err(|e| DomainError::Internal(e.to_string()))?; + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE user_id = $1") + .bind(uid) + .fetch_one(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; - let rows = sqlx::query_as::<_, ThoughtRow>( - &format!("{THOUGHT_SELECT} WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3") - ) + let rows = sqlx::query_as::<_, ThoughtRow>(&format!( + "{THOUGHT_SELECT} WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3" + )) .bind(uid) .bind(page.limit()) .bind(page.offset()) @@ -150,9 +160,15 @@ impl ThoughtRepository for PgThoughtRepository { #[cfg(test)] mod tests { use super::*; - use domain::{models::{thought::{Thought, Visibility}, user::User}, value_objects::*}; use crate::user::PgUserRepository; use domain::ports::UserRepository; + use domain::{ + models::{ + thought::{Thought, Visibility}, + user::User, + }, + value_objects::*, + }; async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User { let repo = PgUserRepository::new(pool.clone()); @@ -189,7 +205,15 @@ mod tests { async fn delete_thought(pool: sqlx::PgPool) { let user = seed_user(&pool, "bob", "bob@ex.com").await; let repo = PgThoughtRepository::new(pool); - let t = Thought::new_local(ThoughtId::new(), user.id.clone(), Content::new_local("bye").unwrap(), None, Visibility::Public, None, false); + let t = Thought::new_local( + ThoughtId::new(), + user.id.clone(), + Content::new_local("bye").unwrap(), + None, + Visibility::Public, + None, + false, + ); repo.save(&t).await.unwrap(); repo.delete(&t.id, &user.id).await.unwrap(); assert!(repo.find_by_id(&t.id).await.unwrap().is_none()); @@ -200,7 +224,15 @@ mod tests { let alice = seed_user(&pool, "alice", "alice@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await; let repo = PgThoughtRepository::new(pool); - let t = Thought::new_local(ThoughtId::new(), alice.id.clone(), Content::new_local("secret").unwrap(), None, Visibility::Public, None, false); + let t = Thought::new_local( + ThoughtId::new(), + alice.id.clone(), + Content::new_local("secret").unwrap(), + None, + Visibility::Public, + None, + false, + ); repo.save(&t).await.unwrap(); let err = repo.delete(&t.id, &bob.id).await.unwrap_err(); assert!(matches!(err, DomainError::NotFound)); @@ -210,8 +242,24 @@ mod tests { async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) { let user = seed_user(&pool, "charlie", "charlie@ex.com").await; let repo = PgThoughtRepository::new(pool); - let root = Thought::new_local(ThoughtId::new(), user.id.clone(), Content::new_local("root").unwrap(), None, Visibility::Public, None, false); - let reply = Thought::new_local(ThoughtId::new(), user.id.clone(), Content::new_local("reply").unwrap(), Some(root.id.clone()), Visibility::Public, None, false); + let root = Thought::new_local( + ThoughtId::new(), + user.id.clone(), + Content::new_local("root").unwrap(), + None, + Visibility::Public, + None, + false, + ); + let reply = Thought::new_local( + ThoughtId::new(), + user.id.clone(), + Content::new_local("reply").unwrap(), + Some(root.id.clone()), + Visibility::Public, + None, + false, + ); repo.save(&root).await.unwrap(); repo.save(&reply).await.unwrap(); let thread = repo.get_thread(&root.id).await.unwrap(); diff --git a/crates/adapters/postgres/src/top_friend.rs b/crates/adapters/postgres/src/top_friend.rs index 1e691a4..e53b46b 100644 --- a/crates/adapters/postgres/src/top_friend.rs +++ b/crates/adapters/postgres/src/top_friend.rs @@ -1,34 +1,74 @@ use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::{top_friend::TopFriend, user::User}, + ports::TopFriendRepository, + value_objects::UserId, +}; use sqlx::PgPool; -use domain::{errors::DomainError, models::{top_friend::TopFriend, user::User}, ports::TopFriendRepository, value_objects::UserId}; -pub struct PgTopFriendRepository { pool: PgPool } -impl PgTopFriendRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } +pub struct PgTopFriendRepository { + pool: PgPool, +} +impl PgTopFriendRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} #[async_trait] impl TopFriendRepository for PgTopFriendRepository { - async fn set_top_friends(&self, user_id: &UserId, friends: Vec<(UserId, i16)>) -> Result<(), DomainError> { - let mut tx = self.pool.begin().await.map_err(|e| DomainError::Internal(e.to_string()))?; + async fn set_top_friends( + &self, + user_id: &UserId, + friends: Vec<(UserId, i16)>, + ) -> Result<(), DomainError> { + let mut tx = self + .pool + .begin() + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; sqlx::query("DELETE FROM top_friends WHERE user_id=$1") - .bind(user_id.as_uuid()).execute(&mut *tx).await.map_err(|e| DomainError::Internal(e.to_string()))?; + .bind(user_id.as_uuid()) + .execute(&mut *tx) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; for (friend_id, pos) in friends { sqlx::query("INSERT INTO top_friends(user_id,friend_id,position) VALUES($1,$2,$3)") - .bind(user_id.as_uuid()).bind(friend_id.as_uuid()).bind(pos) - .execute(&mut *tx).await.map_err(|e| DomainError::Internal(e.to_string()))?; + .bind(user_id.as_uuid()) + .bind(friend_id.as_uuid()) + .bind(pos) + .execute(&mut *tx) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; } - tx.commit().await.map_err(|e| DomainError::Internal(e.to_string())) + tx.commit() + .await + .map_err(|e| DomainError::Internal(e.to_string())) } async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError> { #[derive(sqlx::FromRow)] struct Row { - tf_user_id: uuid::Uuid, friend_id: uuid::Uuid, position: i16, - id: uuid::Uuid, username: String, email: String, password_hash: String, - display_name: Option, bio: Option, avatar_url: Option, - header_url: Option, custom_css: Option, local: bool, - ap_id: Option, inbox_url: Option, public_key: Option, + tf_user_id: uuid::Uuid, + friend_id: uuid::Uuid, + position: i16, + id: uuid::Uuid, + username: String, + email: String, + password_hash: String, + display_name: Option, + bio: Option, + avatar_url: Option, + header_url: Option, + custom_css: Option, + local: bool, + ap_id: Option, + inbox_url: Option, + public_key: Option, private_key: Option, - created_at: chrono::DateTime, updated_at: chrono::DateTime, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, } let rows = sqlx::query_as::<_, Row>( "SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position, @@ -36,44 +76,73 @@ impl TopFriendRepository for PgTopFriendRepository { u.avatar_url, u.header_url, u.custom_css, u.local, u.ap_id, u.inbox_url, u.public_key, u.private_key, u.created_at, u.updated_at FROM top_friends tf JOIN users u ON u.id=tf.friend_id - WHERE tf.user_id=$1 ORDER BY tf.position" - ).bind(user_id.as_uuid()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; + WHERE tf.user_id=$1 ORDER BY tf.position", + ) + .bind(user_id.as_uuid()) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(rows.into_iter().map(|r| { - use domain::value_objects::{Email, PasswordHash, Username}; - let tf = TopFriend { user_id: UserId::from_uuid(r.tf_user_id), friend_id: UserId::from_uuid(r.friend_id), position: r.position }; - let u = User { - id: UserId::from_uuid(r.id), username: Username::from_trusted(r.username), - email: Email::from_trusted(r.email), password_hash: PasswordHash(r.password_hash), - display_name: r.display_name, bio: r.bio, avatar_url: r.avatar_url, - header_url: r.header_url, custom_css: r.custom_css, local: r.local, - ap_id: r.ap_id, inbox_url: r.inbox_url, public_key: r.public_key, - private_key: r.private_key, created_at: r.created_at, updated_at: r.updated_at, - }; - (tf, u) - }).collect()) + Ok(rows + .into_iter() + .map(|r| { + use domain::value_objects::{Email, PasswordHash, Username}; + let tf = TopFriend { + user_id: UserId::from_uuid(r.tf_user_id), + friend_id: UserId::from_uuid(r.friend_id), + position: r.position, + }; + let u = User { + id: UserId::from_uuid(r.id), + username: Username::from_trusted(r.username), + email: Email::from_trusted(r.email), + password_hash: PasswordHash(r.password_hash), + display_name: r.display_name, + bio: r.bio, + avatar_url: r.avatar_url, + header_url: r.header_url, + custom_css: r.custom_css, + local: r.local, + ap_id: r.ap_id, + inbox_url: r.inbox_url, + public_key: r.public_key, + private_key: r.private_key, + created_at: r.created_at, + updated_at: r.updated_at, + }; + (tf, u) + }) + .collect()) } } #[cfg(test)] mod tests { use super::*; - use domain::{models::user::User, value_objects::*}; use crate::user::PgUserRepository; use domain::ports::UserRepository; + use domain::{models::user::User, value_objects::*}; async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User { let repo = PgUserRepository::new(pool.clone()); - let u = User::new_local(UserId::new(), Username::new(username).unwrap(), Email::new(email).unwrap(), PasswordHash("h".into())); - repo.save(&u).await.unwrap(); u + let u = User::new_local( + UserId::new(), + Username::new(username).unwrap(), + Email::new(email).unwrap(), + PasswordHash("h".into()), + ); + repo.save(&u).await.unwrap(); + u } #[sqlx::test(migrations = "./migrations")] async fn set_and_list_top_friends(pool: sqlx::PgPool) { let alice = seed_user(&pool, "alice", "alice@ex.com").await; - let bob = seed_user(&pool, "bob", "bob@ex.com").await; + let bob = seed_user(&pool, "bob", "bob@ex.com").await; let repo = PgTopFriendRepository::new(pool); - repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)]).await.unwrap(); + repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)]) + .await + .unwrap(); let friends = repo.list_for_user(&alice.id).await.unwrap(); assert_eq!(friends.len(), 1); assert_eq!(friends[0].0.position, 1); @@ -83,11 +152,15 @@ mod tests { #[sqlx::test(migrations = "./migrations")] async fn replace_top_friends(pool: sqlx::PgPool) { let alice = seed_user(&pool, "alice", "alice@ex.com").await; - let bob = seed_user(&pool, "bob", "bob@ex.com").await; + let bob = seed_user(&pool, "bob", "bob@ex.com").await; let carol = seed_user(&pool, "carol", "carol@ex.com").await; let repo = PgTopFriendRepository::new(pool); - repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)]).await.unwrap(); - repo.set_top_friends(&alice.id, vec![(carol.id.clone(), 1)]).await.unwrap(); + repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)]) + .await + .unwrap(); + repo.set_top_friends(&alice.id, vec![(carol.id.clone(), 1)]) + .await + .unwrap(); let friends = repo.list_for_user(&alice.id).await.unwrap(); assert_eq!(friends.len(), 1); assert_eq!(friends[0].1.username.as_str(), "carol"); diff --git a/crates/adapters/postgres/src/user.rs b/crates/adapters/postgres/src/user.rs index 4245d67..dbe23f6 100644 --- a/crates/adapters/postgres/src/user.rs +++ b/crates/adapters/postgres/src/user.rs @@ -1,15 +1,21 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; -use sqlx::PgPool; use domain::{ errors::DomainError, models::{feed::UserSummary, user::User}, ports::UserRepository, value_objects::{Email, PasswordHash, UserId, Username}, }; +use sqlx::PgPool; -pub struct PgUserRepository { pool: PgPool } -impl PgUserRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } +pub struct PgUserRepository { + pool: PgPool, +} +impl PgUserRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} #[derive(sqlx::FromRow)] pub(crate) struct UserRow { @@ -120,7 +126,15 @@ impl UserRepository for PgUserRepository { .map(|_| ()) } - async fn update_profile(&self, user_id: &UserId, display_name: Option, bio: Option, avatar_url: Option, header_url: Option, custom_css: Option) -> Result<(), DomainError> { + async fn update_profile( + &self, + user_id: &UserId, + display_name: Option, + bio: Option, + avatar_url: Option, + header_url: Option, + custom_css: Option, + ) -> Result<(), DomainError> { sqlx::query( "UPDATE users SET display_name=$2,bio=$3,avatar_url=$4,header_url=$5,custom_css=$6,updated_at=NOW() WHERE id=$1" ) @@ -159,22 +173,25 @@ impl UserRepository for PgUserRepository { LEFT JOIN follows f2 ON f2.follower_id=u.id AND f2.state='accepted' WHERE u.local=true GROUP BY u.id - ORDER BY u.username" + ORDER BY u.username", ) .fetch_all(&self.pool) .await .map_err(|e| DomainError::Internal(e.to_string()))?; - Ok(rows.into_iter().map(|r| UserSummary { - id: UserId::from_uuid(r.id), - username: r.username, - display_name: r.display_name, - avatar_url: r.avatar_url, - bio: r.bio, - thought_count: r.thought_count, - follower_count: r.follower_count, - following_count: r.following_count, - }).collect()) + Ok(rows + .into_iter() + .map(|r| UserSummary { + id: UserId::from_uuid(r.id), + username: r.username, + display_name: r.display_name, + avatar_url: r.avatar_url, + bio: r.bio, + thought_count: r.thought_count, + follower_count: r.follower_count, + following_count: r.following_count, + }) + .collect()) } async fn count(&self) -> Result { @@ -208,7 +225,10 @@ mod tests { #[sqlx::test(migrations = "./migrations")] async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) { let repo = PgUserRepository::new(pool); - let result = repo.find_by_username(&Username::new("ghost").unwrap()).await.unwrap(); + let result = repo + .find_by_username(&Username::new("ghost").unwrap()) + .await + .unwrap(); assert!(result.is_none()); } @@ -222,7 +242,10 @@ mod tests { PasswordHash("hash".into()), ); repo.save(&user).await.unwrap(); - let found = repo.find_by_email(&Email::new("bob@ex.com").unwrap()).await.unwrap(); + let found = repo + .find_by_email(&Email::new("bob@ex.com").unwrap()) + .await + .unwrap(); assert!(found.is_some()); } @@ -236,7 +259,16 @@ mod tests { PasswordHash("hash".into()), ); repo.save(&user).await.unwrap(); - repo.update_profile(&user.id, Some("Charlie".into()), Some("bio".into()), None, None, None).await.unwrap(); + repo.update_profile( + &user.id, + Some("Charlie".into()), + Some("bio".into()), + None, + None, + None, + ) + .await + .unwrap(); let found = repo.find_by_id(&user.id).await.unwrap().unwrap(); assert_eq!(found.display_name.as_deref(), Some("Charlie")); assert_eq!(found.bio.as_deref(), Some("bio")); diff --git a/docs/superpowers/plans/2026-05-14-federation-follow-ups.md b/docs/superpowers/plans/2026-05-14-federation-follow-ups.md new file mode 100644 index 0000000..ca329be --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-federation-follow-ups.md @@ -0,0 +1,350 @@ +# Federation Follow-ups Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Two targeted follow-ups from the federation handler implementation: (1) handle `BoostRemoved` → `Undo(Announce)` fan-out, which was a known missing feature; (2) extract the repeated follower-filtering block in `ActivityPubService` into a private helper to eliminate duplication across 6 broadcast methods. + +**Architecture:** Both changes are additive and self-contained. Task 1 touches `domain/ports.rs`, `activitypub-base/src/service.rs`, and `application/src/services/federation_event.rs`. Task 2 touches only `activitypub-base/src/service.rs`. + +--- + +## File Map + +``` +Task 1: + Modify: crates/domain/src/ports.rs ← add broadcast_undo_announce to OutboundFederationPort + Modify: crates/adapters/activitypub-base/src/service.rs ← broadcast_undo_announce_to_followers + impl + Modify: crates/application/src/services/federation_event.rs ← handle BoostRemoved + tests + +Task 2: + Modify: crates/adapters/activitypub-base/src/service.rs ← extract accepted_follower_inboxes helper +``` + +--- + +### Task 1: BoostRemoved → Undo(Announce) + +**Files:** +- Modify: `crates/domain/src/ports.rs` +- Modify: `crates/adapters/activitypub-base/src/service.rs` +- Modify: `crates/application/src/services/federation_event.rs` + +#### Step A: Add `broadcast_undo_announce` to `OutboundFederationPort` + +- [ ] In `crates/domain/src/ports.rs`, add one method to `OutboundFederationPort` after `broadcast_announce`: + +```rust +/// Fan out an Undo(Announce) to followers when a boost is removed. +async fn broadcast_undo_announce( + &self, + booster_user_id: &UserId, + object_ap_id: &str, +) -> Result<(), DomainError>; +``` + +- [ ] **Run:** `cargo check -p domain` — Expected: error in activitypub-base (trait impl missing method). This is expected. + +#### Step B: Add `broadcast_undo_announce_to_followers` to `ActivityPubService` and implement the port method + +- [ ] In `crates/adapters/activitypub-base/src/service.rs`, add `broadcast_undo_announce_to_followers` to `impl ActivityPubService` — insert after `broadcast_announce_to_followers`: + +```rust +/// Fan out an Undo(Announce) activity to all accepted followers. +pub async fn broadcast_undo_announce_to_followers( + &self, + local_user_id: uuid::Uuid, + object_ap_id: url::Url, +) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + let local_actor = get_local_actor(local_user_id, &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let followers = data.federation_repo.get_followers(local_user_id).await?; + let blocked = data.federation_repo.get_blocked_actors(local_user_id).await.unwrap_or_default(); + let blocked_set: std::collections::HashSet = blocked.into_iter().collect(); + let blocked_domains = data.federation_repo.get_blocked_domains().await.unwrap_or_default(); + let blocked_domain_set: std::collections::HashSet = + blocked_domains.into_iter().map(|d| d.domain).collect(); + + let accepted: Vec<_> = followers + .into_iter() + .filter(|f| f.status == FollowerStatus::Accepted) + .filter(|f| !blocked_set.contains(&f.actor.url)) + .filter(|f| { + let domain = url::Url::parse(&f.actor.inbox_url) + .ok() + .and_then(|u| u.host_str().map(|s| s.to_string())) + .unwrap_or_default(); + !blocked_domain_set.contains(&domain) + }) + .collect(); + + if accepted.is_empty() { + return Ok(()); + } + + let undo_id = crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; + let undo = crate::activities::UndoActivity { + id: undo_id, + kind: Default::default(), + actor: activitypub_federation::fetch::object_id::ObjectId::from(local_actor.ap_id.clone()), + object: serde_json::json!({ + "type": "Announce", + "actor": local_actor.ap_id.to_string(), + "object": object_ap_id.to_string(), + }), + }; + + let inboxes = collect_inboxes(&accepted); + let sends = activitypub_federation::activity_sending::SendActivityTask::prepare( + &activitypub_federation::protocol::context::WithContext::new_default(undo), + &local_actor, + inboxes, + &data, + ) + .await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!(count = failures.len(), "some Undo(Announce) deliveries failed"); + } + Ok(()) +} +``` + +- [ ] Add `broadcast_undo_announce` to the `impl domain::ports::OutboundFederationPort for ActivityPubService` block: + +```rust +async fn broadcast_undo_announce( + &self, + booster_user_id: &domain::value_objects::UserId, + object_ap_id: &str, +) -> Result<(), domain::errors::DomainError> { + let user_uuid = booster_user_id.as_uuid(); + let ap_id = url::Url::parse(object_ap_id) + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + self.broadcast_undo_announce_to_followers(user_uuid, ap_id) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string())) +} +``` + +- [ ] **Run:** `cargo check -p activitypub-base` — Expected: no errors. +- [ ] **Run:** `cargo check --workspace` — Expected: no errors. + +#### Step C: Handle `BoostRemoved` in `FederationEventService` + +- [ ] **Write failing test** first — add to the `#[cfg(test)] mod tests` block in `crates/application/src/services/federation_event.rs`: + +```rust +#[tokio::test] +async fn boost_removed_sends_undo_announce_for_local_thought() { + let store = TestStore::default(); + let alice = alice(); + let thought = local_thought(alice.id.clone()); // ap_id = None → constructed URL + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::BoostRemoved { + user_id: alice.id.clone(), + thought_id: thought.id.clone(), + }) + .await + .unwrap(); + + let announced = spy.announced.lock().unwrap(); + assert_eq!(announced.len(), 1); + assert_eq!(announced[0], format!("https://example.com/thoughts/{}", thought.id)); +} + +#[tokio::test] +async fn boost_removed_sends_undo_announce_for_remote_thought() { + let store = TestStore::default(); + let alice = alice(); + let mut thought = local_thought(alice.id.clone()); + thought.local = false; + thought.ap_id = Some("https://mastodon.social/users/bob/statuses/456".into()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::BoostRemoved { + user_id: alice.id.clone(), + thought_id: thought.id.clone(), + }) + .await + .unwrap(); + + let announced = spy.announced.lock().unwrap(); + assert_eq!(announced[0], "https://mastodon.social/users/bob/statuses/456"); +} +``` + +NOTE: The `SpyPort` tracks `broadcast_undo_announce` calls in the same `announced` vec as `broadcast_announce` (or a new `undo_announced` vec — your choice, but be consistent in both the spy and the assertions). + +Actually, use a separate `undo_announced` vec for clarity: + +```rust +#[derive(Default)] +struct SpyPort { + created: Mutex>, + deleted: Mutex>, + updated: Mutex>, + announced: Mutex>, + undo_announced: Mutex>, +} +``` + +And add the impl method: +```rust +async fn broadcast_undo_announce(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> { + self.undo_announced.lock().unwrap().push(ap_id.to_string()); + Ok(()) +} +``` + +Update the test assertions to use `spy.undo_announced`. + +- [ ] **Run:** `cargo test -p application -- services::federation_event` — Expected: 2 new tests FAIL (not implemented). + +- [ ] **Add `BoostRemoved` arm** to `FederationEventService::process` — insert after the `BoostAdded` arm: + +```rust +DomainEvent::BoostRemoved { user_id, thought_id } => { + let thought = match self.thoughts.find_by_id(thought_id).await? { + Some(t) => t, + None => return Ok(()), + }; + let object_ap_id = thought.ap_id.clone().unwrap_or_else(|| { + format!("{}/thoughts/{}", self.base_url, thought_id) + }); + self.ap.broadcast_undo_announce(user_id, &object_ap_id).await +} +``` + +- [ ] **Run:** `cargo test -p application -- services::federation_event` — Expected: all tests pass (now 13). + +- [ ] **Run:** `cargo test --workspace` — Expected: only pre-existing postgres DB failures (require live database). + +- [ ] **Commit:** + +```bash +git add crates/domain/src/ports.rs crates/adapters/activitypub-base/src/service.rs crates/application/src/services/federation_event.rs +git commit -m "feat: BoostRemoved → Undo(Announce) fan-out via OutboundFederationPort" +``` + +--- + +### Task 2: Follower-filtering DRY extraction in activitypub-base + +**Files:** +- Modify: `crates/adapters/activitypub-base/src/service.rs` + +The repeated 20-line follower-filtering block appears in 7 methods. Extract it into a private async helper, then call it from the 6 content-broadcast methods. Leave `broadcast_actor_update` alone — it uses different filtering (no blocked-actor/domain check). + +**Methods to update:** `broadcast_to_followers`, `broadcast_delete_to_followers`, `broadcast_update_to_followers`, `broadcast_add_to_followers`, `broadcast_undo_add_to_followers`, `broadcast_announce_to_followers`, `broadcast_undo_announce_to_followers`. + +**Leave unchanged:** `broadcast_actor_update` (filters only on `FollowerStatus::Accepted`, no blocked checks). + +- [ ] **Add private helper** to `impl ActivityPubService` — insert near the top of the impl block, after `request_data`: + +```rust +/// Returns `(local_actor, deduplicated_inboxes)` for all accepted followers, +/// excluding blocked actors and blocked domains. Returns `None` if there are +/// no eligible followers (caller should early-return `Ok(())`). +async fn accepted_follower_inboxes( + &self, + data: &activitypub_federation::config::Data, + local_user_id: uuid::Uuid, +) -> anyhow::Result)>> { + let local_actor = get_local_actor(local_user_id, data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let followers = data.federation_repo.get_followers(local_user_id).await?; + let blocked = data.federation_repo.get_blocked_actors(local_user_id).await.unwrap_or_default(); + let blocked_set: std::collections::HashSet = blocked.into_iter().collect(); + let blocked_domains = data.federation_repo.get_blocked_domains().await.unwrap_or_default(); + let blocked_domain_set: std::collections::HashSet = + blocked_domains.into_iter().map(|d| d.domain).collect(); + + let accepted: Vec<_> = followers + .into_iter() + .filter(|f| f.status == FollowerStatus::Accepted) + .filter(|f| !blocked_set.contains(&f.actor.url)) + .filter(|f| { + let domain = url::Url::parse(&f.actor.inbox_url) + .ok() + .and_then(|u| u.host_str().map(|s| s.to_string())) + .unwrap_or_default(); + !blocked_domain_set.contains(&domain) + }) + .collect(); + + if accepted.is_empty() { + return Ok(None); + } + + Ok(Some((local_actor, collect_inboxes(&accepted)))) +} +``` + +- [ ] **Refactor each of the 7 methods** to use `accepted_follower_inboxes`. + +For each method, replace the block that: +1. Gets `local_actor` +2. Gets followers + filtered inboxes + +with: +```rust +let data = self.federation_config.to_request_data(); +let Some((local_actor, inboxes)) = self.accepted_follower_inboxes(&data, local_user_id).await? else { + return Ok(()); +}; +``` + +Then use `local_actor` and `inboxes` directly in the activity construction (same as before). + +The 7 methods are at these line numbers (before refactor — check actual lines in the file): +- `broadcast_announce_to_followers` +- `broadcast_undo_announce_to_followers` (just added in Task 1) +- `broadcast_to_followers` +- `broadcast_delete_to_followers` +- `broadcast_update_to_followers` +- `broadcast_add_to_followers` +- `broadcast_undo_add_to_followers` + +- [ ] **Run:** `cargo check -p activitypub-base` — Expected: no errors. + +- [ ] **Run:** `cargo check --workspace` — Expected: no errors. + +- [ ] **Run:** `cargo test --workspace` — Expected: same result as before (pre-existing postgres failures only). + +- [ ] **Commit:** + +```bash +git add crates/adapters/activitypub-base/src/service.rs +git commit -m "refactor(activitypub-base): extract accepted_follower_inboxes helper — eliminate 7x duplicated filtering block" +``` + +--- + +## Self-Review + +**Spec coverage:** +- ✅ `broadcast_undo_announce` added to `OutboundFederationPort` (Task 1) +- ✅ `broadcast_undo_announce_to_followers` sends `Undo { object: { type: "Announce", actor, object } }` to accepted, non-blocked followers (Task 1) +- ✅ `FederationEventService` handles `BoostRemoved` with same ap_id construction as `BoostAdded` (Task 1) +- ✅ 2 tests: local thought URL constructed, remote thought uses ap_id (Task 1) +- ✅ `SpyPort` has separate `undo_announced` vec (Task 1) +- ✅ `accepted_follower_inboxes` helper extracts the 20-line filtering block (Task 2) +- ✅ Helper used in 7 content-broadcast methods (Task 2) +- ✅ `broadcast_actor_update` NOT touched — it uses different filtering (Task 2) + +**Placeholder scan:** None. + +**Type consistency:** +- `UndoActivity` is already defined in `activities.rs` with `object: serde_json::Value` — no new activity type needed +- `broadcast_undo_announce_to_followers(uuid::Uuid, url::Url)` — same signature pattern as `broadcast_announce_to_followers` +- `accepted_follower_inboxes` returns `Option<(DbActor, Vec)>` — caller destructures with `let Some(...) = ... else { return Ok(()) }` diff --git a/docs/superpowers/plans/2026-05-14-federation-handler.md b/docs/superpowers/plans/2026-05-14-federation-handler.md new file mode 100644 index 0000000..8a17ed8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-federation-handler.md @@ -0,0 +1,1161 @@ +# Federation Handler Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the `FederationHandler` stub with a real implementation that fans out content events (ThoughtCreated/Deleted/Updated, BoostAdded) as ActivityPub activities, while simultaneously refactoring both worker handlers to be thin adapters over application-layer event services. + +**Architecture:** Domain defines `OutboundFederationPort`; application holds `FederationEventService` and `NotificationEventService` (business logic); `activitypub-base`'s `ActivityPubService` implements the port; worker handlers are one-liners that call the services. A new `worker/src/factory.rs` owns all dependency construction; `main.rs` stays tiny. + +**Dependency chain after refactor:** +``` +domain ← application ← worker +domain ← activitypub-base (impl OutboundFederationPort) +bootstrap/worker → postgres, postgres-federation, activitypub, activitypub-base (composition roots only) +``` + +**Events handled in FederationHandler (async fan-out only):** +- `ThoughtCreated` → `Create(Note)` to local-user followers (local thoughts only) +- `ThoughtDeleted` → `Delete(Note)` to followers +- `ThoughtUpdated` → `Update(Note)` to followers +- `BoostAdded` → `Announce` to followers +- All others → no-op (Follow/Accept/Reject/Block dispatched synchronously in HTTP handlers) + +--- + +## File Map + +``` +Modify: crates/domain/src/ports.rs + + OutboundFederationPort trait (4 methods) + +Create: crates/application/src/services/mod.rs +Create: crates/application/src/services/notification_event.rs +Create: crates/application/src/services/federation_event.rs +Modify: crates/application/src/lib.rs + + pub mod services + +Modify: crates/adapters/activitypub-base/src/activities.rs + + to/cc fields on AnnounceActivity + +Modify: crates/adapters/activitypub-base/src/service.rs + + broadcast_announce_to_followers() + + impl OutboundFederationPort for ActivityPubService + +Modify: crates/worker/src/handlers.rs + — remove all business logic, keep thin delegation wrappers + +Create: crates/worker/src/factory.rs + + build() → builds all deps and returns (consumer, handlers) + +Modify: crates/worker/src/main.rs + — call factory::build(), keep event loop only + +Modify: crates/worker/Cargo.toml + + activitypub-base, activitypub, postgres-federation, application +``` + +--- + +### Task 1: OutboundFederationPort in domain + +**Files:** +- Modify: `crates/domain/src/ports.rs` + +- [ ] **Add `OutboundFederationPort` to `crates/domain/src/ports.rs`** — insert after the `ActivityPubRepository` trait: + +```rust +#[async_trait] +pub trait OutboundFederationPort: Send + Sync { + /// Fan out a new local Note to all accepted followers. + async fn broadcast_create( + &self, + author_user_id: &UserId, + thought: &Thought, + author_username: &str, + ) -> Result<(), DomainError>; + + /// Fan out a Delete tombstone for a now-deleted local Note. + /// `thought_ap_id` is pre-constructed by the caller because the thought + /// has already been deleted from the DB when this fires. + async fn broadcast_delete( + &self, + author_user_id: &UserId, + thought_ap_id: &str, + ) -> Result<(), DomainError>; + + /// Fan out an Update(Note) for an edited local thought. + async fn broadcast_update( + &self, + author_user_id: &UserId, + thought: &Thought, + author_username: &str, + ) -> Result<(), DomainError>; + + /// Fan out an Announce(object_ap_id) for a boost. + async fn broadcast_announce( + &self, + booster_user_id: &UserId, + object_ap_id: &str, + ) -> Result<(), DomainError>; +} +``` + +- [ ] **Run:** `cargo check -p domain` — Expected: no errors. + +- [ ] **Commit:** + +```bash +git add crates/domain/src/ports.rs +git commit -m "feat(domain): OutboundFederationPort — thin AP broadcast abstraction" +``` + +--- + +### Task 2: NotificationEventService in application + +**Files:** +- Create: `crates/application/src/services/mod.rs` +- Create: `crates/application/src/services/notification_event.rs` +- Modify: `crates/application/src/lib.rs` + +- [ ] **Write failing tests** at the bottom of `crates/application/src/services/notification_event.rs` (file doesn't exist yet — create it): + +```rust +#[cfg(test)] +mod tests { + use super::*; + use domain::{ + models::{thought::{Thought, Visibility}, user::User}, + testing::TestStore, + value_objects::*, + }; + use std::sync::Arc; + + fn alice() -> User { + User::new_local( + UserId::new(), + Username::new("alice").unwrap(), + Email::new("alice@ex.com").unwrap(), + PasswordHash("h".into()), + ) + } + + #[tokio::test] + async fn like_creates_notification_for_thought_author() { + let store = TestStore::default(); + let alice = alice(); + let bob_id = UserId::new(); + let thought = Thought::new_local( + ThoughtId::new(), alice.id.clone(), + Content::new_local("hello").unwrap(), + None, Visibility::Public, None, false, + ); + store.thoughts.lock().unwrap().push(thought.clone()); + let svc = NotificationEventService { + thoughts: Arc::new(store.clone()), + notifications: Arc::new(store.clone()), + }; + svc.process(&DomainEvent::LikeAdded { + like_id: LikeId::new(), + user_id: bob_id, + thought_id: thought.id.clone(), + }).await.unwrap(); + let notifs = store.notifications.lock().unwrap(); + assert_eq!(notifs.len(), 1); + assert!(matches!(notifs[0].notification_type, NotificationType::Like)); + } + + #[tokio::test] + async fn self_like_creates_no_notification() { + let store = TestStore::default(); + let alice = alice(); + let thought = Thought::new_local( + ThoughtId::new(), alice.id.clone(), + Content::new_local("hello").unwrap(), + None, Visibility::Public, None, false, + ); + store.thoughts.lock().unwrap().push(thought.clone()); + let svc = NotificationEventService { + thoughts: Arc::new(store.clone()), + notifications: Arc::new(store.clone()), + }; + svc.process(&DomainEvent::LikeAdded { + like_id: LikeId::new(), + user_id: alice.id.clone(), + thought_id: thought.id.clone(), + }).await.unwrap(); + assert!(store.notifications.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn follow_accepted_creates_notification() { + let store = TestStore::default(); + let alice = alice(); + let bob_id = UserId::new(); + let svc = NotificationEventService { + thoughts: Arc::new(store.clone()), + notifications: Arc::new(store.clone()), + }; + svc.process(&DomainEvent::FollowAccepted { + follower_id: bob_id, + following_id: alice.id.clone(), + }).await.unwrap(); + let notifs = store.notifications.lock().unwrap(); + assert_eq!(notifs.len(), 1); + assert!(matches!(notifs[0].notification_type, NotificationType::Follow)); + } + + #[tokio::test] + async fn reply_creates_notification_for_original_author() { + let store = TestStore::default(); + let alice = alice(); + let bob_id = UserId::new(); + let original = Thought::new_local( + ThoughtId::new(), alice.id.clone(), + Content::new_local("original").unwrap(), + None, Visibility::Public, None, false, + ); + store.thoughts.lock().unwrap().push(original.clone()); + let svc = NotificationEventService { + thoughts: Arc::new(store.clone()), + notifications: Arc::new(store.clone()), + }; + svc.process(&DomainEvent::ThoughtCreated { + thought_id: ThoughtId::new(), + user_id: bob_id, + in_reply_to_id: Some(original.id.clone()), + }).await.unwrap(); + let notifs = store.notifications.lock().unwrap(); + assert_eq!(notifs.len(), 1); + assert!(matches!(notifs[0].notification_type, NotificationType::Reply)); + } + + #[tokio::test] + async fn self_reply_creates_no_notification() { + let store = TestStore::default(); + let alice = alice(); + let original = Thought::new_local( + ThoughtId::new(), alice.id.clone(), + Content::new_local("original").unwrap(), + None, Visibility::Public, None, false, + ); + store.thoughts.lock().unwrap().push(original.clone()); + let svc = NotificationEventService { + thoughts: Arc::new(store.clone()), + notifications: Arc::new(store.clone()), + }; + svc.process(&DomainEvent::ThoughtCreated { + thought_id: ThoughtId::new(), + user_id: alice.id.clone(), + in_reply_to_id: Some(original.id.clone()), + }).await.unwrap(); + assert!(store.notifications.lock().unwrap().is_empty()); + } +} +``` + +- [ ] **Run:** `cargo test -p application` — Expected: FAIL (no implementation yet). + +- [ ] **Create `crates/application/src/services/notification_event.rs`:** + +```rust +use std::sync::Arc; +use chrono::Utc; +use domain::{ + errors::DomainError, + events::DomainEvent, + models::notification::{Notification, NotificationType}, + ports::{NotificationRepository, ThoughtRepository}, + value_objects::NotificationId, +}; + +pub struct NotificationEventService { + pub thoughts: Arc, + pub notifications: Arc, +} + +impl NotificationEventService { + pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> { + match event { + DomainEvent::LikeAdded { like_id: _, user_id, thought_id } => { + let thought = match self.thoughts.find_by_id(thought_id).await? { + Some(t) => t, + None => return Ok(()), + }; + if thought.user_id == *user_id { return Ok(()); } + self.notifications.save(&Notification { + id: NotificationId::new(), + user_id: thought.user_id, + notification_type: NotificationType::Like, + from_user_id: Some(user_id.clone()), + thought_id: Some(thought_id.clone()), + read: false, + created_at: Utc::now(), + }).await + } + DomainEvent::BoostAdded { boost_id: _, user_id, thought_id } => { + let thought = match self.thoughts.find_by_id(thought_id).await? { + Some(t) => t, + None => return Ok(()), + }; + if thought.user_id == *user_id { return Ok(()); } + self.notifications.save(&Notification { + id: NotificationId::new(), + user_id: thought.user_id, + notification_type: NotificationType::Boost, + from_user_id: Some(user_id.clone()), + thought_id: Some(thought_id.clone()), + read: false, + created_at: Utc::now(), + }).await + } + DomainEvent::FollowAccepted { follower_id, following_id } => { + self.notifications.save(&Notification { + id: NotificationId::new(), + user_id: following_id.clone(), + notification_type: NotificationType::Follow, + from_user_id: Some(follower_id.clone()), + thought_id: None, + read: false, + created_at: Utc::now(), + }).await + } + DomainEvent::ThoughtCreated { thought_id, user_id, in_reply_to_id } => { + let reply_to_id = match in_reply_to_id { + Some(id) => id, + None => return Ok(()), + }; + let original = match self.thoughts.find_by_id(reply_to_id).await? { + Some(t) => t, + None => return Ok(()), + }; + if original.user_id == *user_id { return Ok(()); } + self.notifications.save(&Notification { + id: NotificationId::new(), + user_id: original.user_id, + notification_type: NotificationType::Reply, + from_user_id: Some(user_id.clone()), + thought_id: Some(thought_id.clone()), + read: false, + created_at: Utc::now(), + }).await + } + _ => Ok(()), + } + } +} +``` + +- [ ] **Create `crates/application/src/services/mod.rs`:** + +```rust +pub mod federation_event; +pub mod notification_event; + +pub use federation_event::FederationEventService; +pub use notification_event::NotificationEventService; +``` + +- [ ] **Modify `crates/application/src/lib.rs`** — add `pub mod services;`: + +```rust +pub mod services; +pub mod use_cases; +``` + +- [ ] **Run:** `cargo test -p application` — Expected: 5 notification tests pass. + +- [ ] **Commit:** + +```bash +git add crates/application/ +git commit -m "feat(application): NotificationEventService — move notification business logic out of worker" +``` + +--- + +### Task 3: FederationEventService in application + +**Files:** +- Create: `crates/application/src/services/federation_event.rs` +- Modify: `crates/application/src/services/mod.rs` (re-export) + +- [ ] **Write failing tests** inside `crates/application/src/services/federation_event.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use domain::{ + errors::DomainError, + events::DomainEvent, + models::thought::{Thought, Visibility}, + models::user::User, + ports::OutboundFederationPort, + testing::TestStore, + value_objects::*, + }; + use std::sync::{Arc, Mutex}; + + // ── Spy port ───────────────────────────────────────────────────────────── + + #[derive(Default)] + struct SpyPort { + created: Mutex>, + deleted: Mutex>, + updated: Mutex>, + announced: Mutex>, + } + + #[async_trait] + impl OutboundFederationPort for SpyPort { + async fn broadcast_create(&self, _: &UserId, thought: &Thought, _: &str) -> Result<(), DomainError> { + self.created.lock().unwrap().push(thought.id.clone()); + Ok(()) + } + async fn broadcast_delete(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> { + self.deleted.lock().unwrap().push(ap_id.to_string()); + Ok(()) + } + async fn broadcast_update(&self, _: &UserId, thought: &Thought, _: &str) -> Result<(), DomainError> { + self.updated.lock().unwrap().push(thought.id.clone()); + Ok(()) + } + async fn broadcast_announce(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> { + self.announced.lock().unwrap().push(ap_id.to_string()); + Ok(()) + } + } + + fn alice() -> User { + User::new_local( + UserId::new(), + Username::new("alice").unwrap(), + Email::new("alice@ex.com").unwrap(), + PasswordHash("h".into()), + ) + } + + fn local_thought(author_id: UserId) -> Thought { + Thought::new_local( + ThoughtId::new(), author_id, + Content::new_local("hello").unwrap(), + None, Visibility::Public, None, false, + ) + } + + fn svc(store: &TestStore, spy: Arc) -> FederationEventService { + FederationEventService { + thoughts: Arc::new(store.clone()), + users: Arc::new(store.clone()), + ap: spy, + base_url: "https://example.com".to_string(), + } + } + + #[tokio::test] + async fn thought_created_broadcasts_create() { + let store = TestStore::default(); + let alice = alice(); + let thought = local_thought(alice.id.clone()); + store.users.lock().unwrap().push(alice.clone()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::ThoughtCreated { + thought_id: thought.id.clone(), + user_id: alice.id.clone(), + in_reply_to_id: None, + }) + .await + .unwrap(); + + assert_eq!(spy.created.lock().unwrap().len(), 1); + assert_eq!(spy.created.lock().unwrap()[0], thought.id); + } + + #[tokio::test] + async fn remote_thought_created_does_not_broadcast() { + let store = TestStore::default(); + let alice = alice(); + // Remote thought: local = false, ap_id = Some(...) + let mut thought = local_thought(alice.id.clone()); + thought.local = false; + thought.ap_id = Some("https://remote.example/notes/1".into()); + store.users.lock().unwrap().push(alice.clone()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::ThoughtCreated { + thought_id: thought.id.clone(), + user_id: alice.id.clone(), + in_reply_to_id: None, + }) + .await + .unwrap(); + + assert!(spy.created.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn thought_deleted_broadcasts_delete_with_constructed_ap_id() { + let store = TestStore::default(); + let alice = alice(); + let tid = ThoughtId::new(); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::ThoughtDeleted { + thought_id: tid.clone(), + user_id: alice.id.clone(), + }) + .await + .unwrap(); + + let deleted = spy.deleted.lock().unwrap(); + assert_eq!(deleted.len(), 1); + assert_eq!(deleted[0], format!("https://example.com/thoughts/{}", tid)); + } + + #[tokio::test] + async fn thought_updated_broadcasts_update() { + let store = TestStore::default(); + let alice = alice(); + let thought = local_thought(alice.id.clone()); + store.users.lock().unwrap().push(alice.clone()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::ThoughtUpdated { + thought_id: thought.id.clone(), + user_id: alice.id.clone(), + }) + .await + .unwrap(); + + assert_eq!(spy.updated.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn boost_of_local_thought_announces_constructed_url() { + let store = TestStore::default(); + let alice = alice(); + let thought = local_thought(alice.id.clone()); // ap_id = None + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::BoostAdded { + boost_id: BoostId::new(), + user_id: alice.id.clone(), + thought_id: thought.id.clone(), + }) + .await + .unwrap(); + + let announced = spy.announced.lock().unwrap(); + assert_eq!(announced.len(), 1); + assert_eq!(announced[0], format!("https://example.com/thoughts/{}", thought.id)); + } + + #[tokio::test] + async fn boost_of_remote_thought_announces_remote_ap_id() { + let store = TestStore::default(); + let alice = alice(); + let mut thought = local_thought(alice.id.clone()); + thought.local = false; + thought.ap_id = Some("https://mastodon.social/users/bob/statuses/123".into()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::BoostAdded { + boost_id: BoostId::new(), + user_id: alice.id.clone(), + thought_id: thought.id.clone(), + }) + .await + .unwrap(); + + let announced = spy.announced.lock().unwrap(); + assert_eq!(announced[0], "https://mastodon.social/users/bob/statuses/123"); + } + + #[tokio::test] + async fn unrelated_events_are_noop() { + let store = TestStore::default(); + let spy = Arc::new(SpyPort::default()); + let svc = svc(&store, spy.clone()); + + svc.process(&DomainEvent::UserBlocked { + blocker_id: UserId::new(), + blocked_id: UserId::new(), + }).await.unwrap(); + + assert!(spy.created.lock().unwrap().is_empty()); + assert!(spy.deleted.lock().unwrap().is_empty()); + assert!(spy.updated.lock().unwrap().is_empty()); + assert!(spy.announced.lock().unwrap().is_empty()); + } +} +``` + +- [ ] **Run:** `cargo test -p application -- services::federation_event` — Expected: FAIL (no implementation). + +- [ ] **Write `crates/application/src/services/federation_event.rs`** — full file including tests already added above: + +```rust +use std::sync::Arc; +use domain::{ + errors::DomainError, + events::DomainEvent, + models::thought::Thought, + ports::{OutboundFederationPort, ThoughtRepository, UserRepository}, + value_objects::UserId, +}; + +pub struct FederationEventService { + pub thoughts: Arc, + pub users: Arc, + pub ap: Arc, + pub base_url: String, +} + +impl FederationEventService { + pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> { + match event { + DomainEvent::ThoughtCreated { thought_id, user_id, .. } => { + let thought = match self.thoughts.find_by_id(thought_id).await? { + Some(t) if t.local => t, + _ => return Ok(()), + }; + let user = match self.users.find_by_id(user_id).await? { + Some(u) => u, + None => return Ok(()), + }; + self.ap.broadcast_create(user_id, &thought, user.username.as_str()).await + } + + DomainEvent::ThoughtDeleted { thought_id, user_id } => { + let ap_id = format!("{}/thoughts/{}", self.base_url, thought_id); + self.ap.broadcast_delete(user_id, &ap_id).await + } + + DomainEvent::ThoughtUpdated { thought_id, user_id } => { + let thought = match self.thoughts.find_by_id(thought_id).await? { + Some(t) if t.local => t, + _ => return Ok(()), + }; + let user = match self.users.find_by_id(user_id).await? { + Some(u) => u, + None => return Ok(()), + }; + self.ap.broadcast_update(user_id, &thought, user.username.as_str()).await + } + + DomainEvent::BoostAdded { boost_id: _, user_id, thought_id } => { + let thought = match self.thoughts.find_by_id(thought_id).await? { + Some(t) => t, + None => return Ok(()), + }; + let object_ap_id = thought.ap_id.clone().unwrap_or_else(|| { + format!("{}/thoughts/{}", self.base_url, thought_id) + }); + self.ap.broadcast_announce(user_id, &object_ap_id).await + } + + _ => Ok(()), + } + } +} +``` + +- [ ] **Update `crates/application/src/services/mod.rs`** to re-export both services: + +```rust +pub mod federation_event; +pub mod notification_event; + +pub use federation_event::FederationEventService; +pub use notification_event::NotificationEventService; +``` + +- [ ] **Run:** `cargo test -p application` — Expected: all 12 tests pass (5 notification + 7 federation). + +- [ ] **Commit:** + +```bash +git add crates/application/src/services/federation_event.rs crates/application/src/services/mod.rs +git commit -m "feat(application): FederationEventService — content fan-out business logic" +``` + +--- + +### Task 4: AnnounceActivity to/cc + impl OutboundFederationPort for ActivityPubService + +**Files:** +- Modify: `crates/adapters/activitypub-base/src/activities.rs` +- Modify: `crates/adapters/activitypub-base/src/service.rs` + +- [ ] **Add `to`/`cc` to `AnnounceActivity`** in `crates/adapters/activitypub-base/src/activities.rs` — replace the struct definition (fields only; leave `impl Activity` intact): + +```rust +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AnnounceActivity { + pub(crate) id: Url, + #[serde(rename = "type", default)] + pub(crate) kind: AnnounceType, + pub(crate) actor: ObjectId, + pub(crate) object: Url, + pub(crate) published: Option>, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) to: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) cc: Vec, +} +``` + +- [ ] **Run:** `cargo check -p activitypub-base` — Expected: no errors (fields are optional in deserialization due to `default`). + +- [ ] **Add `broadcast_announce_to_followers`** to `ActivityPubService` in `crates/adapters/activitypub-base/src/service.rs` — insert before the `follow` method: + +```rust +/// Fan out an Announce activity to all accepted followers. +pub async fn broadcast_announce_to_followers( + &self, + local_user_id: uuid::Uuid, + object_ap_id: url::Url, +) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + let local_actor = get_local_actor(local_user_id, &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let followers = data.federation_repo.get_followers(local_user_id).await?; + let blocked = data.federation_repo.get_blocked_actors(local_user_id).await.unwrap_or_default(); + let blocked_set: std::collections::HashSet = blocked.into_iter().collect(); + let blocked_domains = data.federation_repo.get_blocked_domains().await.unwrap_or_default(); + let blocked_domain_set: std::collections::HashSet = + blocked_domains.into_iter().map(|d| d.domain).collect(); + + let accepted: Vec<_> = followers + .into_iter() + .filter(|f| f.status == FollowerStatus::Accepted) + .filter(|f| !blocked_set.contains(&f.actor.url)) + .filter(|f| { + let domain = url::Url::parse(&f.actor.inbox_url) + .ok() + .and_then(|u| u.host_str().map(|s| s.to_string())) + .unwrap_or_default(); + !blocked_domain_set.contains(&domain) + }) + .collect(); + + if accepted.is_empty() { + return Ok(()); + } + + let announce = AnnounceActivity { + id: crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?, + kind: Default::default(), + actor: activitypub_federation::fetch::object_id::ObjectId::from(local_actor.ap_id.clone()), + object: object_ap_id, + published: Some(chrono::Utc::now()), + to: vec![crate::urls::AS_PUBLIC.to_string()], + cc: vec![local_actor.followers_url.to_string()], + }; + + let inboxes = collect_inboxes(&accepted); + let sends = activitypub_federation::activity_sending::SendActivityTask::prepare( + &activitypub_federation::protocol::context::WithContext::new_default(announce), + &local_actor, + inboxes, + &data, + ) + .await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!(count = failures.len(), "some Announce deliveries failed"); + } + Ok(()) +} +``` + +- [ ] **Add `impl OutboundFederationPort for ActivityPubService`** at the bottom of `crates/adapters/activitypub-base/src/service.rs`, after the existing `impl ActivityPubService` block: + +```rust +#[async_trait::async_trait] +impl domain::ports::OutboundFederationPort for ActivityPubService { + async fn broadcast_create( + &self, + author_user_id: &domain::value_objects::UserId, + thought: &domain::models::thought::Thought, + author_username: &str, + ) -> Result<(), domain::errors::DomainError> { + let user_uuid = author_user_id.as_uuid(); + let data = self.federation_config.to_request_data(); + let local_actor = get_local_actor(user_uuid, &data) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + + let ap_id = url::Url::parse(&format!("{}/thoughts/{}", self.base_url, thought.id)) + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + + let mut note = serde_json::json!({ + "type": "Note", + "id": ap_id.to_string(), + "attributedTo": local_actor.ap_id.to_string(), + "content": thought.content.as_str(), + "published": thought.created_at.to_rfc3339(), + "to": [crate::urls::AS_PUBLIC], + "cc": [local_actor.followers_url.to_string()], + "sensitive": thought.sensitive, + }); + if let Some(ref cw) = thought.content_warning { + note["summary"] = serde_json::json!(cw); + } + if let Some(ref reply_url) = thought.in_reply_to_url { + note["inReplyTo"] = serde_json::json!(reply_url); + } + + self.broadcast_to_followers(user_uuid, ap_id, note) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string())) + } + + async fn broadcast_delete( + &self, + author_user_id: &domain::value_objects::UserId, + thought_ap_id: &str, + ) -> Result<(), domain::errors::DomainError> { + let user_uuid = author_user_id.as_uuid(); + let ap_id = url::Url::parse(thought_ap_id) + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + self.broadcast_delete_to_followers(user_uuid, ap_id) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string())) + } + + async fn broadcast_update( + &self, + author_user_id: &domain::value_objects::UserId, + thought: &domain::models::thought::Thought, + author_username: &str, + ) -> Result<(), domain::errors::DomainError> { + let user_uuid = author_user_id.as_uuid(); + let data = self.federation_config.to_request_data(); + let local_actor = get_local_actor(user_uuid, &data) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + + let ap_id = format!("{}/thoughts/{}", self.base_url, thought.id); + + let mut note = serde_json::json!({ + "type": "Note", + "id": ap_id, + "attributedTo": local_actor.ap_id.to_string(), + "content": thought.content.as_str(), + "published": thought.created_at.to_rfc3339(), + "to": [crate::urls::AS_PUBLIC], + "cc": [local_actor.followers_url.to_string()], + "sensitive": thought.sensitive, + }); + if let Some(ref cw) = thought.content_warning { + note["summary"] = serde_json::json!(cw); + } + if let Some(ref reply_url) = thought.in_reply_to_url { + note["inReplyTo"] = serde_json::json!(reply_url); + } + + self.broadcast_update_to_followers(user_uuid, note) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string())) + } + + async fn broadcast_announce( + &self, + booster_user_id: &domain::value_objects::UserId, + object_ap_id: &str, + ) -> Result<(), domain::errors::DomainError> { + let user_uuid = booster_user_id.as_uuid(); + let ap_id = url::Url::parse(object_ap_id) + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?; + self.broadcast_announce_to_followers(user_uuid, ap_id) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string())) + } +} +``` + +- [ ] **Run:** `cargo check -p activitypub-base` — Expected: no errors. + +- [ ] **Run:** `cargo check --workspace` — Expected: no errors. + +- [ ] **Commit:** + +```bash +git add crates/adapters/activitypub-base/ +git commit -m "feat(activitypub-base): Announce broadcast + impl OutboundFederationPort for ActivityPubService" +``` + +--- + +### Task 5: Thin worker handlers + factory + main + +**Files:** +- Modify: `crates/worker/Cargo.toml` +- Modify: `crates/worker/src/handlers.rs` +- Create: `crates/worker/src/factory.rs` +- Modify: `crates/worker/src/main.rs` + +- [ ] **Update `crates/worker/Cargo.toml`** — add missing deps: + +```toml +[package] +name = "worker" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "thoughts-worker" +path = "src/main.rs" + +[dependencies] +domain = { workspace = true } +application = { workspace = true } +nats = { workspace = true } +event-payload = { workspace = true } +event-transport = { workspace = true } +activitypub-base = { workspace = true } +activitypub = { workspace = true } +postgres = { workspace = true } +postgres-federation = { workspace = true } +async-nats = { workspace = true } +tokio = { workspace = true, features = ["full"] } +futures = { workspace = true } +async-trait = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +dotenvy = { workspace = true } +serde_json = { workspace = true } +chrono = { workspace = true } +sqlx = { workspace = true } + +[dev-dependencies] +domain = { workspace = true, features = ["test-helpers"] } +``` + +- [ ] **Rewrite `crates/worker/src/handlers.rs`** — thin delegation wrappers only, all tests removed (they now live in `application`): + +```rust +use std::sync::Arc; +use application::services::{FederationEventService, NotificationEventService}; +use domain::{errors::DomainError, events::DomainEvent}; + +pub struct NotificationHandler { + pub service: Arc, +} + +impl NotificationHandler { + pub async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { + self.service.process(event).await + } +} + +pub struct FederationHandler { + pub service: Arc, +} + +impl FederationHandler { + pub async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { + self.service.process(event).await + } +} +``` + +- [ ] **Create `crates/worker/src/factory.rs`:** + +```rust +use std::sync::Arc; +use sqlx::PgPool; + +use activitypub::ThoughtsObjectHandler; +use activitypub_base::ActivityPubService; +use application::services::{FederationEventService, NotificationEventService}; +use postgres::activitypub::PgActivityPubRepository; +use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository}; + +use crate::handlers::{FederationHandler, NotificationHandler}; + +pub struct WorkerHandlers { + pub notification: NotificationHandler, + pub federation: FederationHandler, +} + +pub async fn build( + database_url: &str, + base_url: &str, + nats_url: &str, +) -> ( + event_transport::EventConsumerAdapter, + WorkerHandlers, +) { + let pool = PgPool::connect(database_url) + .await + .expect("DB connect failed"); + + // Repos + let thoughts = Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())); + let users = Arc::new(postgres::user::PgUserRepository::new(pool.clone())); + let notifications = Arc::new(postgres::notification::PgNotificationRepository::new(pool.clone())); + + // ActivityPub service (for federation fan-out) + let ap_service: Arc = Arc::new( + ActivityPubService::new( + Arc::new(PostgresFederationRepository::new(pool.clone())), + Arc::new(PostgresApUserRepository::new(pool.clone(), base_url.to_string())), + Arc::new(ThoughtsObjectHandler::new( + Arc::new(PgActivityPubRepository::new(pool.clone())), + base_url, + )), + base_url.to_string(), + false, + "thoughts".to_string(), + false, + None, + ) + .await + .expect("ActivityPubService build failed"), + ); + + // Application services + let notification_svc = Arc::new(NotificationEventService { + thoughts: thoughts.clone(), + notifications, + }); + let federation_svc = Arc::new(FederationEventService { + thoughts, + users, + ap: ap_service, + base_url: base_url.to_string(), + }); + + // Thin handlers + let handlers = WorkerHandlers { + notification: NotificationHandler { service: notification_svc }, + federation: FederationHandler { service: federation_svc }, + }; + + // NATS consumer + let nats_client = async_nats::connect(nats_url) + .await + .expect("NATS connect failed"); + let consumer = event_transport::EventConsumerAdapter::new( + nats::NatsMessageSource::new(nats_client), + ); + + (consumer, handlers) +} +``` + +- [ ] **Rewrite `crates/worker/src/main.rs`:** + +```rust +mod factory; +mod handlers; + +use futures::StreamExt; +use domain::ports::EventConsumer; + +#[tokio::main] +async fn main() { + dotenvy::dotenv().ok(); + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL required"); + let nats_url = std::env::var("NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".into()); + let base_url = std::env::var("BASE_URL").expect("BASE_URL required"); + + tracing::info!("Building worker..."); + let (consumer, handlers) = factory::build(&database_url, &base_url, &nats_url).await; + + tracing::info!("Worker started, consuming events..."); + let mut stream = consumer.consume(); + while let Some(result) = stream.next().await { + match result { + Ok(envelope) => { + let event = &envelope.event; + tracing::debug!(?event, "received event"); + + let n = handlers.notification.handle(event).await; + let f = handlers.federation.handle(event).await; + + if n.is_ok() && f.is_ok() { + (envelope.ack)(); + } else { + if let Err(e) = n { tracing::error!("notification handler: {e}"); } + if let Err(e) = f { tracing::error!("federation handler: {e}"); } + (envelope.nack)(); + } + } + Err(e) => tracing::error!("consumer error: {e}"), + } + } +} +``` + +- [ ] **Run:** `cargo check -p worker` — Expected: no errors. + +- [ ] **Run:** `cargo check --workspace` — Expected: no errors. + +- [ ] **Run full test suite:** + +```bash +DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -5 +``` + +Expected: all tests pass including the 12 new application service tests. + +- [ ] **Commit:** + +```bash +git add crates/worker/ +git commit -m "refactor(worker): thin handlers + factory — move all business logic to application services" +``` + +--- + +## Self-Review + +**Spec coverage:** +- ✅ `OutboundFederationPort` in domain, 4 methods in domain language (Task 1) +- ✅ `NotificationEventService` in application, business logic out of worker (Task 2) +- ✅ 5 notification tests in application crate (Task 2) +- ✅ `FederationEventService` in application: ThoughtCreated/Deleted/Updated/BoostAdded (Task 3) +- ✅ Remote thought guard: `local == false` → skip broadcast (Task 3) +- ✅ 7 federation event tests including remote thought guard and remote-boost AP ID (Task 3) +- ✅ `to`/`cc` added to `AnnounceActivity` for AP compliance (Task 4) +- ✅ `broadcast_announce_to_followers` respects blocked actors/domains (Task 4) +- ✅ `impl OutboundFederationPort for ActivityPubService` builds Note JSON with `inReplyTo`, `summary`, `sensitive` (Task 4) +- ✅ `worker/src/factory.rs` owns all composition — main.rs stays tiny (Task 5) +- ✅ Worker handlers are one-liner delegations (Task 5) +- ✅ Follow/Accept/Reject/Block remain synchronous in HTTP handlers — unchanged + +**Placeholder scan:** None. + +**Type consistency:** +- `UserId::as_uuid()` used in impl — confirmed available in `value_objects.rs:11` +- `Content::as_str()`, `Username::as_str()` — confirmed available +- `Thought.local: bool` — used for guard in `FederationEventService` +- `Thought.ap_id: Option` — used for boost AP ID construction +- `ActivityPubService::broadcast_to_followers(uuid::Uuid, Url, Value)` — matches existing signature +- `broadcast_update_to_followers(uuid::Uuid, Value)` — matches existing signature +- `ThoughtsObjectHandler::new(Arc, &str)` — matches bootstrap factory usage +- `PostgresApUserRepository::new(PgPool, String)` — matches bootstrap factory usage diff --git a/docs/superpowers/plans/2026-05-14-merge-readiness.md b/docs/superpowers/plans/2026-05-14-merge-readiness.md new file mode 100644 index 0000000..1d6fdeb --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-merge-readiness.md @@ -0,0 +1,562 @@ +# Merge Readiness Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the remaining gaps between v2 and v1 so the new Rust backend can replace the old one. Five tasks: fix feed response hydration, wire missing follower/following routes, add user listing endpoints, add popular tags, harden config (HOST, CORS, rate limiting). + +**Architecture:** All changes are in `presentation`, `domain/ports`, `adapters/postgres`, and `bootstrap`. No changes to `application` or `worker`. + +--- + +## File Map + +``` +Task 1 — Feed hydration: + Modify: crates/presentation/src/handlers/feed.rs ← add to_thought_response helper, fix 4 handlers + Modify: crates/presentation/src/handlers/auth.rs ← move/export to_feed_entry helper if needed + +Task 2 — Wire follower/following routes: + Modify: crates/presentation/src/routes.rs ← add 2 routes + +Task 3 — User listing + count: + Modify: crates/domain/src/ports.rs ← add count() to UserRepository + Modify: crates/adapters/postgres/src/user.rs ← implement count() + Modify: crates/domain/src/testing.rs ← add count() to TestStore + Modify: crates/presentation/src/handlers/users.rs ← add get_users, get_user_count handlers + Modify: crates/presentation/src/routes.rs ← add 2 routes + +Task 4 — Popular tags: + Modify: crates/domain/src/ports.rs ← add popular_tags() to TagRepository + Modify: crates/adapters/postgres/src/tag.rs ← implement popular_tags() + Modify: crates/domain/src/testing.rs ← add popular_tags() to TestStore + Modify: crates/presentation/src/handlers/feed.rs ← add get_popular_tags handler + Modify: crates/presentation/src/routes.rs ← add 1 route (before /tags/{name}) + +Task 5 — Config: HOST, CORS_ORIGINS, RATE_LIMIT: + Modify: crates/bootstrap/src/config.rs ← 3 new fields + Modify: crates/bootstrap/src/main.rs ← use HOST, CORS layer, rate limit layer + Modify: crates/bootstrap/Cargo.toml ← add tower-governor + Modify: .env.example ← document new vars +``` + +--- + +### Task 1: Fix feed response hydration + +**Files:** +- Modify: `crates/presentation/src/handlers/feed.rs` + +**Problem:** `home_feed` and `public_feed` return only UUIDs. `user_thoughts_handler` and `tag_thoughts_handler` are missing `author`, `in_reply_to_id`, `sensitive`, `content_warning`, viewer flags. All four need to use `ThoughtResponse`. + +The `ThoughtResponse` DTO in `api-types` already has every needed field. `FeedEntry` in domain already carries `like_count`, `boost_count`, `reply_count`, `liked_by_viewer`, `boosted_by_viewer`. The conversion is straightforward. + +- [ ] **Add `to_thought_response` helper** at the top of `feed.rs` (after existing imports). This is a private free function: + +```rust +use api_types::responses::ThoughtResponse; + +fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse { + ThoughtResponse { + id: e.thought.id.as_uuid(), + content: e.thought.content.as_str().to_string(), + author: crate::handlers::auth::to_user_response(&e.author), + in_reply_to_id: e.thought.in_reply_to_id.as_ref().map(|id| id.as_uuid()), + visibility: e.thought.visibility.as_str().to_string(), + content_warning: e.thought.content_warning.clone(), + sensitive: e.thought.sensitive, + like_count: e.like_count, + boost_count: e.boost_count, + reply_count: e.reply_count, + liked_by_viewer: e.liked_by_viewer, + boosted_by_viewer: e.boosted_by_viewer, + created_at: e.thought.created_at, + updated_at: e.thought.updated_at, + } +} +``` + +- [ ] **Fix `home_feed`** — replace the UUID-only mapping: + +```rust +pub async fn home_feed( + State(s): State, + AuthUser(uid): AuthUser, + Query(q): Query, +) -> Result, ApiError> { + let page = PageParams { page: q.page(), per_page: q.per_page() }; + let result = get_home_feed(&*s.feed, &*s.follows, &uid, page).await?; + Ok(Json(serde_json::json!({ + "items": result.items.iter().map(to_thought_response).collect::>(), + "total": result.total, + "page": result.page, + "per_page": result.per_page, + }))) +} +``` + +- [ ] **Fix `public_feed`** — same pattern: + +```rust +pub async fn public_feed( + State(s): State, + OptionalAuthUser(viewer): OptionalAuthUser, + Query(q): Query, +) -> Result, ApiError> { + let page = PageParams { page: q.page(), per_page: q.per_page() }; + let result = get_public_feed(&*s.feed, viewer.as_ref(), page).await?; + Ok(Json(serde_json::json!({ + "items": result.items.iter().map(to_thought_response).collect::>(), + "total": result.total, + "page": result.page, + "per_page": result.per_page, + }))) +} +``` + +- [ ] **Fix `user_thoughts_handler`** — replace the partial mapping with `to_thought_response`: + +```rust +pub async fn user_thoughts_handler( + State(s): State, + Path(username): Path, + Query(q): Query, +) -> Result, ApiError> { + let user = get_user_by_username(&*s.users, &username).await?; + let page = PageParams { page: q.page(), per_page: q.per_page() }; + let result = get_user_feed(&*s.thoughts, &user.id, page).await?; + Ok(Json(serde_json::json!({ + "total": result.total, + "page": result.page, + "per_page": result.per_page, + "items": result.items.iter().map(to_thought_response).collect::>(), + }))) +} +``` + +- [ ] **Fix `tag_thoughts_handler`** — same: + +```rust +pub async fn tag_thoughts_handler( + State(s): State, + Path(tag_name): Path, + Query(q): Query, +) -> Result, ApiError> { + let page = PageParams { page: q.page(), per_page: q.per_page() }; + let result = get_by_tag(&*s.tags, &tag_name, page).await?; + Ok(Json(serde_json::json!({ + "tag": tag_name, + "total": result.total, + "page": result.page, + "per_page": result.per_page, + "items": result.items.iter().map(to_thought_response).collect::>(), + }))) +} +``` + +NOTE: `get_by_tag` returns `Paginated`, not `Paginated` — it won't have author or counts. Check the use case signature. If it returns `Paginated`, map manually keeping available fields only (id, content, visibility, dates). If it returns `Paginated`, use `to_thought_response`. + +- [ ] **Run:** `cargo check -p presentation` — Expected: no errors. + +- [ ] **Commit:** + +```bash +git add crates/presentation/src/handlers/feed.rs +git commit -m "fix(presentation): hydrate feed responses with full ThoughtResponse — remove UUID-only stubs" +``` + +--- + +### Task 2: Wire follower/following REST routes + +**Files:** +- Modify: `crates/presentation/src/routes.rs` + +`get_followers_handler` and `get_following_handler` already exist in `feed.rs` (lines 75–80). The AP routes own `/users/{username}/followers` and `/users/{username}/following`. Wire the REST handlers at non-conflicting paths: + +- [ ] **Add two routes to `api_routes`** in `routes.rs`, in the users section (before `/thoughts`): + +```rust +.route("/users/{username}/follower-list", get(feed::get_followers_handler)) +.route("/users/{username}/following-list", get(feed::get_following_handler)) +``` + +- [ ] **Run:** `cargo check -p presentation` — Expected: no errors. + +- [ ] **Commit:** + +```bash +git add crates/presentation/src/routes.rs +git commit -m "feat(presentation): wire GET /users/{username}/follower-list and /following-list" +``` + +--- + +### Task 3: User listing + count + +**Files:** +- Modify: `crates/domain/src/ports.rs` +- Modify: `crates/adapters/postgres/src/user.rs` +- Modify: `crates/domain/src/testing.rs` +- Modify: `crates/presentation/src/handlers/users.rs` +- Modify: `crates/presentation/src/routes.rs` + +- [ ] **Add `count()` to `UserRepository`** in `crates/domain/src/ports.rs`: + +```rust +async fn count(&self) -> Result; +``` + +- [ ] **Implement `count()` in postgres** — find `impl UserRepository for PgUserRepository` in `crates/adapters/postgres/src/user.rs` and add: + +```rust +async fn count(&self) -> Result { + let row = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users WHERE local = true") + .fetch_one(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + Ok(row) +} +``` + +- [ ] **Implement `count()` in TestStore** in `crates/domain/src/testing.rs`: + +```rust +async fn count(&self) -> Result { + Ok(self.users.lock().unwrap().iter().filter(|u| u.local).count() as i64) +} +``` + +- [ ] **Add handlers to `crates/presentation/src/handlers/users.rs`:** + +```rust +use domain::models::feed::UserSummary; + +#[utoipa::path( + get, path = "/users", + params( + ("q" = Option, Query, description = "Search query"), + PaginationQuery, + ), + responses((status = 200, description = "User list")) +)] +pub async fn get_users( + State(s): State, + Query(params): Query>, +) -> Result, ApiError> { + use domain::models::feed::PageParams; + let page = params.get("page").and_then(|v| v.parse().ok()).unwrap_or(1u64); + let per_page = params.get("per_page").and_then(|v| v.parse().ok()).unwrap_or(20u64); + let page_params = PageParams { page, per_page }; + + if let Some(q) = params.get("q").filter(|q| !q.trim().is_empty()) { + let result = s.search.search_users(q, &page_params).await?; + let users: Vec<_> = result.items.iter().map(|u| crate::handlers::auth::to_user_response(u)).collect(); + return Ok(Json(serde_json::json!({ "items": users, "total": result.total, "page": result.page, "per_page": result.per_page }))); + } + + let all = s.users.list_with_stats().await?; + let total = all.len() as i64; + let start = ((page - 1) * per_page) as usize; + let items: Vec<_> = all.into_iter().skip(start).take(per_page as usize) + .map(|u| serde_json::json!({ + "id": u.id.as_uuid(), + "username": u.username, + "display_name": u.display_name, + "avatar_url": u.avatar_url, + "bio": u.bio, + "thought_count": u.thought_count, + "follower_count": u.follower_count, + "following_count": u.following_count, + })) + .collect(); + Ok(Json(serde_json::json!({ "items": items, "total": total, "page": page, "per_page": per_page }))) +} + +#[utoipa::path( + get, path = "/users/count", + responses((status = 200, description = "Local user count")) +)] +pub async fn get_user_count( + State(s): State, +) -> Result, ApiError> { + let count = s.users.count().await?; + Ok(Json(serde_json::json!({ "count": count }))) +} +``` + +Note: `get_users` needs `use api_types::requests::PaginationQuery;` added to imports if not already there. Check the file's existing imports. + +- [ ] **Add routes to `routes.rs`** — add BEFORE `/users/me` (static paths must come before parameterised): + +```rust +.route("/users", get(users::get_users)) +.route("/users/count", get(users::get_user_count)) +``` + +- [ ] **Run:** `cargo check --workspace` — Expected: no errors. + +- [ ] **Commit:** + +```bash +git add crates/domain/src/ports.rs \ + crates/adapters/postgres/src/user.rs \ + crates/domain/src/testing.rs \ + crates/presentation/src/handlers/users.rs \ + crates/presentation/src/routes.rs +git commit -m "feat: GET /users (search/list) and GET /users/count" +``` + +--- + +### Task 4: Popular tags + +**Files:** +- Modify: `crates/domain/src/ports.rs` +- Modify: `crates/adapters/postgres/src/tag.rs` +- Modify: `crates/domain/src/testing.rs` +- Modify: `crates/presentation/src/handlers/feed.rs` +- Modify: `crates/presentation/src/routes.rs` + +- [ ] **Add `popular_tags()` to `TagRepository`** in `crates/domain/src/ports.rs`: + +```rust +/// Returns (tag_name, thought_count) pairs, most-used first. +async fn popular_tags(&self, limit: usize) -> Result, DomainError>; +``` + +- [ ] **Implement `popular_tags()` in postgres** — find `impl TagRepository for PgTagRepository` in `crates/adapters/postgres/src/tag.rs` and add: + +```rust +async fn popular_tags(&self, limit: usize) -> Result, DomainError> { + let rows = sqlx::query_as::<_, (String, i64)>( + "SELECT t.name, COUNT(tt.thought_id) AS thought_count + FROM tags t + JOIN thought_tags tt ON t.id = tt.tag_id + GROUP BY t.id, t.name + ORDER BY thought_count DESC + LIMIT $1" + ) + .bind(limit as i64) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + Ok(rows) +} +``` + +- [ ] **Implement `popular_tags()` in TestStore** in `crates/domain/src/testing.rs`: + +```rust +async fn popular_tags(&self, _limit: usize) -> Result, DomainError> { + Ok(vec![]) +} +``` + +- [ ] **Add `get_popular_tags` handler** to `crates/presentation/src/handlers/feed.rs`: + +```rust +pub async fn get_popular_tags( + State(s): State, + Query(params): Query>, +) -> Result, ApiError> { + let limit: usize = params.get("limit").and_then(|v| v.parse().ok()).unwrap_or(20); + let tags = s.tags.popular_tags(limit.min(100)).await?; + Ok(Json(serde_json::json!({ + "tags": tags.iter().map(|(name, count)| serde_json::json!({ + "name": name, + "thought_count": count, + })).collect::>() + }))) +} +``` + +- [ ] **Wire `GET /tags/popular` in `routes.rs`** — add BEFORE `/tags/{name}` (otherwise `popular` is captured as the `{name}` param): + +```rust +.route("/tags/popular", get(feed::get_popular_tags)) +.route("/tags/{name}", get(feed::tag_thoughts_handler)) +``` + +The existing `.route("/tags/{name}", ...)` line can stay — just add the popular route immediately before it. + +- [ ] **Run:** `cargo check --workspace` — Expected: no errors. + +- [ ] **Run unit tests:** `cargo test --workspace --exclude postgres --exclude postgres-federation --exclude postgres-search` — Expected: all pass. + +- [ ] **Commit:** + +```bash +git add crates/domain/src/ports.rs \ + crates/adapters/postgres/src/tag.rs \ + crates/domain/src/testing.rs \ + crates/presentation/src/handlers/feed.rs \ + crates/presentation/src/routes.rs +git commit -m "feat: GET /tags/popular — top tags by usage count" +``` + +--- + +### Task 5: Config — HOST, CORS_ORIGINS, RATE_LIMIT + +**Files:** +- Modify: `crates/bootstrap/src/config.rs` +- Modify: `crates/bootstrap/src/main.rs` +- Modify: `crates/bootstrap/Cargo.toml` +- Modify: `.env.example` + +- [ ] **Add `tower-governor` to `crates/bootstrap/Cargo.toml`:** + +```toml +tower-governor = "0.6" +``` + +- [ ] **Add three fields to `Config` in `crates/bootstrap/src/config.rs`:** + +```rust +pub struct Config { + pub database_url: String, + pub jwt_secret: String, + pub base_url: String, + pub nats_url: Option, + pub port: u16, + pub host: String, + pub allow_registration: bool, + pub debug: bool, + /// Comma-separated allowed origins, or "*" for permissive. Default: "*". + pub cors_origins: String, + /// Max requests per minute per IP. None = disabled. + pub rate_limit: Option, +} +``` + +In `from_env()` add: +```rust +host: std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into()), +cors_origins: std::env::var("CORS_ORIGINS").unwrap_or_else(|_| "*".into()), +rate_limit: std::env::var("RATE_LIMIT").ok().and_then(|v| v.parse().ok()), +``` + +- [ ] **Update `crates/bootstrap/src/main.rs`:** + +```rust +mod config; +mod factory; + +use std::sync::Arc; +use http::HeaderValue; +use tower_http::cors::{AllowOrigin, CorsLayer}; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() { + let cfg = config::Config::from_env(); + + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .init(); + + let infra = factory::build(&cfg).await; + + // CORS + let cors = if cfg.cors_origins.trim() == "*" { + CorsLayer::permissive() + } else { + let origins: Vec = cfg.cors_origins + .split(',') + .map(|o| o.trim()) + .filter_map(|o| o.parse().ok()) + .collect(); + CorsLayer::new() + .allow_origin(AllowOrigin::list(origins)) + .allow_methods(tower_http::cors::Any) + .allow_headers(tower_http::cors::Any) + }; + + let app = presentation::routes::router(&infra.fed_config) + .with_state(infra.state) + .layer(cors); + + // Rate limiting (optional) + let app = if let Some(rate_limit) = cfg.rate_limit { + use tower_governor::{GovernorLayer, GovernorConfigBuilder}; + let governor_config = Arc::new( + GovernorConfigBuilder::default() + .per_millisecond(60_000 / rate_limit as u64) + .burst_size(rate_limit) + .use_headers() + .finish() + .expect("valid rate limit config"), + ); + let limiter = governor_config.limiter().clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + loop { + interval.tick().await; + limiter.retain_recent(); + } + }); + app.layer(GovernorLayer { config: governor_config }) + } else { + app + }; + + let addr = format!("{}:{}", cfg.host, cfg.port); + tracing::info!("Listening on {addr}"); + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} +``` + +Note: `tower-governor`'s `GovernorLayer` API may differ slightly — check the actual 0.6.x docs and adjust. The `GovernorConfigBuilder` might use `.per_second()` instead of `.per_millisecond()`. Verify and use whichever method produces the desired requests-per-minute rate. + +Note 2: Axum `Router::layer` returns the same type when adding a standard layer. `GovernorLayer` returns a different type. If the type system complains, wrap the app in `tower::ServiceBuilder` or use `.layer(tower::ServiceBuilder::new().layer(GovernorLayer { ... }).into_inner())`. + +- [ ] **Update `.env.example`** — add the three new vars: + +```env +# Optional +HOST=0.0.0.0 +PORT=3000 +ALLOW_REGISTRATION=true +RUST_ENV=development + +# CORS — comma-separated origins, or * for permissive (default: *) +CORS_ORIGINS=* +# CORS_ORIGINS=https://your-nextjs-app.example.com + +# Rate limiting — max requests per minute per IP (disabled by default) +# RATE_LIMIT=60 +``` + +- [ ] **Run:** `cargo check -p bootstrap` — Expected: no errors (fix tower-governor API if needed). + +- [ ] **Commit:** + +```bash +git add crates/bootstrap/ .env.example +git commit -m "feat(bootstrap): configurable HOST, CORS_ORIGINS, and optional rate limiting" +``` + +--- + +## Self-Review + +**Spec coverage:** +- ✅ `home_feed` / `public_feed` return full `ThoughtResponse` (Task 1) +- ✅ `user_thoughts_handler` / `tag_thoughts_handler` use `to_thought_response` (Task 1) +- ✅ `GET /users/{username}/follower-list` and `/following-list` wired (Task 2) +- ✅ `GET /users` (search + list) + `GET /users/count` (Task 3) +- ✅ `UserRepository::count()` in port + postgres + TestStore (Task 3) +- ✅ `GET /tags/popular` wired before `/tags/{name}` (Task 4) +- ✅ `TagRepository::popular_tags()` in port + postgres + TestStore (Task 4) +- ✅ `HOST`, `CORS_ORIGINS`, `RATE_LIMIT` in Config (Task 5) +- ✅ CORS layer uses configured origins (Task 5) +- ✅ Rate limiting via tower-governor, disabled by default (Task 5) + +**Placeholder scan:** None. + +**Type consistency:** +- `to_thought_response` maps `FeedEntry` → `ThoughtResponse` — both types confirmed in source +- `tag_thoughts_handler` uses `get_by_tag` which returns `Paginated` — verify whether it returns `Thought` or `FeedEntry` and adjust the mapping accordingly +- `popular_tags()` returns `Vec<(String, i64)>` — matches the SQL query's two columns +- `GovernorLayer` API — implementer must verify against installed tower-governor version