upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-12-04 15:42:00 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-12-04 15:42:00 +0000
commit819866330c7e2f535a155d1d7efaf2e12dc15dc2 (patch)
treed84c8361811544aad9cad089c0358b9028c8fb80
parentfd0c87c787d0626b3546fa571541c9c809711821 (diff)
refactor: split Nip34WritePolicy into focused sub-policies
Split the ~900 line Nip34WritePolicy into focused sub-policies for improved testability and maintainability: - AnnouncementPolicy - Repository announcement validation - StatePolicy - State event validation + ref alignment - PrEventPolicy - PR/PR Update validation - RelatedEventPolicy - Forward/backward reference checking The main Nip34WritePolicy now delegates to these sub-policies via a shared PolicyContext that provides domain, database, and git_data_path. Also updates: - README.md: Accurate project structure reflecting actual implementation - docs/learnings: Marks this technical debt item as complete
-rw-r--r--README.md40
-rw-r--r--docs/learnings/grasp-01-implementation.md18
-rw-r--r--src/nostr/builder.rs1334
-rw-r--r--src/nostr/mod.rs4
-rw-r--r--src/nostr/policy/announcement.rs157
-rw-r--r--src/nostr/policy/mod.rs41
-rw-r--r--src/nostr/policy/pr_event.rs198
-rw-r--r--src/nostr/policy/related.rs276
-rw-r--r--src/nostr/policy/state.rs419
9 files changed, 1295 insertions, 1192 deletions
diff --git a/README.md b/README.md
index b63c5b1..ec1584c 100644
--- a/README.md
+++ b/README.md
@@ -326,23 +326,35 @@ nix develop -c cargo tarpaulin --out Html
326ngit-grasp/ 326ngit-grasp/
327├── src/ 327├── src/
328│ ├── main.rs # Entry point, server setup 328│ ├── main.rs # Entry point, server setup
329│ ├── lib.rs # Library exports
330│ ├── config.rs # Configuration
329│ ├── git/ 331│ ├── git/
330│ │ ├── mod.rs # Git module 332│ │ ├── mod.rs # Git module + repository operations
331│ │ ├── handler.rs # Git HTTP handlers 333│ │ ├── handlers.rs # Git HTTP handlers
332│ │ └── authorization.rs # Push validation logic 334│ │ ├── authorization.rs # Push validation logic
335│ │ ├── protocol.rs # Git protocol encoding
336│ │ └── subprocess.rs # Git subprocess management
333│ ├── nostr/ 337│ ├── nostr/
334│ │ ├── mod.rs # Nostr module 338│ │ ├── mod.rs # Nostr module
335│ │ ├── relay.rs # Relay setup and policies 339│ │ ├── builder.rs # Relay builder + Nip34WritePolicy
336│ │ └── events.rs # Event handlers 340│ │ ├── events.rs # Event parsing and validation
337│ ├── storage/ 341│ │ └── policy/ # Sub-policies (split for maintainability)
338│ │ ├── mod.rs # Storage abstraction 342│ │ ├── mod.rs # Policy module exports
339│ │ └── repository.rs # Repository management 343│ │ ├── announcement.rs # Repository announcement validation
340│ └── config.rs # Configuration 344│ │ ├── state.rs # State event validation + ref alignment
341├── docs/ 345│ │ ├── pr_event.rs # PR/PR Update validation
342│ └── ARCHITECTURE.md # Detailed architecture 346│ │ └── related.rs # Forward/backward reference checking
343├── tests/ 347│ ├── http/
344│ ├── integration/ # Integration tests 348│ │ ├── mod.rs # HTTP module
345│ └── fixtures/ # Test data 349│ │ ├── landing.rs # Landing page handler
350│ │ └── nip11.rs # NIP-11 relay info document
351│ └── metrics/
352│ ├── mod.rs # Prometheus metrics
353│ ├── bandwidth.rs # Bandwidth tracking
354│ └── connection.rs # Connection tracking
355├── docs/ # Documentation (Diátaxis framework)
356├── tests/ # Integration tests
357├── grasp-audit/ # Compliance audit subproject
346└── README.md 358└── README.md
347``` 359```
348 360
diff --git a/docs/learnings/grasp-01-implementation.md b/docs/learnings/grasp-01-implementation.md
index dea6389..719f751 100644
--- a/docs/learnings/grasp-01-implementation.md
+++ b/docs/learnings/grasp-01-implementation.md
@@ -156,17 +156,17 @@ This enables parallel CI runs without interference.
156 156
157**Better approach:** Treat architecture docs as living documents. When implementation diverges from the plan, update the doc immediately. The initial design document was valuable and should remain, but it should reflect what was built. 157**Better approach:** Treat architecture docs as living documents. When implementation diverges from the plan, update the doc immediately. The initial design document was valuable and should remain, but it should reflect what was built.
158 158
159### 2. Smaller Nip34WritePolicy 159### 2. ~~Smaller Nip34WritePolicy~~ ✅ DONE
160 160
161**What happened:** The [`Nip34WritePolicy`](src/nostr/builder.rs:51) grew to ~900 lines handling all event types. 161**What happened:** The `Nip34WritePolicy` grew to ~900 lines handling all event types.
162 162
163**Better approach:** Split into: 163**Resolution:** Split into focused sub-policies in [`src/nostr/policy/`](src/nostr/policy/mod.rs:1):
164- `AnnouncementPolicy` - Repository announcement validation 164- [`AnnouncementPolicy`](src/nostr/policy/announcement.rs:1) - Repository announcement validation
165- `StatePolicy` - State event validation + ref alignment 165- [`StatePolicy`](src/nostr/policy/state.rs:1) - State event validation + ref alignment
166- `RelatedEventPolicy` - Forward/backward reference checking 166- [`RelatedEventPolicy`](src/nostr/policy/related.rs:1) - Forward/backward reference checking
167- `PrEventPolicy` - PR/PR Update validation 167- [`PrEventPolicy`](src/nostr/policy/pr_event.rs:1) - PR/PR Update validation
168 168
169This would improve testability and readability. 169The main [`Nip34WritePolicy`](src/nostr/builder.rs:51) now delegates to these sub-policies, improving testability and readability.
170 170
171### 3. Git Operations Module Organization 171### 3. Git Operations Module Organization
172 172
@@ -190,7 +190,7 @@ This would improve testability and readability.
190 190
191### High Priority 191### High Priority
192 192
1931. **Split `Nip34WritePolicy`** - Too large, hard to test/maintain 1931. ~~**Split `Nip34WritePolicy`**~~ ✅ DONE - Split into sub-policies in [`src/nostr/policy/`](src/nostr/policy/mod.rs:1)
1942. **Add unit tests for policy logic** - Currently relies on integration tests 1942. **Add unit tests for policy logic** - Currently relies on integration tests
1953. **Document actual architecture** - Docs describe plans, not implementation 1953. **Document actual architecture** - Docs describe plans, not implementation
196 196
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs
index 00e5969..15ff083 100644
--- a/src/nostr/builder.rs
+++ b/src/nostr/builder.rs
@@ -1,64 +1,51 @@
1/// Nostr Relay Builder Configuration 1/// Nostr Relay Builder Configuration
2/// 2///
3/// This module integrates nostr-relay-builder with NIP-34 validation logic 3/// This module integrates nostr-relay-builder with NIP-34 validation logic
4/// preserved from the original implementation. 4/// using modular sub-policies for each event type.
5use std::net::SocketAddr; 5use std::net::SocketAddr;
6use std::path::{Path, PathBuf}; 6use std::path::Path;
7use std::sync::Arc; 7use std::sync::Arc;
8 8
9use nostr::nips::nip19::ToBech32; 9use nostr::nips::nip19::ToBech32;
10use nostr::prelude::{Alphabet, SingleLetterTag};
11use nostr::{EventId, Filter, Kind, PublicKey};
12use nostr_lmdb::NostrLMDB; 10use nostr_lmdb::NostrLMDB;
13use nostr_relay_builder::prelude::*; 11use nostr_relay_builder::prelude::*;
14 12
15use crate::config::{Config, DatabaseBackend}; 13use crate::config::{Config, DatabaseBackend};
16use crate::git;
17use crate::nostr::events::{ 14use crate::nostr::events::{
18 validate_announcement, validate_state, RepositoryAnnouncement, RepositoryState, KIND_PR, 15 RepositoryAnnouncement, RepositoryState, KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT,
19 KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, 16 KIND_REPOSITORY_STATE,
17};
18use crate::nostr::policy::{
19 AnnouncementPolicy, AnnouncementResult, PolicyContext, PrEventPolicy, RelatedEventPolicy,
20 ReferenceResult, StatePolicy, StateResult,
20}; 21};
21 22
22/// Type alias for the shared database used by the relay 23/// Type alias for the shared database used by the relay
23pub type SharedDatabase = Arc<dyn NostrDatabase>; 24pub type SharedDatabase = Arc<dyn NostrDatabase>;
24 25
25/// Result of aligning a repository with authorized state
26#[derive(Debug, Default)]
27struct AlignmentResult {
28 /// Number of refs created
29 refs_created: usize,
30 /// Number of refs updated
31 refs_updated: usize,
32 /// Number of refs deleted
33 refs_deleted: usize,
34 /// Whether HEAD was set
35 head_set: bool,
36}
37
38/// NIP-34 Write Policy with Full GRASP-01 Event Validation 26/// NIP-34 Write Policy with Full GRASP-01 Event Validation
39/// 27///
40/// Validates all events according to GRASP-01 specification: 28/// Validates all events according to GRASP-01 specification using modular sub-policies:
41/// - Repository announcements must list service in clone and relays tags 29/// - `AnnouncementPolicy` - Repository announcement validation
42/// EXCEPTION: Recursive maintainer announcements are accepted even without 30/// - `StatePolicy` - State event validation + ref alignment
43/// listing the service, to enable maintainer chain discovery and GRASP-02 sync 31/// - `PrEventPolicy` - PR/PR Update validation
44/// - Repository state announcements must have valid structure 32/// - `RelatedEventPolicy` - Forward/backward reference checking
45/// - Other events must reference accepted repositories or events
46/// - Forward references are supported (events referenced by accepted events)
47/// - Orphan events with no valid references are rejected
48/// 33///
49/// Uses stateful database queries to check event relationships. 34/// Uses stateful database queries to check event relationships.
50#[derive(Clone)] 35#[derive(Clone)]
51pub struct Nip34WritePolicy { 36pub struct Nip34WritePolicy {
52 domain: String, 37 ctx: PolicyContext,
53 database: SharedDatabase, 38 announcement_policy: AnnouncementPolicy,
54 git_data_path: PathBuf, 39 state_policy: StatePolicy,
40 pr_event_policy: PrEventPolicy,
41 related_event_policy: RelatedEventPolicy,
55} 42}
56 43
57impl std::fmt::Debug for Nip34WritePolicy { 44impl std::fmt::Debug for Nip34WritePolicy {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 f.debug_struct("Nip34WritePolicy") 46 f.debug_struct("Nip34WritePolicy")
60 .field("domain", &self.domain) 47 .field("domain", &self.ctx.domain)
61 .field("git_data_path", &self.git_data_path) 48 .field("git_data_path", &self.ctx.git_data_path)
62 .field("database", &"<database>") 49 .field("database", &"<database>")
63 .finish() 50 .finish()
64 } 51 }
@@ -68,837 +55,200 @@ impl Nip34WritePolicy {
68 pub fn new( 55 pub fn new(
69 domain: impl Into<String>, 56 domain: impl Into<String>,
70 database: SharedDatabase, 57 database: SharedDatabase,
71 git_data_path: impl Into<PathBuf>, 58 git_data_path: impl Into<std::path::PathBuf>,
72 ) -> Self { 59 ) -> Self {
60 let ctx = PolicyContext::new(domain, database, git_data_path);
73 Self { 61 Self {
74 domain: domain.into(), 62 announcement_policy: AnnouncementPolicy::new(ctx.clone()),
75 database, 63 state_policy: StatePolicy::new(ctx.clone()),
76 git_data_path: git_data_path.into(), 64 pr_event_policy: PrEventPolicy::new(ctx.clone()),
65 related_event_policy: RelatedEventPolicy::new(ctx.clone()),
66 ctx,
77 } 67 }
78 } 68 }
79 69
80 /// Create a bare git repository if it doesn't exist 70 /// Handle repository announcement event
81 /// Path format: <git_data_path>/<npub>/<identifier>.git 71 async fn handle_announcement(&self, event: &Event) -> PolicyResult {
82 fn ensure_bare_repository(&self, announcement: &RepositoryAnnouncement) -> Result<(), String> { 72 let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex());
83 let repo_path = self.git_data_path.join(announcement.repo_path()); 73
84 74 match self.announcement_policy.validate(event).await {
85 // Check if repository already exists 75 AnnouncementResult::Accept => {
86 if repo_path.exists() { 76 // Parse announcement to get repository details
87 tracing::debug!("Repository already exists at {}", repo_path.display()); 77 match RepositoryAnnouncement::from_event(event.clone()) {
88 return Ok(()); 78 Ok(announcement) => {
89 } 79 // Try to create bare repository if it doesn't exist
90 80 if let Err(e) = self.announcement_policy.ensure_bare_repository(&announcement)
91 // Create parent directory (npub directory) 81 {
92 let parent = repo_path 82 tracing::warn!(
93 .parent() 83 "Failed to create bare repository for {}: {}",
94 .ok_or_else(|| format!("Invalid repository path: {}", repo_path.display()))?; 84 event_id_str,
95 85 e
96 std::fs::create_dir_all(parent) 86 );
97 .map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?; 87 // Note: We still accept the event even if repo creation fails
98 88 }
99 // Initialize bare repository using git command
100 let output = std::process::Command::new("git")
101 .args(["init", "--bare", repo_path.to_str().unwrap()])
102 .output()
103 .map_err(|e| format!("Failed to execute git init: {}", e))?;
104
105 if !output.status.success() {
106 let stderr = String::from_utf8_lossy(&output.stderr);
107 return Err(format!("git init failed: {}", stderr));
108 }
109
110 tracing::info!("Created bare repository at {}", repo_path.display());
111 Ok(())
112 }
113
114 /// Check if this state event is the latest for its identifier among authorized authors
115 ///
116 /// A state is considered "latest" if no other state event in the database
117 /// from an authorized author has a newer timestamp. This handles out-of-order
118 /// delivery where an older event arrives after a newer one.
119 ///
120 /// The authorized_pubkeys should be the owner and maintainers of a specific
121 /// announcement, so different owners with the same identifier don't interfere.
122 async fn is_latest_state_for_identifier(
123 database: &SharedDatabase,
124 state: &RepositoryState,
125 authorized_pubkeys: &[PublicKey],
126 ) -> Result<bool, String> {
127 let filter = Filter::new()
128 .kind(Kind::from(KIND_REPOSITORY_STATE))
129 .custom_tag(
130 SingleLetterTag::lowercase(Alphabet::D),
131 state.identifier.clone(),
132 );
133 89
134 match database.query(filter).await { 90 tracing::debug!("Accepted repository announcement: {}", event_id_str);
135 Ok(events) => { 91 PolicyResult::Accept
136 for event in events {
137 // Skip comparing to self (same event ID)
138 if event.id == state.event.id {
139 continue;
140 } 92 }
141 // Only consider events from authorized authors for this announcement 93 Err(e) => {
142 if !authorized_pubkeys.contains(&event.pubkey) { 94 tracing::warn!(
143 continue; 95 "Failed to parse repository announcement {}: {}",
144 } 96 event_id_str,
145 // If any existing event from an authorized author is newer, this is not the latest 97 e
146 if event.created_at > state.event.created_at {
147 tracing::debug!(
148 "State {} is not latest: found newer state {} from {} (ts {} > {})",
149 state.event.id.to_hex(),
150 event.id.to_hex(),
151 event.pubkey.to_hex(),
152 event.created_at.as_secs(),
153 state.event.created_at.as_secs()
154 ); 98 );
155 return Ok(false); 99 PolicyResult::Reject(format!("Failed to parse announcement: {}", e))
156 } 100 }
157 } 101 }
158 Ok(true)
159 } 102 }
160 Err(e) => Err(format!("Database query failed: {}", e)), 103 AnnouncementResult::AcceptMaintainer => {
161 } 104 // Parse announcement to get details for logging
162 } 105 match RepositoryAnnouncement::from_event(event.clone()) {
163 106 Ok(announcement) => {
164 /// Find all repository announcements where the given pubkey is authorized 107 tracing::info!(
165 /// 108 "Accepted maintainer announcement {} (author {} is listed as maintainer for {})",
166 /// A pubkey is authorized for an announcement if: 109 event_id_str,
167 /// - They are the owner (pubkey of the announcement event), OR 110 event.pubkey.to_hex(),
168 /// - They are listed in the "maintainers" tag 111 announcement.identifier
169 /// 112 );
170 /// This is needed because a maintainer can publish a state event that 113 // Don't create bare repository for external announcements
171 /// should update HEAD in the repository of the announcement owner, 114 PolicyResult::Accept
172 /// not in the maintainer's own (possibly non-existent) repository. 115 }
173 async fn find_authorized_announcements( 116 Err(e) => {
174 database: &SharedDatabase, 117 tracing::warn!(
175 identifier: &str, 118 "Failed to parse maintainer announcement {}: {}",
176 state_author: &PublicKey, 119 event_id_str,
177 ) -> Result<Vec<RepositoryAnnouncement>, String> { 120 e
178 let filter = Filter::new() 121 );
179 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) 122 PolicyResult::Reject(format!("Failed to parse announcement: {}", e))
180 .custom_tag(
181 SingleLetterTag::lowercase(Alphabet::D),
182 identifier.to_string(),
183 );
184
185 match database.query(filter).await {
186 Ok(events) => {
187 let mut authorized = Vec::new();
188 let state_author_hex = state_author.to_hex();
189
190 for event in events {
191 if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) {
192 // Check if state author is authorized for this announcement
193 let is_owner = event.pubkey == *state_author;
194 let is_maintainer = announcement.maintainers.contains(&state_author_hex);
195
196 if is_owner || is_maintainer {
197 tracing::debug!(
198 "Found authorized announcement for {}: owner={}, maintainer={}",
199 identifier,
200 if is_owner {
201 event.pubkey.to_hex()
202 } else {
203 "n/a".to_string()
204 },
205 is_maintainer
206 );
207 authorized.push(announcement);
208 }
209 } 123 }
210 }
211 Ok(authorized)
212 }
213 Err(e) => Err(format!("Database query failed: {}", e)),
214 }
215 }
216
217 /// Identify all owner repositories for which this state event is the latest authorized state
218 ///
219 /// Returns a list of (announcement, repo_path) pairs where:
220 /// - The state author is authorized (owner or maintainer)
221 /// - This state event is the latest for the identifier in that context
222 async fn identify_owner_repositories(
223 &self,
224 database: &SharedDatabase,
225 state: &RepositoryState,
226 ) -> Result<Vec<(RepositoryAnnouncement, std::path::PathBuf)>, String> {
227 // Find all announcements where state author is authorized
228 let announcements =
229 Self::find_authorized_announcements(database, &state.identifier, &state.event.pubkey)
230 .await?;
231
232 if announcements.is_empty() {
233 tracing::debug!(
234 "No authorized announcements found for state {} by {}",
235 state.identifier,
236 state.event.pubkey.to_hex()
237 );
238 return Ok(Vec::new());
239 }
240
241 let mut owner_repos = Vec::new();
242
243 for announcement in announcements {
244 // Build the list of authorized pubkeys for this specific announcement
245 // (owner + maintainers)
246 let mut authorized_pubkeys = vec![announcement.event.pubkey];
247 for maintainer_hex in &announcement.maintainers {
248 if let Ok(pk) = PublicKey::from_hex(maintainer_hex) {
249 authorized_pubkeys.push(pk);
250 } 124 }
251 } 125 }
252 126 AnnouncementResult::Reject(reason) => {
253 // Check if this is the latest state event for THIS announcement's context 127 tracing::warn!(
254 if !Self::is_latest_state_for_identifier(database, state, &authorized_pubkeys).await? { 128 "Rejected repository announcement {}: {}",
255 tracing::debug!( 129 event_id_str,
256 "Skipping {} in {}'s repo - not the latest state event for this context", 130 reason
257 state.identifier,
258 announcement.event.pubkey.to_hex()
259 ); 131 );
260 continue; 132 PolicyResult::Reject(reason)
261 } 133 }
262
263 // Build repository path: <git_data_path>/<owner_npub>/<identifier>.git
264 let repo_path = self.git_data_path.join(announcement.repo_path().clone());
265 owner_repos.push((announcement, repo_path));
266 } 134 }
267
268 Ok(owner_repos)
269 } 135 }
270 136
271 /// Align an owner repository's refs with the authorized state 137 /// Handle repository state event
272 /// 138 async fn handle_state(&self, event: &Event) -> PolicyResult {
273 /// This function: 139 let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex());
274 /// 1. Deletes refs that are in the repo but not in the state (for refs/heads/ and refs/tags/) 140
275 /// 2. Updates refs that exist in state if we have the commit (for refs/heads/ and refs/tags/) 141 match self.state_policy.validate(event) {
276 /// 3. Sets HEAD if the HEAD branch's commit is available 142 StateResult::Accept => {
277 /// 143 // Parse state to get HEAD and branch info
278 /// Per GRASP-01: "MUST set repository HEAD per repository state announcement 144 match RepositoryState::from_event(event.clone()) {
279 /// as soon as the git data related to that branch has been received." 145 Ok(_state) => {
280 /// 146 // Process state alignment asynchronously
281 /// Returns a summary of actions taken. 147 if let Err(e) = self.state_policy.process_state_event(event).await {
282 fn align_owner_repository_with_state(
283 &self,
284 repo_path: &std::path::Path,
285 state: &RepositoryState,
286 ) -> AlignmentResult {
287 let mut result = AlignmentResult::default();
288
289 // Check if repository exists
290 if !repo_path.exists() {
291 tracing::debug!(
292 "Repository not found at {}, cannot align with state",
293 repo_path.display()
294 );
295 return result;
296 }
297
298 // Get current refs from the repository
299 let current_refs = match git::list_refs(repo_path) {
300 Ok(refs) => refs,
301 Err(e) => {
302 tracing::warn!("Failed to list refs in {}: {}", repo_path.display(), e);
303 return result;
304 }
305 };
306
307 // Build expected refs from state
308 let mut expected_refs: std::collections::HashMap<String, String> =
309 std::collections::HashMap::new();
310
311 for branch in &state.branches {
312 let ref_name = format!("refs/heads/{}", branch.name);
313 expected_refs.insert(ref_name, branch.commit.clone());
314 }
315
316 for tag in &state.tags {
317 let ref_name = format!("refs/tags/{}", tag.name);
318 expected_refs.insert(ref_name, tag.commit.clone());
319 }
320
321 // Process current refs: update or delete as needed
322 for (ref_name, current_commit) in &current_refs {
323 // Only process refs/heads/ and refs/tags/
324 if !ref_name.starts_with("refs/heads/") && !ref_name.starts_with("refs/tags/") {
325 continue;
326 }
327
328 match expected_refs.get(ref_name) {
329 Some(expected_commit) => {
330 // Ref should exist - check if commit matches
331 if current_commit != expected_commit {
332 // Check if we have the expected commit
333 if git::commit_exists(repo_path, expected_commit) {
334 // Update the ref
335 match git::update_ref(repo_path, ref_name, expected_commit) {
336 Ok(()) => {
337 tracing::info!(
338 "Updated {} from {} to {} in {}",
339 ref_name,
340 current_commit,
341 expected_commit,
342 repo_path.display()
343 );
344 result.refs_updated += 1;
345 }
346 Err(e) => {
347 tracing::warn!(
348 "Failed to update {} in {}: {}",
349 ref_name,
350 repo_path.display(),
351 e
352 );
353 }
354 }
355 } else {
356 tracing::debug!(
357 "Commit {} not available for {} in {}",
358 expected_commit,
359 ref_name,
360 repo_path.display()
361 );
362 }
363 }
364 }
365 None => {
366 // Ref should not exist - delete it
367 match git::delete_ref(repo_path, ref_name) {
368 Ok(()) => {
369 tracing::info!(
370 "Deleted {} (not in state) from {}",
371 ref_name,
372 repo_path.display()
373 );
374 result.refs_deleted += 1;
375 }
376 Err(e) => {
377 tracing::warn!( 148 tracing::warn!(
378 "Failed to delete {} from {}: {}", 149 "Failed to process state event {}: {}",
379 ref_name, 150 event_id_str,
380 repo_path.display(),
381 e 151 e
382 ); 152 );
383 } 153 }
384 }
385 }
386 }
387 }
388 154
389 // Add refs that exist in state but not in repo (if we have the commit) 155 tracing::debug!("Accepted repository state: {}", event_id_str);
390 for (ref_name, expected_commit) in &expected_refs { 156 PolicyResult::Accept
391 let exists = current_refs.iter().any(|(r, _)| r == ref_name);
392 if !exists && git::commit_exists(repo_path, expected_commit) {
393 match git::update_ref(repo_path, ref_name, expected_commit) {
394 Ok(()) => {
395 tracing::info!(
396 "Created {} at {} in {}",
397 ref_name,
398 expected_commit,
399 repo_path.display()
400 );
401 result.refs_created += 1;
402 } 157 }
403 Err(e) => { 158 Err(e) => {
404 tracing::warn!( 159 tracing::warn!(
405 "Failed to create {} in {}: {}", 160 "Failed to parse repository state {}: {}",
406 ref_name, 161 event_id_str,
407 repo_path.display(),
408 e 162 e
409 ); 163 );
164 // Still accept the event even if we can't parse it
165 // The validation passed, so it's structurally valid
166 PolicyResult::Accept
410 } 167 }
411 } 168 }
412 } 169 }
413 } 170 StateResult::Reject(reason) => {
414 171 tracing::warn!("Rejected repository state {}: {}", event_id_str, reason);
415 // Set HEAD if specified in state 172 PolicyResult::Reject(reason)
416 if let Some(head_ref) = &state.head {
417 if let Some(branch_name) = state.get_head_branch() {
418 if let Some(head_commit) = state.get_branch_commit(branch_name) {
419 match git::try_set_head_if_available(repo_path, head_ref, head_commit) {
420 Ok(true) => {
421 tracing::info!(
422 "Set HEAD to {} in {} (from state by {})",
423 head_ref,
424 repo_path.display(),
425 state.event.pubkey.to_hex()
426 );
427 result.head_set = true;
428 }
429 Ok(false) => {
430 tracing::debug!(
431 "HEAD commit {} not available yet in {}",
432 head_commit,
433 repo_path.display()
434 );
435 }
436 Err(e) => {
437 tracing::warn!("Failed to set HEAD in {}: {}", repo_path.display(), e);
438 }
439 }
440 }
441 } 173 }
442 } 174 }
443
444 result
445 } 175 }
446 176
447 /// Check if a pubkey is listed as a maintainer in any announcement for this identifier 177 /// Handle PR or PR Update event
448 /// 178 async fn handle_pr_event(&self, event: &Event) -> PolicyResult {
449 /// A pubkey is considered a maintainer if: 179 let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex());
450 /// 1. They are the owner (pubkey) of an accepted announcement with this identifier, OR 180
451 /// 2. They are listed in the maintainers tag of ANY announcement with this identifier 181 // Validate refs/nostr refs for this PR event
452 /// 182 // This deletes any refs/nostr/<event-id> that points to wrong commit
453 /// This enables accepting announcements from maintainers even when they don't list 183 if let Err(e) = self.pr_event_policy.validate_nostr_ref(event).await {
454 /// this GRASP server, for maintainer chain discovery and GRASP-02 sync. 184 tracing::warn!(
455 async fn is_maintainer_in_any_announcement( 185 "Failed to validate refs/nostr for PR event {}: {}",
456 database: &SharedDatabase, 186 event_id_str,
457 identifier: &str, 187 e
458 author: &PublicKey,
459 ) -> Result<bool, String> {
460 // Query all announcements with this identifier that are already in the database
461 let filter = Filter::new()
462 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
463 .custom_tag(
464 SingleLetterTag::lowercase(Alphabet::D),
465 identifier.to_string(),
466 ); 188 );
467 189 // Don't reject - just log the error and proceed with normal validation
468 let announcements: Vec<Event> = match database.query(filter).await {
469 Ok(events) => events.into_iter().collect(),
470 Err(e) => return Err(format!("Database query failed: {}", e)),
471 };
472
473 if announcements.is_empty() {
474 // No existing announcements for this identifier - author cannot be a maintainer
475 return Ok(false);
476 }
477
478 let author_hex = author.to_hex();
479
480 // Check each announcement to see if author is listed as a maintainer
481 for event in &announcements {
482 // Check if author is the owner of this announcement
483 if event.pubkey == *author {
484 return Ok(true);
485 }
486
487 // Check if author is listed in the maintainers tag
488 if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) {
489 if announcement.maintainers.contains(&author_hex) {
490 return Ok(true);
491 }
492 }
493 }
494
495 Ok(false)
496 }
497
498 /// Extract all reference tags from an event (a, A, q, e, E)
499 /// Returns (addressable_refs, event_refs)
500 fn extract_reference_tags(event: &Event) -> (Vec<String>, Vec<EventId>) {
501 let mut addressable_refs = Vec::new();
502 let mut event_refs = Vec::new();
503
504 for tag in event.tags.iter() {
505 let tag_vec = tag.clone().to_vec();
506 if tag_vec.is_empty() {
507 continue;
508 }
509
510 match tag_vec[0].as_str() {
511 // Addressable event references (a, A, q with kind:pubkey:identifier format)
512 "a" | "A" | "q" if tag_vec.len() > 1 && tag_vec[1].contains(':') => {
513 addressable_refs.push(tag_vec[1].clone());
514 }
515 // Event ID references (e, E, q with event ID format)
516 "e" | "E" if tag_vec.len() > 1 => {
517 if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) {
518 event_refs.push(event_id);
519 }
520 }
521 "q" if tag_vec.len() > 1 && !tag_vec[1].contains(':') => {
522 if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) {
523 event_refs.push(event_id);
524 }
525 }
526 _ => {}
527 }
528 } 190 }
529 191
530 (addressable_refs, event_refs) 192 // Continue with reference checking (same as related events)
193 self.handle_related_event(event, "PR").await
531 } 194 }
532 195
533 /// Validate refs/nostr/<event-id> ref against a PR or PR Update event's `c` tag 196 /// Handle events that must reference accepted repositories or events
534 /// 197 async fn handle_related_event(&self, event: &Event, event_type: &str) -> PolicyResult {
535 /// When a PR event (kind 1618) or PR Update event (kind 1619) is received, 198 let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex());
536 /// this checks if a corresponding refs/nostr/<event-id> ref exists in the
537 /// repository and validates that it points to the correct commit (from the
538 /// `c` tag). If the ref exists but points to a different commit, the ref is
539 /// deleted.
540 ///
541 /// PR and PR Update events can have multiple `a` tags to update multiple
542 /// repositories simultaneously.
543 ///
544 /// This is part of GRASP-01 compliance: ensuring refs/nostr refs are consistent
545 /// with their corresponding events.
546 ///
547 /// # Arguments
548 /// * `database` - Database for looking up repository announcements
549 /// * `event` - The PR event (kind 1618) or PR Update event (kind 1619)
550 ///
551 /// # Returns
552 /// Ok(Some(n)) if n refs were deleted, Ok(None) if no action taken, Err on failure
553 async fn validate_pr_nostr_ref(
554 &self,
555 database: &SharedDatabase,
556 event: &Event,
557 ) -> Result<Option<usize>, String> {
558 let event_id = event.id.to_hex();
559
560 // Extract the `c` tag (commit hash) from the PR event
561 let expected_commit = event.tags.iter().find_map(|tag| {
562 let tag_vec = tag.clone().to_vec();
563 if tag_vec.len() >= 2 && tag_vec[0] == "c" {
564 Some(tag_vec[1].clone())
565 } else {
566 None
567 }
568 });
569 199
570 let expected_commit = match expected_commit { 200 match self.related_event_policy.check_references(event).await {
571 Some(c) => c, 201 Ok(ReferenceResult::ReferencesRepository(addr_ref)) => {
572 None => {
573 tracing::debug!( 202 tracing::debug!(
574 "PR event {} has no 'c' tag, skipping ref validation", 203 "Accepted {} event {}: references accepted repository {}",
575 event_id 204 event_type,
205 event_id_str,
206 addr_ref
576 ); 207 );
577 return Ok(None); 208 PolicyResult::Accept
578 } 209 }
579 }; 210 Ok(ReferenceResult::ReferencesEvent(event_ref)) => {
580
581 // Extract ALL `a` tags (repository references) from the PR event
582 // PR events can reference multiple repositories
583 // Format: 30617:<pubkey>:<identifier>
584 let repo_refs: Vec<String> = event
585 .tags
586 .iter()
587 .filter_map(|tag| {
588 let tag_vec = tag.clone().to_vec();
589 if tag_vec.len() >= 2 && tag_vec[0] == "a" && tag_vec[1].starts_with("30617:") {
590 Some(tag_vec[1].clone())
591 } else {
592 None
593 }
594 })
595 .collect();
596
597 if repo_refs.is_empty() {
598 tracing::debug!(
599 "PR event {} has no repo 'a' tags, skipping ref validation",
600 event_id
601 );
602 return Ok(None);
603 }
604
605 let mut deleted_count = 0;
606
607 // Process each repository reference
608 for repo_ref in repo_refs {
609 // Parse the repo reference: 30617:<pubkey>:<identifier>
610 let parts: Vec<&str> = repo_ref.split(':').collect();
611 if parts.len() < 3 {
612 tracing::debug!( 211 tracing::debug!(
613 "PR event {} has invalid 'a' tag format: {}", 212 "Accepted {} event {}: references accepted event {}",
614 event_id, 213 event_type,
615 repo_ref 214 event_id_str,
215 event_ref
616 ); 216 );
617 continue; 217 PolicyResult::Accept
618 } 218 }
619 219 Ok(ReferenceResult::ReferencedByAccepted) => {
620 let repo_pubkey = match PublicKey::from_hex(parts[1]) {
621 Ok(pk) => pk,
622 Err(_) => {
623 tracing::debug!(
624 "PR event {} has invalid pubkey in 'a' tag: {}",
625 event_id,
626 parts[1]
627 );
628 continue;
629 }
630 };
631 let identifier = parts[2];
632
633 // Look up repository announcement to get the npub for path
634 let filter = Filter::new()
635 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
636 .author(repo_pubkey)
637 .custom_tag(
638 SingleLetterTag::lowercase(Alphabet::D),
639 identifier.to_string(),
640 );
641
642 let announcements: Vec<Event> = match database.query(filter).await {
643 Ok(events) => events.into_iter().collect(),
644 Err(e) => {
645 tracing::warn!(
646 "Failed to query for repository announcement for PR {}: {}",
647 event_id,
648 e
649 );
650 continue;
651 }
652 };
653
654 if announcements.is_empty() {
655 tracing::debug!( 220 tracing::debug!(
656 "No repository announcement found for PR event {} (repo {}:{})", 221 "Accepted {} event {}: referenced by accepted event",
657 event_id, 222 event_type,
658 repo_pubkey.to_hex(), 223 event_id_str
659 identifier
660 ); 224 );
661 continue; 225 PolicyResult::Accept
662 }
663
664 // Process each matching announcement (there could be multiple)
665 for announcement_event in announcements {
666 let announcement = match RepositoryAnnouncement::from_event(announcement_event) {
667 Ok(a) => a,
668 Err(e) => {
669 tracing::warn!(
670 "Failed to parse announcement for PR {} validation: {}",
671 event_id,
672 e
673 );
674 continue;
675 }
676 };
677
678 // Build repository path
679 let repo_path = self.git_data_path.join(announcement.repo_path());
680
681 // Validate the ref
682 match git::validate_nostr_ref(&repo_path, &event_id, &expected_commit) {
683 Ok(true) => {
684 tracing::info!(
685 "Deleted mismatched refs/nostr/{} in {} (expected commit {})",
686 event_id,
687 repo_path.display(),
688 expected_commit
689 );
690 deleted_count += 1;
691 }
692 Ok(false) => {
693 tracing::debug!(
694 "refs/nostr/{} in {} is valid or doesn't exist",
695 event_id,
696 repo_path.display()
697 );
698 }
699 Err(e) => {
700 tracing::warn!(
701 "Failed to validate refs/nostr/{} in {}: {}",
702 event_id,
703 repo_path.display(),
704 e
705 );
706 }
707 }
708 }
709 }
710
711 if deleted_count > 0 {
712 Ok(Some(deleted_count))
713 } else {
714 Ok(None)
715 }
716 }
717
718 /// Check if any addressable events (repositories) exist in database
719 /// Returns the first matching addressable reference found, or None if none match
720 async fn find_accepted_repository(
721 database: &SharedDatabase,
722 addressables: &[String],
723 ) -> Result<Option<String>, String> {
724 if addressables.is_empty() {
725 return Ok(None);
726 }
727
728 // Parse all addressable references
729 let mut parsed_refs = Vec::new();
730 for addr in addressables {
731 let parts: Vec<&str> = addr.split(':').collect();
732 if parts.len() < 3 {
733 continue; // Skip invalid format
734 }
735
736 let kind = match parts[0].parse::<u16>() {
737 Ok(k) => k,
738 Err(_) => continue, // Skip invalid kind
739 };
740 let pubkey = match PublicKey::from_hex(parts[1]) {
741 Ok(pk) => pk,
742 Err(_) => continue, // Skip invalid pubkey
743 };
744 let identifier = parts[2].to_string();
745
746 parsed_refs.push((addr.clone(), kind, pubkey, identifier));
747 }
748
749 if parsed_refs.is_empty() {
750 return Ok(None);
751 }
752
753 // Group by kind to reduce queries
754 use std::collections::HashMap;
755 let mut by_kind: HashMap<u16, Vec<_>> = HashMap::new();
756 for (addr, kind, pubkey, identifier) in parsed_refs {
757 by_kind
758 .entry(kind)
759 .or_default()
760 .push((addr, pubkey, identifier));
761 }
762
763 // Query each kind group
764 for (kind, refs) in by_kind {
765 let authors: Vec<PublicKey> = refs.iter().map(|(_, pk, _)| *pk).collect();
766
767 let filter = Filter::new().kind(Kind::from(kind)).authors(authors);
768
769 match database.query(filter).await {
770 Ok(events) => {
771 // Check if any event matches our identifier requirements
772 for event in events {
773 for (addr, _pubkey, identifier) in &refs {
774 // Match identifier tag
775 if event.tags.iter().any(|tag| {
776 let tag_vec = tag.clone().to_vec();
777 tag_vec.len() >= 2 && tag_vec[0] == "d" && tag_vec[1] == *identifier
778 }) {
779 return Ok(Some(addr.clone()));
780 }
781 }
782 }
783 }
784 Err(e) => return Err(format!("Database query failed: {}", e)),
785 } 226 }
786 } 227 Ok(ReferenceResult::Orphan) => {
787 228 let (addressable_refs, event_refs) =
788 Ok(None) 229 RelatedEventPolicy::extract_reference_tags(event);
789 } 230 tracing::info!(
790 231 "Rejected orphan {} event {}: no references to accepted repos or events (checked {} addressable, {} event refs)",
791 /// Check if any events exist in database 232 event_type,
792 /// Returns the first matching event ID found, or None if none match 233 event_id_str,
793 async fn find_accepted_event( 234 addressable_refs.len(),
794 database: &SharedDatabase, 235 event_refs.len()
795 event_ids: &[EventId], 236 );
796 ) -> Result<Option<EventId>, String> { 237 PolicyResult::Reject(format!(
797 if event_ids.is_empty() { 238 "{} event must reference an accepted repository or accepted event",
798 return Ok(None); 239 event_type
799 } 240 ))
800
801 // Single query for all event IDs
802 let filter = Filter::new().ids(event_ids.iter().copied());
803
804 match database.query(filter).await {
805 Ok(events) => {
806 // Get first event from the iterator
807 Ok(events.into_iter().next().map(|e| e.id))
808 }
809 Err(e) => Err(format!("Database query failed: {}", e)),
810 }
811 }
812
813 /// Check if any accepted event references this event (forward reference)
814 ///
815 /// For regular replaceable events (10000-19999): Checks addressable tags with kind:pubkey format
816 /// For parameterized replaceable (30000-39999): Checks addressable tags with kind:pubkey:d-identifier format
817 /// For regular events: Only checks event ID reference tags (e, E, q)
818 ///
819 /// This optimization recognizes that replaceable events are referenced by coordinate address,
820 /// while regular events are referenced by event ID.
821 async fn is_referenced_by_accepted(
822 database: &SharedDatabase,
823 event: &Event,
824 ) -> Result<bool, String> {
825 let kind_u16 = event.kind.as_u16();
826
827 // Check if this is any kind of replaceable event
828 let is_regular_replaceable = (10000..20000).contains(&kind_u16);
829 let is_parameterized_replaceable = (30000..40000).contains(&kind_u16);
830
831 if is_regular_replaceable || is_parameterized_replaceable {
832 // Build the appropriate address format based on event type
833 let address = if is_parameterized_replaceable {
834 // For parameterized replaceable: kind:pubkey:d-identifier format (2 colons)
835 let identifier = event
836 .tags
837 .iter()
838 .find_map(|tag| {
839 let tag_vec = tag.clone().to_vec();
840 if tag_vec.len() >= 2 && tag_vec[0] == "d" {
841 Some(tag_vec[1].clone())
842 } else {
843 None
844 }
845 })
846 .unwrap_or_default(); // Empty string if no 'd' tag
847 format!(
848 "{}:{}:{}",
849 event.kind.as_u16(),
850 event.pubkey.to_hex(),
851 identifier
852 )
853 } else {
854 // For regular replaceable: kind:pubkey format (1 colon)
855 format!("{}:{}", event.kind.as_u16(), event.pubkey.to_hex())
856 };
857
858 // Check addressable reference tags: a, A, q (with address format)
859 let addressable_tags = [
860 SingleLetterTag::lowercase(Alphabet::A), // 'a' - addressable event reference
861 SingleLetterTag::uppercase(Alphabet::A), // 'A' - uppercase addressable reference
862 SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote (can be address or ID)
863 ];
864
865 for tag_type in &addressable_tags {
866 let filter = Filter::new().custom_tag(*tag_type, address.clone());
867
868 match database.query(filter).await {
869 Ok(events) => {
870 if !events.is_empty() {
871 return Ok(true);
872 }
873 }
874 Err(e) => return Err(format!("Database query failed: {}", e)),
875 }
876 } 241 }
877 } else { 242 Err(e) => {
878 // For regular events, check event ID reference tags: e, E, q (with hex ID) 243 tracing::warn!(
879 let event_id_hex = event.id.to_hex(); 244 "Database query failed for {} {}, rejecting (fail-secure): {}",
880 245 event_type,
881 let event_id_tags = [ 246 event_id_str,
882 SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference 247 e
883 SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference 248 );
884 SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote reference 249 PolicyResult::Reject(format!("Database query failed: {}", e))
885 ];
886
887 for tag_type in &event_id_tags {
888 let filter = Filter::new().custom_tag(*tag_type, event_id_hex.clone());
889
890 match database.query(filter).await {
891 Ok(events) => {
892 if !events.is_empty() {
893 return Ok(true);
894 }
895 }
896 Err(e) => return Err(format!("Database query failed: {}", e)),
897 }
898 } 250 }
899 } 251 }
900
901 Ok(false)
902 } 252 }
903} 253}
904 254
@@ -908,366 +258,12 @@ impl WritePolicy for Nip34WritePolicy {
908 event: &'a nostr_relay_builder::prelude::Event, 258 event: &'a nostr_relay_builder::prelude::Event,
909 _addr: &'a SocketAddr, 259 _addr: &'a SocketAddr,
910 ) -> BoxedFuture<'a, PolicyResult> { 260 ) -> BoxedFuture<'a, PolicyResult> {
911 let database = self.database.clone();
912 let domain = self.domain.clone();
913
914 Box::pin(async move { 261 Box::pin(async move {
915 let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex());
916
917 match event.kind.as_u16() { 262 match event.kind.as_u16() {
918 KIND_REPOSITORY_ANNOUNCEMENT => { 263 KIND_REPOSITORY_ANNOUNCEMENT => self.handle_announcement(event).await,
919 // First, try normal validation (announcement lists service) 264 KIND_REPOSITORY_STATE => self.handle_state(event).await,
920 match validate_announcement(event, &domain) { 265 KIND_PR | KIND_PR_UPDATE => self.handle_pr_event(event).await,
921 Ok(_) => { 266 _ => self.handle_related_event(event, "Event").await,
922 // Parse announcement to get repository details
923 match RepositoryAnnouncement::from_event(event.clone()) {
924 Ok(announcement) => {
925 // Try to create bare repository if it doesn't exist
926 if let Err(e) = self.ensure_bare_repository(&announcement) {
927 tracing::warn!(
928 "Failed to create bare repository for {}: {}",
929 event_id_str,
930 e
931 );
932 // Note: We still accept the event even if repo creation fails
933 // The git operation failure shouldn't prevent event acceptance
934 }
935
936 tracing::debug!(
937 "Accepted repository announcement: {}",
938 event_id_str
939 );
940 PolicyResult::Accept
941 }
942 Err(e) => {
943 tracing::warn!(
944 "Failed to parse repository announcement {}: {}",
945 event_id_str,
946 e
947 );
948 PolicyResult::Reject(format!(
949 "Failed to parse announcement: {}",
950 e
951 ))
952 }
953 }
954 }
955 Err(validation_err) => {
956 // Validation failed - check if this is a recursive maintainer announcement
957 // GRASP-01 Exception: Accept announcements from recursive maintainers
958 // even without listing the service, for chain discovery and GRASP-02 sync
959
960 // Try to parse the announcement to get identifier
961 match RepositoryAnnouncement::from_event(event.clone()) {
962 Ok(announcement) => {
963 // Check if author is listed as maintainer in any existing announcement
964 match Self::is_maintainer_in_any_announcement(
965 &database,
966 &announcement.identifier,
967 &event.pubkey,
968 )
969 .await
970 {
971 Ok(true) => {
972 tracing::info!(
973 "Accepted maintainer announcement {} (author {} is listed as maintainer for {})",
974 event_id_str,
975 event.pubkey.to_hex(),
976 announcement.identifier
977 );
978 // Don't create bare repository for external announcements
979 // (they point to other servers)
980 PolicyResult::Accept
981 }
982 Ok(false) => {
983 tracing::warn!(
984 "Rejected repository announcement {}: {} (not a maintainer)",
985 event_id_str,
986 validation_err
987 );
988 PolicyResult::Reject(validation_err.to_string())
989 }
990 Err(e) => {
991 tracing::warn!(
992 "Failed to check maintainer status for {}: {}",
993 event_id_str,
994 e
995 );
996 // Fail-secure: reject on database errors
997 PolicyResult::Reject(validation_err.to_string())
998 }
999 }
1000 }
1001 Err(parse_err) => {
1002 tracing::warn!(
1003 "Rejected repository announcement {}: {} (parse error: {})",
1004 event_id_str,
1005 validation_err,
1006 parse_err
1007 );
1008 PolicyResult::Reject(validation_err.to_string())
1009 }
1010 }
1011 }
1012 }
1013 }
1014 KIND_REPOSITORY_STATE => match validate_state(event) {
1015 Ok(_) => {
1016 // Parse state to get HEAD and branch info
1017 match RepositoryState::from_event(event.clone()) {
1018 Ok(state) => {
1019 // Identify owner repositories for which this is the latest authorized state
1020 match self.identify_owner_repositories(&database, &state).await {
1021 Ok(owner_repos) => {
1022 let repo_count = owner_repos.len();
1023 let mut total_aligned = 0;
1024
1025 // Align each owner repository with the authorized state
1026 for (_announcement, repo_path) in owner_repos {
1027 let result = self.align_owner_repository_with_state(
1028 &repo_path, &state,
1029 );
1030
1031 if result.refs_created > 0
1032 || result.refs_updated > 0
1033 || result.refs_deleted > 0
1034 || result.head_set
1035 {
1036 tracing::info!(
1037 "Aligned {} with state {}: created={}, updated={}, deleted={}, head_set={}",
1038 repo_path.display(),
1039 event_id_str,
1040 result.refs_created,
1041 result.refs_updated,
1042 result.refs_deleted,
1043 result.head_set
1044 );
1045 total_aligned += 1;
1046 }
1047 }
1048
1049 if repo_count > 0 {
1050 tracing::info!(
1051 "Processed state event {} for {} repo(s) ({} aligned) with identifier {}",
1052 event_id_str,
1053 repo_count,
1054 total_aligned,
1055 state.identifier
1056 );
1057 } else {
1058 tracing::debug!(
1059 "No owner repos to align for state {} - git data not available yet or not latest",
1060 event_id_str
1061 );
1062 }
1063 }
1064 Err(e) => {
1065 tracing::warn!(
1066 "Failed to identify owner repositories for state {}: {}",
1067 event_id_str,
1068 e
1069 );
1070 }
1071 }
1072
1073 tracing::debug!("Accepted repository state: {}", event_id_str);
1074 PolicyResult::Accept
1075 }
1076 Err(e) => {
1077 tracing::warn!(
1078 "Failed to parse repository state {}: {}",
1079 event_id_str,
1080 e
1081 );
1082 // Still accept the event even if we can't parse it
1083 // The validation passed, so it's structurally valid
1084 PolicyResult::Accept
1085 }
1086 }
1087 }
1088 Err(e) => {
1089 tracing::warn!("Rejected repository state {}: {}", event_id_str, e);
1090 PolicyResult::Reject(e.to_string())
1091 }
1092 },
1093 // KIND_PR (1618) and KIND_PR_UPDATE (1619): Validate refs/nostr/<event-id> refs before acceptance
1094 KIND_PR | KIND_PR_UPDATE => {
1095 // Validate refs/nostr refs for this PR event
1096 // This deletes any refs/nostr/<event-id> that points to wrong commit
1097 if let Err(e) = self.validate_pr_nostr_ref(&database, event).await {
1098 tracing::warn!(
1099 "Failed to validate refs/nostr for PR event {}: {}",
1100 event_id_str,
1101 e
1102 );
1103 // Don't reject - just log the error and proceed with normal validation
1104 }
1105
1106 // Continue with standard reference checking (same as default case)
1107 let (addressable_refs, event_refs) = Self::extract_reference_tags(event);
1108
1109 // Check 1: Does this event reference an accepted repository?
1110 match Self::find_accepted_repository(&database, &addressable_refs).await {
1111 Ok(Some(addr_ref)) => {
1112 tracing::debug!(
1113 "Accepted PR event {}: references accepted repository {}",
1114 event_id_str,
1115 addr_ref
1116 );
1117 return PolicyResult::Accept;
1118 }
1119 Ok(None) => {
1120 // No matching repositories, continue to next check
1121 }
1122 Err(e) => {
1123 tracing::warn!(
1124 "Database query failed for PR {}, rejecting (fail-secure): {}",
1125 event_id_str,
1126 e
1127 );
1128 return PolicyResult::Reject(format!("Database query failed: {}", e));
1129 }
1130 }
1131
1132 // Check 2: Does this event reference an accepted event?
1133 match Self::find_accepted_event(&database, &event_refs).await {
1134 Ok(Some(event_ref)) => {
1135 tracing::debug!(
1136 "Accepted PR event {}: references accepted event {}",
1137 event_id_str,
1138 event_ref
1139 );
1140 return PolicyResult::Accept;
1141 }
1142 Ok(None) => {
1143 // No matching events, continue to next check
1144 }
1145 Err(e) => {
1146 tracing::warn!(
1147 "Database query failed for PR {}, rejecting (fail-secure): {}",
1148 event_id_str,
1149 e
1150 );
1151 return PolicyResult::Reject(format!("Database query failed: {}", e));
1152 }
1153 }
1154
1155 // Check 3: Is this event referenced by an accepted event?
1156 match Self::is_referenced_by_accepted(&database, event).await {
1157 Ok(true) => {
1158 tracing::debug!(
1159 "Accepted PR event {}: referenced by accepted event",
1160 event_id_str
1161 );
1162 return PolicyResult::Accept;
1163 }
1164 Ok(false) => {
1165 // No forward references found, continue to rejection
1166 }
1167 Err(e) => {
1168 tracing::warn!(
1169 "Database query failed for PR {}, rejecting (fail-secure): {}",
1170 event_id_str,
1171 e
1172 );
1173 return PolicyResult::Reject(format!("Database query failed: {}", e));
1174 }
1175 }
1176
1177 // No valid references found - reject as orphan event
1178 tracing::info!(
1179 "Rejected orphan PR event {}: no references to accepted repos or events",
1180 event_id_str
1181 );
1182 PolicyResult::Reject(
1183 "PR event must reference an accepted repository or accepted event"
1184 .to_string(),
1185 )
1186 }
1187 // GRASP-01: Check if event references accepted repositories or events
1188 _ => {
1189 // Extract all reference tags from event
1190 let (addressable_refs, event_refs) = Self::extract_reference_tags(event);
1191
1192 // Check 1: Does this event reference an accepted repository? (batched)
1193 match Self::find_accepted_repository(&database, &addressable_refs).await {
1194 Ok(Some(addr_ref)) => {
1195 tracing::debug!(
1196 "Accepted event {}: references accepted repository {}",
1197 event_id_str,
1198 addr_ref
1199 );
1200 return PolicyResult::Accept;
1201 }
1202 Ok(None) => {
1203 // No matching repositories, continue to next check
1204 }
1205 Err(e) => {
1206 tracing::warn!(
1207 "Database query failed for event {}, rejecting (fail-secure): {}",
1208 event_id_str,
1209 e
1210 );
1211 return PolicyResult::Reject(format!("Database query failed: {}", e));
1212 }
1213 }
1214
1215 // Check 2: Does this event reference an accepted event? (batched, transitive)
1216 match Self::find_accepted_event(&database, &event_refs).await {
1217 Ok(Some(event_ref)) => {
1218 tracing::debug!(
1219 "Accepted event {}: references accepted event {}",
1220 event_id_str,
1221 event_ref
1222 );
1223 return PolicyResult::Accept;
1224 }
1225 Ok(None) => {
1226 // No matching events, continue to next check
1227 }
1228 Err(e) => {
1229 tracing::warn!(
1230 "Database query failed for event {}, rejecting (fail-secure): {}",
1231 event_id_str,
1232 e
1233 );
1234 return PolicyResult::Reject(format!("Database query failed: {}", e));
1235 }
1236 }
1237
1238 // Check 3: Is this event referenced by an accepted event? (forward reference)
1239 match Self::is_referenced_by_accepted(&database, event).await {
1240 Ok(true) => {
1241 tracing::debug!(
1242 "Accepted event {}: referenced by accepted event",
1243 event_id_str
1244 );
1245 return PolicyResult::Accept;
1246 }
1247 Ok(false) => {
1248 // No forward references found, continue to rejection
1249 }
1250 Err(e) => {
1251 tracing::warn!(
1252 "Database query failed for event {}, rejecting (fail-secure): {}",
1253 event_id_str,
1254 e
1255 );
1256 return PolicyResult::Reject(format!("Database query failed: {}", e));
1257 }
1258 }
1259
1260 // No valid references found - reject as orphan event
1261 tracing::info!(
1262 "Rejected orphan event {}: no references to accepted repos or events (checked {} addressable, {} event refs)",
1263 event_id_str,
1264 addressable_refs.len(),
1265 event_refs.len()
1266 );
1267 PolicyResult::Reject(
1268 "Event must reference an accepted repository or accepted event".to_string(),
1269 )
1270 }
1271 } 267 }
1272 }) 268 })
1273 } 269 }
@@ -1351,4 +347,4 @@ pub fn create_relay(config: &Config) -> Result<RelayWithDatabase> {
1351 relay: LocalRelay::new(builder), 347 relay: LocalRelay::new(builder),
1352 database, 348 database,
1353 }) 349 })
1354} 350} \ No newline at end of file
diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs
index 2bf0346..a2820fc 100644
--- a/src/nostr/mod.rs
+++ b/src/nostr/mod.rs
@@ -1,2 +1,6 @@
1pub mod builder; 1pub mod builder;
2pub mod events; 2pub mod events;
3pub mod policy;
4
5/// Re-export SharedDatabase for use by policy modules
6pub use builder::SharedDatabase;
diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs
new file mode 100644
index 0000000..8d30baf
--- /dev/null
+++ b/src/nostr/policy/announcement.rs
@@ -0,0 +1,157 @@
1/// Announcement Policy - Repository announcement validation
2///
3/// Handles validation of NIP-34 repository announcements (kind 30617)
4/// according to GRASP-01 specification.
5use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
6
7use super::PolicyContext;
8use crate::nostr::events::{
9 validate_announcement, RepositoryAnnouncement, KIND_REPOSITORY_ANNOUNCEMENT,
10};
11
12/// Result of announcement policy evaluation
13#[derive(Debug)]
14pub enum AnnouncementResult {
15 /// Accept: Event passes validation
16 Accept,
17 /// Accept as maintainer: Event accepted via maintainer exception
18 AcceptMaintainer,
19 /// Reject: Event fails validation with reason
20 Reject(String),
21}
22
23/// Policy for validating repository announcements
24#[derive(Clone)]
25pub struct AnnouncementPolicy {
26 ctx: PolicyContext,
27}
28
29impl AnnouncementPolicy {
30 pub fn new(ctx: PolicyContext) -> Self {
31 Self { ctx }
32 }
33
34 /// Validate a repository announcement event
35 ///
36 /// Returns `Accept` if the announcement lists the service properly,
37 /// `AcceptMaintainer` if accepted via maintainer exception,
38 /// or `Reject` with reason.
39 pub async fn validate(&self, event: &Event) -> AnnouncementResult {
40 // First, try normal validation (announcement lists service)
41 match validate_announcement(event, &self.ctx.domain) {
42 Ok(_) => AnnouncementResult::Accept,
43 Err(validation_err) => {
44 // Validation failed - check if this is a recursive maintainer announcement
45 // GRASP-01 Exception: Accept announcements from recursive maintainers
46 // even without listing the service, for chain discovery and GRASP-02 sync
47
48 // Try to parse the announcement to get identifier
49 match RepositoryAnnouncement::from_event(event.clone()) {
50 Ok(announcement) => {
51 // Check if author is listed as maintainer in any existing announcement
52 match self
53 .is_maintainer_in_any_announcement(
54 &announcement.identifier,
55 &event.pubkey,
56 )
57 .await
58 {
59 Ok(true) => AnnouncementResult::AcceptMaintainer,
60 Ok(false) => AnnouncementResult::Reject(validation_err.to_string()),
61 Err(_) => {
62 // Fail-secure: reject on database errors
63 AnnouncementResult::Reject(validation_err.to_string())
64 }
65 }
66 }
67 Err(_) => AnnouncementResult::Reject(validation_err.to_string()),
68 }
69 }
70 }
71 }
72
73 /// Create a bare git repository if it doesn't exist
74 /// Path format: <git_data_path>/<npub>/<identifier>.git
75 pub fn ensure_bare_repository(&self, announcement: &RepositoryAnnouncement) -> Result<(), String> {
76 let repo_path = self.ctx.git_data_path.join(announcement.repo_path());
77
78 // Check if repository already exists
79 if repo_path.exists() {
80 tracing::debug!("Repository already exists at {}", repo_path.display());
81 return Ok(());
82 }
83
84 // Create parent directory (npub directory)
85 let parent = repo_path
86 .parent()
87 .ok_or_else(|| format!("Invalid repository path: {}", repo_path.display()))?;
88
89 std::fs::create_dir_all(parent)
90 .map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
91
92 // Initialize bare repository using git command
93 let output = std::process::Command::new("git")
94 .args(["init", "--bare", repo_path.to_str().unwrap()])
95 .output()
96 .map_err(|e| format!("Failed to execute git init: {}", e))?;
97
98 if !output.status.success() {
99 let stderr = String::from_utf8_lossy(&output.stderr);
100 return Err(format!("git init failed: {}", stderr));
101 }
102
103 tracing::info!("Created bare repository at {}", repo_path.display());
104 Ok(())
105 }
106
107 /// Check if a pubkey is listed as a maintainer in any announcement for this identifier
108 ///
109 /// A pubkey is considered a maintainer if:
110 /// 1. They are the owner (pubkey) of an accepted announcement with this identifier, OR
111 /// 2. They are listed in the maintainers tag of ANY announcement with this identifier
112 ///
113 /// This enables accepting announcements from maintainers even when they don't list
114 /// this GRASP server, for maintainer chain discovery and GRASP-02 sync.
115 async fn is_maintainer_in_any_announcement(
116 &self,
117 identifier: &str,
118 author: &PublicKey,
119 ) -> Result<bool, String> {
120 // Query all announcements with this identifier that are already in the database
121 let filter = Filter::new()
122 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
123 .custom_tag(
124 SingleLetterTag::lowercase(Alphabet::D),
125 identifier.to_string(),
126 );
127
128 let announcements: Vec<Event> = match self.ctx.database.query(filter).await {
129 Ok(events) => events.into_iter().collect(),
130 Err(e) => return Err(format!("Database query failed: {}", e)),
131 };
132
133 if announcements.is_empty() {
134 // No existing announcements for this identifier - author cannot be a maintainer
135 return Ok(false);
136 }
137
138 let author_hex = author.to_hex();
139
140 // Check each announcement to see if author is listed as a maintainer
141 for event in &announcements {
142 // Check if author is the owner of this announcement
143 if event.pubkey == *author {
144 return Ok(true);
145 }
146
147 // Check if author is listed in the maintainers tag
148 if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) {
149 if announcement.maintainers.contains(&author_hex) {
150 return Ok(true);
151 }
152 }
153 }
154
155 Ok(false)
156 }
157} \ No newline at end of file
diff --git a/src/nostr/policy/mod.rs b/src/nostr/policy/mod.rs
new file mode 100644
index 0000000..6d67394
--- /dev/null
+++ b/src/nostr/policy/mod.rs
@@ -0,0 +1,41 @@
1/// Policy module for NIP-34 write policies
2///
3/// This module splits the large Nip34WritePolicy into focused sub-policies:
4/// - `AnnouncementPolicy` - Repository announcement validation
5/// - `StatePolicy` - State event validation + ref alignment
6/// - `PrEventPolicy` - PR/PR Update validation
7/// - `RelatedEventPolicy` - Forward/backward reference checking
8
9mod announcement;
10mod pr_event;
11mod related;
12mod state;
13
14pub use announcement::{AnnouncementPolicy, AnnouncementResult};
15pub use pr_event::PrEventPolicy;
16pub use related::{ReferenceResult, RelatedEventPolicy};
17pub use state::{AlignmentResult, StatePolicy, StateResult};
18
19use super::SharedDatabase;
20
21/// Shared context for all sub-policies
22#[derive(Clone)]
23pub struct PolicyContext {
24 pub domain: String,
25 pub database: SharedDatabase,
26 pub git_data_path: std::path::PathBuf,
27}
28
29impl PolicyContext {
30 pub fn new(
31 domain: impl Into<String>,
32 database: SharedDatabase,
33 git_data_path: impl Into<std::path::PathBuf>,
34 ) -> Self {
35 Self {
36 domain: domain.into(),
37 database,
38 git_data_path: git_data_path.into(),
39 }
40 }
41} \ No newline at end of file
diff --git a/src/nostr/policy/pr_event.rs b/src/nostr/policy/pr_event.rs
new file mode 100644
index 0000000..fee9a2a
--- /dev/null
+++ b/src/nostr/policy/pr_event.rs
@@ -0,0 +1,198 @@
1/// PR Event Policy - PR/PR Update validation
2///
3/// Handles validation of NIP-34 PR events (kind 1618) and PR Update events (kind 1619)
4/// according to GRASP-01 specification.
5use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
6
7use super::PolicyContext;
8use crate::git;
9use crate::nostr::events::{RepositoryAnnouncement, KIND_REPOSITORY_ANNOUNCEMENT};
10
11/// Policy for validating PR and PR Update events
12#[derive(Clone)]
13pub struct PrEventPolicy {
14 ctx: PolicyContext,
15}
16
17impl PrEventPolicy {
18 pub fn new(ctx: PolicyContext) -> Self {
19 Self { ctx }
20 }
21
22 /// Validate refs/nostr/<event-id> ref against a PR or PR Update event's `c` tag
23 ///
24 /// When a PR event (kind 1618) or PR Update event (kind 1619) is received,
25 /// this checks if a corresponding refs/nostr/<event-id> ref exists in the
26 /// repository and validates that it points to the correct commit (from the
27 /// `c` tag). If the ref exists but points to a different commit, the ref is
28 /// deleted.
29 ///
30 /// PR and PR Update events can have multiple `a` tags to update multiple
31 /// repositories simultaneously.
32 ///
33 /// This is part of GRASP-01 compliance: ensuring refs/nostr refs are consistent
34 /// with their corresponding events.
35 ///
36 /// # Returns
37 /// Ok(Some(n)) if n refs were deleted, Ok(None) if no action taken, Err on failure
38 pub async fn validate_nostr_ref(&self, event: &Event) -> Result<Option<usize>, String> {
39 let event_id = event.id.to_hex();
40
41 // Extract the `c` tag (commit hash) from the PR event
42 let expected_commit = event.tags.iter().find_map(|tag| {
43 let tag_vec = tag.clone().to_vec();
44 if tag_vec.len() >= 2 && tag_vec[0] == "c" {
45 Some(tag_vec[1].clone())
46 } else {
47 None
48 }
49 });
50
51 let expected_commit = match expected_commit {
52 Some(c) => c,
53 None => {
54 tracing::debug!(
55 "PR event {} has no 'c' tag, skipping ref validation",
56 event_id
57 );
58 return Ok(None);
59 }
60 };
61
62 // Extract ALL `a` tags (repository references) from the PR event
63 // PR events can reference multiple repositories
64 // Format: 30617:<pubkey>:<identifier>
65 let repo_refs: Vec<String> = event
66 .tags
67 .iter()
68 .filter_map(|tag| {
69 let tag_vec = tag.clone().to_vec();
70 if tag_vec.len() >= 2 && tag_vec[0] == "a" && tag_vec[1].starts_with("30617:") {
71 Some(tag_vec[1].clone())
72 } else {
73 None
74 }
75 })
76 .collect();
77
78 if repo_refs.is_empty() {
79 tracing::debug!(
80 "PR event {} has no repo 'a' tags, skipping ref validation",
81 event_id
82 );
83 return Ok(None);
84 }
85
86 let mut deleted_count = 0;
87
88 // Process each repository reference
89 for repo_ref in repo_refs {
90 // Parse the repo reference: 30617:<pubkey>:<identifier>
91 let parts: Vec<&str> = repo_ref.split(':').collect();
92 if parts.len() < 3 {
93 tracing::debug!(
94 "PR event {} has invalid 'a' tag format: {}",
95 event_id,
96 repo_ref
97 );
98 continue;
99 }
100
101 let repo_pubkey = match PublicKey::from_hex(parts[1]) {
102 Ok(pk) => pk,
103 Err(_) => {
104 tracing::debug!(
105 "PR event {} has invalid pubkey in 'a' tag: {}",
106 event_id,
107 parts[1]
108 );
109 continue;
110 }
111 };
112 let identifier = parts[2];
113
114 // Look up repository announcement to get the npub for path
115 let filter = Filter::new()
116 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
117 .author(repo_pubkey)
118 .custom_tag(
119 SingleLetterTag::lowercase(Alphabet::D),
120 identifier.to_string(),
121 );
122
123 let announcements: Vec<Event> = match self.ctx.database.query(filter).await {
124 Ok(events) => events.into_iter().collect(),
125 Err(e) => {
126 tracing::warn!(
127 "Failed to query for repository announcement for PR {}: {}",
128 event_id,
129 e
130 );
131 continue;
132 }
133 };
134
135 if announcements.is_empty() {
136 tracing::debug!(
137 "No repository announcement found for PR event {} (repo {}:{})",
138 event_id,
139 repo_pubkey.to_hex(),
140 identifier
141 );
142 continue;
143 }
144
145 // Process each matching announcement (there could be multiple)
146 for announcement_event in announcements {
147 let announcement = match RepositoryAnnouncement::from_event(announcement_event) {
148 Ok(a) => a,
149 Err(e) => {
150 tracing::warn!(
151 "Failed to parse announcement for PR {} validation: {}",
152 event_id,
153 e
154 );
155 continue;
156 }
157 };
158
159 // Build repository path
160 let repo_path = self.ctx.git_data_path.join(announcement.repo_path());
161
162 // Validate the ref
163 match git::validate_nostr_ref(&repo_path, &event_id, &expected_commit) {
164 Ok(true) => {
165 tracing::info!(
166 "Deleted mismatched refs/nostr/{} in {} (expected commit {})",
167 event_id,
168 repo_path.display(),
169 expected_commit
170 );
171 deleted_count += 1;
172 }
173 Ok(false) => {
174 tracing::debug!(
175 "refs/nostr/{} in {} is valid or doesn't exist",
176 event_id,
177 repo_path.display()
178 );
179 }
180 Err(e) => {
181 tracing::warn!(
182 "Failed to validate refs/nostr/{} in {}: {}",
183 event_id,
184 repo_path.display(),
185 e
186 );
187 }
188 }
189 }
190 }
191
192 if deleted_count > 0 {
193 Ok(Some(deleted_count))
194 } else {
195 Ok(None)
196 }
197 }
198} \ No newline at end of file
diff --git a/src/nostr/policy/related.rs b/src/nostr/policy/related.rs
new file mode 100644
index 0000000..1937ca7
--- /dev/null
+++ b/src/nostr/policy/related.rs
@@ -0,0 +1,276 @@
1/// Related Event Policy - Forward/backward reference checking
2///
3/// Handles validation of events that reference accepted repositories or events
4/// (backward references) and events that are referenced by accepted events
5/// (forward references).
6use nostr_relay_builder::prelude::{
7 Alphabet, Event, EventId, Filter, Kind, PublicKey, SingleLetterTag,
8};
9
10use super::PolicyContext;
11
12/// Result of reference checking
13#[derive(Debug)]
14pub enum ReferenceResult {
15 /// Event references an accepted repository (addressable ref found)
16 ReferencesRepository(String),
17 /// Event references an accepted event (event ID found)
18 ReferencesEvent(EventId),
19 /// Event is referenced by an accepted event (forward reference)
20 ReferencedByAccepted,
21 /// No valid references found - event is an orphan
22 Orphan,
23}
24
25/// Policy for checking event references (backward and forward)
26#[derive(Clone)]
27pub struct RelatedEventPolicy {
28 ctx: PolicyContext,
29}
30
31impl RelatedEventPolicy {
32 pub fn new(ctx: PolicyContext) -> Self {
33 Self { ctx }
34 }
35
36 /// Check all reference types for an event
37 ///
38 /// Returns the first valid reference found, or `Orphan` if none found.
39 pub async fn check_references(&self, event: &Event) -> Result<ReferenceResult, String> {
40 // Extract all reference tags from event
41 let (addressable_refs, event_refs) = Self::extract_reference_tags(event);
42
43 // Check 1: Does this event reference an accepted repository?
44 if let Some(addr_ref) = self.find_accepted_repository(&addressable_refs).await? {
45 return Ok(ReferenceResult::ReferencesRepository(addr_ref));
46 }
47
48 // Check 2: Does this event reference an accepted event?
49 if let Some(event_ref) = self.find_accepted_event(&event_refs).await? {
50 return Ok(ReferenceResult::ReferencesEvent(event_ref));
51 }
52
53 // Check 3: Is this event referenced by an accepted event?
54 if self.is_referenced_by_accepted(event).await? {
55 return Ok(ReferenceResult::ReferencedByAccepted);
56 }
57
58 // No valid references found
59 Ok(ReferenceResult::Orphan)
60 }
61
62 /// Extract all reference tags from an event (a, A, q, e, E)
63 /// Returns (addressable_refs, event_refs)
64 pub fn extract_reference_tags(event: &Event) -> (Vec<String>, Vec<EventId>) {
65 let mut addressable_refs = Vec::new();
66 let mut event_refs = Vec::new();
67
68 for tag in event.tags.iter() {
69 let tag_vec = tag.clone().to_vec();
70 if tag_vec.is_empty() {
71 continue;
72 }
73
74 match tag_vec[0].as_str() {
75 // Addressable event references (a, A, q with kind:pubkey:identifier format)
76 "a" | "A" | "q" if tag_vec.len() > 1 && tag_vec[1].contains(':') => {
77 addressable_refs.push(tag_vec[1].clone());
78 }
79 // Event ID references (e, E, q with event ID format)
80 "e" | "E" if tag_vec.len() > 1 => {
81 if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) {
82 event_refs.push(event_id);
83 }
84 }
85 "q" if tag_vec.len() > 1 && !tag_vec[1].contains(':') => {
86 if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) {
87 event_refs.push(event_id);
88 }
89 }
90 _ => {}
91 }
92 }
93
94 (addressable_refs, event_refs)
95 }
96
97 /// Check if any addressable events (repositories) exist in database
98 /// Returns the first matching addressable reference found, or None if none match
99 async fn find_accepted_repository(
100 &self,
101 addressables: &[String],
102 ) -> Result<Option<String>, String> {
103 if addressables.is_empty() {
104 return Ok(None);
105 }
106
107 // Parse all addressable references
108 let mut parsed_refs = Vec::new();
109 for addr in addressables {
110 let parts: Vec<&str> = addr.split(':').collect();
111 if parts.len() < 3 {
112 continue; // Skip invalid format
113 }
114
115 let kind = match parts[0].parse::<u16>() {
116 Ok(k) => k,
117 Err(_) => continue, // Skip invalid kind
118 };
119 let pubkey = match PublicKey::from_hex(parts[1]) {
120 Ok(pk) => pk,
121 Err(_) => continue, // Skip invalid pubkey
122 };
123 let identifier = parts[2].to_string();
124
125 parsed_refs.push((addr.clone(), kind, pubkey, identifier));
126 }
127
128 if parsed_refs.is_empty() {
129 return Ok(None);
130 }
131
132 // Group by kind to reduce queries
133 use std::collections::HashMap;
134 let mut by_kind: HashMap<u16, Vec<_>> = HashMap::new();
135 for (addr, kind, pubkey, identifier) in parsed_refs {
136 by_kind
137 .entry(kind)
138 .or_default()
139 .push((addr, pubkey, identifier));
140 }
141
142 // Query each kind group
143 for (kind, refs) in by_kind {
144 let authors: Vec<PublicKey> = refs.iter().map(|(_, pk, _)| *pk).collect();
145
146 let filter = Filter::new().kind(Kind::from(kind)).authors(authors);
147
148 match self.ctx.database.query(filter).await {
149 Ok(events) => {
150 // Check if any event matches our identifier requirements
151 for event in events {
152 for (addr, _pubkey, identifier) in &refs {
153 // Match identifier tag
154 if event.tags.iter().any(|tag| {
155 let tag_vec = tag.clone().to_vec();
156 tag_vec.len() >= 2 && tag_vec[0] == "d" && tag_vec[1] == *identifier
157 }) {
158 return Ok(Some(addr.clone()));
159 }
160 }
161 }
162 }
163 Err(e) => return Err(format!("Database query failed: {}", e)),
164 }
165 }
166
167 Ok(None)
168 }
169
170 /// Check if any events exist in database
171 /// Returns the first matching event ID found, or None if none match
172 async fn find_accepted_event(
173 &self,
174 event_ids: &[EventId],
175 ) -> Result<Option<EventId>, String> {
176 if event_ids.is_empty() {
177 return Ok(None);
178 }
179
180 // Single query for all event IDs
181 let filter = Filter::new().ids(event_ids.iter().copied());
182
183 match self.ctx.database.query(filter).await {
184 Ok(events) => {
185 // Get first event from the iterator
186 Ok(events.into_iter().next().map(|e| e.id))
187 }
188 Err(e) => Err(format!("Database query failed: {}", e)),
189 }
190 }
191
192 /// Check if any accepted event references this event (forward reference)
193 ///
194 /// For regular replaceable events (10000-19999): Checks addressable tags with kind:pubkey format
195 /// For parameterized replaceable (30000-39999): Checks addressable tags with kind:pubkey:d-identifier format
196 /// For regular events: Only checks event ID reference tags (e, E, q)
197 async fn is_referenced_by_accepted(&self, event: &Event) -> Result<bool, String> {
198 let kind_u16 = event.kind.as_u16();
199
200 // Check if this is any kind of replaceable event
201 let is_regular_replaceable = (10000..20000).contains(&kind_u16);
202 let is_parameterized_replaceable = (30000..40000).contains(&kind_u16);
203
204 if is_regular_replaceable || is_parameterized_replaceable {
205 // Build the appropriate address format based on event type
206 let address = if is_parameterized_replaceable {
207 // For parameterized replaceable: kind:pubkey:d-identifier format (2 colons)
208 let identifier = event
209 .tags
210 .iter()
211 .find_map(|tag| {
212 let tag_vec = tag.clone().to_vec();
213 if tag_vec.len() >= 2 && tag_vec[0] == "d" {
214 Some(tag_vec[1].clone())
215 } else {
216 None
217 }
218 })
219 .unwrap_or_default(); // Empty string if no 'd' tag
220 format!(
221 "{}:{}:{}",
222 event.kind.as_u16(),
223 event.pubkey.to_hex(),
224 identifier
225 )
226 } else {
227 // For regular replaceable: kind:pubkey format (1 colon)
228 format!("{}:{}", event.kind.as_u16(), event.pubkey.to_hex())
229 };
230
231 // Check addressable reference tags: a, A, q (with address format)
232 let addressable_tags = [
233 SingleLetterTag::lowercase(Alphabet::A), // 'a' - addressable event reference
234 SingleLetterTag::uppercase(Alphabet::A), // 'A' - uppercase addressable reference
235 SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote (can be address or ID)
236 ];
237
238 for tag_type in &addressable_tags {
239 let filter = Filter::new().custom_tag(*tag_type, address.clone());
240
241 match self.ctx.database.query(filter).await {
242 Ok(events) => {
243 if !events.is_empty() {
244 return Ok(true);
245 }
246 }
247 Err(e) => return Err(format!("Database query failed: {}", e)),
248 }
249 }
250 } else {
251 // For regular events, check event ID reference tags: e, E, q (with hex ID)
252 let event_id_hex = event.id.to_hex();
253
254 let event_id_tags = [
255 SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference
256 SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference
257 SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote reference
258 ];
259
260 for tag_type in &event_id_tags {
261 let filter = Filter::new().custom_tag(*tag_type, event_id_hex.clone());
262
263 match self.ctx.database.query(filter).await {
264 Ok(events) => {
265 if !events.is_empty() {
266 return Ok(true);
267 }
268 }
269 Err(e) => return Err(format!("Database query failed: {}", e)),
270 }
271 }
272 }
273
274 Ok(false)
275 }
276} \ No newline at end of file
diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs
new file mode 100644
index 0000000..5692bd8
--- /dev/null
+++ b/src/nostr/policy/state.rs
@@ -0,0 +1,419 @@
1/// State Policy - State event validation + ref alignment
2///
3/// Handles validation of NIP-34 repository state events (kind 30618)
4/// and aligns git refs with authorized state according to GRASP-01.
5use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
6
7use super::PolicyContext;
8use crate::git;
9use crate::nostr::events::{
10 validate_state, RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT,
11 KIND_REPOSITORY_STATE,
12};
13
14/// Result of aligning a repository with authorized state
15#[derive(Debug, Default)]
16pub struct AlignmentResult {
17 /// Number of refs created
18 pub refs_created: usize,
19 /// Number of refs updated
20 pub refs_updated: usize,
21 /// Number of refs deleted
22 pub refs_deleted: usize,
23 /// Whether HEAD was set
24 pub head_set: bool,
25}
26
27impl AlignmentResult {
28 pub fn has_changes(&self) -> bool {
29 self.refs_created > 0 || self.refs_updated > 0 || self.refs_deleted > 0 || self.head_set
30 }
31}
32
33/// Result of state policy evaluation
34#[derive(Debug)]
35pub enum StateResult {
36 /// Accept: Event passes validation
37 Accept,
38 /// Reject: Event fails validation with reason
39 Reject(String),
40}
41
42/// Policy for validating repository state events and aligning refs
43#[derive(Clone)]
44pub struct StatePolicy {
45 ctx: PolicyContext,
46}
47
48impl StatePolicy {
49 pub fn new(ctx: PolicyContext) -> Self {
50 Self { ctx }
51 }
52
53 /// Validate a repository state event
54 pub fn validate(&self, event: &Event) -> StateResult {
55 match validate_state(event) {
56 Ok(_) => StateResult::Accept,
57 Err(e) => StateResult::Reject(e.to_string()),
58 }
59 }
60
61 /// Process a state event: validate and align owner repositories
62 ///
63 /// Returns the number of repositories aligned if successful.
64 pub async fn process_state_event(&self, event: &Event) -> Result<usize, String> {
65 // Parse state to get HEAD and branch info
66 let state = RepositoryState::from_event(event.clone())
67 .map_err(|e| format!("Failed to parse state: {}", e))?;
68
69 // Identify owner repositories for which this is the latest authorized state
70 let owner_repos = self.identify_owner_repositories(&state).await?;
71 let repo_count = owner_repos.len();
72 let mut total_aligned = 0;
73
74 // Align each owner repository with the authorized state
75 for (_announcement, repo_path) in owner_repos {
76 let result = self.align_repository_with_state(&repo_path, &state);
77
78 if result.has_changes() {
79 tracing::info!(
80 "Aligned {} with state: created={}, updated={}, deleted={}, head_set={}",
81 repo_path.display(),
82 result.refs_created,
83 result.refs_updated,
84 result.refs_deleted,
85 result.head_set
86 );
87 total_aligned += 1;
88 }
89 }
90
91 if repo_count > 0 {
92 tracing::info!(
93 "Processed state event for {} repo(s) ({} aligned) with identifier {}",
94 repo_count,
95 total_aligned,
96 state.identifier
97 );
98 } else {
99 tracing::debug!(
100 "No owner repos to align for state - git data not available yet or not latest"
101 );
102 }
103
104 Ok(total_aligned)
105 }
106
107 /// Check if this state event is the latest for its identifier among authorized authors
108 ///
109 /// A state is considered "latest" if no other state event in the database
110 /// from an authorized author has a newer timestamp.
111 async fn is_latest_state_for_identifier(
112 &self,
113 state: &RepositoryState,
114 authorized_pubkeys: &[PublicKey],
115 ) -> Result<bool, String> {
116 let filter = Filter::new()
117 .kind(Kind::from(KIND_REPOSITORY_STATE))
118 .custom_tag(
119 SingleLetterTag::lowercase(Alphabet::D),
120 state.identifier.clone(),
121 );
122
123 match self.ctx.database.query(filter).await {
124 Ok(events) => {
125 for event in events {
126 // Skip comparing to self (same event ID)
127 if event.id == state.event.id {
128 continue;
129 }
130 // Only consider events from authorized authors for this announcement
131 if !authorized_pubkeys.contains(&event.pubkey) {
132 continue;
133 }
134 // If any existing event from an authorized author is newer, this is not the latest
135 if event.created_at > state.event.created_at {
136 tracing::debug!(
137 "State {} is not latest: found newer state {} from {} (ts {} > {})",
138 state.event.id.to_hex(),
139 event.id.to_hex(),
140 event.pubkey.to_hex(),
141 event.created_at.as_secs(),
142 state.event.created_at.as_secs()
143 );
144 return Ok(false);
145 }
146 }
147 Ok(true)
148 }
149 Err(e) => Err(format!("Database query failed: {}", e)),
150 }
151 }
152
153 /// Find all repository announcements where the given pubkey is authorized
154 async fn find_authorized_announcements(
155 &self,
156 identifier: &str,
157 state_author: &PublicKey,
158 ) -> Result<Vec<RepositoryAnnouncement>, String> {
159 let filter = Filter::new()
160 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
161 .custom_tag(
162 SingleLetterTag::lowercase(Alphabet::D),
163 identifier.to_string(),
164 );
165
166 match self.ctx.database.query(filter).await {
167 Ok(events) => {
168 let mut authorized = Vec::new();
169 let state_author_hex = state_author.to_hex();
170
171 for event in events {
172 if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) {
173 // Check if state author is authorized for this announcement
174 let is_owner = event.pubkey == *state_author;
175 let is_maintainer = announcement.maintainers.contains(&state_author_hex);
176
177 if is_owner || is_maintainer {
178 tracing::debug!(
179 "Found authorized announcement for {}: owner={}, maintainer={}",
180 identifier,
181 if is_owner {
182 event.pubkey.to_hex()
183 } else {
184 "n/a".to_string()
185 },
186 is_maintainer
187 );
188 authorized.push(announcement);
189 }
190 }
191 }
192 Ok(authorized)
193 }
194 Err(e) => Err(format!("Database query failed: {}", e)),
195 }
196 }
197
198 /// Identify all owner repositories for which this state event is the latest authorized state
199 async fn identify_owner_repositories(
200 &self,
201 state: &RepositoryState,
202 ) -> Result<Vec<(RepositoryAnnouncement, std::path::PathBuf)>, String> {
203 // Find all announcements where state author is authorized
204 let announcements = self
205 .find_authorized_announcements(&state.identifier, &state.event.pubkey)
206 .await?;
207
208 if announcements.is_empty() {
209 tracing::debug!(
210 "No authorized announcements found for state {} by {}",
211 state.identifier,
212 state.event.pubkey.to_hex()
213 );
214 return Ok(Vec::new());
215 }
216
217 let mut owner_repos = Vec::new();
218
219 for announcement in announcements {
220 // Build the list of authorized pubkeys for this specific announcement
221 let mut authorized_pubkeys = vec![announcement.event.pubkey];
222 for maintainer_hex in &announcement.maintainers {
223 if let Ok(pk) = PublicKey::from_hex(maintainer_hex) {
224 authorized_pubkeys.push(pk);
225 }
226 }
227
228 // Check if this is the latest state event for THIS announcement's context
229 if !self
230 .is_latest_state_for_identifier(state, &authorized_pubkeys)
231 .await?
232 {
233 tracing::debug!(
234 "Skipping {} in {}'s repo - not the latest state event for this context",
235 state.identifier,
236 announcement.event.pubkey.to_hex()
237 );
238 continue;
239 }
240
241 // Build repository path: <git_data_path>/<owner_npub>/<identifier>.git
242 let repo_path = self.ctx.git_data_path.join(announcement.repo_path().clone());
243 owner_repos.push((announcement, repo_path));
244 }
245
246 Ok(owner_repos)
247 }
248
249 /// Align a repository's refs with the authorized state
250 ///
251 /// This function:
252 /// 1. Deletes refs that are in the repo but not in the state (for refs/heads/ and refs/tags/)
253 /// 2. Updates refs that exist in state if we have the commit
254 /// 3. Sets HEAD if the HEAD branch's commit is available
255 pub fn align_repository_with_state(
256 &self,
257 repo_path: &std::path::Path,
258 state: &RepositoryState,
259 ) -> AlignmentResult {
260 let mut result = AlignmentResult::default();
261
262 // Check if repository exists
263 if !repo_path.exists() {
264 tracing::debug!(
265 "Repository not found at {}, cannot align with state",
266 repo_path.display()
267 );
268 return result;
269 }
270
271 // Get current refs from the repository
272 let current_refs = match git::list_refs(repo_path) {
273 Ok(refs) => refs,
274 Err(e) => {
275 tracing::warn!("Failed to list refs in {}: {}", repo_path.display(), e);
276 return result;
277 }
278 };
279
280 // Build expected refs from state
281 let mut expected_refs: std::collections::HashMap<String, String> =
282 std::collections::HashMap::new();
283
284 for branch in &state.branches {
285 let ref_name = format!("refs/heads/{}", branch.name);
286 expected_refs.insert(ref_name, branch.commit.clone());
287 }
288
289 for tag in &state.tags {
290 let ref_name = format!("refs/tags/{}", tag.name);
291 expected_refs.insert(ref_name, tag.commit.clone());
292 }
293
294 // Process current refs: update or delete as needed
295 for (ref_name, current_commit) in &current_refs {
296 // Only process refs/heads/ and refs/tags/
297 if !ref_name.starts_with("refs/heads/") && !ref_name.starts_with("refs/tags/") {
298 continue;
299 }
300
301 match expected_refs.get(ref_name) {
302 Some(expected_commit) => {
303 // Ref should exist - check if commit matches
304 if current_commit != expected_commit {
305 // Check if we have the expected commit
306 if git::commit_exists(repo_path, expected_commit) {
307 // Update the ref
308 match git::update_ref(repo_path, ref_name, expected_commit) {
309 Ok(()) => {
310 tracing::info!(
311 "Updated {} from {} to {} in {}",
312 ref_name,
313 current_commit,
314 expected_commit,
315 repo_path.display()
316 );
317 result.refs_updated += 1;
318 }
319 Err(e) => {
320 tracing::warn!(
321 "Failed to update {} in {}: {}",
322 ref_name,
323 repo_path.display(),
324 e
325 );
326 }
327 }
328 } else {
329 tracing::debug!(
330 "Commit {} not available for {} in {}",
331 expected_commit,
332 ref_name,
333 repo_path.display()
334 );
335 }
336 }
337 }
338 None => {
339 // Ref should not exist - delete it
340 match git::delete_ref(repo_path, ref_name) {
341 Ok(()) => {
342 tracing::info!(
343 "Deleted {} (not in state) from {}",
344 ref_name,
345 repo_path.display()
346 );
347 result.refs_deleted += 1;
348 }
349 Err(e) => {
350 tracing::warn!(
351 "Failed to delete {} from {}: {}",
352 ref_name,
353 repo_path.display(),
354 e
355 );
356 }
357 }
358 }
359 }
360 }
361
362 // Add refs that exist in state but not in repo (if we have the commit)
363 for (ref_name, expected_commit) in &expected_refs {
364 let exists = current_refs.iter().any(|(r, _)| r == ref_name);
365 if !exists && git::commit_exists(repo_path, expected_commit) {
366 match git::update_ref(repo_path, ref_name, expected_commit) {
367 Ok(()) => {
368 tracing::info!(
369 "Created {} at {} in {}",
370 ref_name,
371 expected_commit,
372 repo_path.display()
373 );
374 result.refs_created += 1;
375 }
376 Err(e) => {
377 tracing::warn!(
378 "Failed to create {} in {}: {}",
379 ref_name,
380 repo_path.display(),
381 e
382 );
383 }
384 }
385 }
386 }
387
388 // Set HEAD if specified in state
389 if let Some(head_ref) = &state.head {
390 if let Some(branch_name) = state.get_head_branch() {
391 if let Some(head_commit) = state.get_branch_commit(branch_name) {
392 match git::try_set_head_if_available(repo_path, head_ref, head_commit) {
393 Ok(true) => {
394 tracing::info!(
395 "Set HEAD to {} in {} (from state by {})",
396 head_ref,
397 repo_path.display(),
398 state.event.pubkey.to_hex()
399 );
400 result.head_set = true;
401 }
402 Ok(false) => {
403 tracing::debug!(
404 "HEAD commit {} not available yet in {}",
405 head_commit,
406 repo_path.display()
407 );
408 }
409 Err(e) => {
410 tracing::warn!("Failed to set HEAD in {}: {}", repo_path.display(), e);
411 }
412 }
413 }
414 }
415 }
416
417 result
418 }
419} \ No newline at end of file