mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(relay): accept kind:30621 multi-repo projects at ingest (#3171)
Buzz renders one card per `kind:30617`, so a project spanning several repositories has no representation. [NIP-MP](https://github.com/block/buzz/pull/3163) defines `kind:30621` as an addressable container holding a group's name, description, channel binding, and member coordinates. This adds the kind to `buzz-core` and its structural validation to the relay ingest path. ## Event shape ```json { "kind": 30621, "tags": [ ["d", "platform"], ["name", "Platform"], ["description", "Relay, desktop, and mobile."], ["a", "30617:<owner-a-hex>:buzz"], ["a", "30617:<owner-b-hex>:buzz-infra"], ["buzz-channel", "<channel-uuid>"], ["buzz-visibility", "listed"] ] } ``` ## Validation at ingest | Rule | Behavior | |------|----------| | `d` tag | exactly one, non-empty (length already bounded by the generic `D_TAG_MAX_LEN` check) | | member `a` tag arity | exactly 2 or 3 elements per NIP-01's `a` tag grammar; a 4th element has no defined meaning and is rejected | | member `a` tag coordinate | must parse as `30617:<lowercase-64-hex-owner>:<non-empty-d>` | | duplicate members | rejected on exact string match of the canonical coordinate | | member cap | 64, counted over raw `a` tags | | metadata cardinality | at most one each of `name`, `description`, `buzz-channel`, `buzz-visibility` | | metadata length | `name` ≤ 256 bytes, `description` ≤ 2048 bytes, `buzz-channel` ≤ 256 bytes, `buzz-visibility` ≤ 256 bytes | | zero members | valid | | unknown tags | ignored | Rejection order is normative so a client can predict which rule fires: `d`-cardinality → `d`-empty → member-cap → member-arity → coordinate parse → member-duplicate → metadata cardinality → metadata length. ## Design notes **No membership authorization.** Members are `a` tags, so one project may name repositories owned by different pubkeys — the entire point of the kind. That is safe because membership grants nothing: push policy reads a repository's own `kind:30617` (`api/git/policy.rs`) and never a project. `buzz-channel` is a metadata reference, not a routing directive, so projects are classified global-only. **Owner-only editing is free.** NIP-33 addressing keys replacement on `(pubkey, kind, d)`, so one signer can never overwrite another's project. No relay-side permission check exists or is needed, and `test_project_same_d_under_two_authors_are_independent` pins it. **Duplicates are rejected, not deduped.** A relay cannot rewrite tags inside a signed event without invalidating its id and signature, so the alternative to rejection is a stored duplicate-member head that every consumer must apply a first-wins rule to. **The cap is checked before the duplicate set is built.** Counting raw `a` tags rather than distinct coordinates means an event naming one coordinate thousands of times is refused on count, instead of being bounded only by the relay frame limit. **No side-effect handler.** Generic NIP-33 replacement and generic NIP-09 coordinate soft-delete already cover replacement and deletion; `kind:30621` needs no entry in `is_side_effect_kind`. ## Generic NIP-09 fix carried along `soft_delete_by_coordinate` (`crates/buzz-db/src/event.rs`) previously deleted the live coordinate head regardless of the tombstone's own `created_at`, so a delayed or replayed `a`-tag deletion signed between two versions destroyed the newer replacement. NIP-09 scopes an `a`-tag deletion to versions at or before the deletion request, so the `UPDATE` now carries `created_at <= $5` and `handle_a_tag_deletion` threads the deletion event's `created_at` through. The bug predates `kind:30621` and affected every parameterized-replaceable kind on the generic path — `kind:30617` repository announcements included — so the fix lands there rather than as a project special case. `events.created_at` is immutable per row, so the predicate guarantees a tombstone can never erase a version newer than itself; the UPDATE re-evaluates its WHERE clause after any lock wait. Under READ COMMITTED, a same-coordinate replacement racing the deletion may cause the deletion to evaluate before the new head lands, returning `Ok(false)` — but that outcome is state-identical to the deletion having arrived first, a valid Nostr ordering Nostr never fixes. The return value feeds only a debug log. No coordinate-level lock is needed. ## Coverage 32 unit tests in `crates/buzz-relay/src/handlers/ingest.rs` pin the envelope contract (accept: minimal, cross-owner, zero-member, same repo `d` under two owners, colon-bearing repo `d`, cap boundary, unknown tags, relay hint on member `a` tag, max-length metadata, stranger-owned member, uninterpreted metadata values, non-empty content; reject: every rule above plus valueless `d`/`a` tags). A fixture-driven test (`project_envelope_validates_all_shared_fixtures`) runs every case in the shared `NIP-MP.fixtures.json` oracle (11 accept + 20 reject) against `validate_project_envelope`, so any future change that breaks a case turns the test suite red. 6 `#[ignore]`d e2e tests in `crates/buzz-test-client/tests/e2e_project.rs` cover behavior that only exists past storage — coordinate round-trip, newer-wins replacement, two authors sharing a `d`, an `a`-tag tombstone that removes the project while leaving referenced `kind:30617`s intact, and a tombstone timestamped between V1 and V2 that must leave V2 live. The negative e2e case asserts on the rejection message so a refusal for an unrelated reason cannot satisfy it; that is what proves the validator is reachable from the live write path rather than merely correct in isolation. The new e2e binary is wired into the Relay E2E job. The timestamp predicate is additionally pinned at the storage layer by `coordinate_delete_spares_head_newer_than_the_deletion` in `crates/buzz-db/src/lib.rs`, which asserts both directions: a stale tombstone deletes nothing and leaves the newer head readable, and a tombstone at the head's own timestamp still deletes it. This test is wired into the Backend Integration job. Related: #3163 (the NIP-MP spec and shared conformance fixtures). Independent — either can merge first. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
e5e5bac2a9
commit
cb9701cd30
@@ -704,6 +704,17 @@ jobs:
|
||||
--run-ignored ignored-only
|
||||
env:
|
||||
RELAY_URL: ws://localhost:3000
|
||||
- name: NIP-MP coordinate deletion guard
|
||||
# Verifies the never-delete-newer invariant of soft_delete_by_coordinate:
|
||||
# a stale tombstone (created_at earlier than the live head) spares that
|
||||
# head, and an equal-timestamp tombstone deletes it.
|
||||
run: |
|
||||
cargo nextest run \
|
||||
--archive-file target/ci/backend-integration-tests.tar.zst \
|
||||
-E 'package(buzz-db) and test(coordinate_delete_spares_head_newer_than_the_deletion)' \
|
||||
--run-ignored ignored-only
|
||||
env:
|
||||
DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
|
||||
- name: Upload relay log
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
@@ -739,7 +750,7 @@ jobs:
|
||||
./scripts/start-relay-for-tests.sh --no-build
|
||||
- name: Relay E2E tests
|
||||
run: |
|
||||
cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop -- --ignored --nocapture
|
||||
cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_project -- --ignored --nocapture
|
||||
cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture
|
||||
cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture
|
||||
env:
|
||||
|
||||
@@ -609,6 +609,15 @@ pub const KIND_GIT_STATUS_CLOSED: u32 = 1632;
|
||||
/// NIP-34: Status — Draft.
|
||||
pub const KIND_GIT_STATUS_DRAFT: u32 = 1633;
|
||||
|
||||
/// NIP-MP: Multi-repo project — a named grouping of `kind:30617` repository
|
||||
/// announcements (parameterized replaceable, d=project slug).
|
||||
///
|
||||
/// Members are `a` tags holding `30617:<owner-hex>:<repo-d>` coordinates, so one
|
||||
/// project may span repositories owned by different pubkeys. The signer gains no
|
||||
/// authority over any member: push policy reads the repository's own
|
||||
/// announcement, never a project. See `docs/nips/NIP-MP.md`.
|
||||
pub const KIND_PROJECT: u32 = 30621;
|
||||
|
||||
/// All registered kind constants — used for duplicate detection and iteration.
|
||||
pub const ALL_KINDS: &[u32] = &[
|
||||
KIND_PROFILE,
|
||||
@@ -739,6 +748,7 @@ pub const ALL_KINDS: &[u32] = &[
|
||||
KIND_GIT_STATUS_MERGED,
|
||||
KIND_GIT_STATUS_CLOSED,
|
||||
KIND_GIT_STATUS_DRAFT,
|
||||
KIND_PROJECT,
|
||||
];
|
||||
|
||||
/// Returns `true` if `kind` is in the ephemeral range (20000–29999).
|
||||
@@ -836,6 +846,7 @@ const _: () = assert!(is_parameterized_replaceable(KIND_TEAM_CATALOG)); // 30178
|
||||
const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999
|
||||
const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999
|
||||
const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999
|
||||
const _: () = assert!(is_parameterized_replaceable(KIND_PROJECT)); // 30621 ∈ 30000–39999
|
||||
const _: () = assert!(is_parameterized_replaceable(KIND_THREAD_SUMMARY)); // 39005 ∈ 30000–39999
|
||||
const _: () = assert!(is_parameterized_replaceable(KIND_WINDOW_BOUNDS)); // 39006 ∈ 30000–39999
|
||||
|
||||
|
||||
@@ -789,7 +789,8 @@ pub async fn soft_delete_event(
|
||||
}
|
||||
|
||||
/// Soft-delete the live row for an addressable coordinate
|
||||
/// `(kind, pubkey, d_tag)` — the NIP-33 replacement key.
|
||||
/// `(kind, pubkey, d_tag)` — the NIP-33 replacement key — provided it is not
|
||||
/// newer than the deletion request.
|
||||
///
|
||||
/// Used by `handle_a_tag_deletion` to honour NIP-09 a-tag deletions for any
|
||||
/// parameterized-replaceable kind. The WHERE clause mirrors
|
||||
@@ -797,23 +798,45 @@ pub async fn soft_delete_event(
|
||||
/// `channel_id` is intentionally NOT in the key (NIP-33 replacement is global
|
||||
/// per the spec — `channel_id` is stored for query scoping, not identity).
|
||||
///
|
||||
/// `deletion_created_at_secs` is the deletion event's own `created_at`. NIP-09
|
||||
/// scopes an `a`-tag deletion to versions at or before that instant, so a
|
||||
/// delayed or replayed tombstone signed between two versions must not erase the
|
||||
/// newer replacement. `events.created_at` is immutable per row, so the predicate
|
||||
/// guarantees a tombstone can never erase a version newer than itself — the UPDATE
|
||||
/// re-evaluates its WHERE clause after any lock wait, so a replacement that races
|
||||
/// the deletion and lands with a later `created_at` is always spared.
|
||||
///
|
||||
/// This does NOT guarantee deletion completeness when a same-coordinate
|
||||
/// replacement races the deletion: the deletion may evaluate its predicate before
|
||||
/// the replacement arrives, miss the incoming head, and return `Ok(false)`. That
|
||||
/// outcome is state-identical to the deletion having arrived first (old head
|
||||
/// gone, new head present), which is a valid Nostr ordering — Nostr never fixes
|
||||
/// the order of concurrent writes from different signers, and even same-signer
|
||||
/// ordering is advisory. The return value feeds only a debug log, not a
|
||||
/// correctness gate.
|
||||
///
|
||||
/// Returns `Ok(true)` if a row was deleted, `Ok(false)` if no live row matched
|
||||
/// (already deleted, or never existed).
|
||||
/// (already deleted, never existed, or strictly newer than the deletion).
|
||||
pub async fn soft_delete_by_coordinate(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
kind: i32,
|
||||
pubkey: &[u8],
|
||||
d_tag: &str,
|
||||
deletion_created_at_secs: i64,
|
||||
) -> Result<bool> {
|
||||
let deletion_created_at = DateTime::from_timestamp(deletion_created_at_secs, 0)
|
||||
.ok_or(DbError::InvalidTimestamp(deletion_created_at_secs))?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE events SET deleted_at = NOW() \
|
||||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL",
|
||||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL \
|
||||
AND created_at <= $5",
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(kind)
|
||||
.bind(pubkey)
|
||||
.bind(d_tag)
|
||||
.bind(deletion_created_at)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -1813,16 +1813,27 @@ impl Db {
|
||||
event::soft_delete_event(&self.pool, community_id, event_id).await
|
||||
}
|
||||
|
||||
/// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)`.
|
||||
/// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds.
|
||||
/// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)`
|
||||
/// when it is not newer than the deletion request.
|
||||
/// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds;
|
||||
/// `deletion_created_at_secs` is the deletion event's `created_at`.
|
||||
pub async fn soft_delete_by_coordinate(
|
||||
&self,
|
||||
community_id: CommunityId,
|
||||
kind: i32,
|
||||
pubkey: &[u8],
|
||||
d_tag: &str,
|
||||
deletion_created_at_secs: i64,
|
||||
) -> Result<bool> {
|
||||
event::soft_delete_by_coordinate(&self.pool, community_id, kind, pubkey, d_tag).await
|
||||
event::soft_delete_by_coordinate(
|
||||
&self.pool,
|
||||
community_id,
|
||||
kind,
|
||||
pubkey,
|
||||
d_tag,
|
||||
deletion_created_at_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Atomically soft-delete an event and decrement thread reply counters.
|
||||
@@ -5227,6 +5238,75 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn coordinate_delete_spares_head_newer_than_the_deletion() {
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||||
|
||||
let db = setup_db().await;
|
||||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||||
let keys = Keys::generate();
|
||||
let kind = buzz_core::kind::KIND_PROJECT as i32;
|
||||
let d_tag = "stale-tombstone-project";
|
||||
let pubkey = keys.public_key().to_bytes().to_vec();
|
||||
let base = Timestamp::now().as_secs();
|
||||
|
||||
let version = |content: &str, offset: u64| {
|
||||
EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content)
|
||||
.tags(vec![Tag::parse(["d", d_tag]).expect("d tag")])
|
||||
.custom_created_at(Timestamp::from(base + offset))
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign project version")
|
||||
};
|
||||
|
||||
for (content, offset) in [("v1", 0), ("v2", 100)] {
|
||||
assert!(
|
||||
db.replace_parameterized_event(community, &version(content, offset), d_tag, None)
|
||||
.await
|
||||
.expect("store project version")
|
||||
.1
|
||||
);
|
||||
}
|
||||
|
||||
// Tombstone timestamped between V1 and V2: it authorizes deleting V1,
|
||||
// never the newer head that replaced it.
|
||||
let stale_deleted = db
|
||||
.soft_delete_by_coordinate(community, kind, &pubkey, d_tag, (base + 50) as i64)
|
||||
.await
|
||||
.expect("stale coordinate delete");
|
||||
assert!(
|
||||
!stale_deleted,
|
||||
"a tombstone older than the live head must delete nothing"
|
||||
);
|
||||
|
||||
let live_content: Option<String> = sqlx::query_scalar(
|
||||
"SELECT content FROM events \
|
||||
WHERE community_id=$1 AND kind=$2 AND pubkey=$3 AND d_tag=$4 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(kind)
|
||||
.bind(&pubkey)
|
||||
.bind(d_tag)
|
||||
.fetch_optional(&db.pool)
|
||||
.await
|
||||
.expect("read live head");
|
||||
assert_eq!(
|
||||
live_content.as_deref(),
|
||||
Some("v2"),
|
||||
"the newer head must survive a stale tombstone"
|
||||
);
|
||||
|
||||
// A tombstone at or after the head's own timestamp still deletes it.
|
||||
let current_deleted = db
|
||||
.soft_delete_by_coordinate(community, kind, &pubkey, d_tag, (base + 100) as i64)
|
||||
.await
|
||||
.expect("current coordinate delete");
|
||||
assert!(
|
||||
current_deleted,
|
||||
"a tombstone at the head's timestamp must delete it (NIP-09 is at-or-before)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn duplicate_nip_rs_discriminator_tags_keep_legacy_retention() {
|
||||
|
||||
@@ -2795,10 +2795,18 @@ mod sec005_read_gate_tests {
|
||||
);
|
||||
|
||||
let owner_pk = f.owner_keys.public_key().to_bytes().to_vec();
|
||||
// Tombstone timestamped after the announcement, per NIP-09's
|
||||
// at-or-before scoping in `soft_delete_by_coordinate`.
|
||||
let deleted =
|
||||
f.db.soft_delete_by_coordinate(f.community, 30617, &owner_pk, &f.repo)
|
||||
.await
|
||||
.expect("soft delete 30617");
|
||||
f.db.soft_delete_by_coordinate(
|
||||
f.community,
|
||||
30617,
|
||||
&owner_pk,
|
||||
&f.repo,
|
||||
chrono::Utc::now().timestamp() + 60,
|
||||
)
|
||||
.await
|
||||
.expect("soft delete 30617");
|
||||
assert!(deleted, "precondition: a live announcement row was deleted");
|
||||
|
||||
assert!(
|
||||
|
||||
@@ -28,7 +28,7 @@ use buzz_core::kind::{
|
||||
KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST,
|
||||
KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST,
|
||||
KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE,
|
||||
KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT,
|
||||
KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT,
|
||||
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF,
|
||||
KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED,
|
||||
KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE,
|
||||
@@ -301,6 +301,9 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
|
||||
| KIND_HUDDLE_GUIDELINES => Ok(Scope::ChannelsWrite),
|
||||
// NIP-34: Git repository events
|
||||
KIND_GIT_REPO_ANNOUNCEMENT | KIND_GIT_REPO_STATE => Ok(Scope::ReposWrite),
|
||||
// NIP-MP: a project is repository metadata — grouping repositories needs
|
||||
// the same scope as announcing them.
|
||||
KIND_PROJECT => Ok(Scope::ReposWrite),
|
||||
KIND_GIT_PATCH
|
||||
| KIND_GIT_PULL_REQUEST
|
||||
| KIND_GIT_PR_UPDATE
|
||||
@@ -437,6 +440,10 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
|
||||
| KIND_GIT_STATUS_MERGED
|
||||
| KIND_GIT_STATUS_CLOSED
|
||||
| KIND_GIT_STATUS_DRAFT
|
||||
// NIP-MP: projects are addressed by (pubkey, kind, d_tag). The
|
||||
// `buzz-channel` tag is a metadata reference, not a routing directive,
|
||||
// so a project's state is never channel-scoped.
|
||||
| KIND_PROJECT
|
||||
// Community moderation commands (9040–9044): community-global
|
||||
// direct commands, same model as the NIP-43 9030-series. A stray
|
||||
// `h` tag must never channel-scope them (pinned contract —
|
||||
@@ -1160,6 +1167,284 @@ fn validate_team_catalog_envelope(event: &Event) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Maximum number of member `a` tags on a kind:30621 project.
|
||||
///
|
||||
/// Counted over raw tags, not distinct coordinates: a duplicate-heavy event
|
||||
/// naming one coordinate thousands of times would otherwise be bounded only by
|
||||
/// the relay frame limit (`config.rs`), so the cap must be checked before any
|
||||
/// set proportional to the tag list is built.
|
||||
const PROJECT_MEMBER_CAP: usize = 64;
|
||||
|
||||
/// Maximum byte length of a project `name` tag value.
|
||||
const PROJECT_NAME_MAX_LEN: usize = 256;
|
||||
|
||||
/// Maximum byte length of a project `description` tag value.
|
||||
const PROJECT_DESCRIPTION_MAX_LEN: usize = 2048;
|
||||
|
||||
/// Maximum byte length of `buzz-channel` and `buzz-visibility` tag values.
|
||||
///
|
||||
/// Both are opaque strings at the relay layer; the bound exists only so an
|
||||
/// unbounded value cannot ride into storage on a tag ingest does not interpret.
|
||||
const PROJECT_METADATA_TAG_MAX_LEN: usize = 256;
|
||||
|
||||
/// Metadata tags a project may carry at most once each.
|
||||
///
|
||||
/// Duplicates would make the effective value reader-dependent — one client
|
||||
/// taking the first, another the last.
|
||||
const PROJECT_SINGLETON_METADATA_TAGS: [&str; 4] =
|
||||
["name", "description", "buzz-channel", "buzz-visibility"];
|
||||
|
||||
/// The kind segment every project member coordinate must carry: a project groups
|
||||
/// repository *announcements*, so a coordinate naming any other kind (notably
|
||||
/// kind:30618 repository state) is malformed.
|
||||
const PROJECT_MEMBER_KIND_SEGMENT: &str = "30617";
|
||||
const _: () = assert!(KIND_GIT_REPO_ANNOUNCEMENT == 30617);
|
||||
|
||||
/// A validation failure from [`validate_project_envelope`] or
|
||||
/// [`parse_project_member_coordinate`].
|
||||
///
|
||||
/// Carries the stable NIP-MP rule identifier alongside the human-readable
|
||||
/// rejection message. The rule ID allows the fixture oracle and any future
|
||||
/// cross-implementation conformance test to assert *which* rule fired, not just
|
||||
/// that rejection occurred — an implementation cannot pass a reject fixture by
|
||||
/// refusing for an unrelated reason.
|
||||
///
|
||||
/// The eight IDs match the `reject_rules` strings in `NIP-MP.fixtures.json`
|
||||
/// exactly: `d-cardinality`, `d-empty`, `member-cap`, `member-tag-arity`,
|
||||
/// `member-coordinate-malformed`, `member-duplicate`, `metadata-cardinality`,
|
||||
/// `metadata-length`.
|
||||
#[derive(Debug)]
|
||||
struct ProjectRejection {
|
||||
/// Stable rule identifier matching the fixture file's `reject_rules` set.
|
||||
rule: &'static str,
|
||||
/// Human-readable explanation forwarded to the client's NOTICE/OK message.
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProjectRejection {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "[{}] {}", self.rule, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl ProjectRejection {
|
||||
fn new(rule: &'static str, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
rule,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the envelope of a kind:30621 NIP-MP project event.
|
||||
///
|
||||
/// Enforces the structural contract in `docs/nips/NIP-MP.md` — exactly one
|
||||
/// non-empty `d` tag, at most [`PROJECT_MEMBER_CAP`] member `a` tags each
|
||||
/// holding a canonical `30617:<lowercase-64-hex-owner>:<non-empty-d>`
|
||||
/// coordinate with no duplicates, and bounded metadata.
|
||||
///
|
||||
/// Deliberately absent: any membership authorization. The signer may reference
|
||||
/// any repository coordinate, including another owner's, because membership
|
||||
/// grants nothing — push policy reads the repository's own kind:30617
|
||||
/// (`api/git/policy.rs`) and never a project. Owner-only replacement comes free
|
||||
/// from NIP-33 addressing.
|
||||
///
|
||||
/// Duplicates are rejected rather than deduped: a relay cannot rewrite tags
|
||||
/// inside a signed event without invalidating its id and signature, so the
|
||||
/// choice is reject or force every consumer to apply a first-wins rule.
|
||||
fn validate_project_envelope(event: &Event) -> Result<(), ProjectRejection> {
|
||||
let mut d_tags: Vec<&str> = Vec::new();
|
||||
let mut members: Vec<&str> = Vec::new();
|
||||
let mut name: Option<&str> = None;
|
||||
let mut description: Option<&str> = None;
|
||||
let mut buzz_channel: Option<&str> = None;
|
||||
let mut buzz_visibility: Option<&str> = None;
|
||||
let mut singleton_counts = [0usize; PROJECT_SINGLETON_METADATA_TAGS.len()];
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
let parts = tag.as_slice();
|
||||
let Some(tag_name) = parts.first().map(|s| s.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let value = parts.get(1).map(|s| s.as_str()).unwrap_or("");
|
||||
match tag_name {
|
||||
"d" => d_tags.push(value),
|
||||
"a" => members.push(value),
|
||||
_ => {
|
||||
if let Some(i) = PROJECT_SINGLETON_METADATA_TAGS
|
||||
.iter()
|
||||
.position(|k| *k == tag_name)
|
||||
{
|
||||
singleton_counts[i] += 1;
|
||||
match tag_name {
|
||||
"name" => name = Some(value),
|
||||
"description" => description = Some(value),
|
||||
"buzz-channel" => buzz_channel = Some(value),
|
||||
"buzz-visibility" => buzz_visibility = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `d-cardinality` / `d-empty`: under NIP-33 a missing `d` is treated as
|
||||
// empty, which collapses every such project into the `(pubkey, 30621, "")`
|
||||
// slot where unrelated projects silently overwrite each other. Several `d`
|
||||
// tags make the address reader-dependent. Length is bounded by the generic
|
||||
// `D_TAG_MAX_LEN` check the ingest pipeline already applies.
|
||||
if d_tags.len() != 1 {
|
||||
return Err(ProjectRejection::new(
|
||||
"d-cardinality",
|
||||
format!(
|
||||
"project event must have exactly one `d` tag (got {})",
|
||||
d_tags.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
if d_tags[0].is_empty() {
|
||||
return Err(ProjectRejection::new(
|
||||
"d-empty",
|
||||
"project event `d` tag must not be empty",
|
||||
));
|
||||
}
|
||||
|
||||
// `member-cap` before `member-coordinate-malformed` and `member-duplicate`:
|
||||
// refuse on count before doing per-tag work.
|
||||
if members.len() > PROJECT_MEMBER_CAP {
|
||||
return Err(ProjectRejection::new(
|
||||
"member-cap",
|
||||
format!(
|
||||
"project event must have at most {PROJECT_MEMBER_CAP} member `a` tags (got {})",
|
||||
members.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
// `member-tag-arity`: every member `a` tag has exactly 2 or 3 elements per
|
||||
// NIP-01's `a` tag grammar. A one-element tag names no coordinate; a fourth
|
||||
// element has no defined meaning, and accepting it would let a writer park
|
||||
// unbounded unvalidated data in a position no consumer reads.
|
||||
for tag in event.tags.iter() {
|
||||
let parts = tag.as_slice();
|
||||
if parts.first().map(|s| s.as_str()) == Some("a") && !(2..=3).contains(&parts.len()) {
|
||||
return Err(ProjectRejection::new(
|
||||
"member-tag-arity",
|
||||
format!(
|
||||
"project event member `a` tag must have exactly 2 or 3 elements (got {})",
|
||||
parts.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::HashSet::with_capacity(members.len());
|
||||
for member in &members {
|
||||
parse_project_member_coordinate(member)?;
|
||||
if !seen.insert(*member) {
|
||||
return Err(ProjectRejection::new(
|
||||
"member-duplicate",
|
||||
format!("project event has duplicate member coordinate {member:?}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for (i, count) in singleton_counts.iter().enumerate() {
|
||||
if *count > 1 {
|
||||
return Err(ProjectRejection::new(
|
||||
"metadata-cardinality",
|
||||
format!(
|
||||
"project event must have at most one `{}` tag (got {count})",
|
||||
PROJECT_SINGLETON_METADATA_TAGS[i]
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(name) = name {
|
||||
if name.len() > PROJECT_NAME_MAX_LEN {
|
||||
return Err(ProjectRejection::new(
|
||||
"metadata-length",
|
||||
format!(
|
||||
"project event `name` tag too long ({} bytes, max {PROJECT_NAME_MAX_LEN})",
|
||||
name.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(description) = description {
|
||||
if description.len() > PROJECT_DESCRIPTION_MAX_LEN {
|
||||
return Err(ProjectRejection::new(
|
||||
"metadata-length",
|
||||
format!(
|
||||
"project event `description` tag too long ({} bytes, max {PROJECT_DESCRIPTION_MAX_LEN})",
|
||||
description.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(buzz_channel) = buzz_channel {
|
||||
if buzz_channel.len() > PROJECT_METADATA_TAG_MAX_LEN {
|
||||
return Err(ProjectRejection::new(
|
||||
"metadata-length",
|
||||
format!(
|
||||
"project event `buzz-channel` tag too long ({} bytes, max {PROJECT_METADATA_TAG_MAX_LEN})",
|
||||
buzz_channel.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(buzz_visibility) = buzz_visibility {
|
||||
if buzz_visibility.len() > PROJECT_METADATA_TAG_MAX_LEN {
|
||||
return Err(ProjectRejection::new(
|
||||
"metadata-length",
|
||||
format!(
|
||||
"project event `buzz-visibility` tag too long ({} bytes, max {PROJECT_METADATA_TAG_MAX_LEN})",
|
||||
buzz_visibility.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check that `coordinate` is a canonical repository-announcement address.
|
||||
///
|
||||
/// Splits on the first two colons only, matching how NIP-09 deletion handling
|
||||
/// parses coordinates (`side_effects.rs`), so a repository whose `d` tag
|
||||
/// contains a colon stays addressable and a project can never disagree with a
|
||||
/// deletion about where the `d` value begins.
|
||||
fn parse_project_member_coordinate(coordinate: &str) -> Result<(), ProjectRejection> {
|
||||
let malformed = || {
|
||||
ProjectRejection::new(
|
||||
"member-coordinate-malformed",
|
||||
format!(
|
||||
"project event member `a` tag must be \
|
||||
`{PROJECT_MEMBER_KIND_SEGMENT}:<lowercase-64-hex-owner>:<repo-d>` (got {coordinate:?})"
|
||||
),
|
||||
)
|
||||
};
|
||||
let mut segments = coordinate.splitn(3, ':');
|
||||
let (Some(kind), Some(owner), Some(repo_d)) =
|
||||
(segments.next(), segments.next(), segments.next())
|
||||
else {
|
||||
return Err(malformed());
|
||||
};
|
||||
if kind != PROJECT_MEMBER_KIND_SEGMENT {
|
||||
return Err(malformed());
|
||||
}
|
||||
// Lowercase-only: `#a` filter matching is byte-exact, so an uppercase-owner
|
||||
// head would be invisible to the lowercase-coordinate queries readers issue.
|
||||
if owner.len() != 64
|
||||
|| !owner
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
|
||||
{
|
||||
return Err(malformed());
|
||||
}
|
||||
if repo_d.is_empty() {
|
||||
return Err(malformed());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate that `content` is a syntactically plausible NIP-44 v2 ciphertext.
|
||||
///
|
||||
/// Checks:
|
||||
@@ -2130,6 +2415,11 @@ async fn ingest_event_inner(
|
||||
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
|
||||
}
|
||||
|
||||
if kind_u32 == KIND_PROJECT {
|
||||
validate_project_envelope(&event)
|
||||
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
|
||||
}
|
||||
|
||||
// Track pre-created channel UUID for compensation on insert failure.
|
||||
let mut pre_created_channel: Option<Uuid> = None;
|
||||
|
||||
@@ -3946,6 +4236,407 @@ mod tests {
|
||||
assert!(!requires_h_channel_scope(KIND_TEAM_CATALOG));
|
||||
}
|
||||
|
||||
// ─── project (NIP-MP kind:30621) envelope tests ──────────────────────────
|
||||
|
||||
const OWNER_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const OWNER_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
fn make_project(tags: &[&[&str]]) -> Event {
|
||||
make_event_with_tags(KIND_PROJECT, "", tags)
|
||||
}
|
||||
|
||||
fn member_coord(owner: &str, repo_d: &str) -> String {
|
||||
format!("30617:{owner}:{repo_d}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_accepts_minimal() {
|
||||
let ev = make_project(&[&["d", "platform"]]);
|
||||
assert!(validate_project_envelope(&ev).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_accepts_full_cross_owner_membership() {
|
||||
// The motivating case: one project spanning two owners' repositories.
|
||||
let a = member_coord(OWNER_A, "buzz");
|
||||
let b = member_coord(OWNER_B, "buzz-infra");
|
||||
let ev = make_project(&[
|
||||
&["d", "platform"],
|
||||
&["name", "Platform"],
|
||||
&["description", "Relay, desktop, and mobile."],
|
||||
&["a", &a],
|
||||
&["a", &b],
|
||||
&["buzz-channel", "3580ca9b-47b4-4af9-b22a-1068778f26c6"],
|
||||
&["buzz-visibility", "listed"],
|
||||
]);
|
||||
assert!(validate_project_envelope(&ev).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_accepts_zero_members() {
|
||||
// Legal at the protocol layer: the natural state after removing a final
|
||||
// member. The create UI requires >= 1; the relay must not.
|
||||
let ev = make_project(&[&["d", "empty"], &["name", "Empty"]]);
|
||||
assert!(validate_project_envelope(&ev).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_accepts_same_repo_d_under_two_owners() {
|
||||
// The NIP-34 fork case. Identity is the whole coordinate, so these are
|
||||
// two distinct members, not a duplicate.
|
||||
let a = member_coord(OWNER_A, "buzz");
|
||||
let b = member_coord(OWNER_B, "buzz");
|
||||
let ev = make_project(&[&["d", "forks"], &["a", &a], &["a", &b]]);
|
||||
assert!(validate_project_envelope(&ev).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_accepts_member_repo_d_containing_colon() {
|
||||
// Coordinates split on the first two colons only, matching NIP-09
|
||||
// deletion parsing, so a colon-bearing repository `d` stays addressable.
|
||||
let coord = member_coord(OWNER_A, "group:repo");
|
||||
let ev = make_project(&[&["d", "external"], &["a", &coord]]);
|
||||
assert!(validate_project_envelope(&ev).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_accepts_member_cap_boundary() {
|
||||
let coords: Vec<String> = (0..PROJECT_MEMBER_CAP)
|
||||
.map(|i| member_coord(OWNER_A, &format!("repo-{i}")))
|
||||
.collect();
|
||||
let mut tags: Vec<Vec<&str>> = vec![vec!["d", "wide"]];
|
||||
tags.extend(coords.iter().map(|c| vec!["a", c.as_str()]));
|
||||
let tag_refs: Vec<&[&str]> = tags.iter().map(|t| t.as_slice()).collect();
|
||||
let ev = make_project(&tag_refs);
|
||||
assert!(
|
||||
validate_project_envelope(&ev).is_ok(),
|
||||
"exactly {PROJECT_MEMBER_CAP} members must be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_ignores_unknown_tags() {
|
||||
// Forward compatibility: a newer writer's extra metadata must not
|
||||
// invalidate the event for this relay.
|
||||
let ev = make_project(&[&["d", "platform"], &["future-field", "whatever"]]);
|
||||
assert!(validate_project_envelope(&ev).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_rejects_missing_d_tag() {
|
||||
let ev = make_project(&[&["name", "No Identity"]]);
|
||||
let err = validate_project_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("exactly one `d` tag"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_rejects_multiple_d_tags() {
|
||||
let ev = make_project(&[&["d", "one"], &["d", "two"]]);
|
||||
let err = validate_project_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("exactly one `d` tag"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_rejects_empty_d_tag() {
|
||||
// An empty `d` collapses every such project into the (pubkey, 30621, "")
|
||||
// slot, where unrelated projects silently overwrite each other.
|
||||
let ev = make_project(&[&["d", ""]]);
|
||||
let err = validate_project_envelope(&ev).unwrap_err();
|
||||
assert!(err.to_string().contains("must not be empty"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_rejects_valueless_d_tag() {
|
||||
// `["d"]` with no value is treated as empty, not as absent.
|
||||
let ev = make_project(&[&["d"]]);
|
||||
let err = validate_project_envelope(&ev).unwrap_err();
|
||||
assert!(err.to_string().contains("must not be empty"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_rejects_duplicate_member_coordinate() {
|
||||
let coord = member_coord(OWNER_A, "buzz");
|
||||
let ev = make_project(&[&["d", "platform"], &["a", &coord], &["a", &coord]]);
|
||||
let err = validate_project_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("duplicate member coordinate"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_rejects_member_cap_exceeded() {
|
||||
let coords: Vec<String> = (0..=PROJECT_MEMBER_CAP)
|
||||
.map(|i| member_coord(OWNER_A, &format!("repo-{i}")))
|
||||
.collect();
|
||||
let mut tags: Vec<Vec<&str>> = vec![vec!["d", "wide"]];
|
||||
tags.extend(coords.iter().map(|c| vec!["a", c.as_str()]));
|
||||
let tag_refs: Vec<&[&str]> = tags.iter().map(|t| t.as_slice()).collect();
|
||||
let ev = make_project(&tag_refs);
|
||||
let err = validate_project_envelope(&ev).unwrap_err();
|
||||
assert!(err.to_string().contains("at most 64 member"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_rejects_duplicate_heavy_list_on_cap_not_duplicate() {
|
||||
// The cap counts raw `a` tags, so a duplicate-heavy list is refused on
|
||||
// count — parse volume is never bounded only by the frame limit.
|
||||
let coord = member_coord(OWNER_A, "buzz");
|
||||
let mut tags: Vec<Vec<&str>> = vec![vec!["d", "wide"]];
|
||||
for _ in 0..=PROJECT_MEMBER_CAP {
|
||||
tags.push(vec!["a", coord.as_str()]);
|
||||
}
|
||||
let tag_refs: Vec<&[&str]> = tags.iter().map(|t| t.as_slice()).collect();
|
||||
let ev = make_project(&tag_refs);
|
||||
let err = validate_project_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("at most 64 member"),
|
||||
"cap must be evaluated before the duplicate set is built, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_rejects_member_wrong_kind_prefix() {
|
||||
// kind:30618 is repository *state*; a project groups announcements.
|
||||
let coord = format!("30618:{OWNER_A}:buzz");
|
||||
let ev = make_project(&[&["d", "platform"], &["a", &coord]]);
|
||||
let err = validate_project_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("member `a` tag must be"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_rejects_member_owner_not_hex() {
|
||||
let coord = member_coord(&"z".repeat(64), "buzz");
|
||||
let ev = make_project(&[&["d", "platform"], &["a", &coord]]);
|
||||
let err = validate_project_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("member `a` tag must be"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_rejects_member_owner_uppercase_hex() {
|
||||
// `#a` filter matching is byte-exact: an uppercase-owner head would be
|
||||
// invisible to the lowercase-coordinate queries every reader issues.
|
||||
let coord = member_coord(&"A".repeat(64), "buzz");
|
||||
let ev = make_project(&[&["d", "platform"], &["a", &coord]]);
|
||||
let err = validate_project_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("member `a` tag must be"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_rejects_member_owner_wrong_length() {
|
||||
let coord = member_coord(&"a".repeat(63), "buzz");
|
||||
let ev = make_project(&[&["d", "platform"], &["a", &coord]]);
|
||||
let err = validate_project_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("member `a` tag must be"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_rejects_member_empty_repo_d() {
|
||||
let coord = member_coord(OWNER_A, "");
|
||||
let ev = make_project(&[&["d", "platform"], &["a", &coord]]);
|
||||
let err = validate_project_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("member `a` tag must be"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_rejects_member_missing_segment() {
|
||||
let coord = format!("30617:{OWNER_A}");
|
||||
let ev = make_project(&[&["d", "platform"], &["a", &coord]]);
|
||||
let err = validate_project_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("member `a` tag must be"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_rejects_valueless_member_tag() {
|
||||
// A one-element `a` tag names no coordinate — caught by the arity check
|
||||
// (rule 4) before the coordinate parse (rule 5) even runs.
|
||||
let ev = make_project(&[&["d", "platform"], &["a"]]);
|
||||
let err = validate_project_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("exactly 2 or 3 elements"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_rejects_duplicate_metadata_tags() {
|
||||
// Every singleton metadata tag is bounded: a duplicate would make the
|
||||
// effective value reader-dependent.
|
||||
for tag_name in PROJECT_SINGLETON_METADATA_TAGS {
|
||||
let ev = make_project(&[&["d", "platform"], &[tag_name, "x"], &[tag_name, "y"]]);
|
||||
let err = validate_project_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains(&format!("at most one `{tag_name}` tag")),
|
||||
"duplicate `{tag_name}` must be rejected, got: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_rejects_name_too_long() {
|
||||
let name = "x".repeat(PROJECT_NAME_MAX_LEN + 1);
|
||||
let ev = make_project(&[&["d", "platform"], &["name", &name]]);
|
||||
let err = validate_project_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("`name` tag too long"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_accepts_name_at_max_length() {
|
||||
let name = "x".repeat(PROJECT_NAME_MAX_LEN);
|
||||
let ev = make_project(&[&["d", "platform"], &["name", &name]]);
|
||||
assert!(validate_project_envelope(&ev).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_rejects_description_too_long() {
|
||||
let description = "x".repeat(PROJECT_DESCRIPTION_MAX_LEN + 1);
|
||||
let ev = make_project(&[&["d", "platform"], &["description", &description]]);
|
||||
let err = validate_project_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("`description` tag too long"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_envelope_accepts_description_at_max_length() {
|
||||
let description = "x".repeat(PROJECT_DESCRIPTION_MAX_LEN);
|
||||
let ev = make_project(&[&["d", "platform"], &["description", &description]]);
|
||||
assert!(validate_project_envelope(&ev).is_ok());
|
||||
}
|
||||
|
||||
/// Membership is an assertion, not a permission grant: the relay must accept
|
||||
/// a project naming a repository the signer does not own. Cross-owner
|
||||
/// grouping is the entire point of the kind, and it is safe precisely because
|
||||
/// membership confers nothing.
|
||||
#[test]
|
||||
fn project_envelope_accepts_member_owned_by_another_pubkey() {
|
||||
let stranger = member_coord(OWNER_B, "not-mine");
|
||||
let ev = make_project(&[&["d", "collection"], &["a", &stranger]]);
|
||||
assert!(validate_project_envelope(&ev).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_is_in_scope_allowlist() {
|
||||
let dummy = make_dummy_event();
|
||||
assert_eq!(
|
||||
required_scope_for_kind(KIND_PROJECT, &dummy).unwrap(),
|
||||
Scope::ReposWrite,
|
||||
"a project is repository metadata — same scope as announcing a repo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_is_global_only() {
|
||||
// `buzz-channel` is a metadata reference, not a routing directive.
|
||||
assert!(is_global_only_kind(KIND_PROJECT));
|
||||
assert!(!requires_h_channel_scope(KIND_PROJECT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_is_parameterized_replaceable() {
|
||||
// Owner-only editing comes free from NIP-33 addressing: replacement is
|
||||
// keyed by (pubkey, kind, d), so one signer can never overwrite another's
|
||||
// project. No relay-side permission check exists or is needed.
|
||||
assert!(is_parameterized_replaceable(KIND_PROJECT));
|
||||
}
|
||||
|
||||
/// Drive every case in the shared NIP-MP fixture file against
|
||||
/// `validate_project_envelope`. All 11 accept cases must pass; all 20
|
||||
/// reject cases must return an error whose rule is in the case's allowed
|
||||
/// `reject_rules` set — an implementation cannot pass by rejecting for an
|
||||
/// unrelated reason. This is the machine-readable oracle the spec promises.
|
||||
#[test]
|
||||
fn project_envelope_validates_all_shared_fixtures() {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct FixtureFile {
|
||||
cases: Vec<Case>,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Case {
|
||||
name: String,
|
||||
expect: String,
|
||||
#[serde(default)]
|
||||
reject_rules: Vec<String>,
|
||||
template: Template,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Template {
|
||||
content: String,
|
||||
tags: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
let raw = include_str!("../../../../docs/nips/NIP-MP.fixtures.json");
|
||||
let file: FixtureFile = serde_json::from_str(raw).expect("fixture file must parse");
|
||||
|
||||
for case in &file.cases {
|
||||
let tag_strs: Vec<Vec<&str>> = case
|
||||
.template
|
||||
.tags
|
||||
.iter()
|
||||
.map(|t| t.iter().map(|s| s.as_str()).collect())
|
||||
.collect();
|
||||
let tag_refs: Vec<&[&str]> = tag_strs.iter().map(|t| t.as_slice()).collect();
|
||||
let ev = make_event_with_tags(KIND_PROJECT, &case.template.content, &tag_refs);
|
||||
let result = validate_project_envelope(&ev);
|
||||
match case.expect.as_str() {
|
||||
"accept" => assert!(
|
||||
result.is_ok(),
|
||||
"fixture {:?} expected accept, got err: {:?}",
|
||||
case.name,
|
||||
result.unwrap_err()
|
||||
),
|
||||
"reject" => {
|
||||
let rejection = match result {
|
||||
Err(r) => r,
|
||||
Ok(()) => {
|
||||
panic!("fixture {:?} expected reject, but was accepted", case.name)
|
||||
}
|
||||
};
|
||||
assert!(
|
||||
case.reject_rules.iter().any(|r| r == rejection.rule),
|
||||
"fixture {:?} fired rule {:?}, which is not in allowed set {:?}",
|
||||
case.name,
|
||||
rejection.rule,
|
||||
case.reject_rules,
|
||||
);
|
||||
}
|
||||
other => panic!(
|
||||
"unknown expect value {:?} in fixture {:?}",
|
||||
other, case.name
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── agent_turn_metric envelope tests ────────────────────────────────────
|
||||
|
||||
/// Build an event for kind:44200 with the given tags and content.
|
||||
|
||||
@@ -2167,9 +2167,18 @@ async fn handle_a_tag_deletion(
|
||||
};
|
||||
// Safe cast: NIP-33 kinds are 30000–39999, well within i32.
|
||||
let kind_i32 = k as i32;
|
||||
// NIP-09 scopes an a-tag deletion to versions at or before the
|
||||
// deletion's own created_at, so a stale/replayed tombstone can never
|
||||
// erase a newer replacement head.
|
||||
let deleted = state
|
||||
.db
|
||||
.soft_delete_by_coordinate(tenant.community(), kind_i32, &pubkey_bytes, d_tag)
|
||||
.soft_delete_by_coordinate(
|
||||
tenant.community(),
|
||||
kind_i32,
|
||||
&pubkey_bytes,
|
||||
d_tag,
|
||||
event.created_at.as_secs() as i64,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
//! End-to-end tests for kind:30621 multi-repo project events (NIP-MP).
|
||||
//!
|
||||
//! The ingest unit tests in `buzz-relay` pin the envelope contract in isolation.
|
||||
//! These tests cover the three behaviors that only exist once an event reaches
|
||||
//! storage, plus proof that the envelope validator is actually wired into the
|
||||
//! live write path:
|
||||
//! - a valid cross-owner project round-trips through its NIP-33 coordinate;
|
||||
//! - replacement is keyed by `(pubkey, 30621, d)` — newer wins for one author,
|
||||
//! and two authors sharing a `d` hold two independent projects (this is what
|
||||
//! makes owner-only editing free rather than a relay permission check);
|
||||
//! - a NIP-09 `a`-tag tombstone removes the project coordinate and leaves every
|
||||
//! referenced kind:30617 announcement untouched, because membership is an
|
||||
//! assertion about repositories and never authority over them;
|
||||
//! - malformed envelopes are refused by the relay, not merely by the validator.
|
||||
//!
|
||||
//! See `docs/nips/NIP-MP.md` for the normative contract.
|
||||
//!
|
||||
//! # Running
|
||||
//!
|
||||
//! Start the relay, then run:
|
||||
//!
|
||||
//! ```text
|
||||
//! RELAY_URL=ws://localhost:3000 cargo test -p buzz-test-client --test e2e_project -- --ignored
|
||||
//! ```
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use buzz_test_client::BuzzTestClient;
|
||||
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp};
|
||||
|
||||
const PROJECT_KIND: u16 = 30621;
|
||||
const REPO_ANNOUNCEMENT_KIND: u16 = 30617;
|
||||
|
||||
fn relay_url() -> String {
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
fn sub_id(name: &str) -> String {
|
||||
format!("e2e-project-{name}-{}", uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// A short unique suffix so concurrent runs never collide on a `d` tag.
|
||||
fn unique(prefix: &str) -> String {
|
||||
format!("{prefix}-{}", &uuid::Uuid::new_v4().to_string()[..8])
|
||||
}
|
||||
|
||||
fn member_coord(owner: &Keys, repo_d: &str) -> String {
|
||||
format!(
|
||||
"{REPO_ANNOUNCEMENT_KIND}:{}:{repo_d}",
|
||||
owner.public_key().to_hex()
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a project event. `members` are canonical `30617:<owner>:<d>`
|
||||
/// coordinates; `created_at` defaults to now when `None`.
|
||||
fn project_event(
|
||||
keys: &Keys,
|
||||
d_tag: &str,
|
||||
name: &str,
|
||||
members: &[String],
|
||||
created_at: Option<u64>,
|
||||
) -> nostr::Event {
|
||||
let mut tags = vec![
|
||||
Tag::parse(["d", d_tag]).unwrap(),
|
||||
Tag::parse(["name", name]).unwrap(),
|
||||
];
|
||||
tags.extend(
|
||||
members
|
||||
.iter()
|
||||
.map(|m| Tag::parse(["a", m.as_str()]).unwrap()),
|
||||
);
|
||||
let builder = EventBuilder::new(Kind::Custom(PROJECT_KIND), "").tags(tags);
|
||||
match created_at {
|
||||
Some(ts) => builder.custom_created_at(Timestamp::from(ts)),
|
||||
None => builder,
|
||||
}
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Announce a repository so a project has a real coordinate to reference.
|
||||
fn repo_announcement(keys: &Keys, repo_d: &str) -> nostr::Event {
|
||||
EventBuilder::new(Kind::Custom(REPO_ANNOUNCEMENT_KIND), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["d", repo_d]).unwrap(),
|
||||
Tag::parse(["name", repo_d]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A NIP-09 `a`-tag-only deletion at a NIP-33 coordinate. No `e` tag, so the
|
||||
/// relay takes the coordinate-delete path rather than the event-id path.
|
||||
/// `created_at` defaults to now when `None`.
|
||||
fn coordinate_delete(keys: &Keys, kind: u16, d_tag: &str, created_at: Option<u64>) -> nostr::Event {
|
||||
let coord = format!("{kind}:{}:{d_tag}", keys.public_key().to_hex());
|
||||
let builder =
|
||||
EventBuilder::new(Kind::Custom(5), "")
|
||||
.tags(vec![Tag::parse(["a", coord.as_str()]).unwrap()]);
|
||||
match created_at {
|
||||
Some(ts) => builder.custom_created_at(Timestamp::from(ts)),
|
||||
None => builder,
|
||||
}
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn addressable_filter(kind: u16, author: &Keys, d_tag: &str) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::Custom(kind))
|
||||
.author(author.public_key())
|
||||
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag])
|
||||
}
|
||||
|
||||
/// Subscribe with `filter` and drain to EOSE.
|
||||
async fn query(client: &mut BuzzTestClient, name: &str, filter: Filter) -> Vec<nostr::Event> {
|
||||
let sid = sub_id(name);
|
||||
client
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect events")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_project_publish_and_query_returns_cross_owner_members() {
|
||||
let url = relay_url();
|
||||
let owner = Keys::generate();
|
||||
let other = Keys::generate();
|
||||
let d_tag = unique("project");
|
||||
|
||||
let members = vec![
|
||||
member_coord(&owner, "buzz"),
|
||||
member_coord(&other, "buzz-infra"),
|
||||
];
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &owner)
|
||||
.await
|
||||
.expect("connect");
|
||||
|
||||
let event = project_event(&owner, &d_tag, "Platform", &members, None);
|
||||
let ok = client.send_event(event).await.expect("send project");
|
||||
assert!(ok.accepted, "relay rejected project event: {}", ok.message);
|
||||
|
||||
let events = query(
|
||||
&mut client,
|
||||
"query",
|
||||
addressable_filter(PROJECT_KIND, &owner, &d_tag),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(events.len(), 1, "expected exactly one project event");
|
||||
let stored: Vec<&str> = events[0]
|
||||
.tags
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
let parts = t.as_slice();
|
||||
(parts.first().map(|s| s.as_str()) == Some("a")).then(|| parts[1].as_str())
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
stored, members,
|
||||
"both members must survive the round trip, including the one owned by another pubkey"
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_project_replacement_keeps_only_newest_for_same_author_and_d() {
|
||||
let url = relay_url();
|
||||
let owner = Keys::generate();
|
||||
let d_tag = unique("project-replace");
|
||||
let now = Timestamp::now().as_secs();
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &owner)
|
||||
.await
|
||||
.expect("connect");
|
||||
|
||||
let first = project_event(&owner, &d_tag, "Old", &[], Some(now - 100));
|
||||
let ok = client.send_event(first).await.expect("send old");
|
||||
assert!(ok.accepted, "relay rejected old project: {}", ok.message);
|
||||
|
||||
let members = vec![member_coord(&owner, "buzz")];
|
||||
let second = project_event(&owner, &d_tag, "New", &members, Some(now));
|
||||
let ok = client.send_event(second).await.expect("send new");
|
||||
assert!(ok.accepted, "relay rejected new project: {}", ok.message);
|
||||
|
||||
let events = query(
|
||||
&mut client,
|
||||
"replace",
|
||||
addressable_filter(PROJECT_KIND, &owner, &d_tag),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
events.len(),
|
||||
1,
|
||||
"NIP-33: only the newest head should remain"
|
||||
);
|
||||
let name = events[0]
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|t| {
|
||||
let parts = t.as_slice();
|
||||
(parts.first().map(|s| s.as_str()) == Some("name")).then(|| parts[1].as_str())
|
||||
})
|
||||
.expect("name tag");
|
||||
assert_eq!(name, "New", "the newer head must win");
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// Owner-only editing is a property of the addressable model, not a relay
|
||||
/// permission check: two authors publishing the same `d` occupy two coordinates,
|
||||
/// so neither can overwrite the other. This is the test that would fail if the
|
||||
/// kind were ever classified as plain-replaceable or keyed on `d` alone.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_project_same_d_under_two_authors_are_independent() {
|
||||
let url = relay_url();
|
||||
let alice = Keys::generate();
|
||||
let bob = Keys::generate();
|
||||
let d_tag = unique("project-shared-d");
|
||||
|
||||
let mut alice_client = BuzzTestClient::connect(&url, &alice)
|
||||
.await
|
||||
.expect("connect");
|
||||
let ok = alice_client
|
||||
.send_event(project_event(&alice, &d_tag, "Alice", &[], None))
|
||||
.await
|
||||
.expect("send alice");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"relay rejected alice's project: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
let mut bob_client = BuzzTestClient::connect(&url, &bob).await.expect("connect");
|
||||
let ok = bob_client
|
||||
.send_event(project_event(&bob, &d_tag, "Bob", &[], None))
|
||||
.await
|
||||
.expect("send bob");
|
||||
assert!(ok.accepted, "relay rejected bob's project: {}", ok.message);
|
||||
|
||||
for (label, keys, expected_name) in [("alice", &alice, "Alice"), ("bob", &bob, "Bob")] {
|
||||
let events = query(
|
||||
&mut alice_client,
|
||||
label,
|
||||
addressable_filter(PROJECT_KIND, keys, &d_tag),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
events.len(),
|
||||
1,
|
||||
"{label} should still hold their own project at the shared `d`"
|
||||
);
|
||||
let name = events[0]
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|t| {
|
||||
let parts = t.as_slice();
|
||||
(parts.first().map(|s| s.as_str()) == Some("name")).then(|| parts[1].as_str())
|
||||
})
|
||||
.expect("name tag");
|
||||
assert_eq!(name, expected_name, "{label}'s project was overwritten");
|
||||
}
|
||||
|
||||
alice_client.disconnect().await.expect("disconnect");
|
||||
bob_client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// Deleting a project must delete only the grouping. A project is metadata about
|
||||
/// repositories; if a tombstone at the project coordinate cascaded to the
|
||||
/// referenced kind:30617s, adding a repo to someone's project would become a way
|
||||
/// to destroy it.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_project_tombstone_deletes_coordinate_and_spares_members() {
|
||||
let url = relay_url();
|
||||
let owner = Keys::generate();
|
||||
let repo_d = unique("repo");
|
||||
let project_d = unique("project-tombstone");
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &owner)
|
||||
.await
|
||||
.expect("connect");
|
||||
|
||||
let ok = client
|
||||
.send_event(repo_announcement(&owner, &repo_d))
|
||||
.await
|
||||
.expect("send announcement");
|
||||
assert!(ok.accepted, "relay rejected announcement: {}", ok.message);
|
||||
|
||||
let members = vec![member_coord(&owner, &repo_d)];
|
||||
let ok = client
|
||||
.send_event(project_event(&owner, &project_d, "Doomed", &members, None))
|
||||
.await
|
||||
.expect("send project");
|
||||
assert!(ok.accepted, "relay rejected project: {}", ok.message);
|
||||
|
||||
let before = query(
|
||||
&mut client,
|
||||
"tombstone-pre",
|
||||
addressable_filter(PROJECT_KIND, &owner, &project_d),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(before.len(), 1, "project should be live before deletion");
|
||||
|
||||
let ok = client
|
||||
.send_event(coordinate_delete(&owner, PROJECT_KIND, &project_d, None))
|
||||
.await
|
||||
.expect("send tombstone");
|
||||
assert!(ok.accepted, "relay rejected tombstone: {}", ok.message);
|
||||
|
||||
let after = query(
|
||||
&mut client,
|
||||
"tombstone-post",
|
||||
addressable_filter(PROJECT_KIND, &owner, &project_d),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
after.is_empty(),
|
||||
"tombstone should remove the project coordinate, got {} event(s)",
|
||||
after.len()
|
||||
);
|
||||
|
||||
let repo = query(
|
||||
&mut client,
|
||||
"member-after",
|
||||
addressable_filter(REPO_ANNOUNCEMENT_KIND, &owner, &repo_d),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
repo.len(),
|
||||
1,
|
||||
"deleting a project must not touch the repositories it referenced"
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// NIP-09 scopes an `a`-tag deletion to versions at or before the deletion's own
|
||||
/// `created_at`. A tombstone signed between V1 and V2 — delayed in transit or
|
||||
/// replayed by a third party — must therefore retire V1 only and leave the newer
|
||||
/// V2 head live. Before the timestamp predicate landed in
|
||||
/// `soft_delete_by_coordinate`, the coordinate delete was timestamp-blind and
|
||||
/// this sequence silently destroyed V2.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_stale_tombstone_between_versions_leaves_newer_project_live() {
|
||||
let url = relay_url();
|
||||
let owner = Keys::generate();
|
||||
let project_d = unique("project-stale-tombstone");
|
||||
let now = Timestamp::now().as_secs();
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &owner)
|
||||
.await
|
||||
.expect("connect");
|
||||
|
||||
let ok = client
|
||||
.send_event(project_event(
|
||||
&owner,
|
||||
&project_d,
|
||||
"V1",
|
||||
&[],
|
||||
Some(now - 100),
|
||||
))
|
||||
.await
|
||||
.expect("send v1");
|
||||
assert!(ok.accepted, "relay rejected V1: {}", ok.message);
|
||||
|
||||
let ok = client
|
||||
.send_event(project_event(&owner, &project_d, "V2", &[], Some(now)))
|
||||
.await
|
||||
.expect("send v2");
|
||||
assert!(ok.accepted, "relay rejected V2: {}", ok.message);
|
||||
|
||||
// Timestamped strictly between V1 and V2: valid for V1, stale for V2.
|
||||
let ok = client
|
||||
.send_event(coordinate_delete(
|
||||
&owner,
|
||||
PROJECT_KIND,
|
||||
&project_d,
|
||||
Some(now - 50),
|
||||
))
|
||||
.await
|
||||
.expect("send stale tombstone");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"a well-formed tombstone is still an acceptable event: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
let after = query(
|
||||
&mut client,
|
||||
"stale-tombstone",
|
||||
addressable_filter(PROJECT_KIND, &owner, &project_d),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
after.len(),
|
||||
1,
|
||||
"a tombstone older than the live head must not delete it, got {} event(s)",
|
||||
after.len()
|
||||
);
|
||||
let name = after[0]
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|t| {
|
||||
let parts = t.as_slice();
|
||||
(parts.first().map(|s| s.as_str()) == Some("name")).then(|| parts[1].as_str())
|
||||
})
|
||||
.expect("surviving head must carry its name tag");
|
||||
assert_eq!(name, "V2", "the surviving head must be the newer version");
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// Proves the envelope validator is reachable from the live write path — a unit
|
||||
/// test of `validate_project_envelope` cannot show that ingest calls it.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_project_malformed_envelope_rejected_by_relay() {
|
||||
let url = relay_url();
|
||||
let owner = Keys::generate();
|
||||
let mut client = BuzzTestClient::connect(&url, &owner)
|
||||
.await
|
||||
.expect("connect");
|
||||
|
||||
let duplicate = member_coord(&owner, "buzz");
|
||||
// Each case pairs a malformed event with the substring its rejection must
|
||||
// carry, so a refusal for an unrelated reason cannot satisfy the assertion.
|
||||
let cases: Vec<(&str, nostr::Event, &str)> = vec![
|
||||
(
|
||||
"duplicate member coordinate",
|
||||
project_event(
|
||||
&owner,
|
||||
&unique("project-dup"),
|
||||
"Dup",
|
||||
&[duplicate.clone(), duplicate],
|
||||
None,
|
||||
),
|
||||
"duplicate member coordinate",
|
||||
),
|
||||
(
|
||||
"member coordinate naming the wrong kind",
|
||||
project_event(
|
||||
&owner,
|
||||
&unique("project-badkind"),
|
||||
"Bad kind",
|
||||
&[format!("30618:{}:buzz", owner.public_key().to_hex())],
|
||||
None,
|
||||
),
|
||||
"member `a` tag must be",
|
||||
),
|
||||
(
|
||||
"member coordinate with an uppercase-hex owner",
|
||||
project_event(
|
||||
&owner,
|
||||
&unique("project-upper"),
|
||||
"Uppercase",
|
||||
&[format!("{REPO_ANNOUNCEMENT_KIND}:{}:buzz", "A".repeat(64))],
|
||||
None,
|
||||
),
|
||||
"member `a` tag must be",
|
||||
),
|
||||
];
|
||||
|
||||
for (label, event, expected) in cases {
|
||||
let ok = client.send_event(event).await.expect("send");
|
||||
assert!(
|
||||
!ok.accepted,
|
||||
"relay must reject a project with a {label}, got OK: {}",
|
||||
ok.message
|
||||
);
|
||||
assert!(
|
||||
ok.message.contains(expected),
|
||||
"rejection for {label} must name the rule that fired, got: {}",
|
||||
ok.message
|
||||
);
|
||||
}
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
+2
-2
@@ -181,7 +181,7 @@ A relay accepting `kind:30621` MUST validate the envelope at ingest. The rule na
|
||||
|
||||
Rules 3 through 6 are evaluated in that order, so an oversized tag list is refused on count before any per-tag parse or set proportional to it is built.
|
||||
|
||||
Three checks land in the Buzz validator together with the fixture wiring that exercises them: the `buzz-channel` and `buzz-visibility` bounds in rule 8, and rule 4's arity. The validator bounds `name` and `description` today, and reads element 1 of each member `a` tag while ignoring any element past it.
|
||||
The Buzz validator enforces all eight rules. The shared fixtures in [`NIP-MP.fixtures.json`](NIP-MP.fixtures.json) are wired as its test oracle: the relay's unit test suite runs every case against `validate_project_envelope` and asserts each `expect` outcome.
|
||||
|
||||
**Duplicates are rejected, never normalized.** A relay cannot dedupe tags inside a signed event: rewriting the tag array changes the event id and invalidates the signature. The choices are reject, or accept and require every present and future consumer to apply a first-wins interpretation rule. Rejecting keeps every stored head canonical and spares all consumers a defensive parse.
|
||||
|
||||
@@ -297,7 +297,7 @@ Legacy `<owner>:<dtag>` repository routes remain valid and resolve to that repos
|
||||
|
||||
## Conformance Fixtures
|
||||
|
||||
Two fixture files carry the machine-checkable contract. Neither has consumers yet; each states what its consumers are required to do.
|
||||
Two fixture files carry the machine-checkable contract. `NIP-MP.fixtures.json` is already wired as the relay ingest consumer; the remaining consumers listed below are Phase 2 work.
|
||||
|
||||
### Ingest
|
||||
|
||||
|
||||
Reference in New Issue
Block a user