add guestbook and moderation
This commit is contained in:
@@ -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
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user