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.
20 KiB
Explanation: Inline Authorization
Purpose: Understand why ngit-grasp validates Git pushes inline rather than using Git hooks
Audience: Developers and architects wanting to understand design decisions
The Problem
Git hosting with authorization requires validating pushes before accepting them. The question is: where should this validation happen?
Two approaches exist:
- Git Hooks (traditional): Use Git's pre-receive hook mechanism
- Inline Authorization (our approach): Validate before spawning Git
This document explains why we chose inline authorization and what benefits it provides.
Background: How Git Hooks Work
Git provides a pre-receive hook that runs during git push:
Client Server
| |
|--- git push ----->|
| |--- spawn git-receive-pack
| |
| |--- pre-receive hook runs
| | (reads stdin: old new ref)
| | (exit 0 = accept, 1 = reject)
| |
|<--- success ------| (if hook exits 0)
|<--- error --------| (if hook exits 1)
Pros:
- Standard Git mechanism
- Language-agnostic (hook can be any executable)
- Well-documented
Cons:
- Hook output goes to stderr (client sees as
remote:messages) - Hard to provide structured error messages
- Requires hook installation and management
- Difficult to test (needs Git repository setup)
- Hook runs after Git has started processing
Background: How Inline Authorization Works
With inline authorization, we validate before spawning Git:
Client Server (ngit-grasp)
| |
|--- git push ----->|--- HTTP handler receives request
| |
| |--- Parse ref updates from request
| |--- Query database + purgatory for state
| |--- Validate push against state
| |
| |--- If invalid: return HTTP error
| |--- If valid: spawn git-receive-pack
| |
|<--- success ------| (if valid)
|<--- HTTP error ---| (if invalid)
Pros:
- Full control over error messages (HTTP response)
- Can skip spawning Git entirely for invalid pushes
- Easier testing (pure Rust, no Git setup needed)
- Shared state between Git and Nostr components
- Better performance (early rejection)
- Can check both database and purgatory for authorization
Cons:
- Requires parsing Git protocol ourselves
- Less standard than hooks
- Tighter coupling to Git HTTP protocol
Why Inline Authorization Is Better for GRASP
1. Purgatory Integration
Critical advantage: Inline authorization allows checking both database and purgatory during authorization:
// From src/git/authorization.rs
pub async fn authorize_push(
database: &SharedDatabase,
identifier: &str,
selected_pubkey: &str,
request_body: &Bytes,
purgatory: &Arc<Purgatory>, // Can check purgatory!
repo_path: &std::path::Path,
) -> anyhow::Result<AuthorizationResult>
Why this matters: State events go to purgatory when git data doesn't exist yet. Without inline authorization checking purgatory, we'd have a deadlock:
- State event arrives → No git data → Goes to purgatory (not database)
- Git push arrives → Hook checks database only → No state found → REJECTED ❌
With inline authorization:
- State event arrives → No git data → Goes to purgatory
- Git push arrives → Checks database + purgatory → State found → AUTHORIZED ✅
- After push succeeds → Save event to database → Remove from purgatory
See src/git/authorization.rs:342-400 for implementation.
otherwise we'd need another way of storing purgatory events.
2. Better Error Messages
With hooks:
$ git push
remote: error: Push rejected - not authorized for ref refs/heads/main
remote: See https://docs.gitnostr.com/errors/unauthorized
To https://gitnostr.com/alice/myrepo.git
! [remote rejected] main -> main (pre-receive hook declined)
With inline authorization:
$ git push
error: RPC failed; HTTP 403 Forbidden
error: Push rejected: No state event found in purgatory from authorized publishers
The inline approach provides clear, actionable error messages directly in the HTTP response.
3. Performance Benefits
With hooks:
- Git process spawns
- Git starts receiving pack data
- Hook runs (might query Nostr relay)
- If rejected, Git throws away received data
With inline authorization:
- Parse ref updates from HTTP request (pkt-line format)
- Validate against database + purgatory state
- If rejected, return HTTP error immediately
- Never spawn Git for invalid pushes
Result: Faster rejection, less resource usage, no wasted pack data transfer.
4. Easier Testing
With hooks:
# Test setup
mkdir -p /tmp/test-repo
cd /tmp/test-repo
git init --bare
cp pre-receive.sh hooks/pre-receive
chmod +x hooks/pre-receive
# Test execution
git push /tmp/test-repo main
# Cleanup
rm -rf /tmp/test-repo
With inline authorization:
#[tokio::test]
async fn test_unauthorized_push() {
let relay = TestRelay::start().await;
let result = validate_push(&state, "refs/heads/main", alice_pubkey).await;
assert!(result.is_err());
relay.stop().await;
}
Result: Pure Rust unit tests, no shell scripts, no Git setup.
See tests/push_authorization.rs for actual test examples.
5. Shared State and Types
With hooks:
- Hook is separate process
- Must query Nostr relay over WebSocket
- Can't share in-memory cache
- Can't access purgatory
- Separate error types
With inline authorization:
// From src/git/handlers.rs
pub async fn handle_receive_pack(
repo_path: PathBuf,
body: Bytes,
database: Option<SharedDatabase>, // Shared with Nostr relay!
purgatory: Option<Arc<Purgatory>>, // Shared purgatory access!
npub: &str,
identifier: &str,
) -> Result<Response<Full<Bytes>>, GitError> {
// Direct database + purgatory access for authorization
let auth = authorize_push(
&database,
identifier,
selected_pubkey,
&body,
&purgatory, // Can check purgatory!
&repo_path
).await?;
// ...
}
Result: Better performance, type safety, simpler architecture, purgatory integration.
6. Simpler Deployment
With hooks (ngit-relay):
Docker container:
- nginx (HTTP frontend)
- git-http-backend (C binary)
- pre-receive hook (Go binary)
- Khatru relay (Go binary)
- supervisord (process manager)
Setup steps:
1. Install all components
2. Configure nginx
3. Install hook in each repository
4. Set up supervisord
5. Configure inter-process communication
With inline authorization (ngit-grasp):
Single Rust binary:
- HTTP server (Hyper)
- Git protocol handler
- Nostr relay (nostr-relay-builder)
- Authorization logic
Setup steps:
1. Run binary
2. Configure environment variables
Result: Simpler deployment, fewer moving parts.
Technical Implementation
How We Parse Ref Updates
The Git HTTP protocol sends ref updates in pkt-line format:
POST /alice/myrepo.git/git-receive-pack HTTP/1.1
Content-Type: application/x-git-receive-pack-request
00a5 0000...0000 abc123...def456 refs/heads/main\0 report-status\n
0000
PACK...
We parse this before spawning Git. See src/git/authorization.rs:695-778 for the implementation:
/// Parse the refs being updated from a Git pack
///
/// The receive-pack protocol sends ref updates in pkt-line format:
/// - 4-byte hex length prefix (e.g., "00a5")
/// - Payload: `<old-oid> <new-oid> <ref-name>\0<capabilities>\n`
/// - Flush packet "0000" terminates the list
pub fn parse_pushed_refs(data: &[u8]) -> Vec<(String, String, String)> {
// Handles both pkt-line format (real Git clients)
// and simple text format (for unit tests)
}
How We Validate
The authorization flow (from src/git/authorization.rs:51-162):
pub async fn authorize_push(
database: &SharedDatabase,
identifier: &str,
selected_pubkey: &str,
request_body: &Bytes,
purgatory: &Arc<Purgatory>,
repo_path: &std::path::Path,
) -> anyhow::Result<AuthorizationResult> {
// 1. Parse refs from push request
let pushed_refs = parse_pushed_refs(request_body);
// 2. Separate refs/nostr/ refs from state refs
let (nostr_refs, state_refs) = partition_refs(&pushed_refs);
// 3. Handle refs/nostr/ refs (PR events)
// - Validate event ID format
// - Check purgatory for PR event
// - Create placeholder if git-data-first scenario
// 4. Handle normal refs (state events)
// - Check database + purgatory for state events
// - Resolve the selected coordinate's active lead path
// - Compute reciprocal confirmed membership from that root
// - Find latest authorized state
// - Validate refs match state
// 5. Return authorization result with purgatory events
}
Key validation checks:
-
For state refs (
refs/heads/*,refs/tags/*):- Parse role history into current activity only
- Resolve the URL-selected coordinate through one active
Mpath - 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
- Validate pushed refs match state event refs
-
For PR refs (
refs/nostr/<event-id>):- Validate event ID format
- Check purgatory for PR event with matching commit
- If no event found, create placeholder (git-data-first scenario)
- Collect PR events from purgatory for post-push processing
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
State events (kind 30618) undergo authorization checks at three points (defense-in-depth):
1. On Arrival (StatePolicy)
When a state event arrives via WebSocket or sync:
// src/nostr/policy/state.rs
impl StatePolicy {
async fn admit_event(&self, event: &Event) -> Result<Decision, Error> {
// Check 1: Does announcement exist for this repository?
let announcements = query_announcements(identifier);
if announcements.is_empty() {
return Reject("No announcement exists for repository");
}
// 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
// Otherwise, accepted to database
}
}
2. On Announcement Acceptance (Purgatory Re-evaluation)
When a repository announcement is accepted, waiting state events are re-evaluated:
// After announcement is saved to database
for state_event in purgatory.get_state_events(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
}
}
3. On Git Data Arrival (Purgatory Sync)
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
let coordinates = repository_coordinates_authorized_by(
state_event.author,
announcements,
);
if !coordinates.is_empty() {
database.save(state_event);
purgatory.remove(state_event);
} else {
purgatory.remove(state_event); // Not authorized
}
}
Why Three Checkpoints?
Defense-in-depth ensures authorization is always validated:
- On arrival: Prevents unauthorized events from entering the system
- On announcement acceptance: Handles race condition where state arrives before announcement
- On git data arrival: Final check before committing to database
This prevents scenarios where:
- Unauthorized state events are saved after maintainer changes
- Race conditions bypass authorization
- Purgatory holds events that will never be authorized
Rejection Tracking
State events rejected during authorization are tracked in the rejected events index:
- Reason: MaintainerNotYetValid - Author not in maintainer set (may become valid later)
- Reason: Other - Other validation failures
When a repository announcement is accepted, rejected state events still present in the hot cache are re-processed immediately. Reciprocal invitation announcements can enter purgatory before their source state and Git data are available. During that bootstrap flow, rejected state events are:
- Retained in the cold index until policy processing succeeds
- Retrieved from the hot cache and re-processed immediately when the full event is still available
- Fetched by exact event ID from maintainer-discovery relay dependencies when the hot-cache copy has expired
- 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 requests run outside the main sync-manager lock, and unsuccessful requests retain their IDs for a throttled retry. This enables rapid recovery from arrival-order races without reopening a broad synchronization query.
See GRASP-02: Integration with Rejected Events Index for complete details on rejection tracking and re-processing.
Comparison with Reference Implementation
| Aspect | ngit-relay (hooks) | ngit-grasp (inline) |
|---|---|---|
| Components | nginx + git-http-backend + hook + Khatru | Single Rust binary |
| Validation | Pre-receive hook (separate process) | Inline HTTP handler |
| Error messages | Hook stderr → remote: |
HTTP response JSON |
| Performance | Spawns Git first | Validates first |
| Testing | Shell scripts + Go tests | Pure Rust tests |
| Deployment | Docker + supervisord | Single binary |
| State sharing | WebSocket query | Direct database access |
Both are GRASP-compliant, but inline authorization is simpler and more efficient.
Trade-offs and Limitations
What We Gain
- ✅ Purgatory integration - Can check database + purgatory during authorization
- ✅ Prevents deadlock - State events in purgatory can authorize pushes
- ✅ Better error messages
- ✅ Better performance (early rejection)
- ✅ Easier testing (pure Rust)
- ✅ Simpler deployment (single binary)
- ✅ Tighter integration (shared state)
What We Lose
- ❌ Non-standard approach (not using Git's hook system)
- ❌ Tighter coupling to Git HTTP protocol
- ❌ Must parse pkt-line protocol ourselves
Is It Worth It?
Absolutely, because:
- Purgatory integration is essential - Without it, we'd have a deadlock where state events in purgatory can't authorize pushes
- Protocol parsing is isolated in
src/git/authorization.rs - GRASP is already non-standard (Nostr authorization)
- Benefits far outweigh the coupling cost
- We can still add hook support later if needed (but purgatory checking would still need inline access)
Implementation References
Key files in the ngit-grasp implementation:
| Component | Location |
|---|---|
| HTTP routing | src/http/mod.rs |
| Git handlers | src/git/handlers.rs |
| Push authorization | src/git/authorization.rs |
| Pkt-line parsing | src/git/authorization.rs:695-778 |
| Subprocess management | src/git/subprocess.rs |
| Purgatory integration | src/purgatory/mod.rs |
| Event acceptance policy | src/nostr/builder.rs - Nip34WritePolicy |
Future Considerations
If We Need Hooks Later
We can add hook support without removing inline validation:
pub struct GitConfig {
inline_validation: bool, // Default: true
hook_validation: bool, // Default: false
}
This would allow:
- Migration path for hook-based systems
- Extra validation for paranoid deployments
- Compatibility with other Git tools
If Git Protocol Changes
The protocol parsing is isolated in src/git/protocol.rs. If the Git protocol changes:
- Update the protocol module
- Tests will catch any breakage
Conclusion
Inline authorization is the right choice for ngit-grasp because:
- Purgatory integration - Without inline authorization, state events in purgatory couldn't authorize pushes, creating a deadlock
- Better error messages - Direct HTTP responses with clear rejection reasons
- Better performance - Early rejection before spawning Git
- Easier testing - Pure Rust unit tests, no Git setup needed
- Simpler deployment - Single binary with shared state
- Shared database + purgatory - Both authorization sources accessible during validation
The trade-off (coupling to Git HTTP protocol) is acceptable because:
- The pkt-line protocol is stable and well-specified
- Protocol parsing is isolated in
src/git/authorization.rs - Purgatory integration requires inline access anyway
- Benefits far outweigh the cost
This decision aligns with our goal of creating a developer-friendly, production-ready GRASP implementation that properly handles the event-git-data ordering problem via purgatory.
Related Documentation
- Architecture Overview - Full system design
- Design Decisions - All architectural choices
- Comparison with ngit-relay - Detailed comparison
- Git Protocol Reference - Protocol details
- Test Strategy - How we test this
Part of the ngit-grasp explanation docs