upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/docs/explanation
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-12-04 12:34:20 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-12-04 13:02:59 +0000
commitd9bc5ed7fddef3a26de8e69a7124e1dbe5b8602f (patch)
treec76ffcbf246c8bef7545337316c0afb90433bbf5 /docs/explanation
parent40831a9025d05fa354b7d8386eeebd902092ea86 (diff)
docs: update based on current implementation
Diffstat (limited to 'docs/explanation')
-rw-r--r--docs/explanation/architecture.md716
-rw-r--r--docs/explanation/inline-authorization.md126
2 files changed, 231 insertions, 611 deletions
diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md
index ebf7a74..3fb895c 100644
--- a/docs/explanation/architecture.md
+++ b/docs/explanation/architecture.md
@@ -2,16 +2,16 @@
2 2
3## Executive Summary 3## Executive Summary
4 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. 5`ngit-grasp` implements the GRASP protocol in Rust with **inline authorization** rather than Git hooks. The key architectural insight is that we can intercept and validate Git push operations at the HTTP handler level before reaching the Git repository, eliminating the need for pre-receive hooks.
6 6
7## Architectural Decision: Inline vs. Hook-Based Authorization 7## Architectural Decision: Inline vs. Hook-Based Authorization
8 8
9### Investigation Summary 9### Investigation Summary
10 10
11After examining both the reference implementation and the `git-http-backend` Rust crate, we have two options: 11After examining both the reference implementation and HTTP server options, we have two options:
12 12
13#### Option 1: Hook-Based (Reference Implementation Approach) 13#### Option 1: Hook-Based (Reference Implementation Approach)
14- Use `git-http-backend` crate as-is 14- Use standard Git HTTP backend
15- Create pre-receive and post-receive hooks 15- Create pre-receive and post-receive hooks
16- Hooks query the Nostr relay and validate pushes 16- Hooks query the Nostr relay and validate pushes
17- **Pros**: Follows reference implementation closely 17- **Pros**: Follows reference implementation closely
@@ -28,7 +28,7 @@ After examining both the reference implementation and the `git-http-backend` Rus
28 28
29**Rationale:** 29**Rationale:**
30 30
311. **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. 311. **Full control over HTTP layer**: Using Hyper directly gives us complete control over request handling, WebSocket upgrades, and CORS headers.
32 32
332. **Better Developer Experience**: 332. **Better Developer Experience**:
34 - Validation errors can be returned as proper HTTP responses 34 - Validation errors can be returned as proper HTTP responses
@@ -56,7 +56,7 @@ After examining both the reference implementation and the `git-http-backend` Rus
56│ │ 56│ │
57│ ┌──────────────────┐ ┌──────────────────┐ │ 57│ ┌──────────────────┐ ┌──────────────────┐ │
58│ │ HTTP Router │ │ Nostr Relay │ │ 58│ │ HTTP Router │ │ Nostr Relay │ │
59│ │ (actix-web) │ │ (nostr-relay- │ │ 59│ │ (Hyper) │ │ (nostr-relay- │ │
60│ │ │ │ builder) │ │ 60│ │ │ │ builder) │ │
61│ └────────┬─────────┘ └────────┬─────────┘ │ 61│ └────────┬─────────┘ └────────┬─────────┘ │
62│ │ │ │ 62│ │ │ │
@@ -90,299 +90,149 @@ After examining both the reference implementation and the `git-http-backend` Rus
90 90
91## Component Design 91## Component Design
92 92
93### 1. Main Server (`src/main.rs`) 93### 1. Main Server ([`src/main.rs`](src/main.rs))
94 94
95**Responsibilities:** 95**Responsibilities:**
96- Initialize configuration from environment 96- Initialize configuration from environment (clap + dotenvy)
97- Set up actix-web HTTP server 97- Set up Hyper HTTP server with request routing
98- Initialize Nostr relay builder 98- Initialize Nostr relay builder with custom [`Nip34WritePolicy`](src/nostr/builder.rs:51)
99- Set up shared storage 99- Set up shared storage (LMDB, NostrDB, or Memory)
100- Configure routes for both Git and Nostr endpoints 100- Handle WebSocket upgrades for Nostr relay
101- Handle graceful shutdown 101- Handle graceful shutdown
102 102
103**Key Dependencies:** 103**Key Dependencies:**
104```rust 104```rust
105actix-web = "4" 105hyper = "1"
106tokio = { version = "1", features = ["full"] } 106tokio = { version = "1", features = ["full"] }
107nostr-relay-builder = "0.43" 107nostr-relay-builder = "0.43"
108nostr-sdk = "0.43" 108nostr-sdk = "0.43"
109nostr-lmdb = "0.43"
109``` 110```
110 111
111### 2. Git Module (`src/git/`) 112### 2. HTTP Module ([`src/http/mod.rs`](src/http/mod.rs))
112 113
113#### `handler.rs` - Git HTTP Handlers 114**Responsibilities:**
115- Route HTTP requests to appropriate handlers
116- WebSocket upgrade for Nostr relay at `/`
117- Git Smart HTTP endpoints at `/<npub>/<identifier>.git/*`
118- Landing pages and NIP-11 document serving
119- CORS headers on all responses (GRASP-01 requirement)
114 120
115Implements actix-web handlers for Git Smart HTTP protocol: 121**Key Implementation Details:**
116 122
117```rust 123```rust
118// GET /<npub>/<identifier>.git/info/refs?service=git-upload-pack 124// CORS headers required by GRASP-01 specification
119async fn info_refs_upload_pack( 125const CORS_ALLOW_ORIGIN: &str = "*";
120 req: HttpRequest, 126const CORS_ALLOW_METHODS: &str = "GET, POST";
121 state: web::Data<AppState>, 127const CORS_ALLOW_HEADERS: &str = "Content-Type";
122) -> Result<HttpResponse> 128
123 129/// Add CORS headers to a response builder
124// POST /<npub>/<identifier>.git/git-upload-pack 130fn add_cors_headers(builder: http::response::Builder) -> http::response::Builder {
125async fn git_upload_pack( 131 builder
126 req: HttpRequest, 132 .header("Access-Control-Allow-Origin", CORS_ALLOW_ORIGIN)
127 body: web::Payload, 133 .header("Access-Control-Allow-Methods", CORS_ALLOW_METHODS)
128 state: web::Data<AppState>, 134 .header("Access-Control-Allow-Headers", CORS_ALLOW_HEADERS)
129) -> Result<HttpResponse> 135}
130
131// GET /<npub>/<identifier>.git/info/refs?service=git-receive-pack
132async 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
139async fn git_receive_pack(
140 req: HttpRequest,
141 body: web::Payload,
142 state: web::Data<AppState>,
143) -> Result<HttpResponse>
144``` 136```
145 137
146#### `authorization.rs` - Push Validation 138See [`src/http/mod.rs:29-84`](src/http/mod.rs:29-84) for the full CORS implementation.
147 139
148**Core Logic:** 140### 3. Git Module ([`src/git/`](src/git/))
149 141
150```rust 142#### [`handlers.rs`](src/git/handlers.rs) - Git HTTP Handlers
151pub struct PushValidator {
152 nostr_client: Arc<Client>,
153 relay_url: String,
154}
155 143
156impl PushValidator { 144Implements handlers for Git Smart HTTP protocol:
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 145
197```rust 146```rust
198/// Parse ref updates from git-receive-pack request body 147/// Handle GET /info/refs?service=git-{upload,receive}-pack
199fn parse_ref_updates(body: &[u8]) -> Result<Vec<RefUpdate>> 148pub async fn handle_info_refs(
200 149 repo_path: PathBuf,
201/// Recursively find all maintainers 150 service: GitService,
202fn get_maintainers( 151) -> Result<Response<Full<Bytes>>, GitError>
203 events: &[Event], 152
204 pubkey: &str, 153/// Handle POST /git-upload-pack (clone/fetch)
154pub async fn handle_upload_pack(
155 repo_path: PathBuf,
156 body: Bytes,
157) -> Result<Response<Full<Bytes>>, GitError>
158
159/// Handle POST /git-receive-pack (push)
160/// THIS IS WHERE THE MAGIC HAPPENS - validates against state before accepting
161pub async fn handle_receive_pack(
162 repo_path: PathBuf,
163 body: Bytes,
164 database: SharedDatabase,
165 npub: &str,
205 identifier: &str, 166 identifier: &str,
206) -> Vec<String> 167) -> Result<Response<Full<Bytes>>, GitError>
207
208/// Get latest state from maintainer set
209fn get_state_from_maintainers(
210 events: &[Event],
211 maintainers: &[String],
212) -> Result<RepositoryState>
213
214/// Validate a ref matches the state event
215fn validate_state_ref(
216 state: &RepositoryState,
217 ref_update: &RefUpdate,
218) -> Result<()>
219``` 168```
220 169
221### 3. Nostr Module (`src/nostr/`) 170See [`src/git/handlers.rs:22-98`](src/git/handlers.rs:22-98) for the info-refs implementation.
222 171
223#### `relay.rs` - Relay Configuration 172#### [`authorization.rs`](src/git/authorization.rs) - Push Validation
224 173
225```rust 174**Core Logic:**
226pub 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 175
242```rust 176```rust
243/// Hook called when events are saved 177/// Get authorization info for a repository owner
244pub fn create_repository_hook( 178pub async fn get_authorization_for_owner(
245 git_data_path: PathBuf, 179 database: &SharedDatabase,
246) -> impl Fn(&Event) -> BoxFuture<'static, ()> { 180 pubkey: &PublicKey,
247 move |event: &Event| { 181 identifier: &str,
248 let git_path = git_data_path.clone(); 182) -> Result<AuthorizationResult, AuthorizationError>
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 183
259async fn handle_repository_announcement(event: &Event, git_path: &Path) { 184/// Validate that pushed refs match the authorized state
260 // 1. Parse repository from event 185pub fn validate_push_refs(
261 // 2. Check if listed in clone and relays tags 186 pushed_refs: &[PushedRef],
262 // 3. Create empty bare Git repository 187 state: &RepositoryState,
263 // 4. Configure uploadpack.allowTipSHA1InWant 188) -> Result<(), AuthorizationError>
264 // 5. Configure uploadpack.allowUnreachable
265 // 6. Configure http.receivepack
266}
267 189
268async fn handle_repository_state(event: &Event, git_path: &Path) { 190/// Validate refs/nostr/<event-id> pushes
269 // 1. Parse state from event 191pub fn validate_nostr_ref_pushes(
270 // 2. Update repository HEAD if needed 192 pushed_refs: &[PushedRef],
271 // 3. Trigger proactive sync (GRASP-02) 193 database: &SharedDatabase,
272} 194) -> Result<(), AuthorizationError>
273``` 195```
274 196
275**Write Policies:** 197### 4. Nostr Module ([`src/nostr/`](src/nostr/))
276 198
277```rust 199#### [`builder.rs`](src/nostr/builder.rs) - Relay Configuration
278/// Accept repository announcements that list this instance
279pub struct RepositoryAnnouncementPolicy {
280 domain: String,
281}
282
283impl 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 200
307/// Accept events related to stored announcements/issues/patches 201The [`Nip34WritePolicy`](src/nostr/builder.rs:51) is the core event validation logic:
308pub struct RelatedEventsPolicy;
309 202
310impl WritePolicy for RelatedEventsPolicy { 203```rust
311 fn admit_event(&self, event: &Event, _addr: &SocketAddr) 204/// NIP-34 Write Policy with Full GRASP-01 Event Validation
312 -> BoxFuture<PolicyResult> 205///
313 { 206/// Validates all events according to GRASP-01 specification:
314 // Accept if event tags or is tagged by stored events 207/// - Repository announcements must list service in clone and relays tags
315 // Implementation requires querying the event store 208/// EXCEPTION: Recursive maintainer announcements are accepted even without
316 } 209/// listing the service, to enable maintainer chain discovery and GRASP-02 sync
210/// - Repository state announcements must have valid structure
211/// - Other events must reference accepted repositories or events
212/// - Forward references are supported (events referenced by accepted events)
213/// - Orphan events with no valid references are rejected
214pub struct Nip34WritePolicy {
215 domain: String,
216 database: SharedDatabase,
217 git_data_path: PathBuf,
317} 218}
318``` 219```
319 220
320### 4. Storage Module (`src/storage/`) 221See [`src/nostr/builder.rs:38-78`](src/nostr/builder.rs:38-78) for the full policy struct.
321 222
322#### `repository.rs` - Repository Management 223#### [`events.rs`](src/nostr/events.rs) - Event Parsing
224
225Provides structures for parsing NIP-34 events:
323 226
324```rust 227```rust
325pub struct RepositoryManager { 228/// Parsed repository announcement (Kind 30617)
326 git_data_path: PathBuf, 229pub struct RepositoryAnnouncement { ... }
327}
328 230
329impl RepositoryManager { 231/// Parsed repository state (Kind 30618)
330 /// Create a new bare Git repository 232pub struct RepositoryState { ... }
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``` 233```
384 234
385### 5. Configuration (`src/config.rs`) 235### 5. Configuration ([`src/config.rs`](src/config.rs))
386 236
387```rust 237```rust
388pub struct Config { 238pub struct Config {
@@ -393,34 +243,18 @@ pub struct Config {
393 pub git_data_path: PathBuf, 243 pub git_data_path: PathBuf,
394 pub relay_data_path: PathBuf, 244 pub relay_data_path: PathBuf,
395 pub bind_address: SocketAddr, 245 pub bind_address: SocketAddr,
396 pub log_level: String, 246 pub database_backend: DatabaseBackend,
397} 247}
398 248
399impl Config { 249pub enum DatabaseBackend {
400 pub fn from_env() -> Result<Self> { 250 Lmdb, // Default, production use
401 Ok(Config { 251 NostrDb, // Alternative
402 domain: env::var("NGIT_DOMAIN")?, 252 Memory, // Testing
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} 253}
422``` 254```
423 255
256Configuration is loaded via **clap CLI > environment variables > .env > defaults**.
257
424## Data Flow 258## Data Flow
425 259
426### Push Operation Flow 260### Push Operation Flow
@@ -428,327 +262,120 @@ impl Config {
428``` 262```
4291. Git Client → POST /<npub>/<id>.git/git-receive-pack 2631. Git Client → POST /<npub>/<id>.git/git-receive-pack
430 264
4312. git_receive_pack handler receives request 2652. HttpService routes to git::handlers::handle_receive_pack()
432 266
4333. Parse ref updates from request body 2673. Parse ref updates from request body (pkt-line format)
434 268
4354. Extract npub and identifier from URL 2694. Extract npub and identifier from URL
436 270
4375. PushValidator::validate_push() 2715. authorization::get_authorization_for_owner()
438 ├─ Fetch events from local Nostr relay 272 ├─ Query database for announcements
439 ├─ Get maintainers recursively 273 ├─ Build recursive maintainer set
440 ├─ Get latest state from maintainers 274 └─ Get latest authorized state
441 └─ Validate each ref update 275
2766. authorization::validate_push_refs()
277 ├─ Check each ref matches state
278 └─ Validate refs/nostr/ pushes
442 279
4436. If VALID: 2807. If VALID:
444 ├─ Spawn git-receive-pack subprocess 281 ├─ Spawn git-receive-pack subprocess
445 ├─ Stream request body to git stdin 282 ├─ Stream request body to git stdin
446 └─ Stream git stdout back to client 283 └─ Stream git stdout back to client
447 284
4487. If INVALID: 2858. If INVALID:
449 └─ Return HTTP 403 with error message 286 └─ Return HTTP 403 with error message
450``` 287```
451 288
452### Repository Announcement Flow 289### Repository Announcement Flow
453 290
454``` 291```
4551. Nostr Client → EVENT (Kind 30317) 2921. Nostr Client → EVENT (Kind 30617)
456 293
4572. Nostr relay receives event 2942. Nostr relay receives event
458 295
4593. RepositoryAnnouncementPolicy::admit_event() 2963. Nip34WritePolicy::admit_event()
460 ├─ Check if instance in clone tags 297 ├─ Check if instance in clone tags
461 ├─ Check if instance in relays tags 298 ├─ Check if instance in relays tags
299 ├─ OR: Check if recursive maintainer
462 └─ Accept or reject 300 └─ Accept or reject
463 301
4644. If ACCEPTED: 3024. If ACCEPTED:
465 ├─ Event saved to store 303 ├─ Event saved to database
466 └─ on_event_saved hook triggered 304 └─ ensure_bare_repository() called
467 305
4685. handle_repository_announcement() 3065. Bare Git repository created at
469 ├─ Parse repository details 307 <git_data_path>/<npub>/<identifier>.git
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
479The Git receive-pack protocol uses a pkt-line format. We need to parse:
480
481```
4820000-0000-0000-0000 0000-0000-0000-0000 refs/heads/main\0 report-status
4830000-0000-0000-0000 0000-0000-0000-0000 refs/heads/dev
484```
485
486Each 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
495pub struct RefUpdate {
496 pub old_sha: String,
497 pub new_sha: String,
498 pub ref_name: String,
499}
500
501pub fn parse_ref_updates(body: &[u8]) -> Result<Vec<RefUpdate>> {
502 // Parse pkt-line format
503 // Extract ref updates
504 // Return structured data
505}
506``` 308```
507 309
508### 2. Maintainer Recursion 310### State Event Flow
509
510The maintainer resolution must handle cycles and correctly build the set:
511 311
512```rust
513fn 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``` 312```
539 3131. Nostr Client → EVENT (Kind 30618)
540### 3. State Event Validation 314
541 3152. Nostr relay receives event
542```rust 316
543fn validate_state_ref( 3173. Nip34WritePolicy::admit_event()
544 state: &RepositoryState, 318 ├─ Check author is in maintainer set
545 ref_update: &RefUpdate, 319 ├─ Validate state structure
546) -> Result<()> { 320 └─ Accept or reject
547 if ref_update.ref_name.starts_with("refs/heads/") { 321
548 let branch_name = &ref_update.ref_name[11..]; 3224. If ACCEPTED and is latest state:
549 if let Some(commit) = state.branches.get(branch_name) { 323 ├─ Align repository refs to match state
550 if commit == &ref_update.new_sha { 324 ├─ Create/update/delete refs as needed
551 return Ok(()); 325 └─ Set HEAD if commit available
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
583As per GRASP-01, we must support CORS:
584
585```rust
586use actix_cors::Cors;
587
588fn 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
597App::new()
598 .wrap(configure_cors())
599 .configure(git_routes)
600 .configure(nostr_routes)
601``` 326```
602 327
603## Testing Strategy 328## Testing Strategy
604 329
605See [TEST_STRATEGY.md](TEST_STRATEGY.md) for comprehensive testing documentation, including: 330See [test-strategy.md](../reference/test-strategy.md) for comprehensive testing documentation.
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 331
612### Quick Overview 332### Quick Overview
613 333
614```rust 334**Integration Tests** ([`tests/`](tests/)):
615// Unit Tests - Individual functions 335- Use [`TestRelay`](tests/common/relay.rs:14) fixture for automatic relay lifecycle
616#[test] 336- Each test file in [`tests/`](tests/) covers a GRASP-01 requirement
617fn 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 337
624// Integration Tests - Component interaction 338**Audit Tests** ([`grasp-audit/`](grasp-audit/)):
625#[tokio::test] 339- Reusable compliance testing for any GRASP implementation
626async fn test_full_push_flow() { 340- Spec-mirrored structure in [`grasp-audit/src/specs/grasp01/`](grasp-audit/src/specs/grasp01/)
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 341
637// Compliance Tests - GRASP spec validation 342```rust
343// Example: tests/nip01_compliance.rs
638#[tokio::test] 344#[tokio::test]
639async fn test_grasp_01_compliance() { 345async fn test_nip01_websocket_connection() {
640 use grasp_compliance_tests::{TestContext, Grasp01Spec}; 346 let relay = TestRelay::start().await;
641 347 // Test NIP-01 compliance...
642 let ctx = TestContext::builder() 348 relay.stop().await;
643 .base_url(&server.url())
644 .build();
645
646 let results = Grasp01Spec::test_compliance(&ctx).await;
647 assert!(results.all_passed(), "{}", results.report());
648} 349}
649``` 350```
650 351
651The 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 352## Performance Considerations
658 353
659### 1. Async All The Way 354### 1. Async All The Way
660 355
661- Use `tokio` for all I/O 356- Use `tokio` for all I/O
662- Non-blocking Git subprocess spawning 357- Non-blocking Git subprocess spawning via [`GitSubprocess`](src/git/subprocess.rs)
663- Stream large pack files without buffering 358- Stream large pack files without buffering
664 359
665### 2. Connection Pooling 360### 2. Shared Database
666
667- Reuse Nostr relay connections
668- Connection pool for internal relay queries
669
670### 3. Caching
671 361
672- Cache parsed state events (with TTL) 362- Single database instance shared between relay and Git handlers
673- Cache maintainer sets 363- Direct queries for push authorization (no WebSocket round-trip)
674- Invalidate on new state events
675 364
676```rust 365### 3. Write Policy Caching
677pub struct StateCache {
678 cache: Arc<RwLock<HashMap<String, CachedState>>>,
679}
680
681struct CachedState {
682 state: RepositoryState,
683 maintainers: Vec<String>,
684 timestamp: Instant,
685}
686 366
687impl StateCache { 367- Maintainer sets computed once per event validation
688 pub async fn get_or_fetch( 368- State lookups use database indexes
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 369
700## Future Extensions 370## Future Extensions
701 371
702### GRASP-02: Proactive Sync 372### GRASP-02: Proactive Sync
703 373
704Add background tasks: 374See [grasp-02-proactive-sync.md](grasp-02-proactive-sync.md) for detailed design.
705
706```rust
707pub struct ProactiveSyncTask {
708 relay_client: Client,
709 git_manager: RepositoryManager,
710}
711
712impl 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 375
735### GRASP-05: Archive 376### GRASP-05: Archive
736 377
737Relax the policy: 378Relax the write policy to accept all repository announcements regardless of clone/relays tags.
738
739```rust
740pub struct ArchiveAnnouncementPolicy;
741
742impl 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 379
753## Deployment 380## Deployment
754 381
@@ -756,7 +383,7 @@ impl WritePolicy for ArchiveAnnouncementPolicy {
756 383
757```bash 384```bash
758cargo build --release 385cargo build --release
759./target/release/ngit-grasp 386./target/release/ngit-grasp --domain example.com --owner-npub npub1...
760``` 387```
761 388
762### Docker 389### Docker
@@ -799,10 +426,17 @@ WantedBy=multi-user.target
7992. **Path Traversal**: Prevent directory traversal in repository paths 4262. **Path Traversal**: Prevent directory traversal in repository paths
8003. **DoS Protection**: Rate limiting on both HTTP and WebSocket 4273. **DoS Protection**: Rate limiting on both HTTP and WebSocket
8014. **Resource Limits**: Limit pack file sizes, event sizes 4284. **Resource Limits**: Limit pack file sizes, event sizes
8025. **Nostr Event Validation**: Strict signature verification 4295. **Nostr Event Validation**: Strict signature verification (handled by nostr-relay-builder)
803 430
804## Conclusion 431## Conclusion
805 432
806The 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. 433The inline authorization approach provides a cleaner, more maintainable architecture than hook-based authorization while maintaining full GRASP-01 compliance. Using Hyper for the HTTP layer gives us complete control over request handling, WebSocket upgrades, and CORS headers.
807 434
808The 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. 435The 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.
436
437## Related Documentation
438
439- [Inline Authorization Explanation](inline-authorization.md) - Why we chose this approach
440- [GRASP-02 Proactive Sync](grasp-02-proactive-sync.md) - Future work design
441- [Test Strategy](../reference/test-strategy.md) - Comprehensive testing documentation
442- [GRASP-01 Implementation Learnings](../learnings/grasp-01-implementation.md) - Patterns and lessons learned \ No newline at end of file
diff --git a/docs/explanation/inline-authorization.md b/docs/explanation/inline-authorization.md
index 98f6e5a..4538602 100644
--- a/docs/explanation/inline-authorization.md
+++ b/docs/explanation/inline-authorization.md
@@ -150,14 +150,17 @@ rm -rf /tmp/test-repo
150```rust 150```rust
151#[tokio::test] 151#[tokio::test]
152async fn test_unauthorized_push() { 152async fn test_unauthorized_push() {
153 let state = create_test_state().await; 153 let relay = TestRelay::start().await;
154 let result = validate_push(&state, "refs/heads/main", alice_pubkey).await; 154 let result = validate_push(&state, "refs/heads/main", alice_pubkey).await;
155 assert!(result.is_err()); 155 assert!(result.is_err());
156 relay.stop().await;
156} 157}
157``` 158```
158 159
159**Result:** Pure Rust unit tests, no shell scripts, no Git setup. 160**Result:** Pure Rust unit tests, no shell scripts, no Git setup.
160 161
162See [`tests/push_authorization.rs`](tests/push_authorization.rs) for actual test examples.
163
161### 4. Shared State and Types 164### 4. Shared State and Types
162 165
163**With hooks:** 166**With hooks:**
@@ -168,19 +171,17 @@ async fn test_unauthorized_push() {
168 171
169**With inline authorization:** 172**With inline authorization:**
170```rust 173```rust
171pub struct GitHandler { 174// From src/git/handlers.rs
172 nostr_relay: Arc<NostrRelay>, // Shared! 175pub async fn handle_receive_pack(
173 state_cache: Arc<StateCache>, // Shared! 176 repo_path: PathBuf,
174} 177 body: Bytes,
175 178 database: SharedDatabase, // Shared with Nostr relay!
176impl GitHandler { 179 npub: &str,
177 async fn validate_push(&self, refs: &[RefUpdate]) -> Result<()> { 180 identifier: &str,
178 // Direct access to Nostr state 181) -> Result<Response<Full<Bytes>>, GitError> {
179 let state = self.state_cache.get_latest().await?; 182 // Direct database access for authorization
180 // Validate using shared types 183 let auth = get_authorization_for_owner(&database, pubkey, identifier).await?;
181 state.validate_refs(refs)?; 184 // ...
182 Ok(())
183 }
184} 185}
185``` 186```
186 187
@@ -208,9 +209,9 @@ Setup steps:
208**With inline authorization (ngit-grasp):** 209**With inline authorization (ngit-grasp):**
209``` 210```
210Single Rust binary: 211Single Rust binary:
211 - HTTP server (actix-web) 212 - HTTP server (Hyper)
212 - Git protocol handler 213 - Git protocol handler
213 - Nostr relay 214 - Nostr relay (nostr-relay-builder)
214 - Authorization logic 215 - Authorization logic
215 216
216Setup steps: 217Setup steps:
@@ -235,44 +236,38 @@ Content-Type: application/x-git-receive-pack-request
2350000000000000000000000000000000000000000 abc123... refs/heads/main\0 report-status 2360000000000000000000000000000000000000000 abc123... refs/heads/main\0 report-status
236``` 237```
237 238
238We parse this **before** spawning Git: 239We parse this **before** spawning Git. See [`src/git/authorization.rs`](src/git/authorization.rs) for the implementation:
239 240
240```rust 241```rust
241pub async fn git_receive_pack( 242/// Parse ref updates from git-receive-pack request body
242 req: HttpRequest, 243pub fn parse_pushed_refs(body: &[u8]) -> Result<Vec<PushedRef>, AuthorizationError> {
243 body: web::Bytes, 244 // Parse pkt-line format
244) -> Result<HttpResponse, Error> { 245 // Extract ref updates
245 // 1. Parse ref updates from request body 246 // Return structured data
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} 247}
255``` 248```
256 249
257### How We Validate 250### How We Validate
258 251
259Validation checks: 252Validation checks (from [`src/git/authorization.rs`](src/git/authorization.rs)):
253
2601. Does pusher's pubkey have write access? 2541. Does pusher's pubkey have write access?
2612. Are they listed as a maintainer in the latest state event? 2552. Are they listed as a maintainer in the latest state event?
2623. Do maintainer sets form a valid chain? 2563. Do the refs match the state event?
263 257
264```rust 258```rust
265async fn validate_push( 259/// Validate that pushed refs match the authorized state
266 state: &RepoState, 260pub fn validate_push_refs(
267 refs: &[RefUpdate], 261 pushed_refs: &[PushedRef],
268) -> Result<()> { 262 state: &RepositoryState,
269 for ref_update in refs { 263) -> Result<(), AuthorizationError> {
270 // Check if pusher is authorized for this ref 264 for pushed_ref in pushed_refs {
271 if !state.is_authorized(&ref_update.name, pusher_pubkey) { 265 if pushed_ref.ref_name.starts_with("refs/heads/") {
272 return Err(Error::Unauthorized { 266 // Validate branch against state
273 ref_name: ref_update.name.clone(), 267 } else if pushed_ref.ref_name.starts_with("refs/tags/") {
274 pubkey: pusher_pubkey, 268 // Validate tag against state
275 }); 269 } else if pushed_ref.ref_name.starts_with("refs/nostr/") {
270 // Allow refs/nostr/<event-id> for PRs
276 } 271 }
277 } 272 }
278 Ok(()) 273 Ok(())
@@ -291,7 +286,7 @@ async fn validate_push(
291| **Performance** | Spawns Git first | Validates first | 286| **Performance** | Spawns Git first | Validates first |
292| **Testing** | Shell scripts + Go tests | Pure Rust tests | 287| **Testing** | Shell scripts + Go tests | Pure Rust tests |
293| **Deployment** | Docker + supervisord | Single binary | 288| **Deployment** | Docker + supervisord | Single binary |
294| **State sharing** | WebSocket query | Direct memory access | 289| **State sharing** | WebSocket query | Direct database access |
295 290
296Both are GRASP-compliant, but inline authorization is simpler and more efficient. 291Both are GRASP-compliant, but inline authorization is simpler and more efficient.
297 292
@@ -314,34 +309,25 @@ Both are GRASP-compliant, but inline authorization is simpler and more efficient
314### Is It Worth It? 309### Is It Worth It?
315 310
316**Yes**, because: 311**Yes**, because:
3171. The `git-http-backend` crate handles protocol parsing 3121. We handle protocol parsing in [`src/git/protocol.rs`](src/git/protocol.rs)
3182. GRASP is already non-standard (Nostr authorization) 3132. GRASP is already non-standard (Nostr authorization)
3193. Benefits far outweigh the coupling cost 3143. Benefits far outweigh the coupling cost
3204. We can still add hook support later if needed 3154. We can still add hook support later if needed
321 316
322--- 317---
323 318
324## Alternative Considered: Hybrid Approach 319## Implementation References
325
326We could use **both** inline validation and hooks:
327
328```rust
329// Inline: Fast path for common cases
330if !quick_validate(pusher).await? {
331 return Err(Error::Unauthorized);
332}
333
334// Hook: Detailed validation
335spawn_git_with_hook().await?;
336```
337 320
338**Why we didn't choose this:** 321Key files in the ngit-grasp implementation:
339- Added complexity
340- Redundant validation
341- Slower (two validation steps)
342- Harder to maintain
343 322
344If inline validation is sufficient, why add hooks? 323| Component | Location |
324|-----------|----------|
325| HTTP routing | [`src/http/mod.rs`](src/http/mod.rs) |
326| Git handlers | [`src/git/handlers.rs`](src/git/handlers.rs) |
327| Push authorization | [`src/git/authorization.rs`](src/git/authorization.rs) |
328| Git protocol parsing | [`src/git/protocol.rs`](src/git/protocol.rs) |
329| Subprocess management | [`src/git/subprocess.rs`](src/git/subprocess.rs) |
330| Event acceptance policy | [`src/nostr/builder.rs:51`](src/nostr/builder.rs:51) - `Nip34WritePolicy` |
345 331
346--- 332---
347 333
@@ -365,9 +351,8 @@ This would allow:
365 351
366### If Git Protocol Changes 352### If Git Protocol Changes
367 353
368The `git-http-backend` crate abstracts protocol details. If the Git protocol changes: 354The protocol parsing is isolated in [`src/git/protocol.rs`](src/git/protocol.rs). If the Git protocol changes:
369- Update the crate dependency 355- Update the protocol module
370- Adjust our ref parsing if needed
371- Tests will catch any breakage 356- Tests will catch any breakage
372 357
373--- 358---
@@ -380,11 +365,11 @@ The `git-http-backend` crate abstracts protocol details. If the Git protocol cha
3802. It's more performant (early rejection) 3652. It's more performant (early rejection)
3813. It's easier to test (pure Rust) 3663. It's easier to test (pure Rust)
3824. It's simpler to deploy (single binary) 3674. It's simpler to deploy (single binary)
3835. It enables better integration (shared state) 3685. It enables better integration (shared database)
384 369
385The trade-off (coupling to Git HTTP protocol) is acceptable because: 370The trade-off (coupling to Git HTTP protocol) is acceptable because:
386- The protocol is stable and well-specified 371- The protocol is stable and well-specified
387- The `git-http-backend` crate abstracts details 372- Protocol handling is isolated in one module
388- Benefits far outweigh the cost 373- Benefits far outweigh the cost
389 374
390This decision aligns with our goal of creating a **developer-friendly, production-ready GRASP implementation**. 375This decision aligns with our goal of creating a **developer-friendly, production-ready GRASP implementation**.
@@ -397,7 +382,8 @@ This decision aligns with our goal of creating a **developer-friendly, productio
397- [Design Decisions](decisions.md) - All architectural choices 382- [Design Decisions](decisions.md) - All architectural choices
398- [Comparison with ngit-relay](comparison.md) - Detailed comparison 383- [Comparison with ngit-relay](comparison.md) - Detailed comparison
399- [Git Protocol Reference](../reference/git-protocol.md) - Protocol details 384- [Git Protocol Reference](../reference/git-protocol.md) - Protocol details
385- [Test Strategy](../reference/test-strategy.md) - How we test this
400 386
401--- 387---
402 388
403*Part of the [ngit-grasp explanation docs](./)* 389*Part of the [ngit-grasp explanation docs](./)* \ No newline at end of file