refactor(auth): make current maintainer authority explicit

The v3.0.1 authorization fix is intentionally small. Follow it with a separate structural pass so the implementation and documentation express the present-tense maintainer model directly instead of leaving the security behavior hidden behind owner-oriented names and repeated raw-tag interpretation.

Parse indexed roles once into a current-only snapshot of active maintainers, active lead targets, and announcement-author activity. Preserve detailed lead-resolution failures internally while policy callers continue to fail closed, distinguish selected authorization coordinates from physical owner views, and name broad announcement admission as discovery rather than authority.

Keep history relevant only while deriving current activity and retain active leads only for selected-coordinate resolution. Preserve the v3.0 public API through compatibility projections and deprecated aliases; this commit is not intended to change the authorization outcome established by 650cfb57.

Refresh architecture, inline authorization, storage, sync, and audit documentation. Correct the audit fixture description that claimed a listed maintainer authorized with no reciprocal announcement even though its setup already published one.

Validated with cargo test --lib (903 tests), cargo test --test state_authorization (53 tests), cargo test -p grasp-audit --lib (54 passed, 5 ignored), cargo test --test push_authorization (56 tests), and cargo clippy --tests -- -D warnings.
This commit is contained in:
DanConwayDev
2026-08-29 21:20:49 +00:00
parent 650cfb57ee
commit fd7c6bb851
28 changed files with 577 additions and 329 deletions
+1 -1
View File
@@ -135,7 +135,7 @@ for the shared storage model.
- ✅ Accepts NIP-34 repository announcements and state events
- ✅ Git Smart HTTP service at `/<npub>/<identifier>.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/<event-id>` for PRs
- ✅ Git capabilities: `allow-tip-sha1-in-want`, `allow-reachable-sha1-in-want`, `uploadpack.allowFilter`
- ✅ CORS support for web-based Git clients
+1 -1
View File
@@ -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.
+22 -8
View File
@@ -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<AuthorizationResult, AuthorizationError>
@@ -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 `<announcement-pubkey>/<identifier>.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()
+10 -4
View File
@@ -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.
@@ -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/<event-id>` 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
+1 -1
View File
@@ -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.
+38 -16
View File
@@ -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<Purgatory>, // 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<Purgatory>,
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<Decision, Error> {
// 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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+2 -1
View File
@@ -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/<event-id>` in an owner
view when either the confirmed-maintainer overlap selects that view or an
+5 -5
View File
@@ -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 `/<npub>/<identifier>.git`
- ✅ Push validation against state events
- ✅ Recursive maintainer chain support
- ✅ Active-lead resolution with reciprocal maintainer confirmation
- ✅ HEAD set from state events
- ✅ `refs/nostr/<event-id>` 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*
*Created: December 4, 2025*
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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
+26 -23
View File
@@ -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<Event> {
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
));
}
+6 -6
View File
@@ -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<String, String> =
std::collections::HashMap::new();
let mut latest_ts: std::collections::HashMap<String, u64> =
@@ -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
));
}
@@ -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.
///
+256 -77
View File
@@ -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<Purgatory>,
repo_path: &std::path::Path,
) -> anyhow::Result<AuthorizationResult> {
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<String> {
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<String> {
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<String>,
/// Pubkeys currently listed as maintainers whose own announcement does
@@ -326,7 +337,31 @@ pub struct RepoMembership {
pub invited: HashSet<String>,
}
/// 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<String>,
},
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<RepoMembership, MembershipResolutionError> {
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<String, Vec<String>> {
let mut by_owner: HashMap<String, Vec<String>> = HashMap::new();
let mut by_coordinate: HashMap<String, Vec<String>> = 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<String, Vec<String>> {
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<crate::purgatory::Purgatory>,
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<String> = match by_owner.get(owner_pubkey) {
// Look up the authorized set resolved from this specific coordinate.
let authorized: HashSet<String> = 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<crate::purgatory::Purgatory>,
pushed_refs: &[(String, String, String)],
repo_path: &std::path::Path,
) -> Result<AuthorizationResult> {
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<_>>(),
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]
+1 -1
View File
@@ -631,7 +631,7 @@ async fn stream_upload_pack_output<S, E>(
/// * `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)]
+2 -2
View File
@@ -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
+11 -10
View File
@@ -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<String> = 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"
);
+98 -74
View File
@@ -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<String>,
active_leads: Vec<String>,
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<String>,
/// 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<Vec<String>>,
/// 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<Vec<String>>,
/// 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<CurrentMaintainerRoles>,
}
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<String> = Vec::new();
let mut active_maintainers: Vec<String> = Vec::new();
let mut active_leads: Vec<String> = 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", "<pubkey1-hex>", "<pubkey2-hex>", ...]
@@ -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<String> {
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<String> = 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<String> {
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]
+3 -3
View File
@@ -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();
+16 -19
View File
@@ -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,
+2 -2
View File
@@ -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();
+25 -21
View File
@@ -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);
+2 -2
View File
@@ -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)`.
//! ```
+1 -1
View File
@@ -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);