add guestbook and moderation

This commit is contained in:
2026-09-17 07:30:38 +07:00
parent d1b83fdc33
commit ea9abae554
7 changed files with 1196 additions and 58 deletions
+88 -26
View File
@@ -389,40 +389,91 @@ pub struct Edition<'a> { subkind: &'a str, entity: [u8; 32], content: &'a str,
pub struct ControlWriter { pub author: PublicKey, pub read: GroupKey, pub signer: GroupKey } pub struct ControlWriter { pub author: PublicKey, pub read: GroupKey, pub signer: GroupKey }
impl ControlWriter { impl ControlWriter {
pub fn publish(&self, keys: &Keys, edition: Edition<'_>, at_secs: u64) -> Result<(Event, EntityHead)>; pub fn publish(&self, keys: &Keys, edition: Edition<'_>, at_secs: u64) -> Result<(Event, EntityHead)>;
pub fn set_community_metadata(&self, keys, community_id, metadata, head, at_secs) -> Result<(Event, EntityHead)>; pub fn set_community_metadata(&self, keys, community_id, metadata, head,
pub fn set_channel_metadata(&self, keys, channel, metadata, head, at_secs) -> Result<(Event, EntityHead)>; citation: Option<AuthorityCitation>, at_secs) -> Result<(Event, EntityHead)>;
pub fn set_channel_metadata(&self, keys, channel, metadata, head, citation, at_secs) -> Result<(Event, EntityHead)>;
pub fn set_role(&self, keys, role: &Role, head, citation, at_secs) -> Result<(Event, EntityHead)>;
pub fn set_grant(&self, keys, community_id, grant: &Grant, head, citation, at_secs) -> Result<(Event, EntityHead)>;
pub fn set_banlist(&self, keys, community_id, banned: &BTreeSet<PublicKey>, head, citation, at_secs)
-> Result<(Event, EntityHead)>;
} }
``` ```
`keys` is the acting member's own signer: the seal carries their signature, while the wrap is signed by the plane's published `control_pk`. Roles, grants and banlists ride the same `publish`, and their wrappers land with the moderation API (M5). A remote signer (NIP-46) is not yet plumbed — `publish` takes `&Keys`, not a `NostrSigner`. `keys` is the acting member's own signer: the seal carries their signature, while the wrap is signed by the plane's published `control_pk`. **Every wrapper takes the citation** (M5): a delegated admin edits metadata, roles, grants and the banlist only under a `vac`, so a wrapper that hardcoded `None` would be an owner-only API. Only `publish` is usable without one. A remote signer (NIP-46) is not yet plumbed — every entry point takes `&Keys`, not a `NostrSigner`.
Channel keying follows CORD-03 §1: a public channel derives from `community_root` at the base epoch, a private one from its own random key at its own epoch. Public→private is a rekey at the next channel epoch (monotonic, never reset); private→public derives from the base again and the prior private history stays unreadable to later joiners. The store applies only the public declaration and the deletion: the public-to-private flip is ignored until the convert flow (key mint plus cursor rebase) lands, and a channel this client holds no key for is not added at all — it arrives with the invite that carries the key. Channel keying follows CORD-03 §1: a public channel derives from `community_root` at the base epoch, a private one from its own random key at its own epoch. Public→private is a rekey at the next channel epoch (monotonic, never reset); private→public derives from the base again and the prior private history stays unreadable to later joiners. The store applies only the public declaration and the deletion: the public-to-private flip is ignored until the convert flow (key mint plus cursor rebase) lands, and a channel this client holds no key for is not added at all — it arrives with the invite that carries the key.
### 8.3 Guestbook and member list (`guestbook.rs`) ### 8.3 Guestbook and member list (`guestbook.rs`) — implemented in M5
```rust ```rust
pub const KIND_JOIN_LEAVE: u16 = 3306;
pub const KIND_KICK: u16 = 3309;
pub const KIND_SNAPSHOT: u16 = 3312;
pub const MAX_SNAPSHOT_CHUNK: usize = 400;
pub const MAX_FUTURE_SKEW_MS: u64 = 60 * 60 * 1000;
pub enum GuestbookEntry { pub enum GuestbookEntry {
Join { member: PublicKey, at_ms: u64, invited_by: Option<(String, String)> }, Join { member: PublicKey, at_ms: u64, invited_by: Option<(String, String)> },
Leave { member: PublicKey, at_ms: u64 }, Leave { member: PublicKey, at_ms: u64 },
Kick { actor: PublicKey, target: PublicKey, at_ms: u64, citation: Option<AuthorityCitation> }, Kick { actor: PublicKey, target: PublicKey, at_ms: u64, citation: Option<AuthorityCitation> },
Snapshot { refounder: PublicKey, members: Vec<PublicKey>, snapshot_id: [u8; 32], chunk: (u32, u32), at_ms: u64 }, Snapshot { refounder: PublicKey, members: Vec<PublicKey>, snapshot_id: [u8; 32], chunk: (u32, u32), at_ms: u64 },
} }
pub struct GuestbookRumor { pub id: EventId, pub author: PublicKey, pub kind: Kind, pub at_ms: u64,
pub entry: GuestbookEntry }
pub enum MemberState {
Joined { at_ms: u64, invited_by: Option<(String, String)> },
Left { at_ms: u64 },
Kicked { at_ms: u64, actor: PublicKey },
}
pub fn coalesce(events: &[GuestbookEvent], now_ms: u64, snapshot_authority: Option<&PublicKey>, pub fn build_join(member: PublicKey, invited_by: Option<(&str, &str)>, at_ms: u64) -> UnsignedEvent;
pub fn build_leave(member: PublicKey, at_ms: u64) -> UnsignedEvent;
pub fn build_kick(actor, target: &PublicKey, citation: Option<&AuthorityCitation>, at_ms) -> UnsignedEvent;
pub fn build_snapshot_chunks(refounder, members: &[PublicKey], snapshot_id: [u8; 32], at_ms)
-> Vec<UnsignedEvent>;
pub fn seal_rumor(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys)
-> Result<(Event, Keys), GuestbookError>;
pub fn open(wrap: &Event, group: &GroupKey)
-> Result<(OpenedStream, GuestbookRumor), GuestbookError>;
pub fn coalesce(rumors: &[GuestbookRumor], now_ms: u64, snapshot_authority: Option<&PublicKey>,
can_kick: impl Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool) can_kick: impl Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool)
-> BTreeMap<PublicKey, MemberState>; -> BTreeMap<PublicKey, MemberState>;
pub fn complete_memberlist(coalesced: &BTreeMap<PublicKey, MemberState>, pub fn complete_memberlist(coalesced: &BTreeMap<PublicKey, MemberState>,
observed: &BTreeMap<PublicKey, u64>, // author → newest ms published observed: &BTreeMap<PublicKey, u64>, // author → newest ms published
banned: &BTreeSet<PublicKey>, banned_at: &BTreeMap<PublicKey, u64>, granted: &BTreeSet<PublicKey>,
refound: Option<&Refound>) -> BTreeSet<PublicKey>; banned: &BTreeSet<PublicKey>, banned_at: &BTreeMap<PublicKey, u64>)
-> BTreeSet<PublicKey>;
``` ```
- Entries dated more than an hour ahead of local time are dropped. An `ms` outside `0..999` drops the entry rather than being interpreted. - The plane is community-wide, and its key already carries the epoch, so a guestbook rumor binds no
`channel`/`epoch` tags and `GuestbookRumor` carries neither. Coalescing spans every held epoch, which
is what lets a snapshot bridge a Refounding.
- Entries dated more than an hour ahead of local time are dropped. That is a coalesce-time check (`now_ms`
is an argument, so it is testable without a clock); the `ms` range is not — `open` inherits
`open_wrap`'s strict resolution, so an `ms` outside `0..999` drops the entry at parse instead.
- Coalesce is per npub, one final state each, by millisecond time, ties on the lower inner rumor id. - Coalesce is per npub, one final state each, by millisecond time, ties on the lower inner rumor id.
- A Kick counts only when its signer holds `KICK` and outranks the target. - A Join or Leave is self-signed by construction: `member` comes from the rumor's own author, so there is
- A Snapshot counts only from the npub whose Refounding minted the epoch. There is deliberately no owner fallback. no wire field that could name somebody else.
- The member list is `coalesced Joined observed authors banlist`, and observation counts **forward only** (an author re-enters on activity newer than their latest Leave/Kick/Ban). A Grant holder with `ms == 0` is present by construction. - A Kick counts only where `can_kick(actor, target, citation)` admits it — the roster's `KICK` plus a
strictly higher rank plus a resolvable citation, composed by the caller. A Snapshot counts only from the
npub whose Refounding minted the epoch. There is deliberately no owner fallback.
- `invited_by` is an echo of the optional `invite` tag, never authority, so a malformed or repeated tag
costs the label and not the member's own word.
- `build_snapshot_chunks` is the only snapshot builder: 400 members per event, every chunk sharing one id
and one timestamp. A chunk is independently useful, so `coalesce` seeds each chunk's members at that
chunk's own time and never waits for its siblings.
- The member list is `coalesced Joined observed authors Grant holders banlist`. `granted` is the one
addition to the spec's literal formula: a Grant recipient holds keys, so they are a member even with no
Join and no published activity. It counts as an *unjdated* positive, so any dated Leave, Kick or Ban
beats it. Observation counts forward only: an author re-enters on activity newer than their latest
Leave, Kick or Ban.
- `banned_at` is caller-supplied, and an entry missing from it excludes its npub outright — an empty map
therefore means "every ban is terminal", which is the safe reading. The fold does not yet expose the
banlist head's timestamp, so that plumbing belongs with the registry rather than here.
- The earlier sketch's `refound: Option<&Refound>` argument is dropped: nothing mints a Refound until M7,
and a parameter no caller can fill is a guess. It returns with the refounding that produces one.
### 8.4 Chat plane (`chat.rs`) — implemented in M4 ### 8.4 Chat plane (`chat.rs`) — implemented in M4
@@ -436,7 +487,7 @@ pub enum ChatAction {
Message { reply_to: Option<ReplyRef>, thread_root: Option<ReplyRef> }, Message { reply_to: Option<ReplyRef>, thread_root: Option<ReplyRef> },
Reaction { target: EventId, emoji: String }, Reaction { target: EventId, emoji: String },
Edit { target: EventId, content: String }, Edit { target: EventId, content: String },
Delete { target: EventId, target_kind: Option<u16> }, Delete { target: EventId, target_kind: Option<u16>, citation: Option<AuthorityCitation> },
Typing, Typing,
Opaque, Opaque,
} }
@@ -447,7 +498,8 @@ pub fn build_message(author, channel, epoch, content, quote: Option<&ReplyRef>,
pub fn build_comment(author, channel, epoch, content, parent: &Target, root: Option<&Target>, at_ms) -> UnsignedEvent; pub fn build_comment(author, channel, epoch, content, parent: &Target, root: Option<&Target>, at_ms) -> UnsignedEvent;
pub fn build_reaction(author, channel, epoch, target: &Target, emoji: &str, at_ms) -> UnsignedEvent; pub fn build_reaction(author, channel, epoch, target: &Target, emoji: &str, at_ms) -> UnsignedEvent;
pub fn build_edit(author, channel, epoch, target: EventId, content: &str, at_ms) -> UnsignedEvent; pub fn build_edit(author, channel, epoch, target: EventId, content: &str, at_ms) -> UnsignedEvent;
pub fn build_delete(author, channel, epoch, target: EventId, target_kind: Option<u16>, at_ms) -> UnsignedEvent; pub fn build_delete(author, channel, epoch, target: EventId, target_kind: Option<u16>,
citation: Option<&AuthorityCitation>, at_ms) -> UnsignedEvent;
pub fn build_typing(author, channel, epoch, at_ms) -> UnsignedEvent; pub fn build_typing(author, channel, epoch, at_ms) -> UnsignedEvent;
pub fn seal_rumor(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, ephemeral: bool) pub fn seal_rumor(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, ephemeral: bool)
@@ -455,7 +507,9 @@ pub fn seal_rumor(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, epheme
pub fn open(wrap: &Event, group: &GroupKey, channel: &ChannelId, epoch: Epoch) pub fn open(wrap: &Event, group: &GroupKey, channel: &ChannelId, epoch: Epoch)
-> Result<(OpenedStream, ChatRumor), ChatError>; -> Result<(OpenedStream, ChatRumor), ChatError>;
pub fn plane_keys(held: &[(Epoch, [u8; 32])], channel: &ChannelId) -> Result<Vec<(Epoch, GroupKey)>>; pub fn plane_keys(held: &[(Epoch, [u8; 32])], channel: &ChannelId) -> Result<Vec<(Epoch, GroupKey)>>;
pub fn fold(rumors: &[ChatRumor]) -> Vec<ChatMessage>; pub fn fold(rumors: &[ChatRumor],
can_delete: impl Fn(&PublicKey, Option<&AuthorityCitation>, &PublicKey) -> bool)
-> Vec<ChatMessage>;
pub struct ChatMessage { pub struct ChatMessage {
pub id: EventId, pub id: EventId,
@@ -480,10 +534,12 @@ pub struct ChatMessage {
newest first; mutations replay ascending on `(at_ms, Reverse(id))` so the last one newest first; mutations replay ascending on `(at_ms, Reverse(id))` so the last one
applied wins — the highest `at_ms` and, between equal ones, the lower id. A deletion is applied wins — the highest `at_ms` and, between equal ones, the lower id. A deletion is
terminal: a later edit never revives it. terminal: a later edit never revives it.
- **M4 honors a delete only from the message's own author.** A moderator delete (a `vac` - **Since M5 a delete is honored from the message's own author unconditionally, or from anyone
citation under `MANAGE_MESSAGES`) needs the roster, so the builder's `citation`, the `can_delete` admits.** That predicate is `(actor, citation, target_author)`, composed by the caller
fold's `can_delete` predicate and its tests land with M5. Failing closed here loses a from the roster: a resolvable `vac` citation, `MANAGE_MESSAGES`, and a strictly higher rank than the
moderator's reach, never a member's authorship. author — the delete is the one Chat-plane authority action, which is why the builder takes a citation
and the fold takes a gate. A self-delete never consults the predicate, matching CORD-02 §9's carve-out
that a member's erasure of their own words survives even Dissolution.
- `kind 15` is coop's own file-message convention, outside the CORD registry — it is - `kind 15` is coop's own file-message convention, outside the CORD registry — it is
accepted on the read side so a second coop device's files are not dropped, and accepted on the read side so a second coop device's files are not dropped, and
`send_file` lands with the registry. `send_file` lands with the registry.
@@ -543,7 +599,7 @@ pub fn compact(fold, epoch, new_control_root, ...) -> Vec<Event>; // re-wrap h
Dissolution (CORD-02 §9) also lives here: a chainless, owner-signed `vsk 10` tombstone at `dissolved_group_key(id)`, plaintext-sealed, and a verifier **must** refuse any tombstone whose `eid` is not the community's own id (including the all-zero placeholder — accepting it lets an owner's genuine tombstone for one community be re-wrapped at another of theirs and kill it permanently). On sight the community is sealed read-only: subscriptions halt, nothing new is honored, existing history stays readable, and a member's delete of their own message is still honored. Dissolution (CORD-02 §9) also lives here: a chainless, owner-signed `vsk 10` tombstone at `dissolved_group_key(id)`, plaintext-sealed, and a verifier **must** refuse any tombstone whose `eid` is not the community's own id (including the all-zero placeholder — accepting it lets an owner's genuine tombstone for one community be re-wrapped at another of theirs and kill it permanently). On sight the community is sealed read-only: subscriptions halt, nothing new is honored, existing history stays readable, and a member's delete of their own message is still honored.
## 9. Storage (`store.rs`) — local layer implemented in M1, state document in M2, fold bridge in M3 ## 9. Storage (`store.rs`) — local layer implemented in M1, state document in M2, fold bridge in M3, banlist in M5
Three layers, no new storage engine: Three layers, no new storage engine:
@@ -587,11 +643,12 @@ pub struct CommunityState {
pub channels: Vec<ChannelKeyRef>, // id, name, private, epoch pub channels: Vec<ChannelKeyRef>, // id, name, private, epoch
pub relays: Vec<RelayUrl>, pub relays: Vec<RelayUrl>,
pub heads: Vec<EntityHead>, // entity, version, self_hash, inner id pub heads: Vec<EntityHead>, // entity, version, self_hash, inner id
pub banned: BTreeSet<PublicKey>, // the held banlist, fed back into the next fold
pub added_at_ms: u64, pub added_at_ms: u64,
} }
``` ```
Landed in M2 with exactly the fields genesis can populate: `save_state`/`load_state` and `CommunityState::from_genesis`. Three fields the plan sketched are deliberately absent until something can fill them — `epoch_keys` (needs rekeys, M7), and `guestbook`/`observed`/`banned`/`dissolved` (need the guestbook, M5). `control_pks` keyed by `u64` rather than `Epoch` and `heads` as a `Vec` rather than a `BTreeMap<[u8; 32], _>`, because serde_json cannot use a byte-array map key. Landed in M2 with exactly the fields genesis can populate: `save_state`/`load_state` and `CommunityState::from_genesis`. Three fields the plan sketched are deliberately absent until something can fill them — `epoch_keys` (needs rekeys, M7), `dissolved` (needs the tombstone, M7), and `observed`/`guestbook` (need the guestbook ingest of §10). `control_pks` keyed by `u64` rather than `Epoch` and `heads` as a `Vec` rather than a `BTreeMap<[u8; 32], _>`, because serde_json cannot use a byte-array map key. `banned` is a `BTreeSet` — serde has no such problem with a `Vec`-like sequence.
M3 added the two bridges between this document and the fold: M3 added the two bridges between this document and the fold:
@@ -602,7 +659,7 @@ impl CommunityState {
} }
``` ```
`apply_fold` merges channels rather than replacing them, so a locally-held key survives a metadata edit. `banned` is not yet persisted here: `fold_control` takes the held list as an argument and returns the folded one, and the field lands with the moderation API (M5) that first writes it. `apply_fold` merges channels rather than replacing them, so a locally-held key survives a metadata edit, and **assigns `banned` wholesale** (M5): the fold already retains a withheld list, so its output is the authority and a caller must not merge it by hand. `floors()` and `banned` are the two inputs the next `fold_control` call needs, which makes the state document a fold cache rather than a second source of truth.
Writes are debounced (a fold head changes on every edition); reads load once at init. Writes are debounced (a fold head changes on every edition); reads load once at init.
@@ -756,8 +813,9 @@ Each of these has burned a real implementation, or is a documented cross-client
- Enforce the NIP-44 65 535-byte cap at every layer before publishing, and the 5-relay / 256-channel / 50-membership / 100-roles / 64-roles-per-member / 500-banlist / 25-pin caps at their ingest and write points. - Enforce the NIP-44 65 535-byte cap at every layer before publishing, and the 5-relay / 256-channel / 50-membership / 100-roles / 64-roles-per-member / 500-banlist / 25-pin caps at their ingest and write points.
- Lowercase hex only; x-only pubkeys only; no version tag anywhere. - Lowercase hex only; x-only pubkeys only; no version tag anywhere.
- **Enforced in M3:** a Role's `role_id` is its own coordinate and never 0; a Grant's `member` hashes to its coordinate; a `vsk 4` sits at this community's banlist locator; a banned npub's editions are dropped and a grant naming them carries no rank; a revocation carries a citation; the 100-role cap keeps the lowest ids *after* authorization; a below-floor edition is never a candidate. - **Enforced in M3:** a Role's `role_id` is its own coordinate and never 0; a Grant's `member` hashes to its coordinate; a `vsk 4` sits at this community's banlist locator; a banned npub's editions are dropped and a grant naming them carries no rank; a revocation carries a citation; the 100-role cap keeps the lowest ids *after* authorization; a below-floor edition is never a candidate.
- **Enforced in M4:** the chat plane's encrypted-seal requirement, at both publish and open; `channel` **and** `epoch` strict-equal to the plane whose key opened the wrap; a retired or unregistered rumor kind rejected on both sides; a target bearing tag that appears twice rejected outright; and a delete honored only from the message's own author. - **Enforced in M4:** the chat plane's encrypted-seal requirement, at both publish and open; `channel` **and** `epoch` strict-equal to the plane whose key opened the wrap; a retired or unregistered rumor kind rejected on both sides; a target bearing tag that appears twice rejected outright; and a delete from anybody other than the message's own author refused by the caller's gate.
- Still owed to M8's audit: the byte caps, the 256-channel and 25-pin caps, the guestbook's future-clock and Snapshot rules, and the write-side counterparts. - **Enforced in M5:** a Kick counts only where the roster admits its actor under `KICK` with a strictly higher rank and a resolvable citation; a Snapshot counts only from the epoch's refounder, with no owner fallback; a guestbook entry more than an hour ahead is dropped, and an out-of-range `ms` or a non-verb `3306` entry is malformed, not interpreted; a duplicated `vac` is rejected outright; and a delete is honored from its target's author unconditionally, or from another actor only where `can_delete` admits them.
- Still owed to M8's audit: the byte caps, the 256-channel and 25-pin caps, and the write-side counterparts of the caps the folds already apply.
## 13. Milestones ## 13. Milestones
@@ -768,7 +826,7 @@ Each of these has burned a real implementation, or is a documented cross-client
| M2 | `edition.rs` + `control.rs` genesis + `store.rs` state document | ✅ `cargo test -p concord` (7 tests): `edition_hash` reproduces the cross-client vector `2daf42e6…`, and a community minted by one holder has both genesis wraps open for a second holder holding only the invite keys, folding to version 1 | | M2 | `edition.rs` + `control.rs` genesis + `store.rs` state document | ✅ `cargo test -p concord` (7 tests): `edition_hash` reproduces the cross-client vector `2daf42e6…`, and a community minted by one holder has both genesis wraps open for a second holder holding only the invite keys, folding to version 1 |
| M3 | Control fold + roster + metadata/channels | ✅ `cargo test -p concord` (14 tests): the chain fold, its gaps, fork tiebreak, downgrade refusal and compaction dangle are pinned; the delegation fixpoint resolves outward from the owner and refuses escalation, an unauthorized higher version, rank inversion by republish and an uncited revoke; a community minted by one holder has its metadata and channel edits fold for a second holder from the invite keys alone | | M3 | Control fold + roster + metadata/channels | ✅ `cargo test -p concord` (14 tests): the chain fold, its gaps, fork tiebreak, downgrade refusal and compaction dangle are pinned; the delegation fixpoint resolves outward from the owner and refuses escalation, an unauthorized higher version, rank inversion by republish and an uncited revoke; a community minted by one holder has its metadata and channel edits fold for a second holder from the invite keys alone |
| M4 | Chat plane | ✅ `cargo test -p concord` (19 tests): a second holder folds a message's reactions, its author's edit and its author's delete, and ignores an edit or a delete from anybody else; a comment's root and parent survive the wire; a foreign channel, a replayed epoch, a plaintext seal, a retired kind and a duplicated target are each rejected; and history pages backwards across a rekey in order | | M4 | Chat plane | ✅ `cargo test -p concord` (19 tests): a second holder folds a message's reactions, its author's edit and its author's delete, and ignores an edit or a delete from anybody else; a comment's root and parent survive the wire; a foreign channel, a replayed epoch, a plaintext seal, a retired kind and a duplicated target are each rejected; and history pages backwards across a rekey in order |
| M5 | Guestbook + member list + moderation | join/leave/kick/ban converge to the same memberlist on both clients; every authority rule from §8.1 has a negative test | | M5 | Guestbook + member list + moderation |`cargo test -p concord` (23 tests): a second holder folds joins, leaves, a cited kick and a chunked snapshot into one memberlist in either arrival order, with a ban and a Grant deciding the edges; an uncited, unranked or owner-directed kick and a foreign snapshot are dropped; a future-dated entry, a malformed `ms`, a non-verb `3306`, a duplicated `vac` and a bad snapshot chunk are each refused; and a moderator delete lands only under a citation the roster admits, while a self-delete never asks |
| M6 | Invites + Community List | link mint → fetch → join round-trips; revoked link refuses; direct invite lands in the recipient's giftwrap inbox via the `k` tag; a second device reconstructs membership from 13302 | | M6 | Invites + Community List | link mint → fetch → join round-trips; revoked link refuses; direct invite lands in the recipient's giftwrap inbox via the `k` tag; a second device reconstructs membership from 13302 |
| M7 | Rekeys + refounding + dissolution | a removed member stops reading after a rekey; continuity and race rules tested; a tombstone seals the community and a foreign-id tombstone is refused | | M7 | Rekeys + refounding + dissolution | a removed member stops reading after a rekey; continuity and race rules tested; a tombstone seals the community and a foreign-id tombstone is refused |
| M8 | Pins + disappearing messages + hardening | pins verify from a keyless reader's view; expiry is refused at ingest and purged by the sweep; the audit of §12 is complete with a test per bullet | | M8 | Pins + disappearing messages + hardening | pins verify from a keyless reader's view; expiry is refused at ingest and purged by the sweep; the audit of §12 is complete with a test per bullet |
@@ -783,9 +841,13 @@ M3 closed at 14 tests with no dependency change at all, and `Cargo.lock` untouch
M4 closed at 19 tests, again with no dependency change and `Cargo.lock` untouched — relay paging is `Client::fetch_events` over the same `NostrDatabase` the cache already used, so nothing new was needed. New: `src/chat.rs` (the whole channel plane) and, in `src/store.rs`, `backfill` plus the pure `advance` page step it is built from, which is what the paging test drives instead of a socket. `edition::canonical_decimal` became `pub(crate)` so the chat tag grammar shares one decimal check. M4 closed at 19 tests, again with no dependency change and `Cargo.lock` untouched — relay paging is `Client::fetch_events` over the same `NostrDatabase` the cache already used, so nothing new was needed. New: `src/chat.rs` (the whole channel plane) and, in `src/store.rs`, `backfill` plus the pure `advance` page step it is built from, which is what the paging test drives instead of a socket. `edition::canonical_decimal` became `pub(crate)` so the chat tag grammar shares one decimal check.
What M3 still defers, and to what: the **sync engine's paging** driven by `ControlFold.gapped` and the **`chat::handle_notifications` routing fix** (both §10, together with the `concord::init` wiring — no concord wrap can reach that handler until the subscription exists); the **persisted banlist** and `CommunityState.banned` (M5, with the moderation API that writes it); the **role/grant/banlist write wrappers** (M5 — `ControlWriter::publish` already carries them, only the convenience surface is pending); and the **NIP-46 remote signer**, since `publish` takes `&Keys` rather than a `NostrSigner`. What M3 still defers, and to what: the **sync engine's paging** driven by `ControlFold.gapped` and the **`chat::handle_notifications` routing fix** (both §10, together with the `concord::init` wiring — no concord wrap can reach that handler until the subscription exists); and the **NIP-46 remote signer**, since every writer takes `&Keys` rather than a `NostrSigner`. Its persisted banlist and its role/grant/banlist write wrappers both landed in M5.
What M4 still defers, and to what: **moderator deletes and the `can_delete` predicate** (M5 — M4 honors a delete only from the message's own author, so a moderator's reach is missing rather than forged); **`media`/`mentions`** on `ChatMessage` and **`send_file`** (the registry/UI milestone — the first needs a gpui type and the second needs the blob-upload path); and **the timer's policy** under the `expiration` tag that `seal_rumor` already mirrors (M8). What M4 still defers, and to what: **`media`/`mentions`** on `ChatMessage` and **`send_file`** (the registry/UI milestone — the first needs a gpui type and the second needs the blob-upload path); and **the timer's policy** under the `expiration` tag that `seal_rumor` already mirrors (M8). Its moderator delete landed in M5.
M5 closed at 23 tests, again with no dependency change and `Cargo.lock` untouched. New: `src/guestbook.rs` (the whole membership plane — three rumor codecs, the coalesce, the memberlist), a `vac` citation on a kind-5 delete plus the fold's `can_delete` gate, `ControlWriter::set_role`/`set_grant`/`set_banlist`, and `CommunityState.banned`. Two shared helpers moved into `edition.rs``citation_tag` and `citation_from` — so the control, chat and guestbook grammars parse one `vac`. Every metadata/role/grant/banlist wrapper now takes its citation, which is a fix rather than an addition: M3's wrappers hardcoded `None` and so were owner-only, which the delegated-metadata test had been working around with `publish`.
What M5 still defers, and to what: the **guestbook's fetch and ingest path**`coalesce` is fed wraps by its caller, so the plane has no `backfill` twin of `chat::backfill` until §10's sync engine needs one, and no `CommunityState.observed` to persist what it would learn; the **banlist head's timestamp** that `complete_memberlist`'s `banned_at` wants (the fold does not surface it, so an empty map means "every ban is terminal" until the registry plumbs it); and the **`Refound` seed** argument to `complete_memberlist`, which waits for M7 to mint one.
**M2's "created and published" is verified offline**: "published" is the two wraps existing and being openable by the invite keys, not a relay round-trip. There is no registry to publish through until §10, and a relay test would be testing the SDK, not the protocol. **M2's "created and published" is verified offline**: "published" is the two wraps existing and being openable by the invite keys, not a relay round-trip. There is no registry to publish through until §10, and a relay test would be testing the SDK, not the protocol.
+106 -13
View File
@@ -6,7 +6,9 @@ use anyhow::Result;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use crate::derive::channel_group_key; use crate::derive::channel_group_key;
use crate::edition::canonical_decimal; use crate::edition::{
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
};
use crate::stream::{ use crate::stream::{
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, StreamError, build_rumor_ms, KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, StreamError, build_rumor_ms,
build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict, build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict,
@@ -96,6 +98,7 @@ pub enum ChatAction {
Delete { Delete {
target: EventId, target: EventId,
target_kind: Option<u16>, target_kind: Option<u16>,
citation: Option<AuthorityCitation>,
}, },
Typing, Typing,
Opaque, Opaque,
@@ -217,6 +220,7 @@ pub fn build_delete(
epoch: Epoch, epoch: Epoch,
target: EventId, target: EventId,
target_kind: Option<u16>, target_kind: Option<u16>,
citation: Option<&AuthorityCitation>,
at_ms: u64, at_ms: u64,
) -> UnsignedEvent { ) -> UnsignedEvent {
let mut tags = channel_binding_tags(channel, epoch); let mut tags = channel_binding_tags(channel, epoch);
@@ -226,6 +230,10 @@ pub fn build_delete(
tags.push(Tag::custom(TAG_TARGET_KIND, [target_kind.to_string()])); tags.push(Tag::custom(TAG_TARGET_KIND, [target_kind.to_string()]));
} }
if let Some(citation) = citation {
tags.push(citation_tag(citation));
}
build_rumor_ms(KIND_DELETE, author, "", tags, at_ms) build_rumor_ms(KIND_DELETE, author, "", tags, at_ms)
} }
@@ -317,10 +325,10 @@ pub fn plane_keys(
.collect() .collect()
} }
/// Folds the chat plane into timeline rows, newest first. A delete is honored pub fn fold(
/// only from the message's own author, and a deletion is terminal: an edit or a rumors: &[ChatRumor],
/// reaction arriving later never revives it. can_delete: impl Fn(&PublicKey, Option<&AuthorityCitation>, &PublicKey) -> bool,
pub fn fold(rumors: &[ChatRumor]) -> Vec<ChatMessage> { ) -> Vec<ChatMessage> {
let mut order: Vec<usize> = (0..rumors.len()).collect(); let mut order: Vec<usize> = (0..rumors.len()).collect();
order.sort_by_key(|&index| (rumors[index].at_ms, rumors[index].id)); order.sort_by_key(|&index| (rumors[index].at_ms, rumors[index].id));
@@ -356,8 +364,6 @@ pub fn fold(rumors: &[ChatRumor]) -> Vec<ChatMessage> {
}); });
} }
// Mutations replay so the last one applied is the winner: the highest
// `at_ms` and, between equal ones, the lower inner rumor id.
let mut mutations: Vec<usize> = (0..rumors.len()).collect(); let mut mutations: Vec<usize> = (0..rumors.len()).collect();
mutations.sort_by_key(|&index| (rumors[index].at_ms, Reverse(rumors[index].id))); mutations.sort_by_key(|&index| (rumors[index].at_ms, Reverse(rumors[index].id)));
@@ -378,12 +384,16 @@ pub fn fold(rumors: &[ChatRumor]) -> Vec<ChatMessage> {
message.content = content.clone(); message.content = content.clone();
message.edited_at = Some(rumor.at_ms); message.edited_at = Some(rumor.at_ms);
} }
ChatAction::Delete { target, .. } => { ChatAction::Delete {
target, citation, ..
} => {
let Some(&slot) = slot.get(target) else { let Some(&slot) = slot.get(target) else {
continue; continue;
}; };
if messages[slot].author == rumor.author { let author = messages[slot].author;
if author == rumor.author || can_delete(&rumor.author, citation.as_ref(), &author) {
messages[slot].deleted = true; messages[slot].deleted = true;
} }
} }
@@ -454,6 +464,7 @@ fn action_of(rumor: &UnsignedEvent) -> Result<ChatAction, ChatError> {
KIND_DELETE => Ok(ChatAction::Delete { KIND_DELETE => Ok(ChatAction::Delete {
target: required_id(rumor, TAG_TARGET)?, target: required_id(rumor, TAG_TARGET)?,
target_kind: optional_kind(rumor, TAG_TARGET_KIND)?, target_kind: optional_kind(rumor, TAG_TARGET_KIND)?,
citation: optional_citation(rumor)?,
}), }),
KIND_TYPING => Ok(ChatAction::Typing), KIND_TYPING => Ok(ChatAction::Typing),
KIND_WEBXDC => Ok(ChatAction::Opaque), KIND_WEBXDC => Ok(ChatAction::Opaque),
@@ -469,8 +480,7 @@ fn optional_reply(
return Ok(None); return Ok(None);
}; };
// NIP-C7 `q` and NIP-22 `E`/`e` put a relay hint at index 2 and the // NIP-C7 `q` and NIP-22 `E`/`e` put a relay hint at index 2 and the referenced author at index 3.
// referenced author at index 3, which is a SHOULD, so absent reads as unknown.
let author = match fields.get(3).map(String::as_str) { let author = match fields.get(3).map(String::as_str) {
Some(hex) if !hex.is_empty() => Some(pubkey(hex, name)?), Some(hex) if !hex.is_empty() => Some(pubkey(hex, name)?),
_ => None, _ => None,
@@ -500,6 +510,16 @@ fn optional_kind(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<u16
.map_err(|_| ChatError::BadTag(name)) .map_err(|_| ChatError::BadTag(name))
} }
fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, ChatError> {
let Some(fields) = tag(rumor, TAG_CITATION)? else {
return Ok(None);
};
citation_from(fields)
.map(Some)
.ok_or(ChatError::BadTag(TAG_CITATION))
}
fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError> { fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError> {
let Some(fields) = tag(rumor, TAG_EXPIRATION)? else { let Some(fields) = tag(rumor, TAG_EXPIRATION)? else {
return Ok(None); return Ok(None);
@@ -646,6 +666,7 @@ mod tests {
Epoch(0), Epoch(0),
id, id,
Some(KIND_MESSAGE), Some(KIND_MESSAGE),
None,
AT + 3_000, AT + 3_000,
), ),
&group, &group,
@@ -654,7 +675,7 @@ mod tests {
), ),
]; ];
let folded = fold(&rumors); let folded = fold(&rumors, |_, _, _| false);
assert_eq!(folded.len(), 1); assert_eq!(folded.len(), 1);
assert_eq!(folded[0].id, id); assert_eq!(folded[0].id, id);
@@ -698,6 +719,7 @@ mod tests {
Epoch(0), Epoch(0),
id, id,
Some(KIND_MESSAGE), Some(KIND_MESSAGE),
None,
AT + 2_000, AT + 2_000,
), ),
&group, &group,
@@ -706,7 +728,7 @@ mod tests {
), ),
]; ];
let folded = fold(&rumors); let folded = fold(&rumors, |_, _, _| false);
assert_eq!(folded.len(), 1); assert_eq!(folded.len(), 1);
assert_eq!(folded[0].content, "hello"); assert_eq!(folded[0].content, "hello");
@@ -851,4 +873,75 @@ mod tests {
Err(ChatError::DuplicateTag(TAG_TARGET)) Err(ChatError::DuplicateTag(TAG_TARGET))
)); ));
} }
#[test]
fn a_moderator_delete_needs_the_roster_and_a_citation() {
let alice = Keys::generate();
let moderator = Keys::generate();
let peer = Keys::generate();
let group = group();
let message = read(
&build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT),
&group,
&alice,
Epoch(0),
);
let id = message.id;
let citation = AuthorityCitation {
entity: [0x33; 32],
version: 1,
hash: [0x44; 32],
};
let delete = |author: &Keys, citation: Option<&AuthorityCitation>| {
read(
&build_delete(
author.public_key(),
&channel(),
Epoch(0),
id,
Some(KIND_MESSAGE),
citation,
AT + 1_000,
),
&group,
author,
Epoch(0),
)
};
let can_delete =
|actor: &PublicKey, citation: Option<&AuthorityCitation>, author: &PublicKey| {
actor != author && citation.is_some() && actor == &moderator.public_key()
};
let cited = vec![message.clone(), delete(&moderator, Some(&citation))];
assert!(matches!(
&cited[1].action,
ChatAction::Delete { citation: Some(parsed), .. } if *parsed == citation
));
assert!(
fold(&cited, can_delete)[0].deleted,
"a cited moderator delete lands"
);
let uncited = vec![message.clone(), delete(&moderator, None)];
assert!(
!fold(&uncited, can_delete)[0].deleted,
"an uncited delete names no rank"
);
let peer_delete = vec![message.clone(), delete(&peer, Some(&citation))];
assert!(
!fold(&peer_delete, can_delete)[0].deleted,
"a peer's delete is not authority"
);
let own = vec![message.clone(), delete(&alice, None)];
assert!(
fold(&own, |_, _, _| false)[0].deleted,
"a self-delete never consults the predicate"
);
}
} }
+81 -4
View File
@@ -5,14 +5,15 @@ use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::derive::{ use crate::derive::{
community_id_of, control_group_key, control_signer_group_key, verify_community_id, banlist_locator, community_id_of, control_group_key, control_signer_group_key, grant_locator,
verify_community_id,
}; };
use crate::edition::{ use crate::edition::{
AuthorityCitation, EditionFields, EditionMeta, EntityHead, Floors, ParsedEdition, AuthorityCitation, EditionFields, EditionMeta, EntityHead, Floors, ParsedEdition,
build_edition, fold_head, parse_edition, vsk, build_edition, fold_head, parse_edition, vsk,
}; };
use crate::roles::{ use crate::roles::{
AuthorityEdition, CommunityRoles, Permissions, Roster, citation_ok, fold_roster, AuthorityEdition, CommunityRoles, Grant, Permissions, Role, Roster, citation_ok, fold_roster,
}; };
use crate::stream::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with}; use crate::stream::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, random_32}; use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, random_32};
@@ -220,6 +221,7 @@ impl ControlWriter {
community_id: &CommunityId, community_id: &CommunityId,
metadata: &CommunityMetadata, metadata: &CommunityMetadata,
head: Option<&EntityHead>, head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64, at_secs: u64,
) -> Result<(Event, EntityHead)> { ) -> Result<(Event, EntityHead)> {
let content = encode_metadata(metadata)?; let content = encode_metadata(metadata)?;
@@ -231,7 +233,7 @@ impl ControlWriter {
entity: *community_id.as_bytes(), entity: *community_id.as_bytes(),
content: &content, content: &content,
head, head,
citation: None, citation,
}, },
at_secs, at_secs,
) )
@@ -243,6 +245,7 @@ impl ControlWriter {
channel: &ChannelId, channel: &ChannelId,
metadata: &ChannelMetadata, metadata: &ChannelMetadata,
head: Option<&EntityHead>, head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64, at_secs: u64,
) -> Result<(Event, EntityHead)> { ) -> Result<(Event, EntityHead)> {
let content = serde_json::to_string(metadata)?; let content = serde_json::to_string(metadata)?;
@@ -254,7 +257,79 @@ impl ControlWriter {
entity: *channel.as_bytes(), entity: *channel.as_bytes(),
content: &content, content: &content,
head, head,
citation: None, citation,
},
at_secs,
)
}
pub fn set_role(
&self,
keys: &Keys,
role: &Role,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let content = role.to_content()?;
self.publish(
keys,
Edition {
subkind: vsk::ROLE,
entity: *role.role_id.as_bytes(),
content: &content,
head,
citation,
},
at_secs,
)
}
pub fn set_grant(
&self,
keys: &Keys,
community_id: &CommunityId,
grant: &Grant,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let content = grant.to_content()?;
self.publish(
keys,
Edition {
subkind: vsk::GRANT,
entity: grant_locator(community_id, &grant.member.to_bytes()),
content: &content,
head,
citation,
},
at_secs,
)
}
pub fn set_banlist(
&self,
keys: &Keys,
community_id: &CommunityId,
banned: &BTreeSet<PublicKey>,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let entries: Vec<String> = banned.iter().map(PublicKey::to_hex).collect();
let content = serde_json::to_string(&entries)?;
self.publish(
keys,
Edition {
subkind: vsk::BANLIST,
entity: banlist_locator(community_id),
content: &content,
head,
citation,
}, },
at_secs, at_secs,
) )
@@ -600,6 +675,7 @@ mod tests {
..metadata("coop two") ..metadata("coop two")
}, },
Some(community_head), Some(community_head),
None,
AT + 1, AT + 1,
) )
.expect("publishes"); .expect("publishes");
@@ -613,6 +689,7 @@ mod tests {
..ChannelMetadata::default() ..ChannelMetadata::default()
}, },
Some(channel_head), Some(channel_head),
None,
AT + 2, AT + 2,
) )
.expect("publishes"); .expect("publishes");
+26 -15
View File
@@ -31,7 +31,7 @@ const TAG_SUBKIND: &str = "vsk";
const TAG_ENTITY: &str = "eid"; const TAG_ENTITY: &str = "eid";
const TAG_VERSION: &str = "ev"; const TAG_VERSION: &str = "ev";
const TAG_PREV: &str = "ep"; const TAG_PREV: &str = "ep";
const TAG_CITATION: &str = "vac"; pub const TAG_CITATION: &str = "vac";
#[derive(Debug)] #[derive(Debug)]
pub enum EditionError { pub enum EditionError {
@@ -126,6 +126,29 @@ pub fn edition_hash(
Sha256::digest(signing_bytes(entity, version, prev, content)).into() Sha256::digest(signing_bytes(entity, version, prev, content)).into()
} }
pub fn citation_tag(citation: &AuthorityCitation) -> Tag {
Tag::custom(
TAG_CITATION,
[
HEXLOWER.encode(&citation.entity),
citation.version.to_string(),
HEXLOWER.encode(&citation.hash),
],
)
}
pub fn citation_from(fields: &[String]) -> Option<AuthorityCitation> {
if fields.len() != 4 {
return None;
}
Some(AuthorityCitation {
entity: hex32(&fields[1], TAG_CITATION).ok()?,
version: canonical_decimal(&fields[2])?,
hash: hex32(&fields[3], TAG_CITATION).ok()?,
})
}
pub fn build_edition(fields: EditionFields<'_>) -> UnsignedEvent { pub fn build_edition(fields: EditionFields<'_>) -> UnsignedEvent {
let mut tags = vec![ let mut tags = vec![
Tag::custom(TAG_SUBKIND, [fields.subkind]), Tag::custom(TAG_SUBKIND, [fields.subkind]),
@@ -138,14 +161,7 @@ pub fn build_edition(fields: EditionFields<'_>) -> UnsignedEvent {
} }
if let Some(citation) = fields.citation { if let Some(citation) = fields.citation {
tags.push(Tag::custom( tags.push(citation_tag(&citation));
TAG_CITATION,
[
HEXLOWER.encode(&citation.entity),
citation.version.to_string(),
HEXLOWER.encode(&citation.hash),
],
));
} }
build_rumor_secs( build_rumor_secs(
@@ -187,12 +203,7 @@ pub fn parse_edition(rumor: &UnsignedEvent) -> Result<ParsedEdition, EditionErro
}; };
let citation = match fields(rumor, TAG_CITATION)? { let citation = match fields(rumor, TAG_CITATION)? {
Some(fields) if fields.len() == 4 => Some(AuthorityCitation { Some(fields) => Some(citation_from(fields).ok_or(EditionError::BadField(TAG_CITATION))?),
entity: hex32(&fields[1], TAG_CITATION)?,
version: canonical_decimal(&fields[2]).ok_or(EditionError::BadField(TAG_CITATION))?,
hash: hex32(&fields[3], TAG_CITATION)?,
}),
Some(_) => return Err(EditionError::BadField(TAG_CITATION)),
None => None, None => None,
}; };
+890
View File
@@ -0,0 +1,890 @@
use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use anyhow::Result;
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::*;
use crate::edition::{
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
};
use crate::stream::{
KIND_WRAP, OpenedStream, SealForm, StreamError, build_rumor_ms, build_seal, open_wrap,
wrap_seal,
};
use crate::{GroupKey, decode_hex_32};
pub const KIND_JOIN_LEAVE: u16 = 3306;
pub const KIND_KICK: u16 = 3309;
pub const KIND_SNAPSHOT: u16 = 3312;
pub const MAX_SNAPSHOT_CHUNK: usize = 400;
pub const MAX_FUTURE_SKEW_MS: u64 = 60 * 60 * 1000;
const TAG_INVITE: &str = "invite";
const TAG_TARGET: &str = "p";
const TAG_SNAP: &str = "snap";
const TAG_CONTENT: &str = "content";
const CONTENT_JOIN: &str = "join";
const CONTENT_LEAVE: &str = "leave";
#[derive(Debug)]
pub enum GuestbookError {
Stream(StreamError),
NotEncryptedSealed,
UnknownKind(u16),
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
}
impl fmt::Display for GuestbookError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GuestbookError::Stream(error) => write!(f, "stream: {error}"),
GuestbookError::NotEncryptedSealed => {
write!(f, "guestbook rumor must ride an encrypted seal")
}
GuestbookError::UnknownKind(kind) => {
write!(f, "not a guestbook rumor kind: {kind}")
}
GuestbookError::MissingTag(name) => write!(f, "missing guestbook tag: {name}"),
GuestbookError::DuplicateTag(name) => write!(f, "duplicate guestbook tag: {name}"),
GuestbookError::BadTag(name) => write!(f, "malformed guestbook tag: {name}"),
}
}
}
impl std::error::Error for GuestbookError {}
impl From<StreamError> for GuestbookError {
fn from(error: StreamError) -> Self {
GuestbookError::Stream(error)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GuestbookEntry {
Join {
member: PublicKey,
at_ms: u64,
/// The `(creator, label)` an invite attributed the join to.
invited_by: Option<(String, String)>,
},
Leave {
member: PublicKey,
at_ms: u64,
},
Kick {
actor: PublicKey,
target: PublicKey,
at_ms: u64,
citation: Option<AuthorityCitation>,
},
Snapshot {
refounder: PublicKey,
members: Vec<PublicKey>,
snapshot_id: [u8; 32],
chunk: (u32, u32),
at_ms: u64,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GuestbookRumor {
pub id: EventId,
pub author: PublicKey,
pub kind: Kind,
pub at_ms: u64,
pub entry: GuestbookEntry,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MemberState {
Joined {
at_ms: u64,
invited_by: Option<(String, String)>,
},
Left {
at_ms: u64,
},
Kicked {
at_ms: u64,
actor: PublicKey,
},
}
pub fn build_join(
member: PublicKey,
invited_by: Option<(&str, &str)>,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = Vec::new();
if let Some((creator, label)) = invited_by {
tags.push(Tag::custom(TAG_INVITE, [creator, label]));
}
build_rumor_ms(KIND_JOIN_LEAVE, member, CONTENT_JOIN, tags, at_ms)
}
pub fn build_leave(member: PublicKey, at_ms: u64) -> UnsignedEvent {
build_rumor_ms(KIND_JOIN_LEAVE, member, CONTENT_LEAVE, Vec::new(), at_ms)
}
pub fn build_kick(
actor: PublicKey,
target: &PublicKey,
citation: Option<&AuthorityCitation>,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = vec![Tag::custom(TAG_TARGET, [target.to_hex()])];
if let Some(citation) = citation {
tags.push(citation_tag(citation));
}
build_rumor_ms(KIND_KICK, actor, "", tags, at_ms)
}
pub fn build_snapshot_chunks(
refounder: PublicKey,
members: &[PublicKey],
snapshot_id: [u8; 32],
at_ms: u64,
) -> Vec<UnsignedEvent> {
let chunks: Vec<&[PublicKey]> = members.chunks(MAX_SNAPSHOT_CHUNK).collect();
let total = chunks.len() as u32;
chunks
.iter()
.enumerate()
.map(|(index, chunk)| {
let hex: Vec<String> = chunk.iter().map(PublicKey::to_hex).collect();
let content = format!(
"[{}]",
hex.iter()
.map(|member| format!("\"{member}\""))
.collect::<Vec<_>>()
.join(",")
);
let tags = vec![Tag::custom(
TAG_SNAP,
[
HEXLOWER.encode(&snapshot_id),
(index as u32 + 1).to_string(),
total.to_string(),
],
)];
build_rumor_ms(KIND_SNAPSHOT, refounder, &content, tags, at_ms)
})
.collect()
}
pub fn seal_rumor(
rumor: &UnsignedEvent,
group: &GroupKey,
author: &Keys,
) -> Result<(Event, Keys), GuestbookError> {
let kind = rumor.kind.as_u16();
if !is_guestbook_kind(kind) {
return Err(GuestbookError::UnknownKind(kind));
}
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?;
Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?)
}
pub fn open(
wrap: &Event,
group: &GroupKey,
) -> Result<(OpenedStream, GuestbookRumor), GuestbookError> {
let opened = open_wrap(wrap, group)?;
if opened.seal_form != SealForm::Encrypted {
return Err(GuestbookError::NotEncryptedSealed);
}
let entry = entry_of(&opened)?;
let rumor = GuestbookRumor {
id: opened.rumor_id,
author: opened.author,
kind: opened.rumor.kind,
at_ms: opened.at_ms,
entry,
};
Ok((opened, rumor))
}
pub fn coalesce(
rumors: &[GuestbookRumor],
now_ms: u64,
snapshot_authority: Option<&PublicKey>,
can_kick: impl Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool,
) -> BTreeMap<PublicKey, MemberState> {
let mut states: BTreeMap<PublicKey, (u64, Reverse<EventId>, MemberState)> = BTreeMap::new();
let horizon = now_ms.saturating_add(MAX_FUTURE_SKEW_MS);
for rumor in rumors {
if rumor.at_ms > horizon {
continue;
}
match &rumor.entry {
GuestbookEntry::Join {
member,
at_ms,
invited_by,
} => offer(
&mut states,
*member,
*at_ms,
rumor.id,
MemberState::Joined {
at_ms: *at_ms,
invited_by: invited_by.clone(),
},
),
GuestbookEntry::Leave { member, at_ms } => offer(
&mut states,
*member,
*at_ms,
rumor.id,
MemberState::Left { at_ms: *at_ms },
),
GuestbookEntry::Kick {
actor,
target,
at_ms,
citation,
} => {
if !can_kick(actor, target, citation.as_ref()) {
continue;
}
offer(
&mut states,
*target,
*at_ms,
rumor.id,
MemberState::Kicked {
at_ms: *at_ms,
actor: *actor,
},
);
}
GuestbookEntry::Snapshot {
refounder,
members,
at_ms,
..
} => {
if snapshot_authority != Some(refounder) {
continue;
}
for member in members {
offer(
&mut states,
*member,
*at_ms,
rumor.id,
MemberState::Joined {
at_ms: *at_ms,
invited_by: None,
},
);
}
}
}
}
states
.into_iter()
.map(|(member, (_, _, state))| (member, state))
.collect()
}
pub fn complete_memberlist(
coalesced: &BTreeMap<PublicKey, MemberState>,
observed: &BTreeMap<PublicKey, u64>,
granted: &BTreeSet<PublicKey>,
banned: &BTreeSet<PublicKey>,
banned_at: &BTreeMap<PublicKey, u64>,
) -> BTreeSet<PublicKey> {
let mut candidates: BTreeSet<&PublicKey> = coalesced.keys().collect();
candidates.extend(observed.keys());
candidates.extend(granted.iter());
let mut members = BTreeSet::new();
for member in candidates {
let mut inclusion = observed.get(member).copied();
if let Some(state) = coalesced.get(member) {
match state {
MemberState::Joined { at_ms, .. } => {
inclusion = Some(inclusion.map_or(*at_ms, |seen| seen.max(*at_ms)));
}
MemberState::Left { .. } | MemberState::Kicked { .. } => {}
}
}
if inclusion.is_none() && granted.contains(member) {
inclusion = Some(0);
}
let mut exclusion = match coalesced.get(member) {
Some(MemberState::Left { at_ms }) | Some(MemberState::Kicked { at_ms, .. }) => {
Some(*at_ms)
}
_ => None,
};
if banned.contains(member) {
exclusion = Some(match banned_at.get(member) {
Some(at_ms) => exclusion.map_or(*at_ms, |seen| seen.max(*at_ms)),
None => u64::MAX,
});
}
if let Some(inclusion) = inclusion
&& exclusion.is_none_or(|exclusion| inclusion > exclusion)
{
members.insert(*member);
}
}
members
}
fn offer(
states: &mut BTreeMap<PublicKey, (u64, Reverse<EventId>, MemberState)>,
member: PublicKey,
at_ms: u64,
id: EventId,
state: MemberState,
) {
let candidate = (at_ms, Reverse(id));
if let Some(existing) = states.get(&member)
&& (existing.0, existing.1) >= candidate
{
return;
}
states.insert(member, (at_ms, Reverse(id), state));
}
fn is_guestbook_kind(kind: u16) -> bool {
matches!(kind, KIND_JOIN_LEAVE | KIND_KICK | KIND_SNAPSHOT)
}
fn entry_of(opened: &OpenedStream) -> Result<GuestbookEntry, GuestbookError> {
let rumor = &opened.rumor;
let author = opened.author;
let at_ms = opened.at_ms;
match rumor.kind.as_u16() {
KIND_JOIN_LEAVE => match rumor.content.as_str() {
CONTENT_JOIN => Ok(GuestbookEntry::Join {
member: author,
at_ms,
invited_by: invite_of(rumor),
}),
CONTENT_LEAVE => Ok(GuestbookEntry::Leave {
member: author,
at_ms,
}),
_ => Err(GuestbookError::BadTag(TAG_CONTENT)),
},
KIND_KICK => Ok(GuestbookEntry::Kick {
actor: author,
target: tagged_pubkey(rumor, TAG_TARGET)?,
at_ms,
citation: optional_citation(rumor)?,
}),
KIND_SNAPSHOT => {
let (snapshot_id, chunk) = snapshot_of(rumor)?;
let members = members_of(&rumor.content)?;
Ok(GuestbookEntry::Snapshot {
refounder: author,
members,
snapshot_id,
chunk,
at_ms,
})
}
other => Err(GuestbookError::UnknownKind(other)),
}
}
fn invite_of(rumor: &UnsignedEvent) -> Option<(String, String)> {
rumor.tags.iter().find_map(|candidate| {
let fields = candidate.as_slice();
(fields.len() >= 3 && fields[0] == TAG_INVITE)
.then(|| (fields[1].clone(), fields[2].clone()))
})
}
fn members_of(content: &str) -> Result<Vec<PublicKey>, GuestbookError> {
let entries: Vec<String> =
serde_json::from_str(content).map_err(|_| GuestbookError::BadTag(TAG_CONTENT))?;
if entries.len() > MAX_SNAPSHOT_CHUNK {
return Err(GuestbookError::BadTag(TAG_SNAP));
}
entries
.iter()
.map(|entry| pubkey(entry, TAG_CONTENT))
.collect()
}
fn snapshot_of(rumor: &UnsignedEvent) -> Result<([u8; 32], (u32, u32)), GuestbookError> {
let fields = required(rumor, TAG_SNAP)?;
if fields.len() != 4 {
return Err(GuestbookError::BadTag(TAG_SNAP));
}
let snapshot_id = decode_hex_32(&fields[1]).map_err(|_| GuestbookError::BadTag(TAG_SNAP))?;
let index = decimal(&fields[2])?;
let total = decimal(&fields[3])?;
if index == 0 || index > total {
return Err(GuestbookError::BadTag(TAG_SNAP));
}
Ok((snapshot_id, (index, total)))
}
fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, GuestbookError> {
let Some(fields) = tag(rumor, TAG_CITATION)? else {
return Ok(None);
};
citation_from(fields)
.map(Some)
.ok_or(GuestbookError::BadTag(TAG_CITATION))
}
fn decimal(raw: &str) -> Result<u32, GuestbookError> {
canonical_decimal(raw)
.and_then(|value| u32::try_from(value).ok())
.ok_or(GuestbookError::BadTag(TAG_SNAP))
}
fn required<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<&'a [String], GuestbookError> {
tag(rumor, name)?.ok_or(GuestbookError::MissingTag(name))
}
fn tagged_pubkey(rumor: &UnsignedEvent, name: &'static str) -> Result<PublicKey, GuestbookError> {
pubkey(value(required(rumor, name)?, name)?, name)
}
fn tag<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, GuestbookError> {
let mut found: Option<&[String]> = None;
for candidate in rumor.tags.iter() {
let fields = candidate.as_slice();
if fields.first().map(String::as_str) != Some(name) {
continue;
}
if found.is_some() {
return Err(GuestbookError::DuplicateTag(name));
}
found = Some(fields);
}
Ok(found)
}
fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, GuestbookError> {
fields
.get(1)
.map(String::as_str)
.ok_or(GuestbookError::BadTag(name))
}
fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, GuestbookError> {
let bytes = decode_hex_32(hex).map_err(|_| GuestbookError::BadTag(name))?;
PublicKey::from_slice(&bytes).map_err(|_| GuestbookError::BadTag(name))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::derive::guestbook_group_key;
use crate::stream::build_rumor_secs;
use crate::{CommunityId, Epoch};
const ROOT: [u8; 32] = [0x5au8; 32];
const AT: u64 = 1_700_000_000_000;
fn community() -> CommunityId {
CommunityId::from_bytes([0x11u8; 32])
}
fn group() -> GroupKey {
guestbook_group_key(&ROOT, &community(), Epoch(0)).expect("derives")
}
fn citation() -> AuthorityCitation {
AuthorityCitation {
entity: [0x33u8; 32],
version: 1,
hash: [0x44u8; 32],
}
}
fn publish(rumor: &UnsignedEvent, author: &Keys) -> GuestbookRumor {
let wrap = seal_rumor(rumor, &group(), author).expect("seals").0;
open(&wrap, &group()).expect("opens").1
}
#[test]
fn join_leave_kick_and_snapshot_converge_to_one_memberlist() {
let alice = Keys::generate();
let bob = Keys::generate();
let carol = Keys::generate();
let dave = Keys::generate();
let frank = Keys::generate();
let grace = Keys::generate();
let owner = Keys::generate();
let survivors: Vec<PublicKey> = (0..401).map(|_| Keys::generate().public_key()).collect();
let mut rumors = vec![
publish(
&build_join(
alice.public_key(),
Some((&"ab".repeat(32), "Reddit")),
AT + 1_000,
),
&alice,
),
publish(&build_join(bob.public_key(), None, AT + 2_000), &bob),
publish(&build_leave(bob.public_key(), AT + 3_000), &bob),
publish(&build_join(dave.public_key(), None, AT + 4_000), &dave),
publish(
&build_kick(
carol.public_key(),
&dave.public_key(),
Some(&citation()),
AT + 5_000,
),
&carol,
),
publish(&build_join(frank.public_key(), None, AT + 7_000), &frank),
];
let snapshot_id = "77".repeat(32);
let chunks =
build_snapshot_chunks(carol.public_key(), &survivors, [0x77u8; 32], AT + 6_000);
assert_eq!(chunks.len(), 2, "401 survivors chunk into two events");
for (index, chunk) in chunks.iter().enumerate() {
assert!(chunk.tags.iter().any(|tag| tag.as_slice()
== [
TAG_SNAP,
snapshot_id.as_str(),
&(index + 1).to_string(),
"2"
]));
rumors.push(publish(chunk, &carol));
}
let can_kick =
|actor: &PublicKey, target: &PublicKey, citation: Option<&AuthorityCitation>| {
citation.is_some() && actor == &carol.public_key() && target != &owner.public_key()
};
let states = coalesce(&rumors, AT + 8_000, Some(&carol.public_key()), can_kick);
assert_eq!(
states.get(&alice.public_key()),
Some(&MemberState::Joined {
at_ms: AT + 1_000,
invited_by: Some(("ab".repeat(32), "Reddit".to_owned())),
})
);
assert_eq!(
states.get(&bob.public_key()),
Some(&MemberState::Left { at_ms: AT + 3_000 })
);
assert_eq!(
states.get(&dave.public_key()),
Some(&MemberState::Kicked {
at_ms: AT + 5_000,
actor: carol.public_key(),
})
);
assert!(
survivors
.iter()
.all(|member| matches!(states.get(member), Some(MemberState::Joined { .. }))),
"every chunk seeds its own members"
);
let reversed: Vec<GuestbookRumor> = rumors.iter().rev().cloned().collect();
assert_eq!(
coalesce(&reversed, AT + 8_000, Some(&carol.public_key()), can_kick),
states,
"arrival order cannot change the fold"
);
let observed = BTreeMap::from([
(bob.public_key(), AT + 9_000),
(carol.public_key(), AT + 5_000),
]);
let granted = BTreeSet::from([grace.public_key()]);
let banned = BTreeSet::from([frank.public_key()]);
let banned_at = BTreeMap::from([(frank.public_key(), AT + 8_000)]);
let members = complete_memberlist(&states, &observed, &granted, &banned, &banned_at);
let mut expected = BTreeSet::from([
alice.public_key(),
bob.public_key(),
carol.public_key(),
grace.public_key(),
]);
expected.extend(survivors.iter().copied());
assert_eq!(members, expected);
assert!(
!members.contains(&dave.public_key()),
"a kicked member is out"
);
assert!(
!members.contains(&frank.public_key()),
"a ban wins over a later join"
);
}
#[test]
fn a_kick_or_snapshot_without_authority_is_dropped() {
let moderator = Keys::generate();
let outsider = Keys::generate();
let owner = Keys::generate();
let kicked = Keys::generate();
let uncited = Keys::generate();
let unranked = Keys::generate();
let refounder = Keys::generate();
let impostor = Keys::generate();
let seeded = Keys::generate();
let smuggled = Keys::generate();
let can_kick = |actor: &PublicKey,
target: &PublicKey,
citation: Option<&AuthorityCitation>| {
citation.is_some() && actor == &moderator.public_key() && target != &owner.public_key()
};
let rumors = vec![
publish(
&build_kick(
moderator.public_key(),
&kicked.public_key(),
Some(&citation()),
AT,
),
&moderator,
),
publish(
&build_kick(moderator.public_key(), &uncited.public_key(), None, AT),
&moderator,
),
publish(
&build_kick(
outsider.public_key(),
&unranked.public_key(),
Some(&citation()),
AT,
),
&outsider,
),
publish(
&build_kick(
moderator.public_key(),
&owner.public_key(),
Some(&citation()),
AT,
),
&moderator,
),
];
let states = coalesce(&rumors, AT + 1_000, None, can_kick);
assert_eq!(
states.get(&kicked.public_key()),
Some(&MemberState::Kicked {
at_ms: AT,
actor: moderator.public_key(),
})
);
assert!(
!states.contains_key(&uncited.public_key()),
"a kick cites the Grant it acts under"
);
assert!(
!states.contains_key(&unranked.public_key()),
"a kick needs KICK"
);
assert!(
!states.contains_key(&owner.public_key()),
"nobody kicks the owner"
);
let by_refounder = build_snapshot_chunks(
refounder.public_key(),
&[seeded.public_key()],
[0x77u8; 32],
AT,
)
.remove(0);
let by_impostor = build_snapshot_chunks(
impostor.public_key(),
&[smuggled.public_key()],
[0x88u8; 32],
AT,
)
.remove(0);
for authority in [None, Some(refounder.public_key())] {
let states = coalesce(
&[
publish(&by_refounder, &refounder),
publish(&by_impostor, &impostor),
],
AT + 1_000,
authority.as_ref(),
|_, _, _| true,
);
assert_eq!(
states.contains_key(&seeded.public_key()),
authority.is_some(),
"only the epoch's refounder seeds, and there is no owner fallback"
);
assert!(
!states.contains_key(&smuggled.public_key()),
"a foreign snapshot never seeds"
);
}
}
#[test]
fn a_future_entry_a_bad_ms_and_a_malformed_snapshot_are_dropped() {
let member = Keys::generate();
let moderator = Keys::generate();
let target = Keys::generate();
let future = publish(
&build_join(member.public_key(), None, AT + MAX_FUTURE_SKEW_MS + 1),
&member,
);
let horizon = publish(
&build_join(member.public_key(), None, AT + MAX_FUTURE_SKEW_MS),
&member,
);
assert!(
coalesce(&[future], AT, None, |_, _, _| true).is_empty(),
"an entry more than an hour ahead is dropped"
);
assert_eq!(
coalesce(&[horizon], AT, None, |_, _, _| true).len(),
1,
"the horizon itself is skew, not forgery"
);
let bad_ms = build_rumor_secs(
KIND_JOIN_LEAVE,
member.public_key(),
CONTENT_JOIN,
vec![Tag::custom("ms", ["1000"])],
AT / 1000,
);
assert!(matches!(
open(
&seal_rumor(&bad_ms, &group(), &member).expect("seals").0,
&group()
),
Err(GuestbookError::Stream(StreamError::BadMs))
));
let bad_verb = build_rumor_ms(KIND_JOIN_LEAVE, member.public_key(), "maybe", vec![], AT);
assert!(matches!(
open(
&seal_rumor(&bad_verb, &group(), &member).expect("seals").0,
&group()
),
Err(GuestbookError::BadTag(TAG_CONTENT))
));
let ambiguous = build_rumor_ms(
KIND_KICK,
moderator.public_key(),
"",
vec![
Tag::custom(TAG_TARGET, [target.public_key().to_hex()]),
citation_tag(&citation()),
citation_tag(&citation()),
],
AT,
);
assert!(matches!(
open(
&seal_rumor(&ambiguous, &group(), &moderator)
.expect("seals")
.0,
&group()
),
Err(GuestbookError::DuplicateTag(TAG_CITATION))
));
for fields in [
vec![snapshot_id(), "0".to_owned(), "2".to_owned()],
vec![snapshot_id(), "3".to_owned(), "2".to_owned()],
vec![snapshot_id(), "1".to_owned()],
] {
let rumor = build_rumor_ms(
KIND_SNAPSHOT,
moderator.public_key(),
"[]",
vec![Tag::custom(TAG_SNAP, fields)],
AT,
);
assert!(matches!(
open(
&seal_rumor(&rumor, &group(), &moderator).expect("seals").0,
&group()
),
Err(GuestbookError::BadTag(TAG_SNAP))
));
}
}
fn snapshot_id() -> String {
"ab".repeat(32)
}
}
+1
View File
@@ -2,6 +2,7 @@ pub mod chat;
pub mod control; pub mod control;
pub mod derive; pub mod derive;
pub mod edition; pub mod edition;
pub mod guestbook;
pub mod roles; pub mod roles;
pub mod store; pub mod store;
pub mod stream; pub mod stream;
+4
View File
@@ -118,6 +118,8 @@ pub struct CommunityState {
pub relays: Vec<RelayUrl>, pub relays: Vec<RelayUrl>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub heads: Vec<EntityHead>, pub heads: Vec<EntityHead>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub banned: BTreeSet<PublicKey>,
pub added_at_ms: u64, pub added_at_ms: u64,
} }
@@ -183,6 +185,7 @@ impl CommunityState {
channels, channels,
relays, relays,
heads, heads,
banned: BTreeSet::new(),
added_at_ms, added_at_ms,
}) })
} }
@@ -200,6 +203,7 @@ impl CommunityState {
pub fn apply_fold(&mut self, fold: &ControlFold) { pub fn apply_fold(&mut self, fold: &ControlFold) {
self.heads = fold.floors.values().cloned().collect(); self.heads = fold.floors.values().cloned().collect();
self.banned = fold.banned.clone();
if let Some(community) = &fold.community { if let Some(community) = &fold.community {
self.relays = community self.relays = community