diff --git a/CHANGELOG.md b/CHANGELOG.md index bf6f97e..46dc339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **GRASP-06 contributor PR submission endpoint** (`NGIT_GRASP06_ENABLE`, default off). When enabled, the relay accepts unauthenticated `git push` of `refs/nostr/` to `/prs//.git` from any contributor, even for repositories this relay has no accepted announcement for. The corresponding PR (kind 1618) or PR Update (kind 1619) event is accepted into purgatory when its `clone` tag names this relay's `/prs//.git` endpoint and its `a` tag's d-tag matches the URL identifier. When the event and the push match (signer, d-tag, c-tag commit) the event is released from purgatory and the ref is mirrored into any accepted-announcement repos on this relay. Empty `/prs/` repos (probe pushes, mismatched events) are garbage-collected by the receive handler and by a periodic 10-minute sweep. GRASP-06 is advertised in NIP-11 `supported_grasps` when enabled. See [how-to/enable-grasp-06.md](docs/how-to/enable-grasp-06.md) and [explanation/grasp-06-contributor-pr-submission.md](docs/explanation/grasp-06-contributor-pr-submission.md). + ## [1.0.2] - 2026-04-10 ### Fixed diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index 09737df..2464038 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -545,6 +545,21 @@ Negentropy Sync **Source Code:** [`src/sync/rejected_index.rs`](../../src/sync/rejected_index.rs) +## Contributor PR Submission (GRASP-06) + +Optional endpoint at `/prs//.git`, gated on `NGIT_GRASP06_ENABLE` (default off). Contributors push `refs/nostr/` for PR and PR Update events targeting any repository — even repos this relay has no accepted announcement for. The endpoint is unauthenticated at the HTTP level; validity is established by the signed PR / PR Update event and inline acceptance rules. + +**Module layout:** + +- [`src/grasp06/endpoint.rs`](../../src/grasp06/endpoint.rs) — URL parsing. +- [`src/grasp06/paths.rs`](../../src/grasp06/paths.rs) — on-disk path conventions under `/prs//.git`. +- [`src/grasp06/fetch.rs`](../../src/grasp06/fetch.rs) — empty-repo synthesis for `info/refs` and `git-upload-pack` against repos that don't yet exist on disk. +- [`src/grasp06/receive.rs`](../../src/grasp06/receive.rs) — `git-receive-pack` with init-on-push, strict `refs/nostr/` ref-name validation, and per-ref post-push validation against the database and purgatory. +- [`src/grasp06/policy.rs`](../../src/grasp06/policy.rs) — strict clone-tag URL comparator used by the PR-event acceptance relaxation. +- [`src/grasp06/cleanup.rs`](../../src/grasp06/cleanup.rs) — periodic sweep that removes zero-ref `/prs/` repos older than the purgatory TTL. + +`/prs/` repos are intentionally isolated from other subsystems: empty-repo cleanup skips the `/prs/` subtree, the proactive-sync subsystem never discovers them because subscriptions are built from DB-resident announcements, and the standard repo landing page guards against ever matching a `/prs/` path. Full design: [GRASP-06 Contributor Pull Request Submission](grasp-06-contributor-pr-submission.md). Operator how-to: [Enable GRASP-06](../how-to/enable-grasp-06.md). + ## Future Extensions ### GRASP-02: Proactive Sync diff --git a/docs/explanation/grasp-06-contributor-pr-submission.md b/docs/explanation/grasp-06-contributor-pr-submission.md index 11f1756..a0d2f0e 100644 --- a/docs/explanation/grasp-06-contributor-pr-submission.md +++ b/docs/explanation/grasp-06-contributor-pr-submission.md @@ -1,9 +1,10 @@ # GRASP-06: Contributor Pull Request Submission — Design -**Status**: 🚧 **PLANNED — NOT YET IMPLEMENTED** 🚧 +**Status**: Implemented (opt-in via `NGIT_GRASP06_ENABLE`) **Spec**: [GRASP-06](https://github.com/DanConwayDev/grasp/blob/main/06.md) **Related**: [Purgatory Design](purgatory-design.md), [Architecture](architecture.md), [Inline Authorization](inline-authorization.md) +**Operator how-to**: [Enable GRASP-06](../how-to/enable-grasp-06.md) --- @@ -104,6 +105,10 @@ POST /prs//.git/git-receive-pack The flow mirrors the existing `refs/nostr/` path at the standard endpoint (see [`src/git/handlers.rs:handle_receive_pack`](../../src/git/handlers.rs) and the PR purgatory entries in [`src/purgatory/types.rs`](../../src/purgatory/types.rs)) — it just skips the `authorize_push` path that checks the maintainer set, and applies the spec's validation invariant instead. +#### On-demand bare repo creation + +The first push to `/prs//.git` creates the bare repo on disk. A per-`(submitter, identifier)` `tokio::sync::Mutex` (kept in a `DashMap` on the `HttpService`, see [`crate::grasp06::receive::RepoInitLocks`](../../src/grasp06/receive.rs)) serialises only the `git init --bare` step. Once init has succeeded, git's own ref locking handles intra-push concurrency, so simultaneous pushes to the same path proceed in parallel. + ### Event acceptance relaxation The existing PR event policy in [`src/nostr/policy/pr_event.rs`](../../src/nostr/policy/pr_event.rs) calls `fetch_repository_data_excluding_purgatory` to require an accepted announcement in the database before accepting a PR event. Under GRASP-06 this check is loosened for events that satisfy: @@ -116,6 +121,8 @@ Such events skip the "references accepted announcement" check and are accepted i Events that qualify under the existing GRASP-01 rules still flow through the normal path unchanged — the GRASP-06 branch is only taken when the existing path would have rejected. +The clone-URL match is implemented in [`src/grasp06/policy.rs`](../../src/grasp06/policy.rs) as a strict comparator: it requires `http`/`https` scheme, an exact (case-insensitive) authority match against `config.domain`, no query string or fragment, exactly two path segments `/.git`, the npub segment decoding via `PublicKey::from_bech32` to the event's signer, and the percent-decoded identifier matching one of the event's `a`-tag `` values. Anything else fails the relaxation and the event falls through to the existing rejection path. + ### Cross-service mirror When a PR or PR Update's purgatory entry is released via a `/prs/` push: @@ -127,6 +134,10 @@ When a PR or PR Update's purgatory entry is released via a `/prs/` push: The mirror copies the same ref, same commits. No separate object store. Dedup can be added transparently later via git alternates keyed on d-tag. +The mirror is **one-directional**: pushes to `//.git` are not mirrored into `/prs/*`. Only the `/prs/` → `/` direction fires, and only when the source repo path is under `prs_base_path`. + +The mirror also does **not back-fill** retroactively. If an announcement for one of the event's `a` coords is accepted *after* a matching `/prs/` push has already happened, the ref remains only at `/prs//.git`; clients can still fetch it via the event's `clone` tag. Back-filling on announcement promotion is deferred — the simplest correct shape ships first, and the spec allows clients to resolve the PR through `/prs/` indefinitely. + ### Purgatory integration No new purgatory entry types are strictly required. The existing `PrPurgatoryEntry` already supports event-first and git-first patterns keyed by event id. Under GRASP-06: @@ -138,9 +149,20 @@ Placeholder entries created at `/prs/` should be validated against `(submitter = ### Exclusion from other subsystems -- **Empty-repo cleanup** ([`src/cleanup_empty_repos.rs`](../../src/cleanup_empty_repos.rs)): skip `/prs/*`. The `/prs/` endpoint has its own cleanup rule (zero-refs repos, expired placeholders). -- **Proactive sync** (GRASP-02): `/prs/` repos are not replicated between relays. A GRASP-06 relay is authoritative for the PRs it accepts. Clients that need a PR should fetch it from the relay the event's `clone` tag names. -- **Repo listings / NIP-11**: `/prs/` repos are not advertised as repositories. They are a submission side-channel, not first-class hosted repos. +- **Empty-repo cleanup** ([`src/cleanup_empty_repos.rs`](../../src/cleanup_empty_repos.rs)): skips `/prs/*` via [`is_prs_repo_path`](../../src/grasp06/paths.rs) before recursing, so contributor-submission repos cannot be misreported as orphans. +- **Repo landing pages** ([`src/http/mod.rs::parse_repo_url`](../../src/http/mod.rs)): refuses any path starting with `/prs/` as a defensive guard, regardless of `grasp06_enable`. The `HttpService` routing also intercepts `/prs/*` earlier when the feature is on. +- **Proactive sync** (GRASP-02): `/prs/` repos are not replicated between relays. The proactive-sync subsystem derives every subscription from the DB-resident announcement set; `/prs/` repos have no announcement and so are excluded by construction. No filesystem walk discovers them. A GRASP-06 relay is authoritative for the PRs it accepts. Clients that need a PR should fetch it from the relay the event's `clone` tag names. +- **Repo listings / NIP-11**: `/prs/` repos are not advertised as repositories. They are a submission side-channel, not first-class hosted repos. GRASP-06 itself is advertised in the relay's NIP-11 `supported_grasps` list when the flag is on. + +### Periodic `/prs/` cleanup + +In addition to the receive-handler's post-push zero-ref cleanup, a periodic sweep ([`src/grasp06/cleanup.rs`](../../src/grasp06/cleanup.rs)) walks `/prs/` every ten minutes (one second under `NGIT_TEST=1`) and removes any `/.git` directory that: + +1. has zero refs, +2. has no active `PrPurgatoryEntry` scoped to `(submitter=, identifier=)`, and +3. has a directory mtime older than the purgatory TTL ([`purgatory::DEFAULT_EXPIRY`](../../src/purgatory/mod.rs), 30 minutes). + +The mtime check defends against deleting a repo out from under an in-flight push. Empty submitter directories are removed when the sweep empties them. ## Flow examples diff --git a/docs/how-to/enable-grasp-06.md b/docs/how-to/enable-grasp-06.md new file mode 100644 index 0000000..d1c5888 --- /dev/null +++ b/docs/how-to/enable-grasp-06.md @@ -0,0 +1,91 @@ +# How to enable GRASP-06 contributor PR submission + +GRASP-06 adds an opt-in endpoint at `/prs//.git` that any contributor can push PR (kind 1618) and PR Update (kind 1619) `refs/nostr/` refs to, even when this relay has no accepted announcement for the target repository. The endpoint is unauthenticated at the HTTP level — validity is established by the signed PR event. + +This page covers the operator-facing steps to turn it on and verify it. + +## 1. Flip the feature flag + +Set `NGIT_GRASP06_ENABLE=true` for the relay process. The flag is off by default; everything below is a no-op without it. + +**Environment file** (`.env`): + +```env +NGIT_GRASP06_ENABLE=true +``` + +**CLI flag**: + +```bash +ngit-grasp --grasp06-enable ... +``` + +**NixOS module**: + +```nix +services.ngit-grasp.instances..grasp06Enable = true; +``` + +Restart the relay after the change. + +## 2. Verify NIP-11 advertises GRASP-06 + +NIP-11 is served from `/` with the `Accept: application/nostr+json` header: + +```bash +curl -s -H 'Accept: application/nostr+json' https://your-relay.example/ | jq '.supported_grasps' +``` + +The output must include `"GRASP-06"`. If it does not, the flag did not take effect — re-check the env var name and that the relay actually restarted. + +## 3. Verify `/prs/` is reachable + +Pick any well-formed npub and any identifier. A non-existent contributor + repo combination is fine — the endpoint synthesises an empty bare repo for fetches against paths that don't yet exist: + +```bash +git clone https://your-relay.example/prs/npub1.../any-identifier.git /tmp/probe +``` + +You should get a successful clone of an empty repository (zero refs). No directory is created on the server for this probe. + +To verify a contributor push round-trip, publish a kind 1618 PR event whose `clone` tag names this relay's `/prs//.git` URL, then: + +```bash +git push https://your-relay.example/prs//.git \ + :refs/nostr/ +``` + +The push succeeds, the relay creates `/prs//.git` on demand, and the ref is locked into the repo. Pushes to anything other than `refs/nostr/<64-lowercase-hex>` are rejected with an `ERR` pkt-line. + +## Storage cost + +One bare repo per `(submitter, identifier)` combination under `/prs//.git`. Repos are garbage-collected automatically: + +- After receive-pack, the repo is removed immediately if it has zero refs left (probe pushes that produced no valid state). +- A periodic sweep (every 10 minutes) removes any zero-ref `/prs/` repo with no active purgatory entry, older than the purgatory TTL. + +There is no quota in this release. Disk consumption is bounded only by the rate at which contributors push valid PR refs. + +## Abuse controls + +The current release relies entirely on: + +- the signed PR / PR Update event (no NIP-98 or other HTTP auth on push), +- the requirement that the event's `clone` tag names this relay's `/prs//.git` endpoint, +- the standard 30-minute purgatory TTL, +- post-push zero-ref cleanup, and +- the periodic `/prs/` sweep. + +The following knobs are **future** additions and are not yet wired: + +- per-submitter allowlist, +- per-submitter or per-event disk quotas, +- per-ref pack size cap, +- NIP-98 authenticated push, +- PoW gating. + +If you need any of these today, leave `NGIT_GRASP06_ENABLE=false`. + +## Spec + +[GRASP-06 spec (draft)](https://github.com/DanConwayDev/grasp/blob/main/06.md). Design notes: [docs/explanation/grasp-06-contributor-pr-submission.md](../explanation/grasp-06-contributor-pr-submission.md).