From 312840a885e77a6da32a858086835426ea013a3a Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 15 May 2026 15:15:50 +0000 Subject: [PATCH] feat(grasp06): add NGIT_GRASP06_ENABLE feature flag and NIP-11 advertisement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the `grasp06_enable` config flag, default false, wired through all four config-sync locations (src/config.rs, .env.example, docs/reference/configuration.md, nix/module.nix), and advertises the capability in NIP-11 when the flag is on. Behaviour: - When NGIT_GRASP06_ENABLE=true: NIP-11 supported_grasps includes "GRASP-06" (after GRASP-01 and GRASP-02). No /prs/ routing or event-acceptance changes yet — those land later. - When disabled (default): unchanged. Tests turning green: - test_nip11_advertises_grasp_06_when_enabled (integration, with_grasp_06) - test_nip11_advertises_grasp_06_when_enabled (unit, src/http/nip11.rs) - test_nip11_grasp_06_disabled_by_default (unit) The discovery-gate test `test_prs_namespace_404_when_grasp06_not_advertised_no_grasp_06` remains green and now exercises the real flag and real NIP-11 wiring. `with_grasp_06` tests targeting /prs/ routing and event-acceptance remain TDD-red against later work. See: plans/grasp-06-implementation-plan.md (NIP-11 advertisement folded in) --- .env.example | 19 ++++++++++++++ docs/reference/configuration.md | 45 +++++++++++++++++++++++++++++++++ nix/module.nix | 20 +++++++++++++++ src/config.rs | 15 +++++++++++ src/http/nip11.rs | 28 ++++++++++++++++++++ 5 files changed, 127 insertions(+) diff --git a/.env.example b/.env.example index 24090ef..3621ab5 100644 --- a/.env.example +++ b/.env.example @@ -219,6 +219,25 @@ # Note: Cannot be used with NGIT_REPOSITORY_WHITELIST (mutually exclusive) # NGIT_ARCHIVE_READ_ONLY= +# ============================================================================ +# GRASP-06 CONTRIBUTOR PR SUBMISSION +# ============================================================================ + +# Enable GRASP-06 contributor PR submission endpoint at /prs//.git +# +# When enabled, the relay exposes an unauthenticated PR submission endpoint that +# accepts pushes of refs/nostr/ from any contributor. Security relies +# on the signed PR/PR-Update events the refs reference, not on HTTP-level auth. +# +# When disabled (default), /prs/* returns 404 and event-acceptance is unchanged. +# +# See: https://github.com/DanConwayDev/grasp/blob/main/06.md +# See: docs/explanation/grasp-06-contributor-pr-submission.md +# +# CLI: --grasp06-enable +# Default: false +# NGIT_GRASP06_ENABLE=false + # ============================================================================ # REPOSITORY WHITELIST # ============================================================================ diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index bce1200..677874f 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -720,6 +720,51 @@ NGIT_ARCHIVE_READ_ONLY=true # Default --- +### GRASP-06 Contributor PR Submission + +These options control the optional `/prs//.git` contributor pull-request submission endpoint per the [GRASP-06 specification](https://github.com/DanConwayDev/grasp/blob/main/06.md). + +#### `NGIT_GRASP06_ENABLE` + +**Description:** Enable the GRASP-06 contributor PR submission endpoint at `/prs//.git` +**Type:** Boolean +**Default:** `false` +**Required:** No + +**Examples:** + +```bash +# Enable GRASP-06 (opt-in) +NGIT_GRASP06_ENABLE=true + +# Disable (default) +NGIT_GRASP06_ENABLE=false +``` + +**Behavior:** + +- When `true`: + - `/prs//.git` accepts unauthenticated pushes of `refs/nostr/` + - NIP-11 `supported_grasps` includes `"GRASP-06"` + - PR / PR-Update events naming this relay's `/prs/` endpoint in their `clone` tag are accepted to purgatory even without a matching accepted repository announcement + - Standard `/.git` endpoint behaviour is unchanged +- When `false` (default): + - `/prs/*` returns HTTP 404 + - Event-acceptance is unchanged (standard GRASP-01 rules apply) + +**Security Model:** + +The `/prs/` endpoint is intentionally unauthenticated — there is no NIP-98 auth, no allowlist, no quota, and no proof-of-work in v1. Validity is enforced by: + +- The signed PR / PR-Update Nostr event the pushed `refs/nostr/` references +- Per-submitter / per-identifier scoping enforced when the event arrives +- Inline ref-name validation (only `refs/nostr/<64-hex-event-id>` accepted) +- Periodic cleanup of orphan repositories with zero refs + +Operators should review the design tradeoffs in [`docs/explanation/grasp-06-contributor-pr-submission.md`](../explanation/grasp-06-contributor-pr-submission.md) before enabling. + +--- + ### Repository Whitelist #### `NGIT_REPOSITORY_WHITELIST` diff --git a/nix/module.nix b/nix/module.nix index d8d9e86..16903c0 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -228,6 +228,25 @@ let ''; }; + grasp06Enable = mkOption { + type = types.bool; + default = false; + description = '' + Enable the GRASP-06 contributor PR submission endpoint at + /prs//.git. + + When enabled, the relay exposes an unauthenticated PR submission + endpoint that accepts pushes of refs/nostr/ from any + contributor. Security relies on the signed PR/PR-Update events the + refs reference, not on HTTP-level auth. + + When disabled (default), /prs/* returns 404 and event acceptance is + unchanged. + + See: https://github.com/DanConwayDev/grasp/blob/main/06.md + ''; + }; + repositoryWhitelist = mkOption { type = types.listOf types.str; default = [ ]; @@ -339,6 +358,7 @@ let NGIT_REPOSITORY_BLACKLIST = concatStringsSep "," cfg.repositoryBlacklist; NGIT_EVENT_BLACKLIST = concatStringsSep "," cfg.eventBlacklist; NGIT_LOG_LEVEL = cfg.logLevel; + NGIT_GRASP06_ENABLE = if cfg.grasp06Enable then "true" else "false"; } // optionalAttrs (cfg.maxConnections != null) { NGIT_MAX_CONNECTIONS = toString cfg.maxConnections; } // optionalAttrs (cfg.relayName != null) { diff --git a/src/config.rs b/src/config.rs index 4dd396a..796c3de 100644 --- a/src/config.rs +++ b/src/config.rs @@ -449,6 +449,20 @@ pub struct Config { #[arg(long, env = "NGIT_ARCHIVE_READ_ONLY")] pub archive_read_only: Option, + /// Enable GRASP-06 contributor PR submission endpoint at /prs//.git + /// + /// When enabled, the relay exposes an unauthenticated PR submission endpoint + /// at `/prs//.git` that accepts pushes of `refs/nostr/` + /// from any contributor. Security relies on the signed PR/PR-Update events + /// the refs reference, not on HTTP-level auth. + /// + /// Default: false. This is an opt-in feature that adds an unauthenticated + /// write surface; operators must understand the tradeoffs (see GRASP-06 spec + /// and `docs/explanation/grasp-06-contributor-pr-submission.md`) before + /// enabling it. + #[arg(long, env = "NGIT_GRASP06_ENABLE", default_value_t = false)] + pub grasp06_enable: bool, + /// Repository whitelist: comma-separated list of npub/identifier/npub/identifier entries /// Formats: "npub1...", "npub1.../identifier", "identifier" /// When set, only announcements matching the whitelist AND listing the service are accepted @@ -751,6 +765,7 @@ impl Config { archive_whitelist: String::new(), archive_grasp_services: String::new(), archive_read_only: None, + grasp06_enable: false, repository_whitelist: String::new(), repository_blacklist: String::new(), event_blacklist: String::new(), diff --git a/src/http/nip11.rs b/src/http/nip11.rs index f7af3c2..b13e873 100644 --- a/src/http/nip11.rs +++ b/src/http/nip11.rs @@ -67,6 +67,9 @@ impl RelayInformationDocument { supported_grasps.push("GRASP-05".to_string()); } supported_grasps.push("GRASP-02".to_string()); + if config.grasp06_enable { + supported_grasps.push("GRASP-06".to_string()); + } // Build curation field for archive read-only mode or repository whitelist let repository_config = config.repository_config(); @@ -281,4 +284,29 @@ mod tests { assert!(curation.contains("whitelisted repositories")); assert!(curation.contains("with or without service listing")); } + + #[test] + fn test_nip11_grasp_06_disabled_by_default() { + let config = Config::for_testing(); + let doc = RelayInformationDocument::from_config(&config); + + // GRASP-06 must not be advertised when the flag is off. + assert!(!doc.supported_grasps.iter().any(|g| g == "GRASP-06")); + } + + #[test] + fn test_nip11_advertises_grasp_06_when_enabled() { + let mut config = Config::for_testing(); + config.grasp06_enable = true; + + let doc = RelayInformationDocument::from_config(&config); + + // GRASP-06 must be advertised when the flag is on, alongside the + // standard GRASP-01 / GRASP-02 entries. Order is "GRASP-06 last" + // to match the insertion order in from_config(). + assert_eq!( + doc.supported_grasps, + vec!["GRASP-01", "GRASP-02", "GRASP-06"] + ); + } }