diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-11-04 10:25:53 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-11-04 10:25:53 +0000 |
| commit | 52bad9954cdddf55ab749fd0c6387edbc766632f (patch) | |
| tree | d9dd2078b70a627a71d1adb9555cee83faec5cd0 /docs/explanation | |
| parent | db460efdd4cf34d3b6ac8c19b1b8f89f22bc279f (diff) | |
docs: use Diátaxis structure
Diffstat (limited to 'docs/explanation')
| -rw-r--r-- | docs/explanation/README.md | 225 | ||||
| -rw-r--r-- | docs/explanation/architecture.md | 808 | ||||
| -rw-r--r-- | docs/explanation/comparison.md | 256 | ||||
| -rw-r--r-- | docs/explanation/decisions.md | 174 | ||||
| -rw-r--r-- | docs/explanation/inline-authorization.md | 403 |
5 files changed, 1866 insertions, 0 deletions
diff --git a/docs/explanation/README.md b/docs/explanation/README.md new file mode 100644 index 0000000..cc3ec49 --- /dev/null +++ b/docs/explanation/README.md | |||
| @@ -0,0 +1,225 @@ | |||
| 1 | # Explanation | ||
| 2 | |||
| 3 | **Understanding-oriented documentation** - Concepts, design decisions, and the "why" behind ngit-grasp. | ||
| 4 | |||
| 5 | --- | ||
| 6 | |||
| 7 | ## What Is Explanation? | ||
| 8 | |||
| 9 | Explanation documentation helps you **understand concepts** and design decisions, providing context and discussing alternatives. | ||
| 10 | |||
| 11 | **Characteristics:** | ||
| 12 | - ✅ Understanding-oriented (clarify concepts) | ||
| 13 | - ✅ Theoretical (ideas and design) | ||
| 14 | - ✅ Discuss alternatives | ||
| 15 | - ✅ Provide context and background | ||
| 16 | - ✅ Answer "why" questions | ||
| 17 | |||
| 18 | **Not explanation:** | ||
| 19 | - ❌ Step-by-step lessons (those are Tutorials) | ||
| 20 | - ❌ Problem-solving recipes (those are How-To) | ||
| 21 | - ❌ Technical specifications (those are Reference) | ||
| 22 | |||
| 23 | --- | ||
| 24 | |||
| 25 | ## Available Explanation Documentation | ||
| 26 | |||
| 27 | ### [Architecture Overview](architecture.md) | ||
| 28 | **Understand the system design and component interaction** | ||
| 29 | |||
| 30 | **Topics:** | ||
| 31 | - Overall architecture | ||
| 32 | - Component responsibilities | ||
| 33 | - Data flows | ||
| 34 | - Technology choices | ||
| 35 | - Design patterns | ||
| 36 | |||
| 37 | **Read when:** You want to understand how ngit-grasp works as a system | ||
| 38 | |||
| 39 | --- | ||
| 40 | |||
| 41 | ### [Inline Authorization](inline-authorization.md) | ||
| 42 | **Why we validate pushes inline instead of using Git hooks** | ||
| 43 | |||
| 44 | **Topics:** | ||
| 45 | - The authorization problem | ||
| 46 | - Git hooks approach | ||
| 47 | - Inline approach | ||
| 48 | - Comparison and trade-offs | ||
| 49 | - Implementation details | ||
| 50 | |||
| 51 | **Read when:** You want to understand the core architectural decision | ||
| 52 | |||
| 53 | --- | ||
| 54 | |||
| 55 | ### [Design Decisions](decisions.md) | ||
| 56 | **Key architectural choices and their rationale** | ||
| 57 | |||
| 58 | **Topics:** | ||
| 59 | - Inline authorization vs hooks | ||
| 60 | - Technology stack choices | ||
| 61 | - Storage design | ||
| 62 | - API design | ||
| 63 | - Performance considerations | ||
| 64 | |||
| 65 | **Read when:** You want to know why things are the way they are | ||
| 66 | |||
| 67 | --- | ||
| 68 | |||
| 69 | ### [Comparison with ngit-relay](comparison.md) | ||
| 70 | **How ngit-grasp differs from the reference implementation** | ||
| 71 | |||
| 72 | **Topics:** | ||
| 73 | - Architecture comparison | ||
| 74 | - Component differences | ||
| 75 | - Trade-offs | ||
| 76 | - Migration path | ||
| 77 | - Compatibility | ||
| 78 | |||
| 79 | **Read when:** You're familiar with ngit-relay and want to understand differences | ||
| 80 | |||
| 81 | --- | ||
| 82 | |||
| 83 | ## Planned Explanation Documentation | ||
| 84 | |||
| 85 | ### GRASP Protocol Design | ||
| 86 | **Status:** 🔜 Planned | ||
| 87 | |||
| 88 | **Topics:** | ||
| 89 | - Why Nostr for Git? | ||
| 90 | - Authorization model | ||
| 91 | - Trust and verification | ||
| 92 | - Decentralization benefits | ||
| 93 | |||
| 94 | --- | ||
| 95 | |||
| 96 | ### Storage Architecture | ||
| 97 | **Status:** 🔜 Planned | ||
| 98 | |||
| 99 | **Topics:** | ||
| 100 | - Why separate Git and Nostr storage? | ||
| 101 | - Indexing strategy | ||
| 102 | - Performance considerations | ||
| 103 | - Scaling approach | ||
| 104 | |||
| 105 | --- | ||
| 106 | |||
| 107 | ### Testing Philosophy | ||
| 108 | **Status:** 🔜 Planned | ||
| 109 | |||
| 110 | **Topics:** | ||
| 111 | - Why test isolation? | ||
| 112 | - Integration vs unit tests | ||
| 113 | - Compliance testing approach | ||
| 114 | - Test-driven development | ||
| 115 | |||
| 116 | --- | ||
| 117 | |||
| 118 | ### Performance Considerations | ||
| 119 | **Status:** 🔜 Planned | ||
| 120 | |||
| 121 | **Topics:** | ||
| 122 | - Async architecture | ||
| 123 | - Caching strategy | ||
| 124 | - Database choices | ||
| 125 | - Bottlenecks and solutions | ||
| 126 | |||
| 127 | --- | ||
| 128 | |||
| 129 | ## How to Use Explanation Documentation | ||
| 130 | |||
| 131 | 1. **Read to understand** - Not to accomplish a task | ||
| 132 | 2. **Follow your curiosity** - Read what interests you | ||
| 133 | 3. **Connect concepts** - Link ideas together | ||
| 134 | 4. **Question and explore** - Think critically | ||
| 135 | |||
| 136 | **Not sure if this is what you need?** | ||
| 137 | - Want to learn by doing? → [Tutorials](../tutorials/) | ||
| 138 | - Need to solve a problem? → [How-To Guides](../how-to/) | ||
| 139 | - Looking for technical details? → [Reference](../reference/) | ||
| 140 | |||
| 141 | --- | ||
| 142 | |||
| 143 | ## Contributing Explanation Documentation | ||
| 144 | |||
| 145 | When writing explanation: | ||
| 146 | |||
| 147 | **DO:** | ||
| 148 | - ✅ Discuss concepts and ideas | ||
| 149 | - ✅ Provide context and background | ||
| 150 | - ✅ Explain alternatives | ||
| 151 | - ✅ Use analogies and examples | ||
| 152 | - ✅ Connect to broader context | ||
| 153 | - ✅ Answer "why" questions | ||
| 154 | |||
| 155 | **DON'T:** | ||
| 156 | - ❌ Provide step-by-step instructions (link to Tutorials/How-To) | ||
| 157 | - ❌ List technical details (link to Reference) | ||
| 158 | - ❌ Assume you must be comprehensive | ||
| 159 | - ❌ Avoid opinions (explanation can be opinionated) | ||
| 160 | |||
| 161 | **Template:** | ||
| 162 | ```markdown | ||
| 163 | # Explanation: [Topic] | ||
| 164 | |||
| 165 | **Purpose:** [What concept/decision this explains] | ||
| 166 | **Audience:** [Who wants to understand this] | ||
| 167 | |||
| 168 | --- | ||
| 169 | |||
| 170 | ## The Problem/Question | ||
| 171 | |||
| 172 | [What are we trying to understand?] | ||
| 173 | |||
| 174 | --- | ||
| 175 | |||
| 176 | ## Background | ||
| 177 | |||
| 178 | [Context and history] | ||
| 179 | |||
| 180 | --- | ||
| 181 | |||
| 182 | ## Our Approach | ||
| 183 | |||
| 184 | [How we address it] | ||
| 185 | |||
| 186 | ### Why This Works | ||
| 187 | |||
| 188 | [Explanation of benefits] | ||
| 189 | |||
| 190 | ### Trade-offs | ||
| 191 | |||
| 192 | [What we gain and lose] | ||
| 193 | |||
| 194 | --- | ||
| 195 | |||
| 196 | ## Alternatives Considered | ||
| 197 | |||
| 198 | ### [Alternative 1] | ||
| 199 | |||
| 200 | **Pros:** | ||
| 201 | - [Benefits] | ||
| 202 | |||
| 203 | **Cons:** | ||
| 204 | - [Drawbacks] | ||
| 205 | |||
| 206 | **Why we didn't choose it:** | ||
| 207 | [Reasoning] | ||
| 208 | |||
| 209 | --- | ||
| 210 | |||
| 211 | ## Conclusion | ||
| 212 | |||
| 213 | [Summary of understanding] | ||
| 214 | |||
| 215 | --- | ||
| 216 | |||
| 217 | ## Related Documentation | ||
| 218 | - [Links to relevant docs] | ||
| 219 | ``` | ||
| 220 | |||
| 221 | See [Diátaxis: Explanation](https://diataxis.fr/explanation/) for detailed guidance. | ||
| 222 | |||
| 223 | --- | ||
| 224 | |||
| 225 | *Part of the [ngit-grasp documentation](../README.md) using the [Diátaxis](https://diataxis.fr/) framework.* | ||
diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md new file mode 100644 index 0000000..ebf7a74 --- /dev/null +++ b/docs/explanation/architecture.md | |||
| @@ -0,0 +1,808 @@ | |||
| 1 | # ngit-grasp Architecture | ||
| 2 | |||
| 3 | ## Executive Summary | ||
| 4 | |||
| 5 | `ngit-grasp` implements the GRASP protocol in Rust with **inline authorization** rather than Git hooks. The key architectural insight is that the `git-http-backend` Rust crate provides sufficient flexibility to intercept and validate Git push operations before they reach the Git repository, eliminating the need for pre-receive hooks. | ||
| 6 | |||
| 7 | ## Architectural Decision: Inline vs. Hook-Based Authorization | ||
| 8 | |||
| 9 | ### Investigation Summary | ||
| 10 | |||
| 11 | After examining both the reference implementation and the `git-http-backend` Rust crate, we have two options: | ||
| 12 | |||
| 13 | #### Option 1: Hook-Based (Reference Implementation Approach) | ||
| 14 | - Use `git-http-backend` crate as-is | ||
| 15 | - Create pre-receive and post-receive hooks | ||
| 16 | - Hooks query the Nostr relay and validate pushes | ||
| 17 | - **Pros**: Follows reference implementation closely | ||
| 18 | - **Cons**: Requires hook management, harder to test, less Rust-native | ||
| 19 | |||
| 20 | #### Option 2: Inline Authorization (Recommended) | ||
| 21 | - Intercept Git receive-pack requests in the HTTP handler | ||
| 22 | - Validate against Nostr state before spawning Git process | ||
| 23 | - Only forward valid pushes to Git | ||
| 24 | - **Pros**: Better error handling, easier testing, pure Rust, simpler deployment | ||
| 25 | - **Cons**: Requires custom Git protocol handling | ||
| 26 | |||
| 27 | ### Decision: Inline Authorization (Option 2) | ||
| 28 | |||
| 29 | **Rationale:** | ||
| 30 | |||
| 31 | 1. **The `git-http-backend` crate is sufficiently flexible**: Examining `src/actix/git_receive_pack.rs` shows it spawns `git receive-pack` as a subprocess and streams data. We can intercept this. | ||
| 32 | |||
| 33 | 2. **Better Developer Experience**: | ||
| 34 | - Validation errors can be returned as proper HTTP responses | ||
| 35 | - No need to parse hook stderr output | ||
| 36 | - Shared state between Git and Nostr components | ||
| 37 | - Pure Rust testing without shell scripts | ||
| 38 | |||
| 39 | 3. **Simpler Deployment**: | ||
| 40 | - Single binary | ||
| 41 | - No hook symlinks or permissions to manage | ||
| 42 | - No multi-process coordination | ||
| 43 | |||
| 44 | 4. **Performance**: | ||
| 45 | - Can parse incoming pack data once | ||
| 46 | - Avoid process spawn overhead for invalid pushes | ||
| 47 | - Better async integration | ||
| 48 | |||
| 49 | ## System Architecture | ||
| 50 | |||
| 51 | ``` | ||
| 52 | ┌─────────────────────────────────────────────────────────────┐ | ||
| 53 | │ ngit-grasp │ | ||
| 54 | │ (Single Rust Binary) │ | ||
| 55 | ├─────────────────────────────────────────────────────────────┤ | ||
| 56 | │ │ | ||
| 57 | │ ┌──────────────────┐ ┌──────────────────┐ │ | ||
| 58 | │ │ HTTP Router │ │ Nostr Relay │ │ | ||
| 59 | │ │ (actix-web) │ │ (nostr-relay- │ │ | ||
| 60 | │ │ │ │ builder) │ │ | ||
| 61 | │ └────────┬─────────┘ └────────┬─────────┘ │ | ||
| 62 | │ │ │ │ | ||
| 63 | │ │ │ │ | ||
| 64 | │ ┌────────▼──────────────────────────────────▼─────────┐ │ | ||
| 65 | │ │ Shared State & Storage │ │ | ||
| 66 | │ │ ┌──────────────┐ ┌──────────────┐ │ │ | ||
| 67 | │ │ │ Repository │ │ Event Store │ │ │ | ||
| 68 | │ │ │ Manager │ │ (LMDB/NDB) │ │ │ | ||
| 69 | │ │ └──────────────┘ └──────────────┘ │ │ | ||
| 70 | │ └─────────────────────────────────────────────────────┘ │ | ||
| 71 | │ │ | ||
| 72 | │ ┌──────────────────────────────────────────────────────┐ │ | ||
| 73 | │ │ Git Protocol Handler │ │ | ||
| 74 | │ │ │ │ | ||
| 75 | │ │ 1. Receive git-receive-pack request │ │ | ||
| 76 | │ │ 2. Parse ref updates from request │ │ | ||
| 77 | │ │ 3. Query Nostr relay for state event │ │ | ||
| 78 | │ │ 4. Validate refs against state │ │ | ||
| 79 | │ │ 5. If valid: spawn git-receive-pack │ │ | ||
| 80 | │ │ 6. If invalid: return HTTP error │ │ | ||
| 81 | │ │ │ │ | ||
| 82 | │ └──────────────────────────────────────────────────────┘ │ | ||
| 83 | │ │ | ||
| 84 | └─────────────────────────────────────────────────────────────┘ | ||
| 85 | │ │ | ||
| 86 | │ HTTP/Git │ WebSocket/Nostr | ||
| 87 | ▼ ▼ | ||
| 88 | Git Clients Nostr Clients | ||
| 89 | ``` | ||
| 90 | |||
| 91 | ## Component Design | ||
| 92 | |||
| 93 | ### 1. Main Server (`src/main.rs`) | ||
| 94 | |||
| 95 | **Responsibilities:** | ||
| 96 | - Initialize configuration from environment | ||
| 97 | - Set up actix-web HTTP server | ||
| 98 | - Initialize Nostr relay builder | ||
| 99 | - Set up shared storage | ||
| 100 | - Configure routes for both Git and Nostr endpoints | ||
| 101 | - Handle graceful shutdown | ||
| 102 | |||
| 103 | **Key Dependencies:** | ||
| 104 | ```rust | ||
| 105 | actix-web = "4" | ||
| 106 | tokio = { version = "1", features = ["full"] } | ||
| 107 | nostr-relay-builder = "0.43" | ||
| 108 | nostr-sdk = "0.43" | ||
| 109 | ``` | ||
| 110 | |||
| 111 | ### 2. Git Module (`src/git/`) | ||
| 112 | |||
| 113 | #### `handler.rs` - Git HTTP Handlers | ||
| 114 | |||
| 115 | Implements actix-web handlers for Git Smart HTTP protocol: | ||
| 116 | |||
| 117 | ```rust | ||
| 118 | // GET /<npub>/<identifier>.git/info/refs?service=git-upload-pack | ||
| 119 | async fn info_refs_upload_pack( | ||
| 120 | req: HttpRequest, | ||
| 121 | state: web::Data<AppState>, | ||
| 122 | ) -> Result<HttpResponse> | ||
| 123 | |||
| 124 | // POST /<npub>/<identifier>.git/git-upload-pack | ||
| 125 | async fn git_upload_pack( | ||
| 126 | req: HttpRequest, | ||
| 127 | body: web::Payload, | ||
| 128 | state: web::Data<AppState>, | ||
| 129 | ) -> Result<HttpResponse> | ||
| 130 | |||
| 131 | // GET /<npub>/<identifier>.git/info/refs?service=git-receive-pack | ||
| 132 | async fn info_refs_receive_pack( | ||
| 133 | req: HttpRequest, | ||
| 134 | state: web::Data<AppState>, | ||
| 135 | ) -> Result<HttpResponse> | ||
| 136 | |||
| 137 | // POST /<npub>/<identifier>.git/git-receive-pack | ||
| 138 | // THIS IS WHERE THE MAGIC HAPPENS | ||
| 139 | async fn git_receive_pack( | ||
| 140 | req: HttpRequest, | ||
| 141 | body: web::Payload, | ||
| 142 | state: web::Data<AppState>, | ||
| 143 | ) -> Result<HttpResponse> | ||
| 144 | ``` | ||
| 145 | |||
| 146 | #### `authorization.rs` - Push Validation | ||
| 147 | |||
| 148 | **Core Logic:** | ||
| 149 | |||
| 150 | ```rust | ||
| 151 | pub struct PushValidator { | ||
| 152 | nostr_client: Arc<Client>, | ||
| 153 | relay_url: String, | ||
| 154 | } | ||
| 155 | |||
| 156 | impl PushValidator { | ||
| 157 | /// Validate a push operation against Nostr state | ||
| 158 | pub async fn validate_push( | ||
| 159 | &self, | ||
| 160 | npub: &str, | ||
| 161 | identifier: &str, | ||
| 162 | ref_updates: Vec<RefUpdate>, | ||
| 163 | ) -> Result<ValidationResult> { | ||
| 164 | // 1. Fetch announcement and state events from local relay | ||
| 165 | let events = self.fetch_events(identifier).await?; | ||
| 166 | |||
| 167 | // 2. Extract pubkey from npub | ||
| 168 | let pubkey = decode_npub(npub)?; | ||
| 169 | |||
| 170 | // 3. Get recursive maintainer set | ||
| 171 | let maintainers = get_maintainers(&events, &pubkey, identifier); | ||
| 172 | |||
| 173 | // 4. Get latest state from maintainers | ||
| 174 | let state = get_state_from_maintainers(&events, &maintainers)?; | ||
| 175 | |||
| 176 | // 5. Validate each ref update | ||
| 177 | for ref_update in ref_updates { | ||
| 178 | if ref_update.ref_name.starts_with("refs/nostr/") { | ||
| 179 | // Allow refs/nostr/<event-id> for PRs | ||
| 180 | validate_pr_ref(&ref_update)?; | ||
| 181 | } else if ref_update.ref_name.starts_with("refs/heads/pr/") { | ||
| 182 | // Reject pr/* branches - should use refs/nostr/ | ||
| 183 | return Err(Error::InvalidRef("pr/* branches must use refs/nostr/")); | ||
| 184 | } else { | ||
| 185 | // Validate against state event | ||
| 186 | validate_state_ref(&state, &ref_update)?; | ||
| 187 | } | ||
| 188 | } | ||
| 189 | |||
| 190 | Ok(ValidationResult::Accept) | ||
| 191 | } | ||
| 192 | } | ||
| 193 | ``` | ||
| 194 | |||
| 195 | **Key Functions:** | ||
| 196 | |||
| 197 | ```rust | ||
| 198 | /// Parse ref updates from git-receive-pack request body | ||
| 199 | fn parse_ref_updates(body: &[u8]) -> Result<Vec<RefUpdate>> | ||
| 200 | |||
| 201 | /// Recursively find all maintainers | ||
| 202 | fn get_maintainers( | ||
| 203 | events: &[Event], | ||
| 204 | pubkey: &str, | ||
| 205 | identifier: &str, | ||
| 206 | ) -> Vec<String> | ||
| 207 | |||
| 208 | /// Get latest state from maintainer set | ||
| 209 | fn get_state_from_maintainers( | ||
| 210 | events: &[Event], | ||
| 211 | maintainers: &[String], | ||
| 212 | ) -> Result<RepositoryState> | ||
| 213 | |||
| 214 | /// Validate a ref matches the state event | ||
| 215 | fn validate_state_ref( | ||
| 216 | state: &RepositoryState, | ||
| 217 | ref_update: &RefUpdate, | ||
| 218 | ) -> Result<()> | ||
| 219 | ``` | ||
| 220 | |||
| 221 | ### 3. Nostr Module (`src/nostr/`) | ||
| 222 | |||
| 223 | #### `relay.rs` - Relay Configuration | ||
| 224 | |||
| 225 | ```rust | ||
| 226 | pub async fn build_relay(config: &Config) -> Result<LocalRelay> { | ||
| 227 | let builder = RelayBuilder::default() | ||
| 228 | .write_policy(RepositoryAnnouncementPolicy::new(config.domain.clone())) | ||
| 229 | .write_policy(RelatedEventsPolicy::new()) | ||
| 230 | .query_policy(StandardQueryPolicy::new()) | ||
| 231 | .on_event_saved(create_repository_hook(config.git_data_path.clone())); | ||
| 232 | |||
| 233 | // Configure storage backend (LMDB or NDB) | ||
| 234 | let relay = LocalRelay::run(builder).await?; | ||
| 235 | |||
| 236 | Ok(relay) | ||
| 237 | } | ||
| 238 | ``` | ||
| 239 | |||
| 240 | #### `events.rs` - Event Handlers | ||
| 241 | |||
| 242 | ```rust | ||
| 243 | /// Hook called when events are saved | ||
| 244 | pub fn create_repository_hook( | ||
| 245 | git_data_path: PathBuf, | ||
| 246 | ) -> impl Fn(&Event) -> BoxFuture<'static, ()> { | ||
| 247 | move |event: &Event| { | ||
| 248 | let git_path = git_data_path.clone(); | ||
| 249 | Box::pin(async move { | ||
| 250 | if event.kind == Kind::RepositoryAnnouncement { | ||
| 251 | handle_repository_announcement(event, &git_path).await; | ||
| 252 | } else if event.kind == Kind::RepositoryState { | ||
| 253 | handle_repository_state(event, &git_path).await; | ||
| 254 | } | ||
| 255 | }) | ||
| 256 | } | ||
| 257 | } | ||
| 258 | |||
| 259 | async fn handle_repository_announcement(event: &Event, git_path: &Path) { | ||
| 260 | // 1. Parse repository from event | ||
| 261 | // 2. Check if listed in clone and relays tags | ||
| 262 | // 3. Create empty bare Git repository | ||
| 263 | // 4. Configure uploadpack.allowTipSHA1InWant | ||
| 264 | // 5. Configure uploadpack.allowUnreachable | ||
| 265 | // 6. Configure http.receivepack | ||
| 266 | } | ||
| 267 | |||
| 268 | async fn handle_repository_state(event: &Event, git_path: &Path) { | ||
| 269 | // 1. Parse state from event | ||
| 270 | // 2. Update repository HEAD if needed | ||
| 271 | // 3. Trigger proactive sync (GRASP-02) | ||
| 272 | } | ||
| 273 | ``` | ||
| 274 | |||
| 275 | **Write Policies:** | ||
| 276 | |||
| 277 | ```rust | ||
| 278 | /// Accept repository announcements that list this instance | ||
| 279 | pub struct RepositoryAnnouncementPolicy { | ||
| 280 | domain: String, | ||
| 281 | } | ||
| 282 | |||
| 283 | impl WritePolicy for RepositoryAnnouncementPolicy { | ||
| 284 | fn admit_event(&self, event: &Event, _addr: &SocketAddr) | ||
| 285 | -> BoxFuture<PolicyResult> | ||
| 286 | { | ||
| 287 | Box::pin(async move { | ||
| 288 | if event.kind != Kind::RepositoryAnnouncement { | ||
| 289 | return PolicyResult::Accept; // Not our concern | ||
| 290 | } | ||
| 291 | |||
| 292 | // Check if this instance is in clone and relays tags | ||
| 293 | let has_clone = event.tags.iter() | ||
| 294 | .any(|t| t.kind() == "clone" && t.content() == Some(&self.domain)); | ||
| 295 | let has_relay = event.tags.iter() | ||
| 296 | .any(|t| t.kind() == "relays" && t.content() == Some(&self.domain)); | ||
| 297 | |||
| 298 | if has_clone && has_relay { | ||
| 299 | PolicyResult::Accept | ||
| 300 | } else { | ||
| 301 | PolicyResult::Reject("instance not listed in clone and relays".into()) | ||
| 302 | } | ||
| 303 | }) | ||
| 304 | } | ||
| 305 | } | ||
| 306 | |||
| 307 | /// Accept events related to stored announcements/issues/patches | ||
| 308 | pub struct RelatedEventsPolicy; | ||
| 309 | |||
| 310 | impl WritePolicy for RelatedEventsPolicy { | ||
| 311 | fn admit_event(&self, event: &Event, _addr: &SocketAddr) | ||
| 312 | -> BoxFuture<PolicyResult> | ||
| 313 | { | ||
| 314 | // Accept if event tags or is tagged by stored events | ||
| 315 | // Implementation requires querying the event store | ||
| 316 | } | ||
| 317 | } | ||
| 318 | ``` | ||
| 319 | |||
| 320 | ### 4. Storage Module (`src/storage/`) | ||
| 321 | |||
| 322 | #### `repository.rs` - Repository Management | ||
| 323 | |||
| 324 | ```rust | ||
| 325 | pub struct RepositoryManager { | ||
| 326 | git_data_path: PathBuf, | ||
| 327 | } | ||
| 328 | |||
| 329 | impl RepositoryManager { | ||
| 330 | /// Create a new bare Git repository | ||
| 331 | pub async fn create_repository( | ||
| 332 | &self, | ||
| 333 | npub: &str, | ||
| 334 | identifier: &str, | ||
| 335 | ) -> Result<PathBuf> { | ||
| 336 | let repo_path = self.git_data_path | ||
| 337 | .join(npub) | ||
| 338 | .join(format!("{}.git", identifier)); | ||
| 339 | |||
| 340 | // Create directory | ||
| 341 | tokio::fs::create_dir_all(&repo_path).await?; | ||
| 342 | |||
| 343 | // Initialize bare repo | ||
| 344 | Command::new("git") | ||
| 345 | .args(&["init", "--bare"]) | ||
| 346 | .arg(&repo_path) | ||
| 347 | .output() | ||
| 348 | .await?; | ||
| 349 | |||
| 350 | // Configure | ||
| 351 | self.configure_repository(&repo_path).await?; | ||
| 352 | |||
| 353 | Ok(repo_path) | ||
| 354 | } | ||
| 355 | |||
| 356 | async fn configure_repository(&self, repo_path: &Path) -> Result<()> { | ||
| 357 | // Enable unauthenticated push (we handle auth ourselves) | ||
| 358 | git_config(repo_path, "http.receivepack", "true").await?; | ||
| 359 | |||
| 360 | // Enable tip SHA1 fetching (required for ngit) | ||
| 361 | git_config(repo_path, "uploadpack.allowTipSHA1InWant", "true").await?; | ||
| 362 | |||
| 363 | // Enable unreachable object fetching | ||
| 364 | git_config(repo_path, "uploadpack.allowUnreachable", "true").await?; | ||
| 365 | |||
| 366 | Ok(()) | ||
| 367 | } | ||
| 368 | |||
| 369 | /// Check if repository exists | ||
| 370 | pub async fn repository_exists( | ||
| 371 | &self, | ||
| 372 | npub: &str, | ||
| 373 | identifier: &str, | ||
| 374 | ) -> bool { | ||
| 375 | let repo_path = self.git_data_path | ||
| 376 | .join(npub) | ||
| 377 | .join(format!("{}.git", identifier)); | ||
| 378 | |||
| 379 | repo_path.join("HEAD").exists() && | ||
| 380 | repo_path.join("config").exists() | ||
| 381 | } | ||
| 382 | } | ||
| 383 | ``` | ||
| 384 | |||
| 385 | ### 5. Configuration (`src/config.rs`) | ||
| 386 | |||
| 387 | ```rust | ||
| 388 | pub struct Config { | ||
| 389 | pub domain: String, | ||
| 390 | pub owner_npub: String, | ||
| 391 | pub relay_name: String, | ||
| 392 | pub relay_description: String, | ||
| 393 | pub git_data_path: PathBuf, | ||
| 394 | pub relay_data_path: PathBuf, | ||
| 395 | pub bind_address: SocketAddr, | ||
| 396 | pub log_level: String, | ||
| 397 | } | ||
| 398 | |||
| 399 | impl Config { | ||
| 400 | pub fn from_env() -> Result<Self> { | ||
| 401 | Ok(Config { | ||
| 402 | domain: env::var("NGIT_DOMAIN")?, | ||
| 403 | owner_npub: env::var("NGIT_OWNER_NPUB")?, | ||
| 404 | relay_name: env::var("NGIT_RELAY_NAME")?, | ||
| 405 | relay_description: env::var("NGIT_RELAY_DESCRIPTION")?, | ||
| 406 | git_data_path: PathBuf::from( | ||
| 407 | env::var("NGIT_GIT_DATA_PATH") | ||
| 408 | .unwrap_or_else(|_| "./data/git".to_string()) | ||
| 409 | ), | ||
| 410 | relay_data_path: PathBuf::from( | ||
| 411 | env::var("NGIT_RELAY_DATA_PATH") | ||
| 412 | .unwrap_or_else(|_| "./data/relay".to_string()) | ||
| 413 | ), | ||
| 414 | bind_address: env::var("NGIT_BIND_ADDRESS") | ||
| 415 | .unwrap_or_else(|_| "127.0.0.1:8080".to_string()) | ||
| 416 | .parse()?, | ||
| 417 | log_level: env::var("RUST_LOG") | ||
| 418 | .unwrap_or_else(|_| "info".to_string()), | ||
| 419 | }) | ||
| 420 | } | ||
| 421 | } | ||
| 422 | ``` | ||
| 423 | |||
| 424 | ## Data Flow | ||
| 425 | |||
| 426 | ### Push Operation Flow | ||
| 427 | |||
| 428 | ``` | ||
| 429 | 1. Git Client → POST /<npub>/<id>.git/git-receive-pack | ||
| 430 | ↓ | ||
| 431 | 2. git_receive_pack handler receives request | ||
| 432 | ↓ | ||
| 433 | 3. Parse ref updates from request body | ||
| 434 | ↓ | ||
| 435 | 4. Extract npub and identifier from URL | ||
| 436 | ↓ | ||
| 437 | 5. PushValidator::validate_push() | ||
| 438 | ├─ Fetch events from local Nostr relay | ||
| 439 | ├─ Get maintainers recursively | ||
| 440 | ├─ Get latest state from maintainers | ||
| 441 | └─ Validate each ref update | ||
| 442 | ↓ | ||
| 443 | 6. If VALID: | ||
| 444 | ├─ Spawn git-receive-pack subprocess | ||
| 445 | ├─ Stream request body to git stdin | ||
| 446 | └─ Stream git stdout back to client | ||
| 447 | ↓ | ||
| 448 | 7. If INVALID: | ||
| 449 | └─ Return HTTP 403 with error message | ||
| 450 | ``` | ||
| 451 | |||
| 452 | ### Repository Announcement Flow | ||
| 453 | |||
| 454 | ``` | ||
| 455 | 1. Nostr Client → EVENT (Kind 30317) | ||
| 456 | ↓ | ||
| 457 | 2. Nostr relay receives event | ||
| 458 | ↓ | ||
| 459 | 3. RepositoryAnnouncementPolicy::admit_event() | ||
| 460 | ├─ Check if instance in clone tags | ||
| 461 | ├─ Check if instance in relays tags | ||
| 462 | └─ Accept or reject | ||
| 463 | ↓ | ||
| 464 | 4. If ACCEPTED: | ||
| 465 | ├─ Event saved to store | ||
| 466 | └─ on_event_saved hook triggered | ||
| 467 | ↓ | ||
| 468 | 5. handle_repository_announcement() | ||
| 469 | ├─ Parse repository details | ||
| 470 | ├─ Create Git repository directory | ||
| 471 | ├─ Initialize bare Git repo | ||
| 472 | └─ Configure Git settings | ||
| 473 | ``` | ||
| 474 | |||
| 475 | ## Key Implementation Details | ||
| 476 | |||
| 477 | ### 1. Parsing Git Receive-Pack Protocol | ||
| 478 | |||
| 479 | The Git receive-pack protocol uses a pkt-line format. We need to parse: | ||
| 480 | |||
| 481 | ``` | ||
| 482 | 0000-0000-0000-0000 0000-0000-0000-0000 refs/heads/main\0 report-status | ||
| 483 | 0000-0000-0000-0000 0000-0000-0000-0000 refs/heads/dev | ||
| 484 | ``` | ||
| 485 | |||
| 486 | Each line has: | ||
| 487 | - Old SHA (40 hex chars) | ||
| 488 | - Space | ||
| 489 | - New SHA (40 hex chars) | ||
| 490 | - Space | ||
| 491 | - Ref name | ||
| 492 | - Optional capabilities (first line only, after \0) | ||
| 493 | |||
| 494 | ```rust | ||
| 495 | pub struct RefUpdate { | ||
| 496 | pub old_sha: String, | ||
| 497 | pub new_sha: String, | ||
| 498 | pub ref_name: String, | ||
| 499 | } | ||
| 500 | |||
| 501 | pub fn parse_ref_updates(body: &[u8]) -> Result<Vec<RefUpdate>> { | ||
| 502 | // Parse pkt-line format | ||
| 503 | // Extract ref updates | ||
| 504 | // Return structured data | ||
| 505 | } | ||
| 506 | ``` | ||
| 507 | |||
| 508 | ### 2. Maintainer Recursion | ||
| 509 | |||
| 510 | The maintainer resolution must handle cycles and correctly build the set: | ||
| 511 | |||
| 512 | ```rust | ||
| 513 | fn get_maintainers_recursive( | ||
| 514 | events: &[Event], | ||
| 515 | pubkey: &str, | ||
| 516 | identifier: &str, | ||
| 517 | visited: &mut HashSet<String>, | ||
| 518 | ) -> HashSet<String> { | ||
| 519 | if visited.contains(pubkey) { | ||
| 520 | return HashSet::new(); | ||
| 521 | } | ||
| 522 | |||
| 523 | visited.insert(pubkey.to_string()); | ||
| 524 | |||
| 525 | let announcement = find_announcement(events, pubkey, identifier); | ||
| 526 | if announcement.is_none() { | ||
| 527 | return HashSet::new(); | ||
| 528 | } | ||
| 529 | |||
| 530 | let repo = parse_repository(announcement.unwrap()); | ||
| 531 | |||
| 532 | for maintainer in repo.maintainers { | ||
| 533 | get_maintainers_recursive(events, &maintainer, identifier, visited); | ||
| 534 | } | ||
| 535 | |||
| 536 | visited.clone() | ||
| 537 | } | ||
| 538 | ``` | ||
| 539 | |||
| 540 | ### 3. State Event Validation | ||
| 541 | |||
| 542 | ```rust | ||
| 543 | fn validate_state_ref( | ||
| 544 | state: &RepositoryState, | ||
| 545 | ref_update: &RefUpdate, | ||
| 546 | ) -> Result<()> { | ||
| 547 | if ref_update.ref_name.starts_with("refs/heads/") { | ||
| 548 | let branch_name = &ref_update.ref_name[11..]; | ||
| 549 | if let Some(commit) = state.branches.get(branch_name) { | ||
| 550 | if commit == &ref_update.new_sha { | ||
| 551 | return Ok(()); | ||
| 552 | } | ||
| 553 | return Err(Error::StateMismatch { | ||
| 554 | ref_name: ref_update.ref_name.clone(), | ||
| 555 | expected: commit.clone(), | ||
| 556 | got: ref_update.new_sha.clone(), | ||
| 557 | }); | ||
| 558 | } | ||
| 559 | return Err(Error::RefNotInState(ref_update.ref_name.clone())); | ||
| 560 | } | ||
| 561 | |||
| 562 | if ref_update.ref_name.starts_with("refs/tags/") { | ||
| 563 | let tag_name = &ref_update.ref_name[10..]; | ||
| 564 | if let Some(commit) = state.tags.get(tag_name) { | ||
| 565 | if commit == &ref_update.new_sha { | ||
| 566 | return Ok(()); | ||
| 567 | } | ||
| 568 | return Err(Error::StateMismatch { | ||
| 569 | ref_name: ref_update.ref_name.clone(), | ||
| 570 | expected: commit.clone(), | ||
| 571 | got: ref_update.new_sha.clone(), | ||
| 572 | }); | ||
| 573 | } | ||
| 574 | return Err(Error::RefNotInState(ref_update.ref_name.clone())); | ||
| 575 | } | ||
| 576 | |||
| 577 | Err(Error::InvalidRef(ref_update.ref_name.clone())) | ||
| 578 | } | ||
| 579 | ``` | ||
| 580 | |||
| 581 | ### 4. CORS Support | ||
| 582 | |||
| 583 | As per GRASP-01, we must support CORS: | ||
| 584 | |||
| 585 | ```rust | ||
| 586 | use actix_cors::Cors; | ||
| 587 | |||
| 588 | fn configure_cors() -> Cors { | ||
| 589 | Cors::default() | ||
| 590 | .allow_any_origin() | ||
| 591 | .allowed_methods(vec!["GET", "POST", "OPTIONS"]) | ||
| 592 | .allowed_headers(vec!["Content-Type"]) | ||
| 593 | .max_age(3600) | ||
| 594 | } | ||
| 595 | |||
| 596 | // In main.rs | ||
| 597 | App::new() | ||
| 598 | .wrap(configure_cors()) | ||
| 599 | .configure(git_routes) | ||
| 600 | .configure(nostr_routes) | ||
| 601 | ``` | ||
| 602 | |||
| 603 | ## Testing Strategy | ||
| 604 | |||
| 605 | See [TEST_STRATEGY.md](TEST_STRATEGY.md) for comprehensive testing documentation, including: | ||
| 606 | |||
| 607 | - **GRASP Compliance Testing Tool**: Reusable test suite that validates any GRASP implementation against the spec | ||
| 608 | - **Spec-Mirrored Tests**: Test structure matches GRASP protocol documents exactly | ||
| 609 | - **Clear Failure Messages**: Test failures cite exact spec lines (e.g., "GRASP-01:12-13") | ||
| 610 | - **Multiple Test Levels**: Unit, integration, compliance, and end-to-end tests | ||
| 611 | |||
| 612 | ### Quick Overview | ||
| 613 | |||
| 614 | ```rust | ||
| 615 | // Unit Tests - Individual functions | ||
| 616 | #[test] | ||
| 617 | fn test_parse_ref_updates() { | ||
| 618 | let body = b"0000... 0000... refs/heads/main\0report-status\n"; | ||
| 619 | let updates = parse_ref_updates(body).unwrap(); | ||
| 620 | assert_eq!(updates.len(), 1); | ||
| 621 | assert_eq!(updates[0].ref_name, "refs/heads/main"); | ||
| 622 | } | ||
| 623 | |||
| 624 | // Integration Tests - Component interaction | ||
| 625 | #[tokio::test] | ||
| 626 | async fn test_full_push_flow() { | ||
| 627 | let app = test_app().await; | ||
| 628 | let (announcement, state) = app.create_repo_with_state() | ||
| 629 | .branch("main", "commit-123") | ||
| 630 | .build() | ||
| 631 | .await; | ||
| 632 | |||
| 633 | let result = app.git_push("main", "commit-123").await; | ||
| 634 | assert!(result.success); | ||
| 635 | } | ||
| 636 | |||
| 637 | // Compliance Tests - GRASP spec validation | ||
| 638 | #[tokio::test] | ||
| 639 | async fn test_grasp_01_compliance() { | ||
| 640 | use grasp_compliance_tests::{TestContext, Grasp01Spec}; | ||
| 641 | |||
| 642 | let ctx = TestContext::builder() | ||
| 643 | .base_url(&server.url()) | ||
| 644 | .build(); | ||
| 645 | |||
| 646 | let results = Grasp01Spec::test_compliance(&ctx).await; | ||
| 647 | assert!(results.all_passed(), "{}", results.report()); | ||
| 648 | } | ||
| 649 | ``` | ||
| 650 | |||
| 651 | The compliance testing tool is designed as a **standalone crate** that can be: | ||
| 652 | - Used by ngit-grasp for self-validation | ||
| 653 | - Published for other GRASP implementations to use | ||
| 654 | - Updated as new GRASP specs are released | ||
| 655 | - Run in CI/CD for continuous compliance verification | ||
| 656 | |||
| 657 | ## Performance Considerations | ||
| 658 | |||
| 659 | ### 1. Async All The Way | ||
| 660 | |||
| 661 | - Use `tokio` for all I/O | ||
| 662 | - Non-blocking Git subprocess spawning | ||
| 663 | - Stream large pack files without buffering | ||
| 664 | |||
| 665 | ### 2. Connection Pooling | ||
| 666 | |||
| 667 | - Reuse Nostr relay connections | ||
| 668 | - Connection pool for internal relay queries | ||
| 669 | |||
| 670 | ### 3. Caching | ||
| 671 | |||
| 672 | - Cache parsed state events (with TTL) | ||
| 673 | - Cache maintainer sets | ||
| 674 | - Invalidate on new state events | ||
| 675 | |||
| 676 | ```rust | ||
| 677 | pub struct StateCache { | ||
| 678 | cache: Arc<RwLock<HashMap<String, CachedState>>>, | ||
| 679 | } | ||
| 680 | |||
| 681 | struct CachedState { | ||
| 682 | state: RepositoryState, | ||
| 683 | maintainers: Vec<String>, | ||
| 684 | timestamp: Instant, | ||
| 685 | } | ||
| 686 | |||
| 687 | impl StateCache { | ||
| 688 | pub async fn get_or_fetch( | ||
| 689 | &self, | ||
| 690 | identifier: &str, | ||
| 691 | fetcher: impl Future<Output = Result<(RepositoryState, Vec<String>)>>, | ||
| 692 | ) -> Result<(RepositoryState, Vec<String>)> { | ||
| 693 | // Check cache | ||
| 694 | // Return if fresh | ||
| 695 | // Otherwise fetch and cache | ||
| 696 | } | ||
| 697 | } | ||
| 698 | ``` | ||
| 699 | |||
| 700 | ## Future Extensions | ||
| 701 | |||
| 702 | ### GRASP-02: Proactive Sync | ||
| 703 | |||
| 704 | Add background tasks: | ||
| 705 | |||
| 706 | ```rust | ||
| 707 | pub struct ProactiveSyncTask { | ||
| 708 | relay_client: Client, | ||
| 709 | git_manager: RepositoryManager, | ||
| 710 | } | ||
| 711 | |||
| 712 | impl ProactiveSyncTask { | ||
| 713 | pub async fn run(&self) { | ||
| 714 | loop { | ||
| 715 | tokio::time::sleep(Duration::from_secs(3600)).await; | ||
| 716 | |||
| 717 | // Fetch all announcements from our relay | ||
| 718 | let announcements = self.fetch_announcements().await; | ||
| 719 | |||
| 720 | for ann in announcements { | ||
| 721 | // Sync events from listed relays | ||
| 722 | self.sync_events(&ann).await; | ||
| 723 | |||
| 724 | // Sync git data from listed clones | ||
| 725 | self.sync_git_data(&ann).await; | ||
| 726 | |||
| 727 | // Fetch PR data | ||
| 728 | self.sync_pr_data(&ann).await; | ||
| 729 | } | ||
| 730 | } | ||
| 731 | } | ||
| 732 | } | ||
| 733 | ``` | ||
| 734 | |||
| 735 | ### GRASP-05: Archive | ||
| 736 | |||
| 737 | Relax the policy: | ||
| 738 | |||
| 739 | ```rust | ||
| 740 | pub struct ArchiveAnnouncementPolicy; | ||
| 741 | |||
| 742 | impl WritePolicy for ArchiveAnnouncementPolicy { | ||
| 743 | fn admit_event(&self, event: &Event, _addr: &SocketAddr) | ||
| 744 | -> BoxFuture<PolicyResult> | ||
| 745 | { | ||
| 746 | // Accept all repository announcements | ||
| 747 | // Don't check clone/relays tags | ||
| 748 | PolicyResult::Accept | ||
| 749 | } | ||
| 750 | } | ||
| 751 | ``` | ||
| 752 | |||
| 753 | ## Deployment | ||
| 754 | |||
| 755 | ### Single Binary | ||
| 756 | |||
| 757 | ```bash | ||
| 758 | cargo build --release | ||
| 759 | ./target/release/ngit-grasp | ||
| 760 | ``` | ||
| 761 | |||
| 762 | ### Docker | ||
| 763 | |||
| 764 | ```dockerfile | ||
| 765 | FROM rust:1.75 as builder | ||
| 766 | WORKDIR /app | ||
| 767 | COPY . . | ||
| 768 | RUN cargo build --release | ||
| 769 | |||
| 770 | FROM debian:bookworm-slim | ||
| 771 | RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* | ||
| 772 | COPY --from=builder /app/target/release/ngit-grasp /usr/local/bin/ | ||
| 773 | EXPOSE 8080 | ||
| 774 | CMD ["ngit-grasp"] | ||
| 775 | ``` | ||
| 776 | |||
| 777 | ### Systemd | ||
| 778 | |||
| 779 | ```ini | ||
| 780 | [Unit] | ||
| 781 | Description=ngit-grasp GRASP server | ||
| 782 | After=network.target | ||
| 783 | |||
| 784 | [Service] | ||
| 785 | Type=simple | ||
| 786 | User=git | ||
| 787 | WorkingDirectory=/opt/ngit-grasp | ||
| 788 | EnvironmentFile=/opt/ngit-grasp/.env | ||
| 789 | ExecStart=/usr/local/bin/ngit-grasp | ||
| 790 | Restart=on-failure | ||
| 791 | |||
| 792 | [Install] | ||
| 793 | WantedBy=multi-user.target | ||
| 794 | ``` | ||
| 795 | |||
| 796 | ## Security Considerations | ||
| 797 | |||
| 798 | 1. **Input Validation**: All npub/identifier inputs must be validated | ||
| 799 | 2. **Path Traversal**: Prevent directory traversal in repository paths | ||
| 800 | 3. **DoS Protection**: Rate limiting on both HTTP and WebSocket | ||
| 801 | 4. **Resource Limits**: Limit pack file sizes, event sizes | ||
| 802 | 5. **Nostr Event Validation**: Strict signature verification | ||
| 803 | |||
| 804 | ## Conclusion | ||
| 805 | |||
| 806 | The inline authorization approach provides a cleaner, more maintainable architecture than hook-based authorization while maintaining full GRASP-01 compliance. The Rust ecosystem provides excellent libraries for both Git and Nostr protocols, enabling a high-performance, type-safe implementation. | ||
| 807 | |||
| 808 | The key insight is that we don't need to rely on Git's hook mechanism when we have full control over the HTTP layer that Git operates through. By intercepting at the HTTP handler level, we gain better error handling, easier testing, and tighter integration between the Git and Nostr components. | ||
diff --git a/docs/explanation/comparison.md b/docs/explanation/comparison.md new file mode 100644 index 0000000..be16f9e --- /dev/null +++ b/docs/explanation/comparison.md | |||
| @@ -0,0 +1,256 @@ | |||
| 1 | # ngit-grasp vs ngit-relay Comparison | ||
| 2 | |||
| 3 | ## High-Level Comparison | ||
| 4 | |||
| 5 | | Aspect | ngit-relay (Reference) | ngit-grasp (This Project) | | ||
| 6 | |--------|------------------------|---------------------------| | ||
| 7 | | **Language** | Go | Rust | | ||
| 8 | | **Architecture** | Multi-process (nginx, git-http-backend, hooks, relay) | Single integrated process | | ||
| 9 | | **Authorization** | Git pre-receive hook | Inline HTTP handler | | ||
| 10 | | **Packaging** | Docker + supervisord | Single binary or Docker | | ||
| 11 | | **Configuration** | Multiple config files | Environment variables | | ||
| 12 | | **Deployment** | Docker Compose | Binary or Docker | | ||
| 13 | | **Testing** | Go tests + shell scripts | Rust unit + integration tests | | ||
| 14 | |||
| 15 | ## Component Breakdown | ||
| 16 | |||
| 17 | ### ngit-relay (Go) | ||
| 18 | |||
| 19 | ``` | ||
| 20 | ┌─────────────────────────────────────────────────┐ | ||
| 21 | │ Docker Container │ | ||
| 22 | ├─────────────────────────────────────────────────┤ | ||
| 23 | │ │ | ||
| 24 | │ ┌──────────┐ ┌─────────────────────┐ │ | ||
| 25 | │ │ nginx │────────▶│ git-http-backend │ │ | ||
| 26 | │ │ :80 │ │ (C binary) │ │ | ||
| 27 | │ └──────────┘ └──────────┬──────────┘ │ | ||
| 28 | │ │ │ │ | ||
| 29 | │ │ ▼ │ | ||
| 30 | │ │ ┌─────────────────┐ │ | ||
| 31 | │ │ │ Git Repo │ │ | ||
| 32 | │ │ │ + Hooks │ │ | ||
| 33 | │ │ └────────┬────────┘ │ | ||
| 34 | │ │ │ │ | ||
| 35 | │ │ ▼ │ | ||
| 36 | │ │ ┌─────────────────┐ │ | ||
| 37 | │ │ │ pre-receive │ │ | ||
| 38 | │ │ │ (Go binary) │ │ | ||
| 39 | │ │ └────────┬────────┘ │ | ||
| 40 | │ │ │ │ | ||
| 41 | │ │ │ WebSocket │ | ||
| 42 | │ │ ▼ │ | ||
| 43 | │ │ ┌─────────────────┐ │ | ||
| 44 | │ └─────────────────▶│ Khatru Relay │ │ | ||
| 45 | │ │ (Go) │ │ | ||
| 46 | │ └─────────────────┘ │ | ||
| 47 | │ │ | ||
| 48 | │ ┌──────────────────────────────────────────┐ │ | ||
| 49 | │ │ supervisord │ │ | ||
| 50 | │ │ - nginx │ │ | ||
| 51 | │ │ - khatru │ │ | ||
| 52 | │ │ - proactive-sync │ │ | ||
| 53 | │ └──────────────────────────────────────────┘ │ | ||
| 54 | │ │ | ||
| 55 | └─────────────────────────────────────────────────┘ | ||
| 56 | ``` | ||
| 57 | |||
| 58 | ### ngit-grasp (Rust) | ||
| 59 | |||
| 60 | ``` | ||
| 61 | ┌─────────────────────────────────────────────────┐ | ||
| 62 | │ ngit-grasp (Single Binary) │ | ||
| 63 | ├─────────────────────────────────────────────────┤ | ||
| 64 | │ │ | ||
| 65 | │ ┌──────────────────────────────────────────┐ │ | ||
| 66 | │ │ actix-web HTTP Server │ │ | ||
| 67 | │ │ :8080 │ │ | ||
| 68 | │ └───────┬──────────────────────┬────────────┘ │ | ||
| 69 | │ │ │ │ | ||
| 70 | │ ▼ ▼ │ | ||
| 71 | │ ┌──────────────┐ ┌──────────────────┐ │ | ||
| 72 | │ │ Git Handlers │ │ Nostr Relay │ │ | ||
| 73 | │ │ │ │ (relay-builder) │ │ | ||
| 74 | │ │ - upload-pk │ │ │ │ | ||
| 75 | │ │ - receive-pk │◀─────│ - Policies │ │ | ||
| 76 | │ │ + inline │ query│ - Event store │ │ | ||
| 77 | │ │ validation │ │ - WebSocket │ │ | ||
| 78 | │ └──────┬───────┘ └──────────────────┘ │ | ||
| 79 | │ │ │ | ||
| 80 | │ ▼ │ | ||
| 81 | │ ┌──────────────┐ │ | ||
| 82 | │ │ Git Repos │ │ | ||
| 83 | │ │ (spawned │ │ | ||
| 84 | │ │ git cmds) │ │ | ||
| 85 | │ └──────────────┘ │ | ||
| 86 | │ │ | ||
| 87 | │ ┌──────────────────────────────────────────┐ │ | ||
| 88 | │ │ Shared State (Arc<AppState>) │ │ | ||
| 89 | │ │ - RepositoryManager │ │ | ||
| 90 | │ │ - NostrClient │ │ | ||
| 91 | │ │ - StateCache │ │ | ||
| 92 | │ └──────────────────────────────────────────┘ │ | ||
| 93 | │ │ | ||
| 94 | └─────────────────────────────────────────────────┘ | ||
| 95 | ``` | ||
| 96 | |||
| 97 | ## Detailed Feature Comparison | ||
| 98 | |||
| 99 | ### Git Protocol Handling | ||
| 100 | |||
| 101 | | Feature | ngit-relay | ngit-grasp | | ||
| 102 | |---------|-----------|-----------| | ||
| 103 | | Implementation | git-http-backend (C) | git-http-backend (Rust crate) | | ||
| 104 | | Process model | nginx → C binary | actix-web → Rust handler | | ||
| 105 | | Upload pack | Passthrough | Passthrough with validation | | ||
| 106 | | Receive pack | Hook-based auth | Inline validation | | ||
| 107 | | Error handling | Hook stderr | HTTP response | | ||
| 108 | | CORS | nginx config | actix-cors middleware | | ||
| 109 | |||
| 110 | ### Nostr Relay | ||
| 111 | |||
| 112 | | Feature | ngit-relay | ngit-grasp | | ||
| 113 | |---------|-----------|-----------| | ||
| 114 | | Implementation | Khatru (Go) | nostr-relay-builder (Rust) | | ||
| 115 | | Event store | Badger (Go) | LMDB or NDB (Rust) | | ||
| 116 | | Policies | Go functions | Rust traits | | ||
| 117 | | WebSocket | Khatru built-in | nostr-relay-builder | | ||
| 118 | | NIP-11 | Manual JSON | Built-in support | | ||
| 119 | |||
| 120 | ### Authorization Logic | ||
| 121 | |||
| 122 | | Feature | ngit-relay | ngit-grasp | | ||
| 123 | |---------|-----------|-----------| | ||
| 124 | | Location | pre-receive hook | HTTP handler | | ||
| 125 | | Language | Go | Rust | | ||
| 126 | | State query | WebSocket to localhost:3334 | In-process function call | | ||
| 127 | | Error reporting | stderr → git client | HTTP response body | | ||
| 128 | | Ref validation | Line-by-line stdin | Parsed from request body | | ||
| 129 | | Maintainer resolution | Recursive Go function | Recursive Rust function | | ||
| 130 | | State caching | Per-request | Shared cache with TTL | | ||
| 131 | |||
| 132 | ### Repository Management | ||
| 133 | |||
| 134 | | Feature | ngit-relay | ngit-grasp | | ||
| 135 | |---------|-----------|-----------| | ||
| 136 | | Creation | Event hook + shell commands | Event hook + tokio::process | | ||
| 137 | | Configuration | git config via shell | git config via tokio::process | | ||
| 138 | | Hook installation | Symlinks | Not needed (inline auth) | | ||
| 139 | | Permissions | chown nginx:nginx | tokio::fs permissions | | ||
| 140 | | Path structure | `<npub>/<id>.git` | `<npub>/<id>.git` (same) | | ||
| 141 | |||
| 142 | ### Deployment | ||
| 143 | |||
| 144 | | Feature | ngit-relay | ngit-grasp | | ||
| 145 | |---------|-----------|-----------| | ||
| 146 | | Dependencies | nginx, git, Go runtime | git, Rust binary (no runtime) | | ||
| 147 | | Process management | supervisord | Single process (tokio) | | ||
| 148 | | Configuration | Multiple files + .env | .env only | | ||
| 149 | | Docker image size | ~500MB (Alpine + tools) | ~50MB (scratch + binary + git) | | ||
| 150 | | Startup time | ~2-5 seconds | ~0.5 seconds | | ||
| 151 | | Memory usage | ~100-200MB (multiple processes) | ~50-100MB (single process) | | ||
| 152 | |||
| 153 | ### Development Experience | ||
| 154 | |||
| 155 | | Feature | ngit-relay | ngit-grasp | | ||
| 156 | |---------|-----------|-----------| | ||
| 157 | | Build time | Fast (Go) | Medium (Rust first build, then fast) | | ||
| 158 | | Type safety | Go (good) | Rust (excellent) | | ||
| 159 | | Testing | Go test + shell | Rust test (unit + integration) | | ||
| 160 | | Debugging | Multiple processes | Single process | | ||
| 161 | | Hot reload | Manual | cargo-watch | | ||
| 162 | | IDE support | Good (Go) | Excellent (rust-analyzer) | | ||
| 163 | |||
| 164 | ## Performance Comparison (Estimated) | ||
| 165 | |||
| 166 | | Metric | ngit-relay | ngit-grasp | Notes | | ||
| 167 | |--------|-----------|-----------|-------| | ||
| 168 | | Startup | ~2-5s | ~0.5s | Fewer processes | | ||
| 169 | | Memory | ~150MB | ~75MB | Single process, no GC | | ||
| 170 | | CPU (idle) | ~1-2% | ~0.5% | Fewer processes | | ||
| 171 | | Push latency | +50-100ms | +10-20ms | No hook spawn overhead | | ||
| 172 | | Clone latency | ~same | ~same | Both passthrough to Git | | ||
| 173 | | Concurrent pushes | Good | Excellent | Tokio async vs goroutines | | ||
| 174 | | Event ingestion | Good | Excellent | Rust async + zero-copy | | ||
| 175 | |||
| 176 | *Note: These are estimates. Actual performance depends on workload and hardware.* | ||
| 177 | |||
| 178 | ## Code Complexity | ||
| 179 | |||
| 180 | ### Lines of Code (Estimated) | ||
| 181 | |||
| 182 | | Component | ngit-relay | ngit-grasp | | ||
| 183 | |-----------|-----------|-----------| | ||
| 184 | | Main server | ~150 | ~200 | | ||
| 185 | | Git handlers | ~0 (C binary) | ~500 | | ||
| 186 | | Auth logic | ~200 | ~300 | | ||
| 187 | | Nostr relay | ~500 | ~100 (using library) | | ||
| 188 | | Shared utils | ~300 | ~200 | | ||
| 189 | | Config/setup | ~200 | ~100 | | ||
| 190 | | **Total** | **~1,350** | **~1,400** | | ||
| 191 | |||
| 192 | Similar complexity, but ngit-grasp has: | ||
| 193 | - More Git protocol code (we implement it) | ||
| 194 | - Less Nostr relay code (using library) | ||
| 195 | - Less deployment code (no hooks/supervisord) | ||
| 196 | |||
| 197 | ## Migration Path | ||
| 198 | |||
| 199 | For users of ngit-relay, migration to ngit-grasp would involve: | ||
| 200 | |||
| 201 | 1. **Export data** from Badger to LMDB/NDB | ||
| 202 | 2. **Copy Git repositories** (same structure) | ||
| 203 | 3. **Update environment variables** (mostly compatible) | ||
| 204 | 4. **Change deployment** from Docker Compose to binary/Docker | ||
| 205 | 5. **Update URLs** if domain changes | ||
| 206 | |||
| 207 | The **Nostr events** and **Git data** are compatible - only the server changes. | ||
| 208 | |||
| 209 | ## When to Choose Each | ||
| 210 | |||
| 211 | ### Choose ngit-relay (Reference) if: | ||
| 212 | |||
| 213 | - ✅ You need a proven, production-tested implementation | ||
| 214 | - ✅ You're already familiar with Go | ||
| 215 | - ✅ You want to stay close to the reference | ||
| 216 | - ✅ You need to deploy immediately | ||
| 217 | - ✅ You prefer Docker Compose workflows | ||
| 218 | |||
| 219 | ### Choose ngit-grasp (This Project) if: | ||
| 220 | |||
| 221 | - ✅ You want better performance and lower resource usage | ||
| 222 | - ✅ You prefer Rust's type safety and ecosystem | ||
| 223 | - ✅ You want simpler deployment (single binary) | ||
| 224 | - ✅ You want to contribute to a modern codebase | ||
| 225 | - ✅ You're building on top of the GRASP protocol | ||
| 226 | - ✅ You want inline authorization over hooks | ||
| 227 | - ✅ You need better integration testing | ||
| 228 | |||
| 229 | ## Future Roadmap Comparison | ||
| 230 | |||
| 231 | ### ngit-relay (Reference) | ||
| 232 | - ✅ GRASP-01 complete | ||
| 233 | - 🔄 GRASP-02 in progress | ||
| 234 | - ⏭️ GRASP-05 planned | ||
| 235 | - ⏭️ NIP-42 auth-to-read | ||
| 236 | - ⏭️ NIP-70 protected events | ||
| 237 | - ⏭️ Spam prevention | ||
| 238 | |||
| 239 | ### ngit-grasp (This Project) | ||
| 240 | - 🔄 GRASP-01 in development | ||
| 241 | - ⏭️ GRASP-02 planned (easier with Rust async) | ||
| 242 | - ⏭️ GRASP-05 planned | ||
| 243 | - ⏭️ Advanced caching strategies | ||
| 244 | - ⏭️ Metrics and observability | ||
| 245 | - ⏭️ Plugin system for custom policies | ||
| 246 | |||
| 247 | ## Conclusion | ||
| 248 | |||
| 249 | Both implementations are valid approaches to GRASP: | ||
| 250 | |||
| 251 | - **ngit-relay** is the mature, proven reference implementation | ||
| 252 | - **ngit-grasp** is a modern, performant alternative with better DX | ||
| 253 | |||
| 254 | The choice depends on your priorities: stability vs. performance, familiarity vs. innovation, proven vs. cutting-edge. | ||
| 255 | |||
| 256 | For new deployments where performance and simplicity matter, **ngit-grasp** is the recommended choice. For production systems requiring maximum stability, **ngit-relay** is the safer bet until ngit-grasp reaches maturity. | ||
diff --git a/docs/explanation/decisions.md b/docs/explanation/decisions.md new file mode 100644 index 0000000..e9b7422 --- /dev/null +++ b/docs/explanation/decisions.md | |||
| @@ -0,0 +1,174 @@ | |||
| 1 | # Architecture Decision Summary | ||
| 2 | |||
| 3 | ## Question: Pre-receive Hook vs. Inline Authorization? | ||
| 4 | |||
| 5 | After investigating the `git-http-backend` Rust crate and the reference implementation, we have determined that **inline authorization is both pragmatic and superior**. | ||
| 6 | |||
| 7 | ## Investigation Findings | ||
| 8 | |||
| 9 | ### git-http-backend Crate Analysis | ||
| 10 | |||
| 11 | The `git-http-backend` crate (v0.1.3) provides: | ||
| 12 | |||
| 13 | 1. **Low-level Git protocol handling** via actix-web handlers | ||
| 14 | 2. **Process spawning** of `git-receive-pack` and `git-upload-pack` | ||
| 15 | 3. **Stream-based I/O** between HTTP and Git processes | ||
| 16 | 4. **Flexible path rewriting** through the `GitConfig` trait | ||
| 17 | |||
| 18 | **Key Finding**: The crate spawns Git as a subprocess in `git_receive_pack.rs`. We can intercept **before** this spawn happens. | ||
| 19 | |||
| 20 | ### Reference Implementation (ngit-relay) Analysis | ||
| 21 | |||
| 22 | The Go-based reference uses: | ||
| 23 | |||
| 24 | 1. **nginx** as HTTP frontend | ||
| 25 | 2. **git-http-backend** (C binary) for Git protocol | ||
| 26 | 3. **Pre-receive hook** (Go binary) for authorization | ||
| 27 | 4. **Khatru** (Go) for Nostr relay | ||
| 28 | 5. **supervisord** for process management | ||
| 29 | 6. **Docker** for packaging | ||
| 30 | |||
| 31 | The pre-receive hook: | ||
| 32 | - Reads ref updates from stdin | ||
| 33 | - Queries local Nostr relay via WebSocket | ||
| 34 | - Validates each ref against state events | ||
| 35 | - Exits with 0 (accept) or 1 (reject) | ||
| 36 | - Errors printed to stderr appear as `remote:` messages in git client | ||
| 37 | |||
| 38 | ## Decision: Inline Authorization ✅ | ||
| 39 | |||
| 40 | ### Why This Is Pragmatic | ||
| 41 | |||
| 42 | 1. **The crate supports it**: We can implement a custom `git_receive_pack` handler that validates before spawning Git | ||
| 43 | 2. **Better error handling**: Direct HTTP responses vs. parsing hook stderr | ||
| 44 | 3. **Simpler deployment**: Single binary, no hook management | ||
| 45 | 4. **Easier testing**: Pure Rust unit tests, no shell scripts | ||
| 46 | 5. **Performance**: Avoid spawning Git for invalid pushes | ||
| 47 | 6. **Type safety**: Share types between Git and Nostr modules | ||
| 48 | |||
| 49 | ### Implementation Approach | ||
| 50 | |||
| 51 | ```rust | ||
| 52 | // Instead of using git-http-backend's handler as-is: | ||
| 53 | pub async fn git_receive_pack( | ||
| 54 | req: HttpRequest, | ||
| 55 | body: web::Payload, | ||
| 56 | state: web::Data<AppState>, | ||
| 57 | ) -> Result<HttpResponse> { | ||
| 58 | // 1. Parse repository path from URL | ||
| 59 | let (npub, identifier) = parse_repo_path(&req)?; | ||
| 60 | |||
| 61 | // 2. Buffer enough of the request to parse ref updates | ||
| 62 | let ref_updates = parse_ref_updates(&body).await?; | ||
| 63 | |||
| 64 | // 3. VALIDATE AGAINST NOSTR STATE | ||
| 65 | let validator = PushValidator::new(&state.nostr_client); | ||
| 66 | match validator.validate_push(&npub, &identifier, &ref_updates).await { | ||
| 67 | Ok(_) => { | ||
| 68 | // 4. Valid! Spawn git-receive-pack and stream | ||
| 69 | spawn_git_receive_pack(req, body, state).await | ||
| 70 | } | ||
| 71 | Err(e) => { | ||
| 72 | // 5. Invalid! Return HTTP error | ||
| 73 | Ok(HttpResponse::Forbidden() | ||
| 74 | .body(format!("Push rejected: {}", e))) | ||
| 75 | } | ||
| 76 | } | ||
| 77 | } | ||
| 78 | ``` | ||
| 79 | |||
| 80 | ### Advantages Over Hooks | ||
| 81 | |||
| 82 | | Aspect | Pre-receive Hook | Inline Authorization | | ||
| 83 | |--------|------------------|---------------------| | ||
| 84 | | Error messages | Via stderr, prefixed with `remote:` | Direct HTTP response body | | ||
| 85 | | Testing | Requires Git repo setup | Pure Rust unit tests | | ||
| 86 | | Debugging | Hook logs separate from server | Unified logging | | ||
| 87 | | Deployment | Symlinks, permissions, hook scripts | Single binary | | ||
| 88 | | Performance | Always spawn Git | Skip Git for invalid pushes | | ||
| 89 | | State sharing | IPC or network | Direct memory access | | ||
| 90 | | Type safety | Separate binaries | Shared Rust types | | ||
| 91 | |||
| 92 | ### Potential Concerns & Mitigations | ||
| 93 | |||
| 94 | **Concern**: "What if we need to validate the actual pack data, not just refs?" | ||
| 95 | |||
| 96 | **Mitigation**: We can still do this inline! Parse the pack stream before forwarding to Git. The `git-http-backend` crate already buffers the request body. | ||
| 97 | |||
| 98 | **Concern**: "Doesn't Git expect hooks for certain operations?" | ||
| 99 | |||
| 100 | **Mitigation**: We're not eliminating hooks entirely. Post-receive hooks might still be useful for notifications. We're just moving *authorization* out of hooks. | ||
| 101 | |||
| 102 | **Concern**: "What about compatibility with standard Git setups?" | ||
| 103 | |||
| 104 | **Mitigation**: The Git Smart HTTP protocol is standardized. Our inline validation is transparent to clients. We're still using real Git repositories and spawning real `git-receive-pack`. | ||
| 105 | |||
| 106 | ## Comparison with Reference Implementation | ||
| 107 | |||
| 108 | ### Reference (ngit-relay) | ||
| 109 | ``` | ||
| 110 | Client → nginx → git-http-backend → Git → pre-receive hook → validate → accept/reject | ||
| 111 | ↓ | ||
| 112 | Query Nostr relay (WebSocket) | ||
| 113 | ``` | ||
| 114 | |||
| 115 | ### Our Approach (ngit-grasp) | ||
| 116 | ``` | ||
| 117 | Client → actix-web → validate → Git → accept | ||
| 118 | ↓ | ||
| 119 | Query Nostr relay (in-process) | ||
| 120 | ↓ | ||
| 121 | reject ← return HTTP error | ||
| 122 | ``` | ||
| 123 | |||
| 124 | ## Implementation Complexity | ||
| 125 | |||
| 126 | ### Hook-based (if we went that route) | ||
| 127 | - ✅ Simpler: Follow reference implementation | ||
| 128 | - ❌ More components: Hook binaries, symlinks | ||
| 129 | - ❌ More complex testing: Need Git repos, shell scripts | ||
| 130 | - ❌ More complex deployment: Hook installation, permissions | ||
| 131 | |||
| 132 | ### Inline (our choice) | ||
| 133 | - ❌ More complex: Custom Git protocol handling | ||
| 134 | - ✅ Fewer components: Single binary | ||
| 135 | - ✅ Simpler testing: Pure Rust | ||
| 136 | - ✅ Simpler deployment: Just run the binary | ||
| 137 | |||
| 138 | **Verdict**: Slightly more complex initially, but much simpler long-term. | ||
| 139 | |||
| 140 | ## Code Reuse from Reference | ||
| 141 | |||
| 142 | We can still reuse the **logic** from the reference implementation: | ||
| 143 | |||
| 144 | - Maintainer recursion algorithm | ||
| 145 | - State validation logic | ||
| 146 | - Event filtering policies | ||
| 147 | - Repository provisioning workflow | ||
| 148 | |||
| 149 | We're just implementing it in Rust within our HTTP handlers rather than in Git hooks. | ||
| 150 | |||
| 151 | ## Conclusion | ||
| 152 | |||
| 153 | **Inline authorization is both pragmatic and superior for a Rust implementation.** | ||
| 154 | |||
| 155 | The `git-http-backend` crate provides sufficient flexibility through its handler architecture. By intercepting at the HTTP layer, we gain: | ||
| 156 | |||
| 157 | 1. Better error handling and user experience | ||
| 158 | 2. Simpler deployment and operations | ||
| 159 | 3. Easier testing and debugging | ||
| 160 | 4. Better performance characteristics | ||
| 161 | 5. Tighter integration between components | ||
| 162 | |||
| 163 | The additional complexity of parsing the Git protocol is minimal compared to the benefits, and we're still using the standard Git binaries for the actual repository operations. | ||
| 164 | |||
| 165 | ## Next Steps | ||
| 166 | |||
| 167 | 1. ✅ Document architecture (this file + ARCHITECTURE.md) | ||
| 168 | 2. ⏭️ Set up project structure with Cargo workspace | ||
| 169 | 3. ⏭️ Implement core types (RefUpdate, RepositoryState, etc.) | ||
| 170 | 4. ⏭️ Implement Git protocol parsing | ||
| 171 | 5. ⏭️ Implement Nostr relay with policies | ||
| 172 | 6. ⏭️ Implement push validation logic | ||
| 173 | 7. ⏭️ Integration tests | ||
| 174 | 8. ⏭️ GRASP-01 compliance testing | ||
diff --git a/docs/explanation/inline-authorization.md b/docs/explanation/inline-authorization.md new file mode 100644 index 0000000..98f6e5a --- /dev/null +++ b/docs/explanation/inline-authorization.md | |||
| @@ -0,0 +1,403 @@ | |||
| 1 | # Explanation: Inline Authorization | ||
| 2 | |||
| 3 | **Purpose:** Understand why ngit-grasp validates Git pushes inline rather than using Git hooks | ||
| 4 | **Audience:** Developers and architects wanting to understand design decisions | ||
| 5 | |||
| 6 | --- | ||
| 7 | |||
| 8 | ## The Problem | ||
| 9 | |||
| 10 | Git hosting with authorization requires validating pushes before accepting them. The question is: **where** should this validation happen? | ||
| 11 | |||
| 12 | Two approaches exist: | ||
| 13 | |||
| 14 | 1. **Git Hooks** (traditional): Use Git's pre-receive hook mechanism | ||
| 15 | 2. **Inline Authorization** (our approach): Validate before spawning Git | ||
| 16 | |||
| 17 | This document explains why we chose inline authorization and what benefits it provides. | ||
| 18 | |||
| 19 | --- | ||
| 20 | |||
| 21 | ## Background: How Git Hooks Work | ||
| 22 | |||
| 23 | Git provides a **pre-receive hook** that runs during `git push`: | ||
| 24 | |||
| 25 | ``` | ||
| 26 | Client Server | ||
| 27 | | | | ||
| 28 | |--- git push ----->| | ||
| 29 | | |--- spawn git-receive-pack | ||
| 30 | | | | ||
| 31 | | |--- pre-receive hook runs | ||
| 32 | | | (reads stdin: old new ref) | ||
| 33 | | | (exit 0 = accept, 1 = reject) | ||
| 34 | | | | ||
| 35 | |<--- success ------| (if hook exits 0) | ||
| 36 | |<--- error --------| (if hook exits 1) | ||
| 37 | ``` | ||
| 38 | |||
| 39 | **Pros:** | ||
| 40 | - Standard Git mechanism | ||
| 41 | - Language-agnostic (hook can be any executable) | ||
| 42 | - Well-documented | ||
| 43 | |||
| 44 | **Cons:** | ||
| 45 | - Hook output goes to stderr (client sees as `remote:` messages) | ||
| 46 | - Hard to provide structured error messages | ||
| 47 | - Requires hook installation and management | ||
| 48 | - Difficult to test (needs Git repository setup) | ||
| 49 | - Hook runs *after* Git has started processing | ||
| 50 | |||
| 51 | --- | ||
| 52 | |||
| 53 | ## Background: How Inline Authorization Works | ||
| 54 | |||
| 55 | With inline authorization, we validate **before** spawning Git: | ||
| 56 | |||
| 57 | ``` | ||
| 58 | Client Server (ngit-grasp) | ||
| 59 | | | | ||
| 60 | |--- git push ----->|--- HTTP handler receives request | ||
| 61 | | | | ||
| 62 | | |--- Parse ref updates from request | ||
| 63 | | |--- Query Nostr relay for state | ||
| 64 | | |--- Validate push against state | ||
| 65 | | | | ||
| 66 | | |--- If invalid: return HTTP error | ||
| 67 | | |--- If valid: spawn git-receive-pack | ||
| 68 | | | | ||
| 69 | |<--- success ------| (if valid) | ||
| 70 | |<--- HTTP error ---| (if invalid) | ||
| 71 | ``` | ||
| 72 | |||
| 73 | **Pros:** | ||
| 74 | - Full control over error messages (HTTP response) | ||
| 75 | - Can skip spawning Git entirely for invalid pushes | ||
| 76 | - Easier testing (pure Rust, no Git setup needed) | ||
| 77 | - Shared state between Git and Nostr components | ||
| 78 | - Better performance (early rejection) | ||
| 79 | |||
| 80 | **Cons:** | ||
| 81 | - Requires parsing Git protocol ourselves | ||
| 82 | - Less standard than hooks | ||
| 83 | - Tighter coupling to Git HTTP protocol | ||
| 84 | |||
| 85 | --- | ||
| 86 | |||
| 87 | ## Why Inline Authorization Is Better for GRASP | ||
| 88 | |||
| 89 | ### 1. Better Error Messages | ||
| 90 | |||
| 91 | **With hooks:** | ||
| 92 | ``` | ||
| 93 | $ git push | ||
| 94 | remote: error: Push rejected - not authorized for ref refs/heads/main | ||
| 95 | remote: See https://docs.gitnostr.com/errors/unauthorized | ||
| 96 | To https://gitnostr.com/alice/myrepo.git | ||
| 97 | ! [remote rejected] main -> main (pre-receive hook declined) | ||
| 98 | ``` | ||
| 99 | |||
| 100 | **With inline authorization:** | ||
| 101 | ``` | ||
| 102 | $ git push | ||
| 103 | error: RPC failed; HTTP 403 Forbidden | ||
| 104 | error: { | ||
| 105 | "error": "unauthorized", | ||
| 106 | "ref": "refs/heads/main", | ||
| 107 | "required_state": "event_id_abc123", | ||
| 108 | "your_pubkey": "npub1alice...", | ||
| 109 | "docs": "https://docs.gitnostr.com/errors/unauthorized" | ||
| 110 | } | ||
| 111 | ``` | ||
| 112 | |||
| 113 | The inline approach can return **structured JSON** with actionable information. | ||
| 114 | |||
| 115 | ### 2. Performance Benefits | ||
| 116 | |||
| 117 | **With hooks:** | ||
| 118 | - Git process spawns | ||
| 119 | - Git starts receiving pack data | ||
| 120 | - Hook runs (might query Nostr relay) | ||
| 121 | - If rejected, Git throws away received data | ||
| 122 | |||
| 123 | **With inline authorization:** | ||
| 124 | - Parse ref updates from HTTP request | ||
| 125 | - Validate against Nostr state (cached) | ||
| 126 | - If rejected, return HTTP 403 immediately | ||
| 127 | - Never spawn Git for invalid pushes | ||
| 128 | |||
| 129 | **Result:** Faster rejection, less resource usage. | ||
| 130 | |||
| 131 | ### 3. Easier Testing | ||
| 132 | |||
| 133 | **With hooks:** | ||
| 134 | ```bash | ||
| 135 | # Test setup | ||
| 136 | mkdir -p /tmp/test-repo | ||
| 137 | cd /tmp/test-repo | ||
| 138 | git init --bare | ||
| 139 | cp pre-receive.sh hooks/pre-receive | ||
| 140 | chmod +x hooks/pre-receive | ||
| 141 | |||
| 142 | # Test execution | ||
| 143 | git push /tmp/test-repo main | ||
| 144 | |||
| 145 | # Cleanup | ||
| 146 | rm -rf /tmp/test-repo | ||
| 147 | ``` | ||
| 148 | |||
| 149 | **With inline authorization:** | ||
| 150 | ```rust | ||
| 151 | #[tokio::test] | ||
| 152 | async fn test_unauthorized_push() { | ||
| 153 | let state = create_test_state().await; | ||
| 154 | let result = validate_push(&state, "refs/heads/main", alice_pubkey).await; | ||
| 155 | assert!(result.is_err()); | ||
| 156 | } | ||
| 157 | ``` | ||
| 158 | |||
| 159 | **Result:** Pure Rust unit tests, no shell scripts, no Git setup. | ||
| 160 | |||
| 161 | ### 4. Shared State and Types | ||
| 162 | |||
| 163 | **With hooks:** | ||
| 164 | - Hook is separate process | ||
| 165 | - Must query Nostr relay over WebSocket | ||
| 166 | - Can't share in-memory cache | ||
| 167 | - Separate error types | ||
| 168 | |||
| 169 | **With inline authorization:** | ||
| 170 | ```rust | ||
| 171 | pub struct GitHandler { | ||
| 172 | nostr_relay: Arc<NostrRelay>, // Shared! | ||
| 173 | state_cache: Arc<StateCache>, // Shared! | ||
| 174 | } | ||
| 175 | |||
| 176 | impl GitHandler { | ||
| 177 | async fn validate_push(&self, refs: &[RefUpdate]) -> Result<()> { | ||
| 178 | // Direct access to Nostr state | ||
| 179 | let state = self.state_cache.get_latest().await?; | ||
| 180 | // Validate using shared types | ||
| 181 | state.validate_refs(refs)?; | ||
| 182 | Ok(()) | ||
| 183 | } | ||
| 184 | } | ||
| 185 | ``` | ||
| 186 | |||
| 187 | **Result:** Better performance, type safety, simpler architecture. | ||
| 188 | |||
| 189 | ### 5. Simpler Deployment | ||
| 190 | |||
| 191 | **With hooks (ngit-relay):** | ||
| 192 | ``` | ||
| 193 | Docker container: | ||
| 194 | - nginx (HTTP frontend) | ||
| 195 | - git-http-backend (C binary) | ||
| 196 | - pre-receive hook (Go binary) | ||
| 197 | - Khatru relay (Go binary) | ||
| 198 | - supervisord (process manager) | ||
| 199 | |||
| 200 | Setup steps: | ||
| 201 | 1. Install all components | ||
| 202 | 2. Configure nginx | ||
| 203 | 3. Install hook in each repository | ||
| 204 | 4. Set up supervisord | ||
| 205 | 5. Configure inter-process communication | ||
| 206 | ``` | ||
| 207 | |||
| 208 | **With inline authorization (ngit-grasp):** | ||
| 209 | ``` | ||
| 210 | Single Rust binary: | ||
| 211 | - HTTP server (actix-web) | ||
| 212 | - Git protocol handler | ||
| 213 | - Nostr relay | ||
| 214 | - Authorization logic | ||
| 215 | |||
| 216 | Setup steps: | ||
| 217 | 1. Run binary | ||
| 218 | 2. Configure environment variables | ||
| 219 | ``` | ||
| 220 | |||
| 221 | **Result:** Simpler deployment, fewer moving parts. | ||
| 222 | |||
| 223 | --- | ||
| 224 | |||
| 225 | ## Technical Implementation | ||
| 226 | |||
| 227 | ### How We Parse Ref Updates | ||
| 228 | |||
| 229 | The Git HTTP protocol sends ref updates in the request body: | ||
| 230 | |||
| 231 | ``` | ||
| 232 | POST /alice/myrepo.git/git-receive-pack HTTP/1.1 | ||
| 233 | Content-Type: application/x-git-receive-pack-request | ||
| 234 | |||
| 235 | 0000000000000000000000000000000000000000 abc123... refs/heads/main\0 report-status | ||
| 236 | ``` | ||
| 237 | |||
| 238 | We parse this **before** spawning Git: | ||
| 239 | |||
| 240 | ```rust | ||
| 241 | pub async fn git_receive_pack( | ||
| 242 | req: HttpRequest, | ||
| 243 | body: web::Bytes, | ||
| 244 | ) -> Result<HttpResponse, Error> { | ||
| 245 | // 1. Parse ref updates from request body | ||
| 246 | let ref_updates = parse_ref_updates(&body)?; | ||
| 247 | |||
| 248 | // 2. Validate against Nostr state | ||
| 249 | let state = get_latest_state(&repo).await?; | ||
| 250 | validate_push(&state, &ref_updates).await?; | ||
| 251 | |||
| 252 | // 3. If valid, spawn git-receive-pack | ||
| 253 | spawn_git_receive_pack(req, body).await | ||
| 254 | } | ||
| 255 | ``` | ||
| 256 | |||
| 257 | ### How We Validate | ||
| 258 | |||
| 259 | Validation checks: | ||
| 260 | 1. Does pusher's pubkey have write access? | ||
| 261 | 2. Are they listed as a maintainer in the latest state event? | ||
| 262 | 3. Do maintainer sets form a valid chain? | ||
| 263 | |||
| 264 | ```rust | ||
| 265 | async fn validate_push( | ||
| 266 | state: &RepoState, | ||
| 267 | refs: &[RefUpdate], | ||
| 268 | ) -> Result<()> { | ||
| 269 | for ref_update in refs { | ||
| 270 | // Check if pusher is authorized for this ref | ||
| 271 | if !state.is_authorized(&ref_update.name, pusher_pubkey) { | ||
| 272 | return Err(Error::Unauthorized { | ||
| 273 | ref_name: ref_update.name.clone(), | ||
| 274 | pubkey: pusher_pubkey, | ||
| 275 | }); | ||
| 276 | } | ||
| 277 | } | ||
| 278 | Ok(()) | ||
| 279 | } | ||
| 280 | ``` | ||
| 281 | |||
| 282 | --- | ||
| 283 | |||
| 284 | ## Comparison with Reference Implementation | ||
| 285 | |||
| 286 | | Aspect | ngit-relay (hooks) | ngit-grasp (inline) | | ||
| 287 | |--------|-------------------|---------------------| | ||
| 288 | | **Components** | nginx + git-http-backend + hook + Khatru | Single Rust binary | | ||
| 289 | | **Validation** | Pre-receive hook (separate process) | Inline HTTP handler | | ||
| 290 | | **Error messages** | Hook stderr → `remote:` | HTTP response JSON | | ||
| 291 | | **Performance** | Spawns Git first | Validates first | | ||
| 292 | | **Testing** | Shell scripts + Go tests | Pure Rust tests | | ||
| 293 | | **Deployment** | Docker + supervisord | Single binary | | ||
| 294 | | **State sharing** | WebSocket query | Direct memory access | | ||
| 295 | |||
| 296 | Both are GRASP-compliant, but inline authorization is simpler and more efficient. | ||
| 297 | |||
| 298 | --- | ||
| 299 | |||
| 300 | ## Trade-offs and Limitations | ||
| 301 | |||
| 302 | ### What We Gain | ||
| 303 | - ✅ Better error messages | ||
| 304 | - ✅ Better performance | ||
| 305 | - ✅ Easier testing | ||
| 306 | - ✅ Simpler deployment | ||
| 307 | - ✅ Tighter integration | ||
| 308 | |||
| 309 | ### What We Lose | ||
| 310 | - ❌ Non-standard approach (not using Git's hook system) | ||
| 311 | - ❌ Tighter coupling to Git HTTP protocol | ||
| 312 | - ❌ Must parse protocol ourselves | ||
| 313 | |||
| 314 | ### Is It Worth It? | ||
| 315 | |||
| 316 | **Yes**, because: | ||
| 317 | 1. The `git-http-backend` crate handles protocol parsing | ||
| 318 | 2. GRASP is already non-standard (Nostr authorization) | ||
| 319 | 3. Benefits far outweigh the coupling cost | ||
| 320 | 4. We can still add hook support later if needed | ||
| 321 | |||
| 322 | --- | ||
| 323 | |||
| 324 | ## Alternative Considered: Hybrid Approach | ||
| 325 | |||
| 326 | We could use **both** inline validation and hooks: | ||
| 327 | |||
| 328 | ```rust | ||
| 329 | // Inline: Fast path for common cases | ||
| 330 | if !quick_validate(pusher).await? { | ||
| 331 | return Err(Error::Unauthorized); | ||
| 332 | } | ||
| 333 | |||
| 334 | // Hook: Detailed validation | ||
| 335 | spawn_git_with_hook().await?; | ||
| 336 | ``` | ||
| 337 | |||
| 338 | **Why we didn't choose this:** | ||
| 339 | - Added complexity | ||
| 340 | - Redundant validation | ||
| 341 | - Slower (two validation steps) | ||
| 342 | - Harder to maintain | ||
| 343 | |||
| 344 | If inline validation is sufficient, why add hooks? | ||
| 345 | |||
| 346 | --- | ||
| 347 | |||
| 348 | ## Future Considerations | ||
| 349 | |||
| 350 | ### If We Need Hooks Later | ||
| 351 | |||
| 352 | We can add hook support without removing inline validation: | ||
| 353 | |||
| 354 | ```rust | ||
| 355 | pub struct GitConfig { | ||
| 356 | inline_validation: bool, // Default: true | ||
| 357 | hook_validation: bool, // Default: false | ||
| 358 | } | ||
| 359 | ``` | ||
| 360 | |||
| 361 | This would allow: | ||
| 362 | - Migration path for hook-based systems | ||
| 363 | - Extra validation for paranoid deployments | ||
| 364 | - Compatibility with other Git tools | ||
| 365 | |||
| 366 | ### If Git Protocol Changes | ||
| 367 | |||
| 368 | The `git-http-backend` crate abstracts protocol details. If the Git protocol changes: | ||
| 369 | - Update the crate dependency | ||
| 370 | - Adjust our ref parsing if needed | ||
| 371 | - Tests will catch any breakage | ||
| 372 | |||
| 373 | --- | ||
| 374 | |||
| 375 | ## Conclusion | ||
| 376 | |||
| 377 | **Inline authorization is the right choice for ngit-grasp** because: | ||
| 378 | |||
| 379 | 1. It provides better error messages for users | ||
| 380 | 2. It's more performant (early rejection) | ||
| 381 | 3. It's easier to test (pure Rust) | ||
| 382 | 4. It's simpler to deploy (single binary) | ||
| 383 | 5. It enables better integration (shared state) | ||
| 384 | |||
| 385 | The trade-off (coupling to Git HTTP protocol) is acceptable because: | ||
| 386 | - The protocol is stable and well-specified | ||
| 387 | - The `git-http-backend` crate abstracts details | ||
| 388 | - Benefits far outweigh the cost | ||
| 389 | |||
| 390 | This decision aligns with our goal of creating a **developer-friendly, production-ready GRASP implementation**. | ||
| 391 | |||
| 392 | --- | ||
| 393 | |||
| 394 | ## Related Documentation | ||
| 395 | |||
| 396 | - [Architecture Overview](architecture.md) - Full system design | ||
| 397 | - [Design Decisions](decisions.md) - All architectural choices | ||
| 398 | - [Comparison with ngit-relay](comparison.md) - Detailed comparison | ||
| 399 | - [Git Protocol Reference](../reference/git-protocol.md) - Protocol details | ||
| 400 | |||
| 401 | --- | ||
| 402 | |||
| 403 | *Part of the [ngit-grasp explanation docs](./)* | ||