diff --git a/README.md b/README.md index 4020b20..08997ad 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ for the shared storage model. - ✅ Accepts NIP-34 repository announcements and state events - ✅ Git Smart HTTP service at `//.git` - ✅ Push validation against Nostr state events -- ✅ Multi-maintainer support via recursive maintainer sets +- ✅ Multi-maintainer support via active lead resolution and reciprocal confirmation - ✅ Support for `refs/nostr/` for PRs - ✅ Git capabilities: `allow-tip-sha1-in-want`, `allow-reachable-sha1-in-want`, `uploadpack.allowFilter` - ✅ CORS support for web-based Git clients diff --git a/docs/explanation/administration-vision.md b/docs/explanation/administration-vision.md index 71d04d3..27ecdaf 100644 --- a/docs/explanation/administration-vision.md +++ b/docs/explanation/administration-vision.md @@ -311,7 +311,7 @@ This method establishes: - strict management NIP-98 validation; - private HTTP responses; - repository scope parsing; -- recursive maintainer authorization; +- selected-coordinate authorization through active leads and reciprocal membership; - indistinguishable absent/unauthorized behavior; and - a custom method and result schema discoverable by the UI. diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index f0f9542..5ad3efe 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -199,10 +199,10 @@ See [`src/git/handlers.rs:22-98`](src/git/handlers.rs:22-98) for the info-refs i **Core Logic:** ```rust -/// Get authorization info for a repository owner -pub async fn get_authorization_for_owner( +/// Get authorization for the repository coordinate selected by the URL +pub async fn get_state_authorization_for_selected_repo( database: &SharedDatabase, - pubkey: &PublicKey, + selected_pubkey: &str, identifier: &str, ) -> Result @@ -230,8 +230,8 @@ The [`Nip34WritePolicy`](src/nostr/builder.rs:51) is the core event validation l /// /// Validates all events according to GRASP-01 specification: /// - Repository announcements must list service in clone and relays tags -/// EXCEPTION: Recursive maintainer announcements are accepted even without -/// listing the service, to enable maintainer chain discovery and GRASP-02 sync +/// EXCEPTION: Maintainer-discovery candidates are accepted even without +/// listing the service; this admits dependencies but grants no state authority /// - Repository state announcements must have valid structure /// - Other events must reference accepted repositories or events /// - Forward references are supported (events referenced by accepted events) @@ -319,9 +319,14 @@ suppresses the deprecated `maintainers` fallback, and a self-`o` entry is a self-role, so a moderator-only author is not implicitly a maintainer. `o` listings do not create maintainer invitations. -Authorization follows a reciprocal membership rule, computed per owner by +Authorization follows a reciprocal membership rule, computed per selected +announcement coordinate by [`compute_membership`](src/git/authorization.rs): +Here, **selected coordinate** is the authorization term. **Owner view** remains +the storage term for the physical `/.git` +repository selected by a URL; it does not imply that signer retains authority. + - A pubkey listed as a maintainer is only **invited** until its own announcement for the same identifier lists back an existing confirmed maintainer. Invited pubkeys' announcements are still fetched and accepted @@ -339,6 +344,14 @@ Authorization follows a reciprocal membership rule, computed per owner by Missing, ambiguous, and cyclic explicit paths grant no state authority, and a former maintainer's forwarding coordinate does not restore that signer to the confirmed set. +- Parsing retains only a present-tense role view: current `M`/`m` + maintainers, current `M` lead targets, and whether the announcement author + currently claims maintainership. History markers are validated and consumed + to derive that view, then discarded. +- Lead resolution records why a coordinate failed (missing selected or lead + announcement, inactive selected author, incomplete or ambiguous path, or + cycle). Authorization callers deliberately collapse every failure to an + empty set while diagnostics and tests retain the distinction. #### [`policy/state.rs`](src/nostr/policy/state.rs) - State Event Authorization @@ -497,9 +510,10 @@ Configuration is loaded via **clap CLI > environment variables > .env > defaults ↓ 4. Extract npub and identifier from URL ↓ -5. authorization::get_authorization_for_owner() +5. authorization::get_state_authorization_for_selected_repo() ├─ Query database for announcements - ├─ Compute confirmed maintainer set (reciprocal membership) + ├─ Resolve selected coordinate through its active lead path + ├─ Compute confirmed maintainer set (reciprocal fixpoint) └─ Get latest authorized state ↓ 6. authorization::validate_push_refs() diff --git a/docs/explanation/decisions.md b/docs/explanation/decisions.md index b39e8b7..e0456ea 100644 --- a/docs/explanation/decisions.md +++ b/docs/explanation/decisions.md @@ -258,11 +258,17 @@ NIP-34 maintainers model refined in nips commit `781590b`). otherwise-valid membership data over a formatting slip. Since only current activity matters here, consolidation reduces to: a pubkey is a maintainer while any of its `M`/`m` entries is active. -- An announcement using role tags acknowledges its author via an active +- An announcement using role tags acknowledges its author via an active valid self-entry, or implicitly: an author who appears in no role tag is a - maintainer for the repository's entire history. Only an ended self-entry - means the member left, which takes precedence over assignments in other - announcements. + maintainer for the repository's entire history. An ended, deferred, + malformed, or moderator-only self-entry makes the author currently inactive, + which takes precedence over assignments in other announcements. +- Announcement parsing consumes role history into a current-only view: + active maintainers, active lead targets, and current author activity. The + boundary history itself is not retained by authorization. +- Lead resolution preserves distinct internal failures for missing selected or + lead announcements, inactive selected authors, incomplete or ambiguous paths, + and cycles. All are collapsed to no authority at policy boundaries. - A `u` (subordinate fork) tag has no effect on maintainership: the author of a role-less announcement asserts maintainership with or without it. diff --git a/docs/explanation/git-family-object-storage.md b/docs/explanation/git-family-object-storage.md index fda4eb6..343407b 100644 --- a/docs/explanation/git-family-object-storage.md +++ b/docs/explanation/git-family-object-storage.md @@ -413,8 +413,9 @@ bounded counts and OID/diagnostic samples; it does not make availability depend on remote servers. Authorization integrity is the second, view-scoped layer. It derives each -owner's branch, tag, and `HEAD` set from the NIP-01-preferred accepted State -event published by that owner's confirmed maintainer set. It derives +owner view's branch, tag, and `HEAD` set from the NIP-01-preferred accepted +State event published by the confirmed maintainer component resolved from +that selected owner coordinate. It derives `refs/nostr/` from accepted PR and PR Update events when the same confirmed-maintainer overlap as normal event processing selects the view, or when an exact standard clone URL names that owner and identifier on this diff --git a/docs/explanation/grasp-02-proactive-sync.md b/docs/explanation/grasp-02-proactive-sync.md index 45aa36b..691e4c9 100644 --- a/docs/explanation/grasp-02-proactive-sync.md +++ b/docs/explanation/grasp-02-proactive-sync.md @@ -974,7 +974,7 @@ needs the inviter's Git data. In that flow: 1. Dependency-resolvable entries remain in the cold index until processing succeeds. 2. If the full event is still in the hot cache, it is re-processed immediately. 3. If the full event expired, the sync manager requests only the retained event - IDs from connected relays across the recursive maintainer chain. Each + IDs from connected relays across the maintainer-discovery dependency chain. Each request explicitly targets its associated relay connection instead of the SDK's automatic pool-wide relay selection. 4. Relay requests run in parallel as bounded background work; announcements are processed before state events that may depend on them. diff --git a/docs/explanation/inline-authorization.md b/docs/explanation/inline-authorization.md index a15168f..41ca295 100644 --- a/docs/explanation/inline-authorization.md +++ b/docs/explanation/inline-authorization.md @@ -100,7 +100,7 @@ Client Server (ngit-grasp) pub async fn authorize_push( database: &SharedDatabase, identifier: &str, - owner_pubkey: &str, + selected_pubkey: &str, request_body: &Bytes, purgatory: &Arc, // Can check purgatory! repo_path: &std::path::Path, @@ -223,7 +223,7 @@ pub async fn handle_receive_pack( let auth = authorize_push( &database, identifier, - owner_pubkey, + selected_pubkey, &body, &purgatory, // Can check purgatory! &repo_path @@ -310,7 +310,7 @@ The authorization flow (from [`src/git/authorization.rs:51-162`](../../src/git/a pub async fn authorize_push( database: &SharedDatabase, identifier: &str, - owner_pubkey: &str, + selected_pubkey: &str, request_body: &Bytes, purgatory: &Arc, repo_path: &std::path::Path, @@ -328,7 +328,8 @@ pub async fn authorize_push( // 4. Handle normal refs (state events) // - Check database + purgatory for state events - // - Collect authorized maintainers + // - Resolve the selected coordinate's active lead path + // - Compute reciprocal confirmed membership from that root // - Find latest authorized state // - Validate refs match state @@ -340,7 +341,9 @@ pub async fn authorize_push( 1. **For state refs** (`refs/heads/*`, `refs/tags/*`): - - Query database for announcements → collect authorized maintainers + - Parse role history into current activity only + - Resolve the URL-selected coordinate through one active `M` path + - Compute its reciprocal confirmed-maintainer component - Check **purgatory** for matching state events (critical for purgatory flow!) - Filter to events from authorized maintainers - Find latest state event @@ -354,6 +357,14 @@ pub async fn authorize_push( **No-Op Push Acceptance:** Pushes where all refs have `old_oid == new_oid` are accepted without requiring a purgatory state event, matching Git's "Everything up-to-date" behavior and avoiding race condition rejections. +Role history does not flow through authorization. Announcement parsing +validates the alternating start/end markers and derives only current active +maintainers, current active `M` lead targets, and whether the announcement +author currently claims maintainership. Authorization then resolves the +selected coordinate's lead path and computes reciprocal membership. Missing, +inactive, incomplete, ambiguous, or cyclic roots fail closed; their distinct +internal results exist for diagnostics, not as alternative authority modes. + --- ## State Event Authorization @@ -369,15 +380,19 @@ When a state event arrives via WebSocket or sync: impl StatePolicy { async fn admit_event(&self, event: &Event) -> Result { // Check 1: Does announcement exist for this repository? - let announcements = query_announcements(pubkey, identifier); + let announcements = query_announcements(identifier); if announcements.is_empty() { return Reject("No announcement exists for repository"); } - // Check 2: Is author in maintainer set? - let maintainers = build_maintainer_set(announcements); - if !maintainers.contains(&event.author) { - return Reject("Author not in maintainer set"); + // Check 2: Does any selected coordinate resolve to a confirmed + // component containing this state-event author? + let coordinates = repository_coordinates_authorized_by( + event.author, + announcements, + ); + if coordinates.is_empty() { + return Reject("Author not authorized for this repository"); } // If git data doesn't exist yet, goes to purgatory @@ -393,12 +408,15 @@ When a repository announcement is accepted, waiting state events are re-evaluate ```rust // After announcement is saved to database for state_event in purgatory.get_state_events(identifier) { - // Re-check authorization now that announcement exists - if author_in_maintainer_set(state_event.author, identifier) { + // Re-resolve current authorization now that the dependency exists. + if repository_coordinates_authorized_by( + state_event.author, + announcements, + ).is_empty() { + // Remove from purgatory - not currently authorized + } else { // If git data now exists, save to database // Otherwise, keep in purgatory - } else { - // Remove from purgatory - not authorized } } ``` @@ -411,7 +429,11 @@ When git data is pushed, purgatory state events are validated before saving: // src/git/handlers.rs - after successful git push for state_event in purgatory.get_matching_state_events(identifier) { // Final authorization check before database save - if author_in_maintainer_set(state_event.author, identifier) { + let coordinates = repository_coordinates_authorized_by( + state_event.author, + announcements, + ); + if !coordinates.is_empty() { database.save(state_event); purgatory.remove(state_event); } else { @@ -447,7 +469,7 @@ available. During that bootstrap flow, rejected state events are: 1. **Retained** in the cold index until policy processing succeeds 2. **Retrieved** from the hot cache and re-processed immediately when the full event is still available -3. **Fetched by exact event ID** from the recursive maintainer relay chain when the hot-cache copy has expired +3. **Fetched by exact event ID** from maintainer-discovery relay dependencies when the hot-cache copy has expired 4. **Removed from both tiers** only after the event is accepted, enters purgatory, or is already stored Repository announcements are processed before dependent state events. Network diff --git a/docs/explanation/monitoring.md b/docs/explanation/monitoring.md index b0905df..762d3db 100644 --- a/docs/explanation/monitoring.md +++ b/docs/explanation/monitoring.md @@ -344,7 +344,7 @@ When a maintainer announcement arrives before the owner announcement: 1. Maintainer event rejected → hot cache + cold index 2. Reciprocal owner announcement enters purgatory → retain the cold ID until recovery succeeds 3. If still in hot cache → immediate policy re-processing -4. If expired from hot cache → exact-ID requests run in parallel across the recursive maintainer relay chain +4. If expired from hot cache → exact-ID requests run in parallel across the maintainer-discovery relay dependencies 5. Empty or failed requests keep the ID for a throttled retry 6. Successful processing removes the event from both tiers diff --git a/docs/explanation/purgatory-design.md b/docs/explanation/purgatory-design.md index a801e85..5df565e 100644 --- a/docs/explanation/purgatory-design.md +++ b/docs/explanation/purgatory-design.md @@ -375,7 +375,7 @@ The 30-minute purgatory timer is reset (extended) in three scenarios: | Trigger | Location | Why | |---------|----------|-----| | State event arrives | `StatePolicy::process_state_event()` | Repo is actively receiving metadata | -| Git push authorized against purgatory state | `get_state_authorization_for_specific_owner_repo()` | Repo is actively receiving git data | +| Git push authorized against purgatory state | `get_state_authorization_for_selected_repo()` | Repo is actively receiving git data | | Replacement announcement arrives | `AnnouncementPolicy::validate()` | Announcement updated | All three call `purgatory.extend_announcement_expiry(owner, identifier, 1800s)`. @@ -864,10 +864,10 @@ purgatory.extend_announcement_expiry(&owner_pk, &identifier, Duration::from_secs ### 5. Sync Registration (`src/sync/`) -A background timer (`run_purgatory_announcement_sync`, every 5 seconds) ensures purgatory announcements are registered in `RepoSyncIndex` with `SyncLevel::StateOnly`. It connects the announcement's relay hints before recovering rejected dependencies, walks accepted announcements to collect the recursive maintainer relay chain, and re-processes any dependency events still in the hot cache. +A background timer (`run_purgatory_announcement_sync`, every 5 seconds) ensures purgatory announcements are registered in `RepoSyncIndex` with `SyncLevel::StateOnly`. It connects the announcement's relay hints before recovering rejected dependencies, walks accepted announcements to collect maintainer-discovery relay dependencies, and re-processes any dependency events still in the hot cache. If a dependency's full event has expired, the timer starts a background exact-ID -request against all connected relays in that chain. Requests run in parallel and +request against all connected dependency relays. Requests run in parallel and do not hold the sync-manager lock. Announcements are applied before state events; IDs remain in the cold index through empty or failed requests and are removed only after successful policy processing. Production retries are limited to once every diff --git a/docs/how-to/upgrade-git-family-storage.md b/docs/how-to/upgrade-git-family-storage.md index 445a5b3..e012632 100644 --- a/docs/how-to/upgrade-git-family-storage.md +++ b/docs/how-to/upgrade-git-family-storage.md @@ -97,7 +97,8 @@ refs as evidence instead of guessing that deletion is safe. The authorization pass treats the accepted event database as authoritative: -- the latest State event from the owner's confirmed maintainer set defines +- the latest State event from the confirmed maintainer component resolved for + the selected owner coordinate defines all and only `refs/heads/*`, `refs/tags/*`, and `HEAD`; - accepted PR and PR Update events define `refs/nostr/` in an owner view when either the confirmed-maintainer overlap selects that view or an diff --git a/docs/learnings/grasp-01-implementation.md b/docs/learnings/grasp-01-implementation.md index f893d78..e75ab27 100644 --- a/docs/learnings/grasp-01-implementation.md +++ b/docs/learnings/grasp-01-implementation.md @@ -32,14 +32,14 @@ - ✅ Accepts repository announcements listing this service - ✅ Accepts repository state announcements - ✅ Accepts events tagging/tagged by accepted repos -- ✅ Recursive maintainer announcement support +- ✅ Maintainer-discovery announcement support without implicit State authority - ✅ NIP-11 document with `supported_grasps` field - ✅ CORS headers on all endpoints **Git HTTP Service:** - ✅ Serves git at `//.git` - ✅ Push validation against state events -- ✅ Recursive maintainer chain support +- ✅ Active-lead resolution with reciprocal maintainer confirmation - ✅ HEAD set from state events - ✅ `refs/nostr/` support for PRs - ✅ `allow-tip-sha1-in-want` and `allow-reachable-sha1-in-want` (GRASP-01 requirement) @@ -70,8 +70,8 @@ flowchart TD A --> AC{Lists our service in clone AND relays?} AC -->|Yes| ACCEPT1[Accept + Create Repo] - AC -->|No| RM{Is recursive maintainer?} - RM -->|Yes| ACCEPT2[Accept - for discovery] + AC -->|No| RM{Has maintainer discovery path?} + RM -->|Yes| ACCEPT2[Admit dependency - no authority granted] RM -->|No| REJECT1[Reject] S --> SA{Author authorized?} @@ -238,4 +238,4 @@ Based on GRASP-01 experience: --- -*Created: December 4, 2025* \ No newline at end of file +*Created: December 4, 2025* diff --git a/docs/reference/git-protocol.md b/docs/reference/git-protocol.md index c0ecb3b..2b07aab 100644 --- a/docs/reference/git-protocol.md +++ b/docs/reference/git-protocol.md @@ -278,10 +278,10 @@ pub async fn validate_push( // 2. Get pubkey from npub let pubkey = decode_npub(npub)?; - // 3. Get maintainer set (recursive) - let maintainers = get_maintainers(&events, &pubkey, identifier); + // 3. Resolve this selected coordinate's current authority + let maintainers = resolve_current_maintainers(&events, &pubkey, identifier); if maintainers.is_empty() { - return Err(Error::NoAnnouncement); + return Err(Error::NoResolvedAuthority); } // 4. Get latest state from maintainers diff --git a/grasp-audit/README.md b/grasp-audit/README.md index 51ce3a9..18ab97d 100644 --- a/grasp-audit/README.md +++ b/grasp-audit/README.md @@ -156,7 +156,7 @@ Test coverage of GRASP-01 specification: - Repository announcement acceptance - State event handling -- Push authorization (owner, maintainer, recursive maintainer) +- Push authorization (selected coordinate, direct maintainer, and transitively confirmed maintainer) - Event acceptance policy - Git clone over HTTP - CORS headers @@ -367,7 +367,7 @@ pub async fn test_something(client: &AuditClient) -> TestResult { | `PREventSentAfterWrongPush` | PR event sent after wrong commit was pushed. Tests cleanup behavior. | Testing post-event ref cleanup | | `OwnerStateDataPushed` | Full owner push flow: state event + git data pushed. Points to `DETERMINISTIC_COMMIT_HASH`. | Testing owner push authorization | | `MaintainerStateDataPushed` | Full maintainer push flow: force-pushes over owner's data. Points to `MAINTAINER_DETERMINISTIC_COMMIT_HASH`. | Testing maintainer push authorization | -| `RecursiveMaintainerStateDataPushed` | Full recursive maintainer push flow: Owner → Maintainer → RecursiveMaintainer chain. Points to `RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH`. | Testing recursive maintainer authorization | +| `RecursiveMaintainerStateDataPushed` | Full transitive reciprocal-confirmation flow: selected coordinate → maintainer → additional maintainer. Points to `RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH`. | Testing transitively confirmed authorization | | `HeadSetToDevelopBranch` | State event with HEAD=refs/heads/develop. Depends on RecursiveMaintainerStateDataPushed. | Testing HEAD branch switching | #### Deterministic Commit Hashes diff --git a/grasp-audit/src/fixtures.rs b/grasp-audit/src/fixtures.rs index 6cd9a5d..5e75ca8 100644 --- a/grasp-audit/src/fixtures.rs +++ b/grasp-audit/src/fixtures.rs @@ -193,8 +193,9 @@ pub trait Fixture: Send + Sync { /// - `MaintainerAnnouncement` + `MaintainerState` → uses ValidRepoSent's repo_id /// - `RecursiveMaintainerRepoAndState` → uses ValidRepoSent's repo_id /// -/// This enables testing recursive maintainer authorization chains where multiple -/// parties publish announcements and state events for the same repository. +/// The legacy `RecursiveMaintainer` fixture name denotes a transitive case. +/// Its announcements are reciprocal, so it tests confirmed membership rather +/// than authority from unilateral recursive listing. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum FixtureKind { /// Basic repository announcement (kind 30617) @@ -403,12 +404,13 @@ pub enum FixtureKind { /// Maintainer's state event with git data successfully pushed (full 5-stage fixture) /// - /// This fixture tests that a maintainer can authorize pushes with ONLY a state event, - /// without publishing their own repo announcement. + /// This fixture tests that a reciprocally confirmed maintainer can + /// authorize pushes. It publishes the maintainer's acknowledgment + /// announcement before the State event. /// /// This fixture represents the complete flow for testing maintainer push authorization: /// 1. **OwnerStateDataPushed dependency**: Owner's repo and state event already on relay, git data pushed - /// 2. **Sent**: Sends maintainer state event to relay (returns OK, accepted but 'purgatory:...' message) + /// 2. **Sent**: Sends the reciprocal announcement, then the maintainer State event /// 3. **Verify Not Served**: Confirms event is not served by relays /// 4. **DataPushed**: Clones repo, creates maintainer deterministic commit, force-pushes to relay /// 5. **Verified**: Confirms event is served by relay @@ -419,26 +421,27 @@ pub enum FixtureKind { /// - Git push verified to succeed (force push with maintainer's state event authorizes the commit) MaintainerStateDataPushed, - /// Recursive maintainer's state event with git data successfully pushed (full 5-stage fixture) + /// Transitively confirmed maintainer's state event with git data pushed. /// - /// This fixture tests that a recursive maintainer (authorized via maintainer chain) can - /// authorize pushes. The recursive maintainer is listed in the maintainer's announcement, - /// not the owner's announcement, so this tests the recursive maintainer traversal. + /// The legacy fixture name says “recursive maintainer”; authority actually + /// comes from reciprocal confirmation through the selected component. The + /// additional maintainer is listed by the direct maintainer, not by the + /// selected coordinate's author. /// - /// This fixture represents the complete flow for testing recursive maintainer push authorization: + /// This fixture represents the complete transitive-confirmation flow: /// 1. **Generated**: (MaintainerStateDataPushed dependency includes ValidRepoSent + OwnerStateDataPushed) - /// Creates MaintainerAnnouncement + RecursiveMaintainerState + /// Creates the additional maintainer's reciprocal announcement and State event /// 2. **Sent**: Sends events to relay (returns OK, accepted but 'purgatory:...' message) /// 3. **Verify Not Served**: Confirms event is not served by relays - /// 4. **DataPushed**: Clones repo, creates recursive maintainer deterministic commit, pushes to relay + /// 4. **DataPushed**: Clones repo, creates the additional maintainer's deterministic commit, pushes to relay /// 5. **Verified**: Confirms event is served by relay /// - /// Chain: Owner -> Maintainer -> RecursiveMaintainer + /// Component: selected author ↔ direct maintainer ↔ additional maintainer /// /// - Requires MaintainerStateDataPushed (establishes Owner -> Maintainer chain with git data) - /// - State event signed by recursive maintainer keys (`client.recursive_maintainer_keys()`) + /// - State event signed by the legacy-named recursive maintainer keys (`client.recursive_maintainer_keys()`) /// - Points to RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH - /// - Git push verified to succeed (recursive maintainer's state event authorizes the commit) + /// - Git push verified to succeed through transitively confirmed membership RecursiveMaintainerStateDataPushed, } @@ -1709,11 +1712,11 @@ impl<'a> TestContext<'a> { Ok(maintainer_state_event) } - /// Build RecursiveMaintainerStateDataPushed fixture: full 5-stage fixture for recursive maintainer push authorization + /// Build the legacy-named recursive fixture for transitive confirmed authorization. /// - /// This tests that a recursive maintainer (authorized via maintainer chain) can authorize pushes. - /// The recursive maintainer is listed in the maintainer's announcement, not the owner's announcement, - /// so this tests the recursive maintainer traversal (Owner -> Maintainer -> RecursiveMaintainer). + /// The additional maintainer is listed in the direct maintainer's + /// announcement rather than the selected author's announcement, and + /// publishes a reciprocal announcement before gaining authority. /// /// Depends on MaintainerStateDataPushed - the maintainer's data has already been /// pushed and the maintainer's announcement (which assigns the recursive @@ -1723,14 +1726,14 @@ impl<'a> TestContext<'a> { /// /// This handles all stages of the fixture: /// 1. **Generated**: (MaintainerStateDataPushed dependency includes ValidRepoSent + OwnerStateDataPushed) - /// Creates MaintainerAnnouncement + RecursiveMaintainerState + /// Creates the additional maintainer's reciprocal announcement and State event /// 2. **Sent**: Sends events to relay (returns OK, accepted but 'purgatory:...' message) /// 3. **Verify Not Served**: Confirms event is not served by relays - /// 4. **DataPushed**: Clones repo, creates recursive maintainer deterministic commit, pushes to relay + /// 4. **DataPushed**: Clones repo, creates the additional maintainer's deterministic commit, pushes to relay /// 5. **Verified**: Confirms event is served by relay /// /// # Returns - /// The recursive maintainer's state event (kind 30618) after all stages complete successfully + /// The transitively confirmed maintainer's State event after all stages complete async fn build_recursive_maintainer_state_data_pushed(&self) -> Result { use nostr_sdk::prelude::*; @@ -1877,7 +1880,7 @@ impl<'a> TestContext<'a> { "Push was rejected but should have been accepted. \ The recursive maintainer published a state event with commit {}, \ and the relay should authorize pushes matching this state event \ - through recursive maintainer traversal (Owner -> Maintainer -> RecursiveMaintainer).", + through the transitively confirmed reciprocal component.", RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH )); } diff --git a/grasp-audit/src/probe.rs b/grasp-audit/src/probe.rs index 6414ed8..d181cf0 100644 --- a/grasp-audit/src/probe.rs +++ b/grasp-audit/src/probe.rs @@ -1466,8 +1466,8 @@ pub async fn run_probe_with_options( // git_refs_match_state: fetch all served kind 30618 state events for this // repo (by #d tag), derive expected refs (latest timestamp wins per ref - // across all authorized state events — relay already validated auth, - // including recursive maintainer chains), then compare against git refs. + // across all authorized state events — relay already validated the + // selected coordinate's resolved reciprocal component), then compare. match refs_body_fallback { None => { checks.push(skipped( @@ -1479,8 +1479,8 @@ pub async fn run_probe_with_options( let fetched_refs = parse_refs(&body); // Fetch all state events for this repo_id from the relay. - // The relay only serves authorized state events (owner + full - // recursive maintainer chain already resolved by the relay). + // The relay only serves state events authorized by a selected + // coordinate's resolved reciprocal component. let state_filter = Filter::new().kind(Kind::RepoState).custom_tag( nostr_sdk::prelude::SingleLetterTag::LOWERCASE_D, ann_id.clone(), @@ -1510,8 +1510,8 @@ pub async fn run_probe_with_options( } else { // Build expected refs: for each ref name, the state event with // the highest created_at timestamp wins (mirrors relay behaviour). - // This correctly handles recursive maintainership — any authorized - // party's state event may be the most recent for a given ref. + // Any confirmed member's state event may be the most recent + // for a given ref. let mut expected: std::collections::HashMap = std::collections::HashMap::new(); let mut latest_ts: std::collections::HashMap = diff --git a/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs b/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs index 69be2e1..337f9b9 100644 --- a/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs +++ b/grasp-audit/src/specs/grasp01/event_acceptance_policy.rs @@ -5,9 +5,9 @@ //! This file validates that a GRASP-01 compliant relay: //! - Accepts valid NIP-34 repository announcements listing the service //! - Rejects announcements that don't list the service in clone and relays tags -//! EXCEPTION: maintainer announcements (from authors in the maintainer chain) -//! MUST be accepted even without listing the service - this enables recursive maintainer -//! chain discovery and more reliable GRASP-02 sync capabilities +//! EXCEPTION: maintainer-discovery candidates MUST be accepted even without +//! listing the service. Admission enables dependency discovery and GRASP-02 +//! sync; it does not itself grant State-event authority. //! - Accepts repository state announcements //! - Accepts events that TAG accepted repositories //! - Accepts events that ARE TAGGED BY accepted events (transitive) @@ -397,18 +397,16 @@ impl EventAcceptancePolicyTests { .await } - /// Test: Accept recursive maintainer announcement without service in clone tag + /// Test: Admit a maintainer-discovery announcement without the service. /// /// Spec: Line 7 of ../grasp/01.md (EXCEPTION to rejection rule) - /// Requirement: MUST accept recursive maintainer announcements for chain discovery + /// Requirement: MUST accept candidate announcements for dependency discovery /// /// GRASP-01: "respecting the recursive maintainer set" /// - /// When a recursive maintainer is listed in a maintainer's announcement, they may - /// publish their own announcement for the same repo (with their own maintainers). - /// The relay MUST accept this recursive maintainer's announcement even if it doesn't - /// list this GRASP server in its clone tag - because the relay needs it to discover - /// the full recursive maintainer chain. + /// A listed candidate may publish its reciprocal announcement for the same + /// identifier. The relay must admit that announcement without this GRASP + /// server in its clone tag so membership dependencies can be discovered. /// /// This also enables GRASP-02 to sync state events and git data when authoritative /// users publish them to other relays/git servers, keeping repos up-to-date. @@ -416,18 +414,16 @@ impl EventAcceptancePolicyTests { client: &AuditClient, ) -> TestResult { TestResult::new( - "accept_recursive_maintainer_announcement_without_service", + "accept_maintainer_discovery_announcement_without_service", SpecRef::NostrRelayRejectMissingCloneRelays, - "MUST accept recursive maintainer announcements for chain discovery", + "MUST admit maintainer announcements needed for dependency discovery", ) .run(|| async { // Create TestContext for mode-aware fixture management let ctx = TestContext::new(client); - // Step 1: Get RecursiveMaintainerStateDataPushed fixture - // This establishes: Owner -> Maintainer -> RecursiveMaintainer chain - // with all git data pushed. The recursive maintainer is already listed - // in maintainer's announcement (and maintainer in owner's announcement). + // Step 1: Get the legacy-named recursive fixture. It establishes a + // transitively reciprocal component with all Git data pushed. let recursive_state = ctx .get_fixture(FixtureKind::RecursiveMaintainerStateDataPushed) .await @@ -447,7 +443,7 @@ impl EventAcceptancePolicyTests { .ok_or("Missing d tag in recursive maintainer state")? .to_string(); - // Step 2: Build a recursive maintainer announcement that DOES NOT include + // Step 2: Build the additional maintainer's announcement without // this GRASP server in its clone tag - simulating an announcement pointing // to a different server (e.g., another GRASP server) let recursive_maintainer_npub = client @@ -496,7 +492,7 @@ impl EventAcceptancePolicyTests { let event_id = recursive_maintainer_announcement.id; - // Step 3: Send the recursive maintainer announcement + // Step 3: Send the maintainer-discovery announcement. client .send_event(recursive_maintainer_announcement) .await @@ -516,14 +512,13 @@ impl EventAcceptancePolicyTests { .await .map_err(|e| format!("Failed to query events: {}", e))?; - // Verify the recursive maintainer's announcement was stored + // Verify the dependency announcement was stored. if !events.iter().any(|e| e.id == event_id) { return Err(format!( - "Recursive maintainer announcement was NOT accepted by relay. \ - The recursive maintainer (listed in maintainer's announcement, which is \ - listed in owner's announcement) published their own announcement for \ - repo {} with an external clone URL. The relay should accept this to \ - enable full recursive maintainer chain discovery. Event ID: {}", + "Maintainer dependency announcement was NOT accepted by relay. \ + A transitively listed candidate published a reciprocal announcement for \ + repo {} with an external clone URL. The relay should admit it for \ + dependency discovery without treating admission as authority. Event ID: {}", repo_id, event_id )); } diff --git a/grasp-audit/src/specs/grasp01/push_authorization.rs b/grasp-audit/src/specs/grasp01/push_authorization.rs index 9b580ec..415e499 100644 --- a/grasp-audit/src/specs/grasp01/push_authorization.rs +++ b/grasp-audit/src/specs/grasp01/push_authorization.rs @@ -360,8 +360,9 @@ impl PushAuthorizationTests { results.add(Self::test_push_rejected_without_state_event(client, relay_domain).await); results.add(Self::test_push_authorized_by_owner_state(client, relay_domain).await); results.add(Self::test_push_rejected_wrong_commit(client, relay_domain).await); - results - .add(Self::test_push_authorized_by_maintainer_state_only(client, relay_domain).await); + results.add( + Self::test_push_authorized_by_confirmed_maintainer_state(client, relay_domain).await, + ); results.add( Self::test_push_authorized_by_recursive_maintainer_state(client, relay_domain).await, ); @@ -679,12 +680,11 @@ impl PushAuthorizationTests { } } - /// Test push authorized by maintainer state event only (no announcement) + /// Test push authorized by a reciprocally confirmed maintainer State event. /// /// GRASP-01: "respecting the recursive maintainer set" - /// This tests that a maintainer can authorize pushes with ONLY a state event, - /// without publishing their own repo announcement. The maintainer is still - /// listed in the owner's announcement, so they're a valid maintainer. + /// Listing alone is an invitation. The fixture publishes the maintainer's + /// reciprocal announcement before its State event becomes authoritative. /// /// This test uses the MaintainerStateDataPushed fixture which handles all 5 stages: /// 1. **OwnerStateDataPushed dependency**: Owner's repo and state event already on relay, git data pushed @@ -695,11 +695,11 @@ impl PushAuthorizationTests { /// /// The test wraps the fixture result in pass/fail using the error message. #[allow(unused_variables)] // relay_domain is now handled by fixture - pub async fn test_push_authorized_by_maintainer_state_only( + pub async fn test_push_authorized_by_confirmed_maintainer_state( client: &AuditClient, relay_domain: &str, ) -> TestResult { - let test_name = "test_push_authorized_by_maintainer_state_only"; + let test_name = "test_push_authorized_by_confirmed_maintainer_state"; let ctx = TestContext::new(client); // The MaintainerStateDataPushed fixture handles all stages: @@ -711,29 +711,30 @@ impl PushAuthorizationTests { Ok(_maintainer_state_event) => TestResult::new( test_name, SpecRef::GitAcceptPushesAlignState, - "Push authorized by maintainer state event only (no announcement)", + "Push authorized by reciprocally confirmed maintainer state", ) .pass(), Err(e) => TestResult::new( test_name, SpecRef::GitAcceptPushesAlignState, - "Push authorized by maintainer state event only (no announcement)", + "Push authorized by reciprocally confirmed maintainer state", ) .fail(format!("{}", e)), } } - /// Test push authorized by recursive maintainer state event + /// Test push authorized by a transitively confirmed maintainer State event. /// /// GRASP-01: "respecting the recursive maintainer set" - /// This tests recursive maintainer chains: Owner -> Maintainer -> RecursiveMaintainer + /// The legacy fixture name describes a selected author ↔ direct maintainer + /// ↔ additional maintainer reciprocal component. /// /// This test uses the RecursiveMaintainerStateDataPushed fixture which handles all 5 stages: /// 1. **Generated**: (MaintainerStateDataPushed dependency includes ValidRepo + OwnerStateDataPushed) /// Creates MaintainerAnnouncement + RecursiveMaintainerState /// 2. **Sent**: Sends events to relay (returns OK, accepted but 'purgatory:...' message) /// 3. **Verify Not Served**: Confirms event is not served by relays - /// 4. **DataPushed**: Clones repo, creates recursive maintainer deterministic commit, pushes to relay + /// 4. **DataPushed**: Clones repo, creates the additional maintainer's deterministic commit, pushes to relay /// 5. **Verified**: Confirms event is served by relay /// /// The test wraps the fixture result in pass/fail using the error message. @@ -754,13 +755,13 @@ impl PushAuthorizationTests { Ok(_recursive_maintainer_state_event) => TestResult::new( test_name, SpecRef::GitAcceptPushesAlignState, - "Push authorized by recursive maintainer state event", + "Push authorized by transitively confirmed maintainer state", ) .pass(), Err(e) => TestResult::new( test_name, SpecRef::GitAcceptPushesAlignState, - "Push authorized by recursive maintainer state event", + "Push authorized by transitively confirmed maintainer state", ) .fail(format!("{}", e)), } @@ -776,7 +777,7 @@ impl PushAuthorizationTests { /// This test is compatible with any descendant of `OwnerStateDataPushed`: /// - `OwnerStateDataPushed` - owner's state event with git data pushed /// - `MaintainerStateDataPushed` - maintainer's state event with git data pushed - /// - `RecursiveMaintainerStateDataPushed` - recursive maintainer's state event with git data pushed + /// - `RecursiveMaintainerStateDataPushed` - transitively confirmed maintainer's State event with Git data pushed /// /// All of these establish valid state on the relay that a non-maintainer should NOT be able to override. /// diff --git a/src/git/authorization.rs b/src/git/authorization.rs index bc16e47..a2fc668 100644 --- a/src/git/authorization.rs +++ b/src/git/authorization.rs @@ -10,7 +10,8 @@ //! ## Authorization Flow (Efficient Single-Query Approach) //! //! 1. Fetch announcement and state events for the repository from the relay database -//! 2. Compute the confirmed maintainer set for the owner's repository +//! 2. Resolve the selected repository coordinate and compute its confirmed +//! maintainer set //! 3. Find the latest state event authored by a confirmed maintainer //! 4. Validate that the pushed refs match the state event //! @@ -51,14 +52,14 @@ use nostr_sdk::prelude::{Kind, PublicKey}; pub async fn authorize_push( database: &SharedDatabase, identifier: &str, - owner_pubkey: &str, + selected_pubkey: &str, request_body: &Bytes, purgatory: &Arc, repo_path: &std::path::Path, ) -> anyhow::Result { debug!( - "Authorizing push for {} owned by {} via database query", - identifier, owner_pubkey + "Authorizing push for {} through selected coordinate {} via database query", + identifier, selected_pubkey ); // Parse refs from the push request @@ -126,10 +127,10 @@ pub async fn authorize_push( "Found {} non-refs/nostr/ refs - checking state authorization", state_refs.len() ); - let auth_result = get_state_authorization_for_specific_owner_repo( + let auth_result = get_state_authorization_for_selected_repo( database, identifier, - owner_pubkey, + selected_pubkey, purgatory, &pushed_refs, //it would be better to accept state_refs but thats in different format repo_path, @@ -298,25 +299,35 @@ pub async fn fetch_repository_data_with_purgatory( Ok(repo_data) } +pub fn repository_coordinates_authorized_by( + pubkey: &PublicKey, + db_repo_data: &RepositoryData, +) -> Vec { + let mut authorized_coordinates = HashSet::new(); + let collections = collect_state_maintainers_by_coordinate(&db_repo_data.announcements); + let pubkey = pubkey.to_hex(); + for (selected_pubkey, authorized) in collections { + if authorized.contains(&pubkey) { + authorized_coordinates.insert(selected_pubkey); + } + } + authorized_coordinates.into_iter().collect() +} + +/// Compatibility alias retaining the v3.0 public API. +#[deprecated(since = "3.0.1", note = "use repository_coordinates_authorized_by")] pub fn pubkey_authorised_for_repo_owners( pubkey: &PublicKey, db_repo_data: &RepositoryData, ) -> Vec { - let mut repo_owners_authorising_pubkey = HashSet::new(); - let collections = collect_authorized_maintainers(&db_repo_data.announcements); - for (owner, authoised) in collections { - if authoised.contains(&pubkey.to_hex()) { - repo_owners_authorising_pubkey.insert(owner.to_string()); - } - } - repo_owners_authorising_pubkey.iter().cloned().collect() + repository_coordinates_authorized_by(pubkey, db_repo_data) } -/// Confirmed membership of one owner's repository. +/// Confirmed membership resolved from one selected repository coordinate. #[derive(Debug, Default)] pub struct RepoMembership { /// Pubkeys whose repository state events are authoritative for this - /// owner's selected repository view: the resolved lead or selected + /// selected repository view: the resolved lead or selected /// leadless/legacy root plus every confirmed maintainer. pub state_maintainers: HashSet, /// Pubkeys currently listed as maintainers whose own announcement does @@ -326,7 +337,31 @@ pub struct RepoMembership { pub invited: HashSet, } -/// Compute the confirmed maintainer set for `owner`'s repository. +/// Why a selected repository coordinate could not resolve an authority root. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum MembershipResolutionError { + SelectedAnnouncementMissing { + selected_pubkey: String, + }, + SelectedAuthorInactive { + selected_pubkey: String, + }, + LeadAnnouncementMissing { + lead_pubkey: String, + }, + LeadPathIncomplete { + at_pubkey: String, + }, + AmbiguousActiveLeads { + at_pubkey: String, + leads: Vec, + }, + LeadCycle { + at_pubkey: String, + }, +} + +/// Resolve the confirmed maintainer set for a selected repository coordinate. /// /// A pubkey listed as a maintainer is only *invited* until its own /// announcement for the same identifier lists back a pubkey that is already @@ -344,11 +379,11 @@ pub struct RepoMembership { /// path. Missing, ambiguous, or cyclic explicit paths grant no authority. /// This prevents a removed maintainer's forwarding coordinate from restoring /// that signer to the confirmed set. -pub fn compute_membership( +fn resolve_membership( announcements: &[RepositoryAnnouncement], - owner: &str, + selected_pubkey: &str, identifier: &str, -) -> RepoMembership { +) -> Result { let find = |pubkey: &str| { announcements .iter() @@ -359,26 +394,50 @@ pub fn compute_membership( // this is a legacy or deliberately leadless view rooted at the selected // author. Once an explicit path is followed, only an active self-M may // terminate it; incomplete, ambiguous, and cyclic paths fail closed. - let mut root = owner.to_string(); + let mut root = selected_pubkey.to_string(); let mut followed_explicit_lead = false; let mut visited = HashSet::new(); loop { if !visited.insert(root.clone()) { - return RepoMembership::default(); + return Err(MembershipResolutionError::LeadCycle { at_pubkey: root }); } let Some(announcement) = find(&root) else { - return RepoMembership::default(); + return Err(if followed_explicit_lead { + MembershipResolutionError::LeadAnnouncementMissing { lead_pubkey: root } + } else { + MembershipResolutionError::SelectedAnnouncementMissing { + selected_pubkey: root, + } + }); }; let active_leads = announcement.active_leads(); - match active_leads.as_slice() { - [] if !followed_explicit_lead && announcement.author_role_active() => break, - [] => return RepoMembership::default(), - [lead] if lead == &root && announcement.author_role_active() => break, + match active_leads { + [] if !followed_explicit_lead + && announcement.announcement_author_is_active_maintainer() => + { + break; + } + [] if !followed_explicit_lead => { + return Err(MembershipResolutionError::SelectedAuthorInactive { + selected_pubkey: root, + }); + } + [] => { + return Err(MembershipResolutionError::LeadPathIncomplete { at_pubkey: root }); + } + // A valid active self-M necessarily makes the announcement + // author an active maintainer, so it is a complete terminal. + [lead] if lead == &root => break, [lead] if lead != &root => { root = lead.clone(); followed_explicit_lead = true; } - _ => return RepoMembership::default(), + _ => { + return Err(MembershipResolutionError::AmbiguousActiveLeads { + at_pubkey: root, + leads: active_leads.to_vec(), + }); + } } } @@ -401,7 +460,7 @@ pub fn compute_membership( let Some(candidate_announcement) = find(candidate) else { continue; // invited: no announcement of their own yet }; - if !candidate_announcement.author_role_active() { + if !candidate_announcement.announcement_author_is_active_maintainer() { continue; // left: an ended self-role takes precedence } let lists_back = candidate_announcement @@ -420,63 +479,111 @@ pub fn compute_membership( .filter(|pubkey| !confirmed.contains(pubkey)) // A pubkey whose own announcement shows it left is not // invited. - .filter(|pubkey| !find(pubkey).is_some_and(|a| a.author_has_left())) + .filter(|pubkey| { + find(pubkey).is_none_or(|a| a.announcement_author_is_active_maintainer()) + }) .collect(); - return RepoMembership { + return Ok(RepoMembership { state_maintainers: confirmed, invited, - }; + }); } } } -/// Collect authorized state publishers grouped by owner from a set of -/// announcements. +/// Compute membership while preserving the fail-closed public shape. /// -/// For each announcement, returns a map from owner pubkey to the pubkeys -/// whose repository state events are authoritative for that owner's -/// repository: the confirmed maintainer set computed by +/// Callers that only need authorization receive an empty membership when the +/// selected coordinate cannot resolve. The internal resolver retains the +/// precise failure reason for diagnostics and focused tests. +pub fn compute_membership( + announcements: &[RepositoryAnnouncement], + selected_pubkey: &str, + identifier: &str, +) -> RepoMembership { + match resolve_membership(announcements, selected_pubkey, identifier) { + Ok(membership) => membership, + Err(error) => { + debug!( + selected_pubkey, + identifier, + ?error, + "Membership resolution failed closed" + ); + RepoMembership::default() + } + } +} + +/// Collect authorized state publishers grouped by selected announcement +/// coordinate. +/// +/// For each announcement, returns a map from its author pubkey to the pubkeys +/// whose repository state events are authoritative through that selected +/// coordinate: the confirmed maintainer set computed by /// [`compute_membership`]. Invited pubkeys (listed but without a reciprocal -/// announcement) are never included. -pub fn collect_authorized_maintainers( +/// announcement) are never included. Coordinates with invalid lead +/// resolution remain present with an empty set so callers fail closed. +pub fn collect_state_maintainers_by_coordinate( announcements: &[RepositoryAnnouncement], ) -> HashMap> { - let mut by_owner: HashMap> = HashMap::new(); + let mut by_coordinate: HashMap> = HashMap::new(); for announcement in announcements { - let owner = announcement.event.pubkey.to_hex(); - let membership = compute_membership(announcements, &owner, &announcement.identifier); - by_owner.insert(owner, membership.state_maintainers.into_iter().collect()); + let selected_pubkey = announcement.event.pubkey.to_hex(); + let maintainers = + match resolve_membership(announcements, &selected_pubkey, &announcement.identifier) { + Ok(membership) => membership.state_maintainers.into_iter().collect(), + Err(error) => { + debug!( + identifier = %announcement.identifier, + selected_pubkey, + ?error, + "Selected repository coordinate has no state authority" + ); + Vec::new() + } + }; + by_coordinate.insert(selected_pubkey, maintainers); } debug!( - "Collected confirmed state maintainers for {} owners from {} announcements", - by_owner.len(), + "Collected confirmed state maintainers for {} coordinates from {} announcements", + by_coordinate.len(), announcements.len() ); - by_owner + by_coordinate } -/// Get the authorization result for a repository scoped to a specific owner +/// Compatibility alias retaining the v3.0 public API. +#[deprecated(since = "3.0.1", note = "use collect_state_maintainers_by_coordinate")] +pub fn collect_authorized_maintainers( + announcements: &[RepositoryAnnouncement], +) -> HashMap> { + collect_state_maintainers_by_coordinate(announcements) +} + +/// Get the authorization result for a selected repository coordinate. /// /// Push authorization checks ONLY purgatory for state events. The database represents /// the current git state, while purgatory holds the intended future state that pushes /// should be authorized against. /// -/// A push to `alice/my-repo` should only consider authorization from alice's -/// announcement, not bob's announcement for the same identifier. +/// A push to `alice/my-repo` resolves authority from Alice's selected +/// coordinate. That coordinate may validly lead to Bob's announcement, but an +/// unrelated same-identifier component is not considered. /// /// It: /// 1. Fetches announcements for the identifier -/// 2. Collects authorized maintainers from owner's announcement +/// 2. Resolves authorized maintainers from the selected coordinate /// 3. Checks purgatory for matching state events from authorized maintainers /// /// Returns an `AuthorizationResult` that indicates whether a push is authorized. -pub async fn get_state_authorization_for_specific_owner_repo( +pub async fn get_state_authorization_for_selected_repo( database: &SharedDatabase, identifier: &str, - owner_pubkey: &str, + selected_pubkey: &str, purgatory: &std::sync::Arc, pushed_refs: &[(String, String, String)], repo_path: &std::path::Path, @@ -494,16 +601,16 @@ pub async fn get_state_authorization_for_specific_owner_repo( )); } - // Collect authorized maintainers grouped by owner from all announcements - let by_owner = collect_authorized_maintainers(&repo_data.announcements); + // Collect authorized maintainers grouped by selected coordinate. + let by_coordinate = collect_state_maintainers_by_coordinate(&repo_data.announcements); - // Look up the authorized set for this specific owner - let authorized: HashSet = match by_owner.get(owner_pubkey) { + // Look up the authorized set resolved from this specific coordinate. + let authorized: HashSet = match by_coordinate.get(selected_pubkey) { Some(maintainers) => maintainers.iter().cloned().collect(), None => { return Ok(AuthorizationResult::denied(format!( - "No repository announcement found for owner {}", - owner_pubkey + "No repository announcement found for selected coordinate {}", + selected_pubkey ))); } }; @@ -515,10 +622,10 @@ pub async fn get_state_authorization_for_specific_owner_repo( } debug!( - "Found {} authorized maintainers for repository {} (owner: {})", + "Found {} authorized maintainers for repository {} (selected coordinate: {})", authorized.len(), identifier, - owner_pubkey + selected_pubkey ); // Accept pushes where all refs are already at the desired state (old_oid == new_oid) @@ -530,8 +637,8 @@ pub async fn get_state_authorization_for_specific_owner_repo( if all_refs_unchanged { debug!( - "All pushed refs unchanged (old_oid == new_oid) for {} owned by {}, accepting without purgatory check", - identifier, owner_pubkey + "All pushed refs unchanged (old_oid == new_oid) for {} through selected coordinate {}, accepting without purgatory check", + identifier, selected_pubkey ); return Ok(AuthorizationResult { authorized: true, @@ -601,7 +708,7 @@ pub async fn get_state_authorization_for_specific_owner_repo( // also extends the announcement's expiry. The repo is actively receiving // git data, so the announcement should not expire prematurely. // This also revives soft-expired announcements (recreates bare repo). - if let Ok(owner_pk) = PublicKey::parse(owner_pubkey) { + if let Ok(owner_pk) = PublicKey::parse(selected_pubkey) { if purgatory.has_purgatory_announcement(&owner_pk, identifier) { purgatory.extend_announcement_expiry( &owner_pk, @@ -610,7 +717,7 @@ pub async fn get_state_authorization_for_specific_owner_repo( ); debug!( identifier = %identifier, - owner = %owner_pubkey, + selected_pubkey, "Extended purgatory announcement expiry due to git push authorization" ); } @@ -718,6 +825,33 @@ pub async fn get_state_authorization_for_specific_owner_repo( )) } +/// Compatibility alias retaining the v3.0 public API. +/// +/// The legacy `owner_pubkey` argument is the selected announcement coordinate; +/// it never grants implicit authority to that signer. +#[deprecated( + since = "3.0.1", + note = "use get_state_authorization_for_selected_repo" +)] +pub async fn get_state_authorization_for_specific_owner_repo( + database: &SharedDatabase, + identifier: &str, + owner_pubkey: &str, + purgatory: &std::sync::Arc, + pushed_refs: &[(String, String, String)], + repo_path: &std::path::Path, +) -> Result { + get_state_authorization_for_selected_repo( + database, + identifier, + owner_pubkey, + purgatory, + pushed_refs, + repo_path, + ) + .await +} + /// Result of authorization check #[derive(Debug)] pub struct AuthorizationResult { @@ -1298,7 +1432,7 @@ mod tests { } #[test] - fn test_owner_is_sole_state_maintainer() { + fn test_selected_legacy_author_is_sole_state_maintainer() { let alice = create_test_keys(); let identifier = "test-repo"; let announcements = vec![parse(create_announcement_event(&alice, identifier, &[]))]; @@ -1309,17 +1443,23 @@ mod tests { } #[test] - fn test_no_owner_announcement_means_no_membership() { + fn test_missing_selected_announcement_means_no_membership() { let alice = create_test_keys(); let bob = create_test_keys(); let identifier = "test-repo"; - // Only Bob has announced; Alice's repository has no membership. + // Only Bob has announced; Alice's selected coordinate cannot resolve. let announcements = vec![parse(create_announcement_event(&bob, identifier, &[]))]; let membership = compute_membership(&announcements, &hex(&alice), identifier); assert!(membership.state_maintainers.is_empty()); assert!(membership.invited.is_empty()); + assert_eq!( + resolve_membership(&announcements, &hex(&alice), identifier).unwrap_err(), + MembershipResolutionError::SelectedAnnouncementMissing { + selected_pubkey: hex(&alice) + } + ); } #[test] @@ -1339,8 +1479,8 @@ mod tests { assert!(!membership.state_maintainers.contains(&hex(&bob))); assert!(membership.invited.contains(&hex(&bob))); - let by_owner = collect_authorized_maintainers(&announcements); - assert!(!by_owner[&hex(&alice)].contains(&hex(&bob))); + let by_coordinate = collect_state_maintainers_by_coordinate(&announcements); + assert!(!by_coordinate[&hex(&alice)].contains(&hex(&bob))); } #[test] @@ -1506,6 +1646,12 @@ mod tests { let membership = compute_membership(&announcements, &hex(&alice), identifier); assert!(membership.state_maintainers.is_empty()); assert!(membership.invited.is_empty()); + assert_eq!( + resolve_membership(&announcements, &hex(&alice), identifier).unwrap_err(), + MembershipResolutionError::SelectedAuthorInactive { + selected_pubkey: hex(&alice) + } + ); } #[test] @@ -1537,9 +1683,9 @@ mod tests { announcements, states: Vec::new(), }; - assert!(pubkey_authorised_for_repo_owners(&bob.public_key(), &repo_data).is_empty()); + assert!(repository_coordinates_authorized_by(&bob.public_key(), &repo_data).is_empty()); assert_eq!( - pubkey_authorised_for_repo_owners(&alice.public_key(), &repo_data) + repository_coordinates_authorized_by(&alice.public_key(), &repo_data) .into_iter() .collect::>(), HashSet::from([hex(&alice), hex(&bob)]) @@ -1557,32 +1703,65 @@ mod tests { identifier, &[("M", vec![hex(&alice)]), ("m", vec![hex(&bob)])], )); - let missing_lead = compute_membership( + let missing_lead = resolve_membership( std::slice::from_ref(&bob_announcement), &hex(&bob), identifier, + ) + .unwrap_err(); + assert_eq!( + missing_lead, + MembershipResolutionError::LeadAnnouncementMissing { + lead_pubkey: hex(&alice) + } + ); + + let lead_without_active_m = parse(create_announcement_event(&alice, identifier, &[])); + let incomplete = resolve_membership( + &[bob_announcement.clone(), lead_without_active_m], + &hex(&bob), + identifier, + ) + .unwrap_err(); + assert_eq!( + incomplete, + MembershipResolutionError::LeadPathIncomplete { + at_pubkey: hex(&alice) + } ); - assert!(missing_lead.state_maintainers.is_empty()); let ambiguous = parse(create_role_announcement( &alice, identifier, &[("M", vec![hex(&alice)]), ("M", vec![hex(&bob)])], )); - let ambiguous_lead = compute_membership(&[ambiguous], &hex(&alice), identifier); - assert!(ambiguous_lead.state_maintainers.is_empty()); + let ambiguous_lead = + resolve_membership(&[ambiguous], &hex(&alice), identifier).unwrap_err(); + assert_eq!( + ambiguous_lead, + MembershipResolutionError::AmbiguousActiveLeads { + at_pubkey: hex(&alice), + leads: vec![hex(&alice), hex(&bob)], + } + ); let alice_announcement = parse(create_role_announcement( &alice, identifier, &[("M", vec![hex(&bob)]), ("m", vec![hex(&alice)])], )); - let cycle = compute_membership( + let cycle = resolve_membership( &[alice_announcement, bob_announcement], &hex(&alice), identifier, + ) + .unwrap_err(); + assert_eq!( + cycle, + MembershipResolutionError::LeadCycle { + at_pubkey: hex(&alice) + } ); - assert!(cycle.state_maintainers.is_empty()); } #[test] diff --git a/src/git/handlers.rs b/src/git/handlers.rs index ee1b4b3..6da5178 100644 --- a/src/git/handlers.rs +++ b/src/git/handlers.rs @@ -631,7 +631,7 @@ async fn stream_upload_pack_output( /// * `request_body` - The git pack data from the client /// * `database` - Database reference for authorization queries /// * `identifier` - The repository identifier (d tag) for authorization lookup -/// * `owner_pubkey` - The owner's public key (hex) from the URL path, scoping authorization +/// * `owner_pubkey` - Announcement pubkey from the URL path; selects the repository coordinate whose authority is resolved /// * `git_data_path` - Base path for git repositories (for syncing to other owner repos) /// * `git_protocol` - Optional Git protocol version (e.g., "version=2") #[allow(clippy::too_many_arguments)] diff --git a/src/git/process.rs b/src/git/process.rs index c22da0c..2c4a3eb 100644 --- a/src/git/process.rs +++ b/src/git/process.rs @@ -7,7 +7,7 @@ //! - When git pushes trigger purgatory releases (receive-pack handler) use crate::git; -use crate::git::authorization::{collect_authorized_maintainers, RepositoryData}; +use crate::git::authorization::{collect_state_maintainers_by_coordinate, RepositoryData}; use crate::git::sync::{ align_repository_with_state, copy_missing_oids_between_repos, sync_pr_refs_to_tagged_owner_repos, @@ -75,7 +75,7 @@ pub fn process_state_with_git_data( let state_author = state.event.pubkey.to_hex(); // Collect authorized maintainers per owner - let by_owner = collect_authorized_maintainers(&db_repo_data.announcements); + let by_owner = collect_state_maintainers_by_coordinate(&db_repo_data.announcements); // Step 1: Identify owner repos that the state event author is maintainer for let authorized_owners: Vec<&String> = by_owner diff --git a/src/git/sync.rs b/src/git/sync.rs index 3f7673e..dea30af 100644 --- a/src/git/sync.rs +++ b/src/git/sync.rs @@ -38,7 +38,7 @@ use async_trait::async_trait; use nostr_sdk::prelude::{Event, SaveEventStatus}; use crate::git::authorization::{ - collect_authorized_maintainers, fetch_repository_data_excluding_purgatory, + collect_state_maintainers_by_coordinate, fetch_repository_data_excluding_purgatory, fetch_repository_data_with_purgatory, RepositoryData, }; use crate::git::{self, oid_exists}; @@ -206,7 +206,7 @@ pub fn sync_pr_refs_to_tagged_owner_repos( ); // Collect authorized maintainers per owner - let by_owner = collect_authorized_maintainers(&db_repo_data.announcements); + let by_owner = collect_state_maintainers_by_coordinate(&db_repo_data.announcements); for (owner, maintainers) in &by_owner { // Skip the source owner - we already have the data there @@ -382,7 +382,7 @@ pub fn sync_to_owner_repos( let mut result = SyncResult::default(); // Collect authorized maintainers per owner - let by_owner = collect_authorized_maintainers(&db_repo_data.announcements); + let by_owner = collect_state_maintainers_by_coordinate(&db_repo_data.announcements); let state_author = state.event.pubkey.to_hex(); debug!( @@ -1002,7 +1002,7 @@ pub async fn process_repos_populated_by_state_copy( ) -> ProcessResult { let empty_oids: HashSet = HashSet::new(); let mut aggregate = ProcessResult::default(); - let maintainers_by_owner = collect_authorized_maintainers(&repo_data.announcements); + let maintainers_by_owner = collect_state_maintainers_by_coordinate(&repo_data.announcements); for announcement in &repo_data.announcements { let target_repo_path = git_data_path.join(announcement.repo_path()); @@ -1134,12 +1134,13 @@ async fn process_purgatory_state_events( // CRITICAL: Check authorization before processing // State events MUST be rejected if author is not in maintainer set - let authorized_owners = crate::git::authorization::pubkey_authorised_for_repo_owners( - &entry.event.pubkey, - &db_repo_data, - ); + let authorized_coordinates = + crate::git::authorization::repository_coordinates_authorized_by( + &entry.event.pubkey, + &db_repo_data, + ); - if authorized_owners.is_empty() { + if authorized_coordinates.is_empty() { warn!( identifier = %identifier, event_id = %entry.event.id, @@ -1160,7 +1161,7 @@ async fn process_purgatory_state_events( identifier = %identifier, event_id = %entry.event.id, author = %entry.event.pubkey.to_hex(), - authorized_for_owners = ?authorized_owners, + authorized_coordinates = ?authorized_coordinates, "State event author authorized via maintainer set" ); diff --git a/src/nostr/events.rs b/src/nostr/events.rs index 8981a53..2489ea1 100644 --- a/src/nostr/events.rs +++ b/src/nostr/events.rs @@ -14,7 +14,7 @@ use nostr_sdk::prelude::{Event, Kind, PublicKey, ToBech32}; /// Values after the pubkey alternate between numeric start and end /// boundaries. `defer` is valid only as the final end boundary and is never /// active. Malformed history must not grant repository authority. -fn role_entry_is_active(slice: &[String]) -> bool { +fn role_entry_is_currently_active(slice: &[String]) -> bool { if slice.len() < 2 { return false; } @@ -34,6 +34,19 @@ fn role_entry_is_active(slice: &[String]) -> bool { boundaries.is_empty() || boundaries.len() % 2 == 1 } +/// Present-tense authorization facts derived from NIP-34 indexed roles. +/// +/// Role history is consumed while parsing the announcement, but is not kept: +/// relay authorization only needs the current maintainer set, current lead +/// targets, and whether the announcement author currently claims a maintainer +/// role. +#[derive(Debug, Clone)] +struct CurrentMaintainerRoles { + active_maintainers: Vec, + active_leads: Vec, + announcement_author_is_active_maintainer: bool, +} + // NOTE: Using rust-nostr Kind variants instead of hardcoded constants: // - KIND_REPOSITORY_ANNOUNCEMENT -> Kind::GitRepoAnnouncement (30617) // - KIND_REPOSITORY_STATE -> Kind::RepoState (30618) @@ -55,20 +68,23 @@ pub struct RepositoryAnnouncement { /// announcement uses `M`/`m`/`o` role tags, which take precedence per /// NIP-34. pub maintainers: Vec, - /// Currently-active maintainer pubkeys from NIP-34 `M`/`m` role tags; - /// `None` when the announcement contains no role tags. Moderator (`o`) - /// entries never contribute: moderators are not maintainers. + /// Compatibility projection of currently active indexed `M`/`m` roles. + /// + /// Internal authorization uses the single parsed `CurrentMaintainerRoles` + /// snapshot. This field preserves the v3.0 public API and should not be + /// interpreted as role history. + #[doc(hidden)] + pub role_maintainers: Option>, + /// Present-tense authorization facts derived from NIP-34 `M`/`m`/`o` + /// role tags; `None` when the announcement contains no indexed roles. + /// Moderator (`o`) entries never contribute maintainer authority. /// /// Both `M` and `m` grant maintainer authority, so they collapse into one - /// maintainer set. Active `M` entries additionally drive lead resolution - /// through [`Self::active_leads`]. Entries whose role history is malformed - /// or ended are excluded entirely: historic listing never grants - /// authority for a past period. - pub role_maintainers: Option>, - /// Whether any role tag names the author, active or ended. Per NIP-34 an - /// author without a self-entry is implicitly a maintainer for the - /// repository's entire history. - author_has_role_entry: bool, + /// maintainer set. Active `M` entries additionally drive lead resolution. + /// Entries whose role history is malformed or ended are excluded entirely + /// after parsing: historic listing never grants authority for a past + /// period. + current_roles: Option, } impl RepositoryAnnouncement { @@ -172,7 +188,8 @@ impl RepositoryAnnouncement { // the author is not implicitly a maintainer. let mut role_tags_present = false; let mut author_has_role_entry = false; - let mut role_active: Vec = Vec::new(); + let mut active_maintainers: Vec = Vec::new(); + let mut active_leads: Vec = Vec::new(); for tag in event.tags.iter() { let slice = tag.as_slice(); let Some(kind) = slice.first() else { continue }; @@ -190,17 +207,28 @@ impl RepositoryAnnouncement { if kind == "o" { continue; } - if role_entry_is_active(slice) { + if role_entry_is_currently_active(slice) { let Some(parsed_pubkey) = parsed_pubkey else { continue; }; let pubkey = parsed_pubkey.to_hex(); - if !role_active.contains(&pubkey) { - role_active.push(pubkey); + if !active_maintainers.contains(&pubkey) { + active_maintainers.push(pubkey.clone()); + } + if kind == "M" && !active_leads.contains(&pubkey) { + active_leads.push(pubkey); } } } - let role_maintainers = role_tags_present.then_some(role_active); + let current_roles = role_tags_present.then(|| CurrentMaintainerRoles { + announcement_author_is_active_maintainer: !author_has_role_entry + || active_maintainers.contains(&event.pubkey.to_hex()), + active_maintainers, + active_leads, + }); + let role_maintainers = current_roles + .as_ref() + .map(|roles| roles.active_maintainers.clone()); // Extract maintainers from the deprecated "maintainers" tag per NIP-34 // Format: ["maintainers", "", "", ...] @@ -231,7 +259,7 @@ impl RepositoryAnnouncement { web_urls, maintainers, role_maintainers, - author_has_role_entry, + current_roles, }) } @@ -244,10 +272,10 @@ impl RepositoryAnnouncement { /// listing alone makes none of their events authoritative. pub fn listed_maintainers(&self) -> Vec { let author = self.event.pubkey.to_hex(); - let source = self - .role_maintainers - .as_deref() - .unwrap_or(&self.maintainers); + let source = match &self.current_roles { + Some(roles) => &roles.active_maintainers, + None => &self.maintainers, + }; let mut listed: Vec = Vec::new(); for pubkey in source { if *pubkey != author && !listed.contains(pubkey) { @@ -262,25 +290,10 @@ impl RepositoryAnnouncement { /// An active `M` is both a maintainer edge and an explicit lead pointer. /// Duplicate records for one target are consolidated; distinct targets /// remain visible so authority resolution can fail closed on ambiguity. - pub fn active_leads(&self) -> Vec { - let mut leads = Vec::new(); - for tag in self.event.tags.iter() { - let slice = tag.as_slice(); - if slice.first().map(String::as_str) != Some("M") || !role_entry_is_active(slice) { - continue; - } - let Some(pubkey) = slice - .get(1) - .and_then(|value| PublicKey::from_hex(value).ok()) - else { - continue; - }; - let pubkey = pubkey.to_hex(); - if !leads.contains(&pubkey) { - leads.push(pubkey); - } - } - leads + pub fn active_leads(&self) -> &[String] { + self.current_roles + .as_ref() + .map_or(&[], |roles| roles.active_leads.as_slice()) } /// Whether this announcement asserts an active maintainer role for its @@ -288,29 +301,31 @@ impl RepositoryAnnouncement { /// /// With role tags the author is active via an active `M`/`m` self-entry, /// or implicitly: per NIP-34 an author who appears in no role tag is a - /// maintainer for the repository's entire history. Only a self-entry - /// that asserts no active maintainer role - an ended entry, or a - /// moderator-only (`o`) entry - means the author is not a maintainer, - /// which takes precedence over assignments in other announcements. In - /// the deprecated format the author always asserts maintainership; a - /// `u` (subordinate fork) tag has no effect on maintainership. - pub fn author_role_active(&self) -> bool { - match &self.role_maintainers { - Some(active) => { - !self.author_has_role_entry || active.contains(&self.event.pubkey.to_hex()) - } - None => true, - } + /// maintainer for the repository's entire history. A self-entry with no + /// active valid `M`/`m` role - ended, deferred, malformed, or + /// moderator-only (`o`) - means the author is not a maintainer. That + /// present-tense result takes precedence over assignments in other + /// announcements. In the deprecated format the author always asserts + /// maintainership; a `u` (subordinate fork) tag has no effect. + pub fn announcement_author_is_active_maintainer(&self) -> bool { + self.current_roles + .as_ref() + .is_none_or(|roles| roles.announcement_author_is_active_maintainer) } - /// Whether this announcement shows its author is not a maintainer: a - /// role tag names the author but no `M`/`m` entry of theirs is active - - /// they left, or hold only the moderator (`o`) role. - /// - /// An author absent from all role tags has *not* left - they are - /// implicitly a maintainer for the repository's entire history. + /// Compatibility alias for v3.0 callers. + #[deprecated(since = "3.0.1", note = "use announcement_author_is_active_maintainer")] + pub fn author_role_active(&self) -> bool { + self.announcement_author_is_active_maintainer() + } + + /// Compatibility alias for v3.0 callers. + #[deprecated( + since = "3.0.1", + note = "test announcement_author_is_active_maintainer directly" + )] pub fn author_has_left(&self) -> bool { - self.role_maintainers.is_some() && !self.author_role_active() + self.current_roles.is_some() && !self.announcement_author_is_active_maintainer() } /// Check if this announcement lists the given domain in clone URLs @@ -1061,7 +1076,7 @@ mod tests { !listed.contains(&author), "author is excluded from the listed set" ); - assert!(announcement.author_role_active()); + assert!(announcement.announcement_author_is_active_maintainer()); } #[test] @@ -1096,10 +1111,23 @@ mod tests { ], ); - assert!(!announcement.author_role_active()); + assert!(!announcement.announcement_author_is_active_maintainer()); assert!(announcement.listed_maintainers().contains(&lead)); } + #[test] + fn test_malformed_self_role_is_inactive() { + let keys = create_test_keys(); + let author = keys.public_key().to_hex(); + + let announcement = role_announcement( + &keys, + vec![("m", vec![author, "not-a-timestamp".to_string()])], + ); + + assert!(!announcement.announcement_author_is_active_maintainer()); + } + #[test] fn test_u_tag_has_no_effect_on_maintainership() { let keys = create_test_keys(); @@ -1108,7 +1136,7 @@ mod tests { // Deprecated format: the author implicitly asserts maintainership, // with or without a `u` (subordinate fork) tag. let plain = role_announcement(&keys, vec![("maintainers", vec![upstream.clone()])]); - assert!(plain.author_role_active()); + assert!(plain.announcement_author_is_active_maintainer()); let fork = role_announcement( &keys, @@ -1117,8 +1145,7 @@ mod tests { ("u", vec![format!("30617:{upstream}:test-repo")]), ], ); - assert!(fork.author_role_active()); - assert!(!fork.author_has_left()); + assert!(fork.announcement_author_is_active_maintainer()); } #[test] @@ -1129,8 +1156,7 @@ mod tests { // Role tags that do not name the author: the author is implicitly a // maintainer for the repository's entire history. let announcement = role_announcement(&keys, vec![("m", vec![other])]); - assert!(announcement.author_role_active()); - assert!(!announcement.author_has_left()); + assert!(announcement.announcement_author_is_active_maintainer()); } #[test] @@ -1186,7 +1212,7 @@ mod tests { assert!(announcement.maintainers.is_empty()); assert!(announcement.listed_maintainers().is_empty()); assert!(!announcement.listed_maintainers().contains(&moderator)); - assert!(announcement.author_role_active()); + assert!(announcement.announcement_author_is_active_maintainer()); } #[test] @@ -1199,8 +1225,7 @@ mod tests { // maintainer, so the implicit-maintainer rule does not apply. let announcement = role_announcement(&keys, vec![("M", vec![lead]), ("o", vec![author])]); - assert!(!announcement.author_role_active()); - assert!(announcement.author_has_left()); + assert!(!announcement.announcement_author_is_active_maintainer()); } #[test] @@ -1284,8 +1309,7 @@ mod tests { .unwrap(); let announcement = RepositoryAnnouncement::from_event(event).unwrap(); - assert!(announcement.author_role_active()); - assert!(!announcement.author_has_left()); + assert!(announcement.announcement_author_is_active_maintainer()); } #[test] diff --git a/src/nostr/lifecycle/deletion/rollback.rs b/src/nostr/lifecycle/deletion/rollback.rs index c6209df..4df9405 100644 --- a/src/nostr/lifecycle/deletion/rollback.rs +++ b/src/nostr/lifecycle/deletion/rollback.rs @@ -5,7 +5,7 @@ use nostr_sdk::prelude::{Event, EventId, Filter, Kind, PublicKey, SingleLetterTa use super::policy::{identifier_from_event, DeletionPolicy}; use crate::git; use crate::git::authorization::{ - collect_authorized_maintainers, fetch_repository_data_excluding_purgatory, + collect_state_maintainers_by_coordinate, fetch_repository_data_excluding_purgatory, }; use crate::git::process; use crate::nostr::events::RepositoryState; @@ -256,7 +256,7 @@ impl DeletionPolicy { } }; - if crate::git::authorization::pubkey_authorised_for_repo_owners( + if crate::git::authorization::repository_coordinates_authorized_by( &candidate.pubkey, &repo_data, ) @@ -410,7 +410,7 @@ impl DeletionPolicy { return; } - let by_owner = collect_authorized_maintainers(&repo_data.announcements); + let by_owner = collect_state_maintainers_by_coordinate(&repo_data.announcements); for announcement in &repo_data.announcements { let owner_hex = announcement.event.pubkey.to_hex(); diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs index fb24c4b..832ebab 100644 --- a/src/nostr/policy/announcement.rs +++ b/src/nostr/policy/announcement.rs @@ -75,7 +75,8 @@ impl AnnouncementPolicy { match validation_result { AnnouncementResult::Reject(reason) => { // Validation failed - check maintainer exception - // GRASP-01 Exception: Accept announcements from recursive maintainers + // GRASP-01 exception: admit maintainer-discovery candidates. + // This does not grant confirmed membership or state authority. match RepositoryAnnouncement::from_event(event.clone()) { Ok(announcement) => { // If this pubkey+identifier has a purgatory entry AND the incoming @@ -128,10 +129,7 @@ impl AnnouncementPolicy { } match self - .is_maintainer_in_any_announcement( - &announcement.identifier, - &event.pubkey, - ) + .has_maintainer_discovery_path(&announcement.identifier, &event.pubkey) .await { Ok(true) => AnnouncementResult::AcceptMaintainer, @@ -420,25 +418,24 @@ impl AnnouncementPolicy { Ok(()) } - /// Check if a pubkey is currently listed as a maintainer in any announcement - /// for this identifier + /// Check whether a pubkey has an admission path for maintainer discovery. /// - /// A pubkey qualifies if: - /// 1. They are the owner (pubkey) of an accepted announcement with this identifier, OR - /// 2. ANY announcement with this identifier currently lists them as a maintainer - /// (active `M`/`m` role tags or the deprecated `maintainers` tag) + /// A pubkey qualifies if it authored an accepted announcement with this + /// identifier, or any announcement with the identifier currently lists it + /// as a maintainer (active `M`/`m` roles or deprecated `maintainers`). /// - /// This enables accepting announcements from maintainers even when they don't list - /// this GRASP server, for maintainer chain discovery and GRASP-02 sync. It - /// deliberately includes *invited* pubkeys - their announcement is exactly how we - /// learn they accepted the role - but excludes pubkeys whose role history shows - /// the role has ended. + /// This enables accepting candidate announcements even when they do not + /// list this GRASP server, for maintainer dependency discovery and + /// GRASP-02 sync. It deliberately includes *invited* pubkeys: their announcement is + /// exactly how the relay learns they accepted the role. It is an admission + /// and dependency-discovery predicate only; it does not establish + /// reciprocal membership or grant repository-state authority. /// /// Checks both the database (promoted announcements) and purgatory (announcements /// waiting for git data). This is necessary because a maintainer's announcement - /// (which lists the recursive maintainer) may still be in purgatory when the - /// recursive maintainer's announcement arrives. - async fn is_maintainer_in_any_announcement( + /// (which lists the next candidate) may still be in purgatory when that + /// candidate's announcement arrives. + async fn has_maintainer_discovery_path( &self, identifier: &str, author: &PublicKey, diff --git a/src/nostr/policy/pr_event.rs b/src/nostr/policy/pr_event.rs index 63970c9..9a78806 100644 --- a/src/nostr/policy/pr_event.rs +++ b/src/nostr/policy/pr_event.rs @@ -8,7 +8,7 @@ use nostr_sdk::prelude::Event; use super::PolicyContext; use crate::git; use crate::git::authorization::{ - collect_authorized_maintainers, fetch_repository_data_excluding_purgatory, + collect_state_maintainers_by_coordinate, fetch_repository_data_excluding_purgatory, }; use crate::grasp06::receive::RepoInitLocks; @@ -427,7 +427,7 @@ impl PrEventPolicy { } // 4. Identify owner repos that list any of the maintainers using this function - let by_owner = collect_authorized_maintainers(&db_repo_data.announcements); + let by_owner = collect_state_maintainers_by_coordinate(&db_repo_data.announcements); // 5. Return the repo_path for each owner whose authorized maintainers include any of our maintainers let mut repo_paths = Vec::new(); diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs index f17b1bc..f856d55 100644 --- a/src/nostr/policy/state.rs +++ b/src/nostr/policy/state.rs @@ -108,12 +108,13 @@ impl StatePolicy { return Ok(reject_invalid("no announcement exists for this repository")); } - let authorized_owners = crate::git::authorization::pubkey_authorised_for_repo_owners( - &event.pubkey, - &db_repo_data, - ); + let authorized_coordinates = + crate::git::authorization::repository_coordinates_authorized_by( + &event.pubkey, + &db_repo_data, + ); - if authorized_owners.is_empty() { + if authorized_coordinates.is_empty() { if is_synced { tracing::debug!( event_id = %event.id, @@ -140,7 +141,7 @@ impl StatePolicy { event_id = %event.id, identifier = %state.identifier, author = %event.pubkey.to_hex(), - authorized_for_owners = ?authorized_owners, + authorized_coordinates = ?authorized_coordinates, "State event author authorized via maintainer set" ); @@ -151,25 +152,25 @@ impl StatePolicy { // premature expiry during slow sync operations — the repo is actively receiving // metadata so it should stay alive. // - // We extend for all owners that authorized this state event, since the state - // event proves the repo is active regardless of which owner's announcement - // authorized it. - for owner_hex in &authorized_owners { - if let Ok(owner_pk) = nostr_sdk::prelude::PublicKey::from_hex(owner_hex) { + // We extend every selected coordinate that authorizes this state event. + for selected_pubkey_hex in &authorized_coordinates { + if let Ok(selected_pubkey) = + nostr_sdk::prelude::PublicKey::from_hex(selected_pubkey_hex) + { if self .ctx .purgatory - .has_purgatory_announcement(&owner_pk, &state.identifier) + .has_purgatory_announcement(&selected_pubkey, &state.identifier) { self.ctx.purgatory.extend_announcement_expiry( - &owner_pk, + &selected_pubkey, &state.identifier, std::time::Duration::from_secs(1800), ); tracing::debug!( event_id = %event.id, identifier = %state.identifier, - owner = %owner_hex, + selected_pubkey = %selected_pubkey_hex, "Extended purgatory announcement expiry due to state event arrival" ); } @@ -185,13 +186,16 @@ impl StatePolicy { // reconcile pass (`reapply_stored`). Reconcile instead of returning // early as an ordinary duplicate. let state_already_in_db = db_repo_data.states.iter().any(|e| e.event.id.eq(&event.id)); - let has_authorized_purgatory_announcement = authorized_owners.iter().any(|owner_hex| { - nostr_sdk::prelude::PublicKey::from_hex(owner_hex).is_ok_and(|owner| { - self.ctx - .purgatory - .has_purgatory_announcement(&owner, &state.identifier) - }) - }); + let has_authorized_purgatory_announcement = + authorized_coordinates.iter().any(|selected_pubkey_hex| { + nostr_sdk::prelude::PublicKey::from_hex(selected_pubkey_hex).is_ok_and( + |selected_pubkey| { + self.ctx + .purgatory + .has_purgatory_announcement(&selected_pubkey, &state.identifier) + }, + ) + }); if state_already_in_db && !has_authorized_purgatory_announcement && !reapply_stored { tracing::debug!("processed state event duplicate (in db): {}", event.id); diff --git a/src/sync/rejected_index.rs b/src/sync/rejected_index.rs index 3144355..718fca3 100644 --- a/src/sync/rejected_index.rs +++ b/src/sync/rejected_index.rs @@ -82,8 +82,8 @@ //! ); //! //! // Re-process `hot_events` immediately. For candidate IDs without a full -//! // hot-cache event, request that exact event from the recursive maintainer -//! // relay chain. Keep each entry indexed until policy processing succeeds, +//! // hot-cache event, request that exact event from the maintainer-discovery +//! // relay dependencies. Keep each entry indexed until processing succeeds, //! // then call `index.remove(&event_id)`. //! ``` diff --git a/tests/push_authorization.rs b/tests/push_authorization.rs index 07f77a6..9150705 100644 --- a/tests/push_authorization.rs +++ b/tests/push_authorization.rs @@ -61,7 +61,7 @@ macro_rules! isolated_push_test { isolated_push_test!(test_push_rejected_without_state_event); isolated_push_test!(test_push_authorized_by_owner_state); isolated_push_test!(test_push_rejected_wrong_commit); -isolated_push_test!(test_push_authorized_by_maintainer_state_only); +isolated_push_test!(test_push_authorized_by_confirmed_maintainer_state); isolated_push_test!(test_push_authorized_by_recursive_maintainer_state); isolated_push_test!(test_push_to_nostr_ref_with_invalid_event_id_rejected); isolated_push_test!(test_pr_push_to_nostr_ref_with_wrong_commit_accepted_before_event_received);