mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 23:18:24 +00:00
Merge #9af177ea: feat(private-repos): gate announcement admission on me…
feat(private-repos): gate announcement admission on membership nostr:nevent1qgsx2lyl2e4zvfadwcvkd9fkrcwczj7mf858hy85mwqclwgut8wpg2spz3mhxue69uhhyetvv9ujumn8d96zuer9wcq3yamnwvaz7tm8d96xummnw3ezucm0d5q3kamnwvaz7tmwva5hgtnyv9hxxmmwwashjer9wchxxmmdqqsf4uthaf2cr5hes246tckccufhntfj3uc5su95t5l7z5p9g6s3jmq059w44 PR-Author: DanConwayDev's Agent nostr:npub1v47f74n2ycn66asev62nv8sas99akj0g0wg0fkup37u3ckwuzs4q7cwtp0 PR description: In GRASP-08 private mode, accepted kind-30617 announcements expand hosting and - via their referenced relays NIP-11 owners - the effective member set itself. Announcement admission never checked the author, so a member could submit a third-party-signed announcement (or sync could import one from an operator-configured source) and thereby mint membership for pubkeys no member ever chose. This PR closes that amplification loop: in private mode a repository announcement is only admitted when its author (event pubkey) is a current effective member (configured members plus admitted relay owners, via the shared PrivateAccess set) evaluated at admission time. The gate sits at the single choke point all arrival paths funnel through - direct publish, sync import, purgatory entry - and rejections use the existing announcement rejection machinery. Public mode is unchanged, no new configuration is added, state events (30618) keep GRASP-01 maintainer rules, and removal is non-retroactive: repositories admitted while their author was a member stay hosted until the operator curates them. Also documents the rationale in the GRASP-08 design doc and architecture doc. Deliberately excluded: outbound authentication when syncing from other private services, which remains a separate follow-up. Validation: cargo clippy --all-targets -D warnings clean; lib tests (783), private_mode (52, incl. a new member/non-member admission integration test), nip34_announcements (60), repository_creation (47), and purgatory (55) suites all pass.
This commit is contained in:
@@ -201,6 +201,20 @@ Explanation documentation helps you **understand concepts** and design decisions
|
||||
|
||||
---
|
||||
|
||||
### [GRASP-08 Private Service Authentication](grasp-08-private-service.md)
|
||||
**Service-wide NIP-42/NIP-98 authentication for private repositories**
|
||||
|
||||
**Topics:**
|
||||
- Fail-closed private mode and indistinguishable 401 responses
|
||||
- The GRASP-08 repository-scoped NIP-98 profile vs generic NIP-98
|
||||
- NIP-42 authentication outside the embedded relay
|
||||
- Service-wide membership and dynamic accepted-relay-owner admission
|
||||
- Trust model and follow-up scope
|
||||
|
||||
**Read when:** You want to understand how a private GRASP instance authenticates clients and peers
|
||||
|
||||
---
|
||||
|
||||
### [Repository Lifecycle](repository-lifecycle.md)
|
||||
**Handling repository removal, holding, archive, recovery, and purgatory**
|
||||
|
||||
|
||||
@@ -686,14 +686,23 @@ Optional endpoint at `/prs/<npub>/<identifier>.git`, gated on `NGIT_GRASP06_ENAB
|
||||
## Private Service Authentication (GRASP-08)
|
||||
|
||||
Private mode is an optional access layer around the normal GRASP runtime. A
|
||||
single `PrivateAccess` set is shared by the HTTP and WebSocket services.
|
||||
Repository admission and push authorization remain the GRASP-01 policies; a
|
||||
private credential proves service membership but never grants push rights.
|
||||
The effective set combines operator-configured members with NIP-11 owner
|
||||
pubkeys learned for relays referenced by accepted announcements. Purgatory-only
|
||||
announcements are excluded. Reconciliation reuses the accepted repository index
|
||||
and the NIP-11 fetch already performed once per connection session, so private
|
||||
mode adds neither outbound connections nor subscriptions.
|
||||
single `PrivateAccess` set is shared by the HTTP and WebSocket services and
|
||||
the announcement admission policy. Push authorization remains the GRASP-01
|
||||
policy; a private credential proves service membership but never grants push
|
||||
rights. The effective set combines operator-configured members with NIP-11
|
||||
owner pubkeys learned for relays referenced by accepted announcements.
|
||||
Purgatory-only announcements are excluded. Reconciliation reuses the accepted
|
||||
repository index and the NIP-11 fetch already performed once per connection
|
||||
session, so private mode adds neither outbound connections nor subscriptions.
|
||||
|
||||
Because accepted announcements drive membership, announcement admission is
|
||||
itself membership-gated in private mode: a kind-30617 event is only admitted
|
||||
when its author is a current effective member at admission time, on every
|
||||
arrival path (direct publish, sync import, purgatory promotion). Non-member
|
||||
announcements are rejected through the normal announcement rejection
|
||||
machinery. State events (kind 30618) keep the GRASP-01 maintainer rules, and
|
||||
removal is non-retroactive — repositories admitted while their author was a
|
||||
member remain hosted until the operator curates them.
|
||||
|
||||
For Nostr, Hyper completes the public WebSocket upgrade and a message-level
|
||||
proxy sends and validates NIP-42 authentication before a connection reaches
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# GRASP-08: Private Service Authentication — Design
|
||||
|
||||
**Status**: Implemented, single-service scope (opt-in via `NGIT_PRIVATE_MODE`)
|
||||
|
||||
**Spec**: [GRASP-08](https://github.com/DanConwayDev/grasp/blob/main/08.md)
|
||||
**Related**: [Architecture](architecture.md), [Defensive Measures](defensive-measures.md), [Repository Lifecycle](repository-lifecycle.md)
|
||||
**Configuration**: [`NGIT_PRIVATE_MODE`, `NGIT_PRIVATE_MEMBERS`, `NGIT_PRIVATE_PUBLIC_ORIGIN`](../reference/configuration.md)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
GRASP-08 turns a GRASP service into a private one: repository events and Git
|
||||
objects must not be readable merely because an endpoint is reachable. Every
|
||||
Nostr WebSocket session must authenticate with NIP-42 and every standard Git
|
||||
Smart HTTP request must carry a repository-scoped NIP-98 credential before any
|
||||
repository data is served.
|
||||
|
||||
Private mode is a **service-wide access boundary**, not a per-repository ACL.
|
||||
A private instance is one trust domain: everything it hosts is readable by
|
||||
every member and by nobody else. Push authorization is unchanged — a private
|
||||
credential proves membership, and GRASP-01's maintainer-based push rules still
|
||||
decide who may write which repository.
|
||||
|
||||
## What GRASP-08 changes
|
||||
|
||||
1. **WebSocket**: after the public upgrade, a message-level proxy issues a
|
||||
NIP-42 `AUTH` challenge and validates the response before the connection is
|
||||
bridged to the embedded relay. Unauthenticated `REQ`/`EVENT`/`COUNT`/
|
||||
`NEG-OPEN` messages receive machine-readable `auth-required:` rejections;
|
||||
a valid authentication by a non-member receives `restricted:` and the
|
||||
connection is closed. Three invalid attempts or thirty seconds of silence
|
||||
terminate the connection.
|
||||
2. **Git Smart HTTP**: requests under `/<npub>/<identifier>.git` must carry the
|
||||
GRASP-08 profile of NIP-98 (see below). Authentication runs before
|
||||
repository lookup or request-body collection.
|
||||
3. **Membership**: one shared member set combines operator-configured npubs
|
||||
(`NGIT_PRIVATE_MEMBERS`) with the NIP-11 owner pubkeys of relays referenced
|
||||
by *accepted* repository announcements. Membership changes propagate to
|
||||
live WebSocket sessions, which are closed when their pubkey is removed.
|
||||
4. **Discovery stays public**: the NIP-11 document (which advertises NIPs 42
|
||||
and 98), the NIP-05 root identity, the landing page, and the icon remain
|
||||
unauthenticated so clients can discover the authentication requirement.
|
||||
This deliberately discloses the operator identity and the service's
|
||||
existence — private mode hides repository *content*, not the service.
|
||||
|
||||
Everything else — announcement purgatory, proactive sync, push authorization,
|
||||
repository lifecycle — is unchanged.
|
||||
|
||||
## Why this shape
|
||||
|
||||
### Why every failure is the same empty 401
|
||||
|
||||
Missing, malformed, expired, and non-member Git credentials all receive an
|
||||
identical empty `401 Unauthorized` with the same `WWW-Authenticate: Nostr`
|
||||
challenge. Distinguishable failures would let an unauthenticated party probe
|
||||
which repositories exist or which pubkeys are members. Authentication runs
|
||||
before repository lookup for the same reason: a 404-before-auth would be an
|
||||
existence oracle.
|
||||
|
||||
### Why the Git credential differs from generic NIP-98
|
||||
|
||||
Generic NIP-98 signs the exact request URL and method and is single-use. A Git
|
||||
clone is not one request — it is a sequence of `info/refs` and pack-transfer
|
||||
requests, issued by tooling that cannot re-sign per request. The GRASP-08
|
||||
profile therefore signs the **canonical repository root** with method `GET`,
|
||||
ignores payload tags, and is reusable across the standard Smart HTTP endpoints
|
||||
for a 60-second validity window. Replay within the window is accepted: the
|
||||
credential grants read access the holder already has for that window, and
|
||||
write operations remain gated by GRASP-01 push authorization, so replay
|
||||
confers nothing beyond what the member could do anyway.
|
||||
|
||||
The canonical public origin is operator-controlled
|
||||
(`NGIT_PRIVATE_PUBLIC_ORIGIN`, falling back to `NGIT_DOMAIN`) so that a
|
||||
reverse proxy cannot influence the identity that credentials sign.
|
||||
|
||||
### Why NIP-42 runs outside the embedded relay
|
||||
|
||||
`nostr-relay-builder`'s query and write policies do not receive the
|
||||
authenticated session pubkey, so the access check cannot live inside them.
|
||||
Instead the proxy authenticates first and only then bridges frames through an
|
||||
in-memory WebSocket pair to `LocalRelay`. The bridge subscribes to a
|
||||
membership generation channel; revoking a member closes their live sessions
|
||||
instead of letting them ride out an old connection.
|
||||
|
||||
### Why membership is service-wide
|
||||
|
||||
Relays are referenced per repository in NIP-34 announcements, but access here
|
||||
is per service. Reconciling the two per-repository would require per-session
|
||||
subscription filtering inside the embedded relay (which the policy interfaces
|
||||
cannot express, see above) and would turn every query into an ACL join. The
|
||||
single-service model instead declares the whole instance one trust domain —
|
||||
the deployment it targets is a team running a private service for its own
|
||||
repositories. Hosting mutually-distrusting sub-groups on one instance is
|
||||
explicitly out of scope and belongs to the future multi-service/fleet
|
||||
proposal. Under this model, per-repository relay lists act as **sync topology
|
||||
hints** (who to connect to), while the service-wide member set is the **ACL**
|
||||
(who may read).
|
||||
|
||||
### Why accepted-relay owners are admitted dynamically
|
||||
|
||||
Two private services mirroring the same repository must be able to read from
|
||||
each other. When an accepted announcement references another relay, that
|
||||
relay's NIP-11 `pubkey` is added to the member set, so a peer service (or the
|
||||
operator of an ordinary relay the team uses) can authenticate without manual
|
||||
whitelisting on both sides. The consequence — accepting one announcement
|
||||
grants its referenced relay operators read access to the whole service —
|
||||
follows directly from the one-trust-domain model and is the operator's
|
||||
opt-in via announcement admission.
|
||||
|
||||
Discovery reuses the NIP-11 document already fetched once per sync
|
||||
connection, and reconciliation rides the existing five-second maintenance
|
||||
pass, so dynamic membership adds no polling, subscriptions, connections, or
|
||||
background tasks.
|
||||
|
||||
### Why announcement admission is membership-gated
|
||||
|
||||
Accepted announcements are not just hosting decisions: the NIP-11 owners of
|
||||
their referenced relays become members. Left ungated, a member could submit a
|
||||
third-party-signed kind-30617 announcement — or sync could import one from an
|
||||
operator-configured source — and thereby mint membership for pubkeys no
|
||||
member ever chose, which those pubkeys' relays could amplify further with
|
||||
announcements of their own. In private mode an announcement is therefore only
|
||||
admitted when its author (the event pubkey) is a current effective member,
|
||||
evaluated against the live member set at admission time; every arrival path
|
||||
(direct publish, sync import, purgatory promotion) funnels through the same
|
||||
admission policy. This closes the loop: hosting and derived membership can
|
||||
only expand through member action. State events (kind 30618) stay governed by
|
||||
GRASP-01 maintainer rules, and removal remains non-retroactive — an admitted
|
||||
repository is not evicted when its author later leaves the member set.
|
||||
|
||||
### Why purgatory announcements grant nothing
|
||||
|
||||
Purgatory holds announcements that have *not* passed repository admission.
|
||||
If purgatory state could contribute relay owners to the member set, anyone
|
||||
able to get an event into purgatory could mint members. Only announcements
|
||||
accepted into the repository index count.
|
||||
|
||||
### Why private mode refuses to combine with GRASP-06
|
||||
|
||||
The GRASP-06 contributor endpoint (`/prs/`) is intentionally unauthenticated —
|
||||
its entire design premise is that any contributor can push a PR (see
|
||||
[GRASP-06 design](grasp-06-contributor-pr-submission.md)). That premise is
|
||||
incompatible with a private service, so `NGIT_PRIVATE_MODE=true` together
|
||||
with `NGIT_GRASP06_ENABLE=true` is a fatal configuration error rather than a
|
||||
silently half-open service.
|
||||
|
||||
### Why configuration fails closed
|
||||
|
||||
Private mode without members would lock everyone out silently, and members
|
||||
without private mode would suggest an operator believes the service is
|
||||
private when it is not. Both are startup errors. Malformed member npubs are
|
||||
fatal rather than skipped: dropping an access-control entry would silently
|
||||
lock a user out.
|
||||
|
||||
## Trust model summary
|
||||
|
||||
- **Configured members** (`NGIT_PRIVATE_MEMBERS`) are the permanent base set,
|
||||
trusted by operator assertion.
|
||||
- **Accepted-relay owners** are derived members, trusted transitively via
|
||||
announcement admission plus the referenced relay's NIP-11 self-assertion
|
||||
(the same HTTPS-from-domain trust anchor a `_@domain` NIP-05 lookup would
|
||||
provide, without an extra fetch or format).
|
||||
- **Membership grants read access only.** Push authorization remains
|
||||
GRASP-01's maintainer model; repository admission remains announcement
|
||||
policy, which in private mode additionally requires the announcement
|
||||
author to be a current effective member.
|
||||
- **Removal is not retroactive.** Removing a member closes their sessions and
|
||||
invalidates future credentials, but repositories admitted while they were a
|
||||
member remain hosted until the operator curates them.
|
||||
|
||||
## Follow-up scope
|
||||
|
||||
Deliberately excluded from the initial single-service implementation:
|
||||
|
||||
- **Outbound authentication**: presenting NIP-42 and GRASP-08 NIP-98
|
||||
credentials when syncing *from* other private services, using a service
|
||||
identity key. This is the missing half of zero-configuration private
|
||||
mirroring.
|
||||
- **Multi-service fleet orchestration** and **encrypted kind-10318 client
|
||||
discovery**, which belong to future GRASP proposals.
|
||||
@@ -32,6 +32,7 @@ use crate::nostr::policy::{
|
||||
StateResult,
|
||||
};
|
||||
use crate::nostr::SharedDatabase;
|
||||
use crate::private::PrivateAccess;
|
||||
use crate::purgatory::promotion_hooks::NostrPurgatoryPromotionHooks;
|
||||
use crate::sync::rejected_index::RejectedEventsIndex;
|
||||
|
||||
@@ -112,6 +113,7 @@ impl Nip34WritePolicy {
|
||||
purgatory: std::sync::Arc<crate::purgatory::Purgatory>,
|
||||
config: crate::config::Config,
|
||||
repo_init_locks: crate::grasp06::receive::RepoInitLocks,
|
||||
private_access: Option<PrivateAccess>,
|
||||
) -> Self {
|
||||
let git_data_path = git_data_path.into();
|
||||
let domain = config.domain.clone();
|
||||
@@ -130,7 +132,7 @@ impl Nip34WritePolicy {
|
||||
|
||||
let ctx = PolicyContext::new(domain, database, git_data_path, purgatory, config.clone());
|
||||
Self {
|
||||
announcement_policy: AnnouncementPolicy::new(ctx.clone(), config.clone()),
|
||||
announcement_policy: AnnouncementPolicy::new(ctx.clone(), config.clone(), private_access),
|
||||
state_policy: StatePolicy::new(ctx.clone()),
|
||||
pr_event_policy: PrEventPolicy::new(ctx.clone(), repo_init_locks),
|
||||
related_event_policy: RelatedEventPolicy::new(ctx.clone()),
|
||||
@@ -939,6 +941,7 @@ pub async fn create_relay(
|
||||
config: &Config,
|
||||
purgatory: Arc<crate::purgatory::Purgatory>,
|
||||
repo_init_locks: crate::grasp06::receive::RepoInitLocks,
|
||||
private_access: Option<PrivateAccess>,
|
||||
) -> Result<RelayRuntime> {
|
||||
tracing::info!("Configuring nostr relay with GRASP-01 validation...");
|
||||
|
||||
@@ -1052,6 +1055,7 @@ pub async fn create_relay(
|
||||
purgatory,
|
||||
config.clone(),
|
||||
repo_init_locks,
|
||||
private_access,
|
||||
);
|
||||
|
||||
let mut builder = LocalRelayBuilder::default()
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::time::Duration;
|
||||
use super::PolicyContext;
|
||||
use crate::config::Config;
|
||||
use crate::nostr::events::{validate_announcement, RepositoryAnnouncement};
|
||||
use crate::private::PrivateAccess;
|
||||
|
||||
/// Result of announcement policy evaluation
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -30,11 +31,21 @@ pub enum AnnouncementResult {
|
||||
pub struct AnnouncementPolicy {
|
||||
ctx: PolicyContext,
|
||||
config: Config,
|
||||
/// GRASP-08 live effective member set; `Some` only in private mode.
|
||||
private_access: Option<PrivateAccess>,
|
||||
}
|
||||
|
||||
impl AnnouncementPolicy {
|
||||
pub fn new(ctx: PolicyContext, config: Config) -> Self {
|
||||
Self { ctx, config }
|
||||
pub fn new(
|
||||
ctx: PolicyContext,
|
||||
config: Config,
|
||||
private_access: Option<PrivateAccess>,
|
||||
) -> Self {
|
||||
Self {
|
||||
ctx,
|
||||
config,
|
||||
private_access,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a repository announcement event
|
||||
@@ -47,6 +58,21 @@ impl AnnouncementPolicy {
|
||||
/// - `AcceptArchive` if accepted via GRASP-05 archive config
|
||||
/// - `Reject` with reason if validation fails
|
||||
pub async fn validate(&self, event: &Event) -> AnnouncementResult {
|
||||
// GRASP-08: on a private service, admitting an announcement expands
|
||||
// hosting and — via its referenced relays' NIP-11 owners — the member
|
||||
// set itself. Only a current effective member may therefore introduce
|
||||
// one; the check runs against the live member set at admission time
|
||||
// and covers every arrival path, since all announcement admission
|
||||
// funnels through this method. Removal is not retroactive:
|
||||
// repositories admitted while their author was a member stay hosted.
|
||||
if let Some(access) = &self.private_access {
|
||||
if !access.contains(&event.pubkey) {
|
||||
return AnnouncementResult::Reject(
|
||||
"Announcement author is not authorized on this private service".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// First, try validation (GRASP-01 + GRASP-05)
|
||||
let validation_result = validate_announcement(event, &self.config);
|
||||
|
||||
@@ -479,3 +505,85 @@ impl AnnouncementPolicy {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr_sdk::prelude::{EventBuilder, FinalizeEvent, Keys, Tag, ToBech32};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn policy(private_access: Option<PrivateAccess>) -> AnnouncementPolicy {
|
||||
let config = Config::for_testing();
|
||||
let ctx = PolicyContext::new_for_test(
|
||||
config.domain.clone(),
|
||||
Arc::new(nostr_memory::MemoryDatabase::unbounded()),
|
||||
PathBuf::new(),
|
||||
Arc::new(crate::purgatory::Purgatory::new(PathBuf::new())),
|
||||
config.clone(),
|
||||
);
|
||||
AnnouncementPolicy::new(ctx, config, private_access)
|
||||
}
|
||||
|
||||
/// A GRASP-01-valid announcement listing the test service in both the
|
||||
/// `clone` and `relays` tags.
|
||||
fn service_announcement(keys: &Keys, domain: &str) -> Event {
|
||||
let npub = keys.public_key().to_bech32().expect("author npub");
|
||||
EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
||||
.tags(vec![
|
||||
Tag::identifier("membership-gate-repo"),
|
||||
Tag::custom(
|
||||
"clone",
|
||||
[format!("https://{domain}/{npub}/membership-gate-repo.git")],
|
||||
),
|
||||
Tag::custom("relays", [format!("wss://{domain}")]),
|
||||
])
|
||||
.finalize(keys)
|
||||
.expect("signed announcement")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn private_mode_rejects_announcement_from_nonmember_author() {
|
||||
let member = Keys::generate();
|
||||
let outsider = Keys::generate();
|
||||
let policy = policy(Some(PrivateAccess::new([member.public_key()])));
|
||||
|
||||
let event = service_announcement(&outsider, &policy.config.domain);
|
||||
let result = policy.validate(&event).await;
|
||||
|
||||
assert!(
|
||||
matches!(result, AnnouncementResult::Reject(_)),
|
||||
"non-member author must be rejected in private mode: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn private_mode_accepts_announcement_from_member_author() {
|
||||
let member = Keys::generate();
|
||||
let policy = policy(Some(PrivateAccess::new([member.public_key()])));
|
||||
|
||||
let event = service_announcement(&member, &policy.config.domain);
|
||||
let result = policy.validate(&event).await;
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
AnnouncementResult::AcceptPurgatory,
|
||||
"member-authored announcement must pass the membership gate"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_mode_ignores_the_membership_gate() {
|
||||
let author = Keys::generate();
|
||||
let policy = policy(None);
|
||||
|
||||
let event = service_announcement(&author, &policy.config.domain);
|
||||
let result = policy.validate(&event).await;
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
AnnouncementResult::AcceptPurgatory,
|
||||
"public mode admission must be unchanged"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-4
@@ -188,10 +188,14 @@ impl RelayServer {
|
||||
}
|
||||
|
||||
// Create Nostr relay runtime with NIP-34 validation and shared stores.
|
||||
let relay_runtime =
|
||||
nostr::builder::create_relay(&config, purgatory.clone(), repo_init_locks.clone())
|
||||
.await
|
||||
.context("failed to create relay runtime")?;
|
||||
let relay_runtime = nostr::builder::create_relay(
|
||||
&config,
|
||||
purgatory.clone(),
|
||||
repo_init_locks.clone(),
|
||||
private_access.clone(),
|
||||
)
|
||||
.await
|
||||
.context("failed to create relay runtime")?;
|
||||
|
||||
info!(
|
||||
"Relay created with NIP-34 validation for domain: {}",
|
||||
|
||||
@@ -8932,6 +8932,7 @@ mod tests {
|
||||
&config,
|
||||
purgatory,
|
||||
crate::grasp06::receive::RepoInitLocks::default(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create test relay runtime");
|
||||
|
||||
@@ -433,6 +433,7 @@ fn test_write_policy(
|
||||
purgatory,
|
||||
config,
|
||||
repo_init_locks,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ fn make_policy(
|
||||
purgatory,
|
||||
config,
|
||||
new_repo_init_locks(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ fn build_policy_for_history_regression(
|
||||
purgatory,
|
||||
config,
|
||||
new_repo_init_locks(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -335,6 +335,85 @@ async fn private_websocket_rejects_valid_nonmember_auth_and_closes() {
|
||||
relay.stop().await;
|
||||
}
|
||||
|
||||
/// Build a GRASP-01-valid repository announcement listing this relay in
|
||||
/// both the `clone` and `relays` tags.
|
||||
fn announcement(keys: &Keys, relay_domain: &str) -> nostr_sdk::prelude::Event {
|
||||
let npub = keys
|
||||
.public_key()
|
||||
.to_bech32()
|
||||
.expect("announcement author npub");
|
||||
let clone_url = format!("https://{relay_domain}/{npub}/private-membership-repo.git");
|
||||
let relay_url = format!("ws://{relay_domain}");
|
||||
EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
||||
.tags(vec![
|
||||
Tag::parse(["d", "private-membership-repo"]).expect("d tag"),
|
||||
Tag::parse(["clone", clone_url.as_str()]).expect("clone tag"),
|
||||
Tag::parse(["relays", relay_url.as_str()]).expect("relays tag"),
|
||||
])
|
||||
.finalize(keys)
|
||||
.expect("signed announcement")
|
||||
}
|
||||
|
||||
/// Bare-repository path an admitted announcement by `keys` would create.
|
||||
fn bare_repo_path(relay: &TestRelay, keys: &Keys) -> std::path::PathBuf {
|
||||
relay
|
||||
.git_data_path()
|
||||
.join(keys.public_key().to_bech32().expect("owner npub"))
|
||||
.join("private-membership-repo.git")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn private_announcement_admission_requires_member_author() {
|
||||
let member = Keys::generate();
|
||||
let outsider = Keys::generate();
|
||||
let relay = TestRelay::start_private(&member.public_key()).await;
|
||||
let (mut stream, challenge) = connect_and_challenge(&relay).await;
|
||||
|
||||
send_text(
|
||||
&mut stream,
|
||||
auth_message(&member, &relay.domain(), &challenge),
|
||||
)
|
||||
.await;
|
||||
let ok: serde_json::Value =
|
||||
serde_json::from_str(&next_text(&mut stream).await).expect("OK JSON");
|
||||
assert_eq!(ok[2], true, "member NIP-42 authentication must succeed: {ok}");
|
||||
|
||||
// A member-authored announcement is admitted (OK true, parked in
|
||||
// purgatory awaiting git data) and its bare repository is created.
|
||||
let admitted = announcement(&member, &relay.domain());
|
||||
send_text(&mut stream, format!("[\"EVENT\",{}]", admitted.as_json())).await;
|
||||
let ok: serde_json::Value =
|
||||
serde_json::from_str(&next_text(&mut stream).await).expect("OK JSON");
|
||||
assert_eq!(ok[1].as_str(), Some(admitted.id.to_hex().as_str()));
|
||||
assert_eq!(
|
||||
ok[2], true,
|
||||
"member-authored announcement must be admitted: {ok}"
|
||||
);
|
||||
assert!(
|
||||
bare_repo_path(&relay, &member).exists(),
|
||||
"admitted announcement must create its bare repository"
|
||||
);
|
||||
|
||||
// The same authenticated member session cannot introduce a valid
|
||||
// announcement signed by a non-member author: membership gates the
|
||||
// announcement's author, not the publishing session.
|
||||
let rejected = announcement(&outsider, &relay.domain());
|
||||
send_text(&mut stream, format!("[\"EVENT\",{}]", rejected.as_json())).await;
|
||||
let ok: serde_json::Value =
|
||||
serde_json::from_str(&next_text(&mut stream).await).expect("OK JSON");
|
||||
assert_eq!(ok[1].as_str(), Some(rejected.id.to_hex().as_str()));
|
||||
assert_eq!(
|
||||
ok[2], false,
|
||||
"non-member-authored announcement must be rejected: {ok}"
|
||||
);
|
||||
assert!(
|
||||
!bare_repo_path(&relay, &outsider).exists(),
|
||||
"rejected announcement must not admit a repository"
|
||||
);
|
||||
|
||||
relay.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn private_websocket_bounds_invalid_authentication_attempts() {
|
||||
let member = Keys::generate();
|
||||
|
||||
Reference in New Issue
Block a user