updat
This commit is contained in:
Generated
-2
@@ -1300,7 +1300,6 @@ dependencies = [
|
||||
"common",
|
||||
"community",
|
||||
"gpui-pre",
|
||||
"log",
|
||||
"nostr-sdk",
|
||||
"person",
|
||||
"settings",
|
||||
@@ -1338,7 +1337,6 @@ dependencies = [
|
||||
"data-encoding",
|
||||
"hkdf",
|
||||
"hmac 0.12.1",
|
||||
"log",
|
||||
"nostr",
|
||||
"nostr-sdk",
|
||||
"rand 0.10.2",
|
||||
|
||||
@@ -5,8 +5,10 @@ use std::time::{Duration, Instant};
|
||||
use anyhow::Result;
|
||||
use concord::cord02::{ControlFold, ImageRef};
|
||||
use concord::cord03::{self, ChatMessage, ReplyRef};
|
||||
use concord::cord04::AuthorityCitation;
|
||||
use concord::cord04::roles::{Permissions, citation_ok};
|
||||
use concord::derive::channel_group_key;
|
||||
use concord::cord06::RekeyScope;
|
||||
use concord::derive::{channel_group_key, grant_locator};
|
||||
use concord::state::{ChannelCursor, ChannelKeyRef, CommunityState, HeldKey, HeldRoot};
|
||||
use concord::{ChannelId, CommunityId, Epoch};
|
||||
use gpui::{App, AppContext, Context, EventEmitter, Task};
|
||||
@@ -255,7 +257,7 @@ impl Community {
|
||||
|
||||
/// The epoch a channel rotation removed us at, when it removed us.
|
||||
pub fn channel_removed_at(&self, channel: &ChannelId) -> Option<Epoch> {
|
||||
self.state.channel_cuts.get(channel).map(|cut| Epoch(*cut))
|
||||
self.state.channel_cuts.get(channel).copied()
|
||||
}
|
||||
|
||||
/// Whether an automatic catch-up for `channel` is worth asking for yet.
|
||||
@@ -653,6 +655,78 @@ impl Community {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Rotate one scope to its next epoch, cutting off `excluded`.
|
||||
pub fn rotate(
|
||||
&mut self,
|
||||
scope: RekeyScope,
|
||||
recipients: &[PublicKey],
|
||||
excluded: &[PublicKey],
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<Task<Result<Epoch>>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let me = nostr.read(cx).current_user()?;
|
||||
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
if self.state.removed_at.is_some() || self.state.stranded || self.state.banned.contains(&me)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let rewrite = rekey::Rewrite {
|
||||
scope,
|
||||
recipients: recipients.to_vec(),
|
||||
excluded: excluded.to_vec(),
|
||||
citation: self.citation(&me),
|
||||
};
|
||||
|
||||
if !rewrite.authorized(&self.control.roles, &self.state.owner, &me) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let state = self.state.clone();
|
||||
let roles = self.control.roles.clone();
|
||||
|
||||
Some(cx.spawn(async move |this, cx| {
|
||||
let epoch = rekey::rotate(
|
||||
&client,
|
||||
&state,
|
||||
&roles,
|
||||
&signer,
|
||||
me,
|
||||
&rewrite,
|
||||
Timestamp::now(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let adoptions = rekey::adopt(&client, &state, &roles, &signer, me).await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
if !adoptions.is_empty() {
|
||||
this.merge_adoptions(adoptions, cx);
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(epoch)
|
||||
}))
|
||||
}
|
||||
|
||||
/// The rank this client cites when it acts.
|
||||
fn citation(&self, me: &PublicKey) -> Option<AuthorityCitation> {
|
||||
let entity = grant_locator(&self.state.id, &me.to_bytes());
|
||||
|
||||
self.state
|
||||
.heads
|
||||
.iter()
|
||||
.find(|head| head.entity == entity)
|
||||
.map(|head| AuthorityCitation {
|
||||
entity: head.entity,
|
||||
version: head.version,
|
||||
hash: head.self_hash,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_rekey(&mut self, result: Result<Adoptions>, cx: &mut Context<Self>) {
|
||||
self.rekey_task = None;
|
||||
|
||||
@@ -691,6 +765,8 @@ impl Community {
|
||||
self.state.control_pks.insert(base.epoch.0, control_pk);
|
||||
}
|
||||
|
||||
// The rotation is authoritative about the new epoch's signing root
|
||||
self.state.control_root = base.control_root;
|
||||
self.state.community_root = base.key;
|
||||
self.state.root_epoch = base.epoch;
|
||||
self.state.removed_at = None;
|
||||
@@ -726,7 +802,7 @@ impl Community {
|
||||
|
||||
for (channel, epoch) in adoptions.cuts {
|
||||
self.state.channels.retain(|held| held.id != channel);
|
||||
self.state.channel_cuts.insert(channel, epoch.0);
|
||||
self.state.channel_cuts.insert(channel, epoch);
|
||||
self.state.cursors.remove(&channel);
|
||||
}
|
||||
|
||||
@@ -934,7 +1010,7 @@ async fn sync_round(
|
||||
Intent::Older { .. } => WrapPage::default(),
|
||||
};
|
||||
|
||||
let bridge = match (intent, newest.oldest_ms, saved.newest_ms) {
|
||||
let bridge = match (intent, newest.oldest, saved.newest) {
|
||||
(Intent::CatchUp, Some(oldest), Some(saved_newest)) if oldest > saved_newest => {
|
||||
let page = history::page(
|
||||
client,
|
||||
@@ -958,8 +1034,8 @@ async fn sync_round(
|
||||
};
|
||||
|
||||
let resume = match intent {
|
||||
Intent::CatchUp => saved.oldest_ms.or(newest.oldest_ms),
|
||||
Intent::Older { .. } => saved.oldest_ms,
|
||||
Intent::CatchUp => saved.oldest.or(newest.oldest),
|
||||
Intent::Older { .. } => saved.oldest,
|
||||
};
|
||||
|
||||
let budget = match intent {
|
||||
@@ -994,16 +1070,16 @@ async fn sync_round(
|
||||
if intent == Intent::CatchUp {
|
||||
let complete = !newest.failed && bridge.exhausted;
|
||||
let top = newest
|
||||
.newest_ms
|
||||
.unwrap_or(0)
|
||||
.max(bridge.newest_ms.unwrap_or(0));
|
||||
.newest
|
||||
.unwrap_or_default()
|
||||
.max(bridge.newest.unwrap_or_default());
|
||||
|
||||
if complete && top > 0 {
|
||||
round.newest_ms = Some(top);
|
||||
if complete && !top.is_zero() {
|
||||
round.newest = Some(top);
|
||||
}
|
||||
}
|
||||
|
||||
round.oldest_ms = older.oldest_ms.or(newest.oldest_ms);
|
||||
round.oldest = older.oldest.or(newest.oldest);
|
||||
round.exhausted = older.exhausted;
|
||||
|
||||
Ok((progress, round))
|
||||
@@ -1082,7 +1158,7 @@ mod tests {
|
||||
|
||||
// ...and a channel cut closes the one channel.
|
||||
let mut cut = state(channel);
|
||||
cut.channel_cuts.insert(channel, 0);
|
||||
cut.channel_cuts.insert(channel, Epoch(0));
|
||||
assert_eq!(community(cut).channel_secret(&channel), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,13 @@ use crate::sync::connect_relays;
|
||||
/// How long one relay is given to answer one page of history.
|
||||
const PAGE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// How far below a cursor a warm window reaches back.
|
||||
pub const CURSOR_OVERLAP_MS: u64 = 60_000;
|
||||
pub const CURSOR_OVERLAP: Duration = Duration::from_secs(60);
|
||||
|
||||
/// The region of history to read.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct Window {
|
||||
pub until_ms: Option<u64>,
|
||||
pub since_ms: Option<u64>,
|
||||
pub until: Option<Timestamp>,
|
||||
pub since: Option<Timestamp>,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
@@ -34,28 +34,28 @@ impl Window {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The wraps strictly older than `oldest_ms`.
|
||||
pub fn older_than(oldest_ms: u64) -> Self {
|
||||
/// The wraps strictly older than `oldest`.
|
||||
pub fn older_than(oldest: Timestamp) -> Self {
|
||||
Self {
|
||||
until_ms: Some(oldest_ms.saturating_sub(1)),
|
||||
since_ms: None,
|
||||
until: Some(oldest - 1u64),
|
||||
since: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The region between `since_ms` and `oldest_ms`, both inclusive.
|
||||
pub fn between(since_ms: u64, oldest_ms: u64) -> Self {
|
||||
/// The region between `since` and `oldest`, both inclusive.
|
||||
pub fn between(since: Timestamp, oldest: Timestamp) -> Self {
|
||||
Self {
|
||||
until_ms: Some(oldest_ms.saturating_sub(1)),
|
||||
since_ms: Some(since_ms),
|
||||
until: Some(oldest - 1u64),
|
||||
since: Some(since),
|
||||
}
|
||||
}
|
||||
|
||||
/// The window a channel is opened with.
|
||||
pub fn opening(cursor: ChannelCursor) -> Self {
|
||||
match cursor.newest_ms {
|
||||
Some(newest_ms) => Self {
|
||||
since_ms: Some(newest_ms.saturating_sub(CURSOR_OVERLAP_MS)),
|
||||
until_ms: None,
|
||||
match cursor.newest {
|
||||
Some(newest) => Self {
|
||||
since: Some(newest - CURSOR_OVERLAP),
|
||||
until: None,
|
||||
},
|
||||
None => Self::default(),
|
||||
}
|
||||
@@ -139,8 +139,8 @@ pub struct WrapPage {
|
||||
pub raw: usize,
|
||||
/// Wraps that reached us under a held plane but that no held key could open.
|
||||
pub unreadable: usize,
|
||||
pub newest_ms: Option<u64>,
|
||||
pub oldest_ms: Option<u64>,
|
||||
pub newest: Option<Timestamp>,
|
||||
pub oldest: Option<Timestamp>,
|
||||
pub exhausted: bool,
|
||||
pub failed: bool,
|
||||
pub errors: usize,
|
||||
@@ -235,7 +235,7 @@ fn read_under(
|
||||
) -> Result<(OpenedStream, ChatRumor)> {
|
||||
if held
|
||||
.retired_at
|
||||
.is_some_and(|retired| wrap.created_at.as_secs() > retired)
|
||||
.is_some_and(|retired| wrap.created_at > retired)
|
||||
{
|
||||
bail!("sealed after the key that reads it was retired");
|
||||
}
|
||||
@@ -250,12 +250,12 @@ fn wrap_filter(authors: &[PublicKey], window: Window, limit: usize) -> Filter {
|
||||
.authors(authors.iter().copied())
|
||||
.limit(limit);
|
||||
|
||||
if let Some(until_ms) = window.until_ms {
|
||||
filter = filter.until(Timestamp::from_secs(until_ms / 1000));
|
||||
if let Some(until) = window.until {
|
||||
filter = filter.until(until);
|
||||
}
|
||||
|
||||
if let Some(since_ms) = window.since_ms {
|
||||
filter = filter.since(Timestamp::from_secs(since_ms / 1000));
|
||||
if let Some(since) = window.since {
|
||||
filter = filter.since(since);
|
||||
}
|
||||
|
||||
filter
|
||||
@@ -373,12 +373,12 @@ where
|
||||
#[derive(Debug)]
|
||||
struct Walk {
|
||||
relays: Vec<Walker>,
|
||||
since_ms: Option<u64>,
|
||||
since: Option<Timestamp>,
|
||||
/// The inclusive upper bound of the next page.
|
||||
cursor: Option<u64>,
|
||||
cursor: Option<Timestamp>,
|
||||
seen: BTreeSet<EventId>,
|
||||
newest_ms: Option<u64>,
|
||||
oldest_ms: Option<u64>,
|
||||
newest: Option<Timestamp>,
|
||||
oldest: Option<Timestamp>,
|
||||
raw: usize,
|
||||
errors: usize,
|
||||
/// Wraps the caller could not read under any held key.
|
||||
@@ -402,11 +402,11 @@ impl Walk {
|
||||
.cloned()
|
||||
.map(|url| Walker { url, dead: false })
|
||||
.collect(),
|
||||
since_ms: window.since_ms,
|
||||
cursor: window.until_ms,
|
||||
since: window.since,
|
||||
cursor: window.until,
|
||||
seen: BTreeSet::new(),
|
||||
newest_ms: None,
|
||||
oldest_ms: None,
|
||||
newest: None,
|
||||
oldest: None,
|
||||
raw: 0,
|
||||
errors: 0,
|
||||
unreadable: 0,
|
||||
@@ -421,8 +421,8 @@ impl Walk {
|
||||
/// The region the next page asks for.
|
||||
fn region(&self) -> Window {
|
||||
Window {
|
||||
until_ms: self.cursor,
|
||||
since_ms: self.since_ms,
|
||||
until: self.cursor,
|
||||
since: self.since,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,13 +444,13 @@ impl Walk {
|
||||
self.bottom = true;
|
||||
}
|
||||
|
||||
let mut oldest: Option<u64> = None;
|
||||
let mut oldest: Option<Timestamp> = None;
|
||||
let mut events = Vec::with_capacity(page.len());
|
||||
|
||||
for event in page {
|
||||
let at_ms = event.created_at.as_secs().saturating_mul(1000);
|
||||
self.newest_ms = Some(self.newest_ms.map_or(at_ms, |newest| newest.max(at_ms)));
|
||||
oldest = Some(oldest.map_or(at_ms, |oldest| oldest.min(at_ms)));
|
||||
let at = event.created_at;
|
||||
self.newest = Some(self.newest.map_or(at, |newest| newest.max(at)));
|
||||
oldest = Some(oldest.map_or(at, |oldest| oldest.min(at)));
|
||||
|
||||
if self.seen.insert(event.id) {
|
||||
self.raw += 1;
|
||||
@@ -459,7 +459,7 @@ impl Walk {
|
||||
}
|
||||
|
||||
match oldest {
|
||||
Some(oldest) if oldest > 0 => self.cursor = Some(oldest - 1),
|
||||
Some(oldest) if !oldest.is_zero() => self.cursor = Some(oldest - 1u64),
|
||||
Some(_) => self.bottom = true,
|
||||
None => {}
|
||||
}
|
||||
@@ -474,8 +474,8 @@ impl Walk {
|
||||
opened,
|
||||
raw: self.raw,
|
||||
unreadable: self.unreadable,
|
||||
newest_ms: self.newest_ms,
|
||||
oldest_ms: self.oldest_ms,
|
||||
newest: self.newest,
|
||||
oldest: self.oldest,
|
||||
exhausted: swept && self.raw > 0,
|
||||
failed: self.errors > 0 || (self.bottom && self.raw == 0),
|
||||
errors: self.errors,
|
||||
@@ -500,9 +500,8 @@ mod tests {
|
||||
let mut events: Vec<Event> = database
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
let at_ms = event.created_at.as_secs().saturating_mul(1000);
|
||||
window.until_ms.is_none_or(|until| at_ms <= until)
|
||||
&& window.since_ms.is_none_or(|since| at_ms >= since)
|
||||
window.until.is_none_or(|until| event.created_at <= until)
|
||||
&& window.since.is_none_or(|since| event.created_at >= since)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
@@ -613,7 +612,7 @@ mod tests {
|
||||
let held = HeldKey {
|
||||
epoch: Epoch(0),
|
||||
key: SECRET,
|
||||
retired_at: Some(1_000),
|
||||
retired_at: Some(Timestamp::from_secs(1_000)),
|
||||
};
|
||||
|
||||
let wrap_at = |channel: &ChannelId, at_ms: u64| {
|
||||
@@ -656,7 +655,7 @@ mod tests {
|
||||
assert!(page.failed);
|
||||
assert!(!page.exhausted);
|
||||
assert_eq!(page.raw, 0);
|
||||
assert_eq!(page.oldest_ms, None);
|
||||
assert_eq!(page.oldest, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -681,7 +680,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_page_boundary_is_exclusive() {
|
||||
let database = BTreeSet::from([event_at(1_700_000_000_000), event_at(1_700_000_001_000)]);
|
||||
let database = BTreeSet::from([
|
||||
event_at(Timestamp::from_secs(1_700_000_000)),
|
||||
event_at(Timestamp::from_secs(1_700_000_001)),
|
||||
]);
|
||||
let mut walk = Walk::new(&[relay_url("history")], Window::newest());
|
||||
|
||||
let first = walk.accept(serve_page(&database, walk.region(), 1), 1);
|
||||
@@ -693,16 +695,16 @@ mod tests {
|
||||
|
||||
let oldest = second
|
||||
.iter()
|
||||
.map(|event| event.created_at.as_secs() * 1000)
|
||||
.map(|event| event.created_at)
|
||||
.min()
|
||||
.expect("one wrap");
|
||||
assert_eq!(oldest, 1_700_000_000_000);
|
||||
assert_eq!(oldest, Timestamp::from_secs(1_700_000_000));
|
||||
}
|
||||
|
||||
fn event_at(at_ms: u64) -> Event {
|
||||
fn event_at(at: Timestamp) -> Event {
|
||||
let keys = Keys::generate();
|
||||
EventBuilder::new(Kind::TextNote, "page")
|
||||
.custom_created_at(Timestamp::from_secs(at_ms / 1000))
|
||||
.custom_created_at(at)
|
||||
.finalize(&keys)
|
||||
.expect("signs")
|
||||
}
|
||||
@@ -714,23 +716,23 @@ mod tests {
|
||||
assert_eq!(Window::opening(ChannelCursor::default()), Window::default());
|
||||
|
||||
let warm = Window::opening(ChannelCursor {
|
||||
newest_ms: Some(2_000_000),
|
||||
oldest_ms: Some(1_000),
|
||||
newest: Some(Timestamp::from_secs(2_000_000)),
|
||||
oldest: Some(Timestamp::from_secs(1_000)),
|
||||
exhausted: false,
|
||||
});
|
||||
assert_eq!(
|
||||
warm,
|
||||
Window {
|
||||
since_ms: Some(2_000_000 - CURSOR_OVERLAP_MS),
|
||||
until_ms: None,
|
||||
since: Some(Timestamp::from_secs(2_000_000) - CURSOR_OVERLAP),
|
||||
until: None,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Window::older_than(1_000),
|
||||
Window::older_than(Timestamp::from_secs(1_000)),
|
||||
Window {
|
||||
since_ms: None,
|
||||
until_ms: Some(999),
|
||||
since: None,
|
||||
until: Some(Timestamp::from_secs(999)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
+297
-17
@@ -1,15 +1,21 @@
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Result, bail};
|
||||
use concord::cord01::{self, KIND_WRAP_EPHEMERAL, SealForm};
|
||||
use concord::cord04::roles::{CommunityRoles, Permissions};
|
||||
use concord::cord06::{self, Continuity, RekeyScope, Rotation, RotationKey};
|
||||
use concord::derive::{GroupKey, channel_rekey_group_key};
|
||||
use concord::cord04::{self, AuthorityCitation};
|
||||
use concord::cord06::{
|
||||
self, Continuity, Refounding, RekeyScope, Rotation, RotationKey, RotationPlan,
|
||||
};
|
||||
use concord::derive::{GroupKey, channel_rekey_group_key, epoch_key_commitment};
|
||||
use concord::state::{CommunityState, HeldKey};
|
||||
use concord::{ChannelId, CommunityId, Epoch, cord01};
|
||||
use concord::{ChannelId, CommunityId, Epoch};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::UniversalSigner;
|
||||
|
||||
use crate::sync::{self, PlaneKind};
|
||||
|
||||
/// Epochs ahead of a held epoch a rotation is looked for.
|
||||
pub const REKEY_LOOKAHEAD: u64 = 8;
|
||||
/// How much of what a relay stores a rekey watch replays.
|
||||
@@ -24,6 +30,15 @@ pub struct Watch {
|
||||
pub epoch: Epoch,
|
||||
}
|
||||
|
||||
/// The permission a rotation of `scope` is judged under, by whoever reads it and
|
||||
/// by this client when it publishes one.
|
||||
fn permissions(scope: RekeyScope) -> &'static [u64] {
|
||||
match scope {
|
||||
RekeyScope::Base => &[Permissions::BAN],
|
||||
RekeyScope::Channel(_) => &[Permissions::MANAGE_CHANNELS, Permissions::BAN],
|
||||
}
|
||||
}
|
||||
|
||||
/// Every address a community's rotations can arrive at.
|
||||
pub fn watches(state: &CommunityState) -> Result<Vec<Watch>> {
|
||||
let mut watches = Vec::new();
|
||||
@@ -135,6 +150,8 @@ pub struct BaseAdoption {
|
||||
pub epoch: Epoch,
|
||||
pub key: [u8; 32],
|
||||
pub control_pk: Option<PublicKey>,
|
||||
/// The new Control Plane signing root, delivered to staff only.
|
||||
pub control_root: Option<[u8; 32]>,
|
||||
pub stepped: Vec<HeldKey>,
|
||||
}
|
||||
|
||||
@@ -209,7 +226,7 @@ pub async fn adopt(
|
||||
|
||||
let base = walk(
|
||||
RekeyScope::Base,
|
||||
&[Permissions::BAN],
|
||||
permissions(RekeyScope::Base),
|
||||
state.root_epoch,
|
||||
state.community_root,
|
||||
state,
|
||||
@@ -226,9 +243,11 @@ pub async fn adopt(
|
||||
epoch: step.epoch,
|
||||
key: step.key,
|
||||
control_pk: step.control_pk,
|
||||
control_root: step.control_root,
|
||||
stepped: step.stepped,
|
||||
});
|
||||
}
|
||||
|
||||
adoptions.removed_at = base.removed_at;
|
||||
adoptions.stranded = base.stranded;
|
||||
|
||||
@@ -243,7 +262,7 @@ pub async fn adopt(
|
||||
|
||||
let step = walk(
|
||||
RekeyScope::Channel(channel.id),
|
||||
&[Permissions::MANAGE_CHANNELS, Permissions::BAN],
|
||||
permissions(RekeyScope::Channel(channel.id)),
|
||||
held_epoch,
|
||||
held_key,
|
||||
state,
|
||||
@@ -272,6 +291,242 @@ pub async fn adopt(
|
||||
Ok(adoptions)
|
||||
}
|
||||
|
||||
/// A rotation this client is asked to perform.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Rewrite {
|
||||
pub scope: RekeyScope,
|
||||
/// Every member the new key must reach.
|
||||
pub recipients: Vec<PublicKey>,
|
||||
/// The members it must not reach: whoever the rotation cuts off.
|
||||
pub excluded: Vec<PublicKey>,
|
||||
/// The rotator's claim to rank, when it holds the grant to cite.
|
||||
pub citation: Option<AuthorityCitation>,
|
||||
}
|
||||
|
||||
impl Rewrite {
|
||||
/// Whether `rotator` may cut `excluded` off at all.
|
||||
pub fn authorized(
|
||||
&self,
|
||||
roles: &CommunityRoles,
|
||||
owner: &PublicKey,
|
||||
rotator: &PublicKey,
|
||||
) -> bool {
|
||||
permissions(self.scope)
|
||||
.iter()
|
||||
.any(|bits| cord06::rekey_authorized(roles, owner, rotator, *bits, &self.excluded))
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish this client's own rotation of one scope to its next epoch.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn rotate(
|
||||
client: &Client,
|
||||
state: &CommunityState,
|
||||
roles: &CommunityRoles,
|
||||
signer: &UniversalSigner,
|
||||
me: PublicKey,
|
||||
rewrite: &Rewrite,
|
||||
at: Timestamp,
|
||||
) -> Result<Epoch> {
|
||||
if rewrite.excluded.contains(&me) {
|
||||
bail!("a rotation cannot cut off the member who publishes it");
|
||||
}
|
||||
|
||||
if !rewrite.recipients.contains(&me) {
|
||||
bail!("a rotation must deliver its own rotator");
|
||||
}
|
||||
|
||||
if rewrite
|
||||
.excluded
|
||||
.iter()
|
||||
.any(|target| rewrite.recipients.contains(target))
|
||||
{
|
||||
bail!("a rotation cannot both deliver to and cut off the same member");
|
||||
}
|
||||
|
||||
let (held_epoch, held_key) = stepping_off(state, rewrite.scope)?;
|
||||
let epoch = Epoch(held_epoch.0 + 1);
|
||||
|
||||
if epoch.0 > cord06::MAX_REKEY_EPOCH {
|
||||
bail!("epoch {} is past the rekey ceiling", epoch.0);
|
||||
}
|
||||
|
||||
let plan = cord06::plan_rotation(rewrite.scope, epoch)?;
|
||||
let new_key = plan.new_key();
|
||||
let control_pk = match &plan {
|
||||
RotationPlan::Base(refounding) => Some(refounding.signer(&state.id)?.pk().to_bytes()),
|
||||
RotationPlan::Channel { .. } => None,
|
||||
};
|
||||
|
||||
let mut blobs = Vec::with_capacity(rewrite.recipients.len());
|
||||
|
||||
for recipient in &rewrite.recipients {
|
||||
// A refounded Control Plane's root reaches staff only.
|
||||
let control_root = match &plan {
|
||||
RotationPlan::Base(refounding) => roles
|
||||
.is_staff(recipient, &state.owner)
|
||||
.then_some(refounding.new_control_root),
|
||||
RotationPlan::Channel { .. } => None,
|
||||
};
|
||||
|
||||
blobs.push(
|
||||
cord06::build_blob(
|
||||
signer,
|
||||
recipient,
|
||||
rewrite.scope,
|
||||
epoch,
|
||||
&new_key,
|
||||
control_pk.as_ref(),
|
||||
control_root.as_ref(),
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
let group = cord06::rekey_group(rewrite.scope, &state.community_root, &state.id, epoch)?;
|
||||
// A rotation that cuts somebody off is the severed kind.
|
||||
let severed = !rewrite.excluded.is_empty();
|
||||
|
||||
let mut wraps = cord06::build_rekey_chunks(
|
||||
signer,
|
||||
&group,
|
||||
rewrite.scope,
|
||||
epoch,
|
||||
held_epoch,
|
||||
&epoch_key_commitment(held_epoch, &held_key),
|
||||
&blobs,
|
||||
rewrite.citation.as_ref(),
|
||||
severed,
|
||||
at.as_secs(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let RotationPlan::Base(refounding) = &plan {
|
||||
let carried = carry_heads(client, state, refounding, at).await?;
|
||||
|
||||
if carried.is_empty() && !state.heads.is_empty() {
|
||||
log::warn!("community: a refounding carried no settled head forward");
|
||||
}
|
||||
|
||||
wraps.extend(carried);
|
||||
}
|
||||
|
||||
sync::publish_wraps(client, &wraps, &state.relays).await;
|
||||
|
||||
log::debug!(
|
||||
"community: rotated epoch {} to {} for {} member(s)",
|
||||
held_epoch.0,
|
||||
epoch.0,
|
||||
blobs.len()
|
||||
);
|
||||
|
||||
Ok(epoch)
|
||||
}
|
||||
|
||||
/// The epoch and key a rotation steps off.
|
||||
fn stepping_off(state: &CommunityState, scope: RekeyScope) -> Result<(Epoch, [u8; 32])> {
|
||||
match scope {
|
||||
RekeyScope::Base => Ok((state.root_epoch, state.community_root)),
|
||||
RekeyScope::Channel(channel) => state
|
||||
.channels
|
||||
.iter()
|
||||
.find(|held| held.id == channel)
|
||||
.and_then(|held| held.current())
|
||||
.ok_or_else(|| anyhow::anyhow!("no current key is held for {}", channel.to_hex())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-seal the settled control heads under a refounding's new groups.
|
||||
///
|
||||
/// A rotation only mints keys. Unless the heads ride across with it, the new
|
||||
/// epoch's Control Plane starts empty, and a member whose material never carried
|
||||
/// the root we are stepping off folds no roles, metadata or banlist at all.
|
||||
async fn carry_heads(
|
||||
client: &Client,
|
||||
state: &CommunityState,
|
||||
refounding: &Refounding,
|
||||
at: Timestamp,
|
||||
) -> Result<Vec<Event>> {
|
||||
if state.heads.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let planes = sync::planes(state)?;
|
||||
let authors: BTreeSet<PublicKey> = planes
|
||||
.iter()
|
||||
.filter(|plane| matches!(plane.kind, PlaneKind::Control(_)))
|
||||
.map(|plane| plane.address)
|
||||
.collect();
|
||||
|
||||
if authors.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let wraps = client
|
||||
.database()
|
||||
.query(
|
||||
Filter::new()
|
||||
.kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)])
|
||||
.authors(authors),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut seals = Vec::new();
|
||||
let mut seen: BTreeSet<EventId> = BTreeSet::new();
|
||||
|
||||
for wrap in &wraps {
|
||||
let Some(plane) = planes.iter().find(|plane| {
|
||||
matches!(plane.kind, PlaneKind::Control(_)) && plane.address == wrap.pubkey
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// A retired root reads only what was sealed before its rotation.
|
||||
if !plane.accepts(wrap) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(opened) =
|
||||
cord01::open_wrap_at(wrap, &plane.address, plane.group.conversation(), true)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Only a plaintext seal can be carried: `compact` re-signs nothing and
|
||||
// re-wraps the edition the author already sealed.
|
||||
if opened.seal_form != SealForm::Plaintext {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(edition) = cord04::parse_edition(&opened.rumor) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let settled = state.heads.iter().any(|head| {
|
||||
head.entity == edition.entity
|
||||
&& head.version == edition.version
|
||||
&& head.self_hash == edition.self_hash
|
||||
});
|
||||
|
||||
// The same edition can be reachable twice once a head has been carried
|
||||
// forward before: a head is published once per refounding.
|
||||
if settled && seen.insert(opened.seal.id) {
|
||||
seals.push(opened.seal);
|
||||
}
|
||||
}
|
||||
|
||||
if seals.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
Ok(cord06::compact(
|
||||
&seals,
|
||||
&refounding.read(&state.id)?,
|
||||
&refounding.signer(&state.id)?,
|
||||
at.as_secs(),
|
||||
)?)
|
||||
}
|
||||
|
||||
/// The key grouping a rotation's chunks, recomputed from a collected rotation.
|
||||
fn rotation_key(rotation: &Rotation) -> RotationKey {
|
||||
(
|
||||
@@ -295,9 +550,19 @@ struct Adopted {
|
||||
epoch: Epoch,
|
||||
key: [u8; 32],
|
||||
control_pk: Option<PublicKey>,
|
||||
control_root: Option<[u8; 32]>,
|
||||
stepped: Vec<HeldKey>,
|
||||
}
|
||||
|
||||
/// What one rotation offered this client, and when it was published.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Delivery {
|
||||
key: [u8; 32],
|
||||
control_pk: Option<PublicKey>,
|
||||
control_root: Option<[u8; 32]>,
|
||||
at_ms: u64,
|
||||
}
|
||||
|
||||
/// Walk a scope's rotations forward, one epoch at a time, off the key held.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn walk(
|
||||
@@ -340,7 +605,7 @@ async fn walk(
|
||||
break;
|
||||
}
|
||||
|
||||
let mut delivery: Option<([u8; 32], Option<PublicKey>, u64)> = None;
|
||||
let mut delivery: Option<Delivery> = None;
|
||||
let mut addressed = false;
|
||||
|
||||
for rotation in candidates
|
||||
@@ -384,34 +649,43 @@ async fn walk(
|
||||
let control_pk = delivered
|
||||
.control_pk
|
||||
.and_then(|bytes| PublicKey::from_slice(&bytes).ok());
|
||||
let raced = delivery.as_ref().map(|held| held.0);
|
||||
let raced = delivery.map(|held| held.key);
|
||||
|
||||
// Racing rotations converge on the lowest new key.
|
||||
if raced.is_none_or(|raced| delivered.new_key < raced) {
|
||||
delivery = Some((delivered.new_key, control_pk, at_ms));
|
||||
delivery = Some(Delivery {
|
||||
key: delivered.new_key,
|
||||
control_pk,
|
||||
control_root: delivered.control_root,
|
||||
at_ms,
|
||||
});
|
||||
} else if let Some(held) = delivery.as_mut() {
|
||||
held.2 = held.2.min(at_ms);
|
||||
held.at_ms = held.at_ms.min(at_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((key, control_pk, at_ms)) = delivery {
|
||||
if let Some(delivered) = delivery {
|
||||
stepped.insert(
|
||||
0,
|
||||
HeldKey {
|
||||
epoch: held_epoch,
|
||||
key: held_key,
|
||||
retired_at: Some(at_ms / 1000),
|
||||
// The cutoff is a second-granular read boundary, so the
|
||||
// rotation's millisecond publish time narrows to the second
|
||||
// it landed in.
|
||||
retired_at: Some(Timestamp::from_secs(delivered.at_ms / 1000)),
|
||||
},
|
||||
);
|
||||
|
||||
held_epoch = target;
|
||||
held_key = key;
|
||||
held_key = delivered.key;
|
||||
|
||||
step.adopted = Some(Adopted {
|
||||
epoch: target,
|
||||
key,
|
||||
control_pk,
|
||||
key: delivered.key,
|
||||
control_pk: delivered.control_pk,
|
||||
control_root: delivered.control_root,
|
||||
stepped: stepped.clone(),
|
||||
});
|
||||
|
||||
@@ -618,7 +892,10 @@ mod tests {
|
||||
assert_eq!(base.stepped.len(), 1);
|
||||
assert_eq!(base.stepped[0].epoch, Epoch(0));
|
||||
assert_eq!(base.stepped[0].key, ROOT);
|
||||
assert_eq!(base.stepped[0].retired_at, Some(AT_MS / 1000));
|
||||
assert_eq!(
|
||||
base.stepped[0].retired_at,
|
||||
Some(Timestamp::from_secs(AT_MS / 1000))
|
||||
);
|
||||
assert!(adoptions.removed_at.is_none());
|
||||
assert!(!adoptions.stranded);
|
||||
});
|
||||
@@ -722,7 +999,10 @@ mod tests {
|
||||
assert_eq!(adopted.stepped.len(), 1);
|
||||
assert_eq!(adopted.stepped[0].epoch, Epoch(0));
|
||||
assert_eq!(adopted.stepped[0].key, CHANNEL_KEY);
|
||||
assert_eq!(adopted.stepped[0].retired_at, Some(AT_MS / 1000));
|
||||
assert_eq!(
|
||||
adopted.stepped[0].retired_at,
|
||||
Some(Timestamp::from_secs(AT_MS / 1000))
|
||||
);
|
||||
assert!(adoptions.cuts.is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ use nostr_sdk::prelude::*;
|
||||
use state::UniversalSigner;
|
||||
|
||||
use crate::cache::{self, Observed};
|
||||
use crate::history::{CURSOR_OVERLAP_MS, Window};
|
||||
use crate::history::{CURSOR_OVERLAP, Window};
|
||||
|
||||
/// How much of what a relay stores a cold subscription replays per relay.
|
||||
const LIVE_REPLAY: usize = 500;
|
||||
@@ -35,15 +35,15 @@ pub struct Plane {
|
||||
/// The wrap's author: the control signer for Control, the group's own key otherwise.
|
||||
pub address: PublicKey,
|
||||
pub group: GroupKey,
|
||||
/// Epoch seconds the key behind this plane was retired.
|
||||
pub retired_at: Option<u64>,
|
||||
/// When the rotation that retired this plane's key published.
|
||||
pub retired_at: Option<Timestamp>,
|
||||
}
|
||||
|
||||
impl Plane {
|
||||
/// Whether a wrap sealed under this plane is still inside its key's life.
|
||||
pub fn accepts(&self, wrap: &Event) -> bool {
|
||||
self.retired_at
|
||||
.is_none_or(|retired| wrap.created_at.as_secs() <= retired)
|
||||
.is_none_or(|retired| wrap.created_at <= retired)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ pub fn planes(state: &CommunityState) -> Result<Vec<Plane>> {
|
||||
control_pk: None,
|
||||
retired_at: None,
|
||||
});
|
||||
|
||||
let group = control_group_key(&root.key, &state.id, epoch)?;
|
||||
planes.push(Plane {
|
||||
kind: PlaneKind::Control(epoch),
|
||||
@@ -124,12 +125,12 @@ pub fn plane_filter(planes: &[Plane]) -> Filter {
|
||||
pub fn live_filter(planes: &[Plane], window: Window) -> Filter {
|
||||
let mut filter = plane_filter(planes);
|
||||
|
||||
if let Some(until_ms) = window.until_ms {
|
||||
filter = filter.until(Timestamp::from_secs(until_ms / 1000));
|
||||
if let Some(until) = window.until {
|
||||
filter = filter.until(until);
|
||||
}
|
||||
|
||||
if let Some(since_ms) = window.since_ms {
|
||||
filter = filter.since(Timestamp::from_secs(since_ms / 1000));
|
||||
if let Some(since) = window.since {
|
||||
filter = filter.since(since);
|
||||
}
|
||||
|
||||
filter.limit(LIVE_REPLAY)
|
||||
@@ -140,13 +141,13 @@ pub fn live_window(state: &CommunityState) -> Window {
|
||||
let floor = state
|
||||
.channels
|
||||
.iter()
|
||||
.filter_map(|channel| state.cursors.get(&channel.id)?.newest_ms)
|
||||
.filter_map(|channel| state.cursors.get(&channel.id)?.newest)
|
||||
.min();
|
||||
|
||||
match floor {
|
||||
Some(floor) => Window {
|
||||
since_ms: Some(floor.saturating_sub(CURSOR_OVERLAP_MS)),
|
||||
until_ms: None,
|
||||
since: Some(floor - CURSOR_OVERLAP),
|
||||
until: None,
|
||||
},
|
||||
None => Window::default(),
|
||||
}
|
||||
@@ -213,7 +214,7 @@ where
|
||||
}
|
||||
|
||||
/// Best-effort publication of the genesis wraps to the community's relays.
|
||||
async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) {
|
||||
pub(crate) async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) {
|
||||
connect_relays(client, relays).await;
|
||||
|
||||
for wrap in wraps {
|
||||
@@ -428,7 +429,7 @@ fn refresh(mut held: CommunityState, fresh: CommunityState) -> CommunityState {
|
||||
Some(held) => {
|
||||
held.name = channel.name;
|
||||
|
||||
if cut.is_some_and(|cut| channel.epoch.0 <= cut) {
|
||||
if cut.is_some_and(|cut| channel.epoch <= cut) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -767,16 +768,16 @@ mod tests {
|
||||
state.cursors.insert(
|
||||
channel,
|
||||
concord::state::ChannelCursor {
|
||||
newest_ms: Some(2_000_000),
|
||||
oldest_ms: Some(1_000),
|
||||
newest: Some(Timestamp::from_secs(2_000_000)),
|
||||
oldest: Some(Timestamp::from_secs(1_000)),
|
||||
exhausted: false,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
live_window(&state),
|
||||
Window {
|
||||
since_ms: Some(2_000_000 - CURSOR_OVERLAP_MS),
|
||||
until_ms: None,
|
||||
since: Some(Timestamp::from_secs(2_000_000) - CURSOR_OVERLAP),
|
||||
until: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1138,7 +1139,7 @@ mod tests {
|
||||
priors: vec![HeldKey {
|
||||
epoch: Epoch(0),
|
||||
key: [0x07; 32],
|
||||
retired_at: Some(1_000),
|
||||
retired_at: Some(Timestamp::from_secs(1_000)),
|
||||
}],
|
||||
}];
|
||||
|
||||
@@ -1148,7 +1149,7 @@ mod tests {
|
||||
.find(|plane| plane.kind == PlaneKind::Channel(channel, Epoch(0)))
|
||||
.expect("the retired epoch keeps a plane");
|
||||
|
||||
assert_eq!(retired.retired_at, Some(1_000));
|
||||
assert_eq!(retired.retired_at, Some(Timestamp::from_secs(1_000)));
|
||||
assert!(retired.accepts(&event_at(1_000_000)));
|
||||
assert!(!retired.accepts(&event_at(1_001_000)));
|
||||
}
|
||||
|
||||
@@ -17,4 +17,3 @@ gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
smallvec.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
@@ -18,7 +18,6 @@ rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
smol.workspace = true
|
||||
|
||||
@@ -534,6 +534,43 @@ pub fn plan_refounding(epoch: Epoch) -> Result<Refounding> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The material one rotation delivers, by scope.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RotationPlan {
|
||||
/// A refounding: a fresh root, with the root that reads and signs under it.
|
||||
Base(Refounding),
|
||||
/// A channel: a fresh channel key, and nothing beside it.
|
||||
Channel { epoch: Epoch, new_key: [u8; 32] },
|
||||
}
|
||||
|
||||
impl RotationPlan {
|
||||
pub fn epoch(&self) -> Epoch {
|
||||
match self {
|
||||
Self::Base(refounding) => refounding.epoch,
|
||||
Self::Channel { epoch, .. } => *epoch,
|
||||
}
|
||||
}
|
||||
|
||||
/// The secret the rotation delivers, which becomes the scope's new read key.
|
||||
pub fn new_key(&self) -> [u8; 32] {
|
||||
match self {
|
||||
Self::Base(refounding) => refounding.new_root,
|
||||
Self::Channel { new_key, .. } => *new_key,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mint what a rotation of `scope` delivers at `epoch`.
|
||||
pub fn plan_rotation(scope: RekeyScope, epoch: Epoch) -> Result<RotationPlan> {
|
||||
match scope {
|
||||
RekeyScope::Base => Ok(RotationPlan::Base(plan_refounding(epoch)?)),
|
||||
RekeyScope::Channel(_) => Ok(RotationPlan::Channel {
|
||||
epoch,
|
||||
new_key: random_32()?,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Carries the settled heads across a refounding.
|
||||
pub fn compact(
|
||||
seals: &[Event],
|
||||
|
||||
+97
-22
@@ -22,8 +22,10 @@ pub const STATE_PREFIX: &str = "concord/";
|
||||
pub struct HeldKey {
|
||||
pub epoch: Epoch,
|
||||
pub key: [u8; 32],
|
||||
/// The publish time of the rotation that superseded this key: a wrap
|
||||
/// sealed under it later than this does not read.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retired_at: Option<u64>,
|
||||
pub retired_at: Option<Timestamp>,
|
||||
}
|
||||
|
||||
/// A community root epoch the client still holds, retained for the same reason.
|
||||
@@ -34,8 +36,9 @@ pub struct HeldRoot {
|
||||
/// The epoch's Control Plane signer, when the rotation delivered one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub control_pk: Option<PublicKey>,
|
||||
/// The publish time of the rotation that superseded this root.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retired_at: Option<u64>,
|
||||
pub retired_at: Option<Timestamp>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -59,15 +62,15 @@ impl ChannelKeyRef {
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a channel's history sync has reached, in epoch milliseconds.
|
||||
/// How far a channel's history sync has reached, in wrap times.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChannelCursor {
|
||||
/// The newest wrap ingested, so a live subscription knows where to resume.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub newest_ms: Option<u64>,
|
||||
pub newest: Option<Timestamp>,
|
||||
/// The oldest wrap paged back to, so the next round resumes below it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub oldest_ms: Option<u64>,
|
||||
pub oldest: Option<Timestamp>,
|
||||
/// History verifiably swept to the bottom.
|
||||
#[serde(default)]
|
||||
pub exhausted: bool,
|
||||
@@ -76,14 +79,14 @@ pub struct ChannelCursor {
|
||||
impl ChannelCursor {
|
||||
pub fn merge(self, round: Self) -> Self {
|
||||
Self {
|
||||
newest_ms: later(self.newest_ms, round.newest_ms),
|
||||
oldest_ms: earlier(self.oldest_ms, round.oldest_ms),
|
||||
newest: later(self.newest, round.newest),
|
||||
oldest: earlier(self.oldest, round.oldest),
|
||||
exhausted: self.exhausted || round.exhausted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn later(held: Option<u64>, round: Option<u64>) -> Option<u64> {
|
||||
fn later(held: Option<Timestamp>, round: Option<Timestamp>) -> Option<Timestamp> {
|
||||
match (held, round) {
|
||||
(Some(held), Some(round)) => Some(held.max(round)),
|
||||
(held, None) => held,
|
||||
@@ -91,7 +94,7 @@ fn later(held: Option<u64>, round: Option<u64>) -> Option<u64> {
|
||||
}
|
||||
}
|
||||
|
||||
fn earlier(held: Option<u64>, round: Option<u64>) -> Option<u64> {
|
||||
fn earlier(held: Option<Timestamp>, round: Option<Timestamp>) -> Option<Timestamp> {
|
||||
match (held, round) {
|
||||
(Some(held), Some(round)) => Some(held.min(round)),
|
||||
(held, None) => held,
|
||||
@@ -121,23 +124,27 @@ pub struct CommunityState {
|
||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||
pub banned: BTreeSet<PublicKey>,
|
||||
/// Where each channel's history sync has reached.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
#[serde(
|
||||
default,
|
||||
rename = "channel_cursors",
|
||||
skip_serializing_if = "BTreeMap::is_empty"
|
||||
)]
|
||||
pub cursors: BTreeMap<ChannelId, ChannelCursor>,
|
||||
/// Root epochs superseded by a rotation we adopted, newest first.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub held_roots: Vec<HeldRoot>,
|
||||
/// The epoch a channel rotation removed us at.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub channel_cuts: BTreeMap<ChannelId, u64>,
|
||||
pub channel_cuts: BTreeMap<ChannelId, Epoch>,
|
||||
/// The base epoch we were excluded at.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub removed_at: Option<Epoch>,
|
||||
/// A complete rotation ahead of our epoch predates our join and carries no
|
||||
/// blob for us: a stale invite landed us on a superseded epoch.
|
||||
/// A complete rotation ahead of our epoch predates our join and carries no blob.
|
||||
#[serde(default)]
|
||||
pub stranded: bool,
|
||||
#[serde(default)]
|
||||
pub dissolved: bool,
|
||||
/// When this community joined the member's list, in milliseconds.
|
||||
pub added_at_ms: u64,
|
||||
}
|
||||
|
||||
@@ -326,7 +333,7 @@ impl CommunityState {
|
||||
pub fn channel_cut(&self, channel: &ChannelId, epoch: Epoch) -> bool {
|
||||
self.channel_cuts
|
||||
.get(channel)
|
||||
.is_some_and(|cut| epoch.0 <= *cut)
|
||||
.is_some_and(|cut| epoch <= *cut)
|
||||
}
|
||||
|
||||
pub fn floors(&self) -> Floors {
|
||||
@@ -431,8 +438,8 @@ mod tests {
|
||||
#[test]
|
||||
fn a_cursor_merge_only_moves_forward_and_never_seals() {
|
||||
let held = ChannelCursor {
|
||||
newest_ms: Some(1_000),
|
||||
oldest_ms: Some(5_000),
|
||||
newest: Some(Timestamp::from_secs(1_000)),
|
||||
oldest: Some(Timestamp::from_secs(5_000)),
|
||||
exhausted: false,
|
||||
};
|
||||
|
||||
@@ -440,15 +447,15 @@ mod tests {
|
||||
assert_eq!(held.merge(ChannelCursor::default()), held);
|
||||
|
||||
let merged = held.merge(ChannelCursor {
|
||||
newest_ms: Some(2_000),
|
||||
oldest_ms: Some(3_000),
|
||||
newest: Some(Timestamp::from_secs(2_000)),
|
||||
oldest: Some(Timestamp::from_secs(3_000)),
|
||||
exhausted: true,
|
||||
});
|
||||
assert_eq!(
|
||||
merged,
|
||||
ChannelCursor {
|
||||
newest_ms: Some(2_000),
|
||||
oldest_ms: Some(3_000),
|
||||
newest: Some(Timestamp::from_secs(2_000)),
|
||||
oldest: Some(Timestamp::from_secs(3_000)),
|
||||
exhausted: true,
|
||||
}
|
||||
);
|
||||
@@ -456,14 +463,82 @@ mod tests {
|
||||
// A later round that learned less cannot walk either bound back.
|
||||
assert_eq!(
|
||||
merged.merge(ChannelCursor {
|
||||
newest_ms: Some(1_500),
|
||||
oldest_ms: Some(4_000),
|
||||
newest: Some(Timestamp::from_secs(1_500)),
|
||||
oldest: Some(Timestamp::from_secs(4_000)),
|
||||
exhausted: false,
|
||||
}),
|
||||
merged
|
||||
);
|
||||
}
|
||||
|
||||
/// A cursor stored when the boundaries were milliseconds must not be read as
|
||||
/// seconds. The key it was stored under is gone, so the document's counters
|
||||
/// are ignored and the channel re-syncs rather than being sealed off by an
|
||||
/// `exhausted` that outlived the bounds it was earned against.
|
||||
#[test]
|
||||
fn a_cursor_stored_in_the_old_unit_is_dropped_rather_than_reinterpreted() {
|
||||
let channel = ChannelId::from_bytes([0x9c; 32]);
|
||||
let cursor = ChannelCursor {
|
||||
newest: Some(Timestamp::from_secs(1_700_000_000)),
|
||||
exhausted: true,
|
||||
..ChannelCursor::default()
|
||||
};
|
||||
let mut state = CommunityState {
|
||||
id: CommunityId::from_bytes([0x42; 32]),
|
||||
name: None,
|
||||
owner: Keys::generate().public_key(),
|
||||
owner_salt: [0x01; 32],
|
||||
community_root: [0x02; 32],
|
||||
root_epoch: Epoch(0),
|
||||
control_root: None,
|
||||
control_pks: BTreeMap::new(),
|
||||
channels: Vec::new(),
|
||||
relays: Vec::new(),
|
||||
heads: Vec::new(),
|
||||
banned: BTreeSet::new(),
|
||||
cursors: BTreeMap::new(),
|
||||
held_roots: Vec::new(),
|
||||
channel_cuts: BTreeMap::new(),
|
||||
removed_at: None,
|
||||
stranded: false,
|
||||
dissolved: false,
|
||||
added_at_ms: 7,
|
||||
};
|
||||
|
||||
// The document a version that stored milliseconds wrote: its own key,
|
||||
// and boundaries padded by a thousand.
|
||||
let mut stored = serde_json::Map::new();
|
||||
stored.insert(
|
||||
channel.to_hex(),
|
||||
serde_json::json!({
|
||||
"newest_ms": 1_700_000_000_000u64,
|
||||
"oldest_ms": 1_699_999_000_000u64,
|
||||
"exhausted": true
|
||||
}),
|
||||
);
|
||||
|
||||
let mut legacy = serde_json::to_value(&state).expect("serializes");
|
||||
legacy
|
||||
.as_object_mut()
|
||||
.expect("a document")
|
||||
.insert("cursors".to_owned(), serde_json::Value::Object(stored));
|
||||
|
||||
let read: CommunityState = serde_json::from_value(legacy).expect("deserializes");
|
||||
|
||||
assert!(
|
||||
read.cursors.is_empty(),
|
||||
"a millisecond cursor is not a seconds cursor"
|
||||
);
|
||||
|
||||
// A typed cursor still round-trips under the key it is written with.
|
||||
state.cursors.insert(channel, cursor);
|
||||
|
||||
let document = serde_json::to_value(&state).expect("serializes");
|
||||
let read: CommunityState = serde_json::from_value(document).expect("deserializes");
|
||||
|
||||
assert_eq!(read.cursors.get(&channel), Some(&cursor));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_join_material_materializes_a_subscribable_state_with_or_without_the_control_root() {
|
||||
let owner = Keys::generate().public_key();
|
||||
|
||||
@@ -35,9 +35,20 @@ subscriptions too, which is what a page REQ is (`nostr-sdk/src/relay/inner.rs`
|
||||
`RelayNotification::Authenticated` arm). Only `AuthenticationFailed` ends a relay's
|
||||
wait, and it is reported rather than rendered as an empty channel.
|
||||
|
||||
**Revision 3.** One more correction, from a review of what revision 2 landed:
|
||||
|
||||
4. **Time is a type, not a `u64` with the unit in its name.** The read path held
|
||||
three different units in the same `u64`: `HeldKey`/`HeldRoot.retired_at` held
|
||||
seconds (but were compared with a `Timestamp` through `.as_secs()`),
|
||||
`ChannelCursor` held milliseconds that were only ever seconds multiplied by a
|
||||
thousand, and `CommunityState.channel_cuts` held epochs. Every filter boundary
|
||||
divided by a thousand on the way out. §10 types the time fields (`Timestamp`,
|
||||
`Epoch`) and leaves a millisecond only where a millisecond is real.
|
||||
|
||||
Read path today (phase 1 landed the walk, phase 2a moved each layer, phase 2b
|
||||
swapped the transport and put the pump in charge of settling pages, phase 3 added
|
||||
the rekey watch and held epochs, phase 4 made an empty or unreadable room say so):
|
||||
the rekey watch and held epochs, phase 4 made an empty or unreadable room say so,
|
||||
phase 5 typed the times they all compare):
|
||||
|
||||
```
|
||||
CommunityPanel::load community_ui/src/lib.rs:192
|
||||
@@ -277,10 +288,10 @@ impl Window {
|
||||
/// Nothing held: ask wide, the round pages down from there.
|
||||
/// Something held: ask only for what is new, plus an overlap for the seam.
|
||||
pub fn opening(cursor: ChannelCursor) -> Self {
|
||||
match cursor.newest_ms {
|
||||
Some(newest_ms) => Window {
|
||||
since_ms: Some(newest_ms.saturating_sub(CURSOR_OVERLAP_MS)),
|
||||
until_ms: None,
|
||||
match cursor.newest {
|
||||
Some(newest) => Window {
|
||||
since: Some(newest - CURSOR_OVERLAP),
|
||||
until: None,
|
||||
},
|
||||
None => Window::default(),
|
||||
}
|
||||
@@ -296,13 +307,13 @@ pub fn live_window(state: &CommunityState) -> Window {
|
||||
let floor = state
|
||||
.channels
|
||||
.iter()
|
||||
.filter_map(|channel| state.cursors.get(&channel.id)?.newest_ms)
|
||||
.filter_map(|channel| state.cursors.get(&channel.id)?.newest)
|
||||
.min();
|
||||
|
||||
match floor {
|
||||
Some(floor) => Window {
|
||||
since_ms: Some(floor.saturating_sub(CURSOR_OVERLAP_MS)),
|
||||
until_ms: None,
|
||||
since: Some(floor - CURSOR_OVERLAP),
|
||||
until: None,
|
||||
},
|
||||
None => Window::default(),
|
||||
}
|
||||
@@ -352,7 +363,7 @@ relay.
|
||||
|
||||
Rules carried over from phase 1, none of which are negotiable:
|
||||
|
||||
- **A page boundary is exclusive.** `until = oldest_seen_ms - 1`, so consecutive
|
||||
- **A page boundary is exclusive.** `until = oldest_seen - 1`, so consecutive
|
||||
pages never share an event and the walk terminates.
|
||||
- **`exhausted` is earned.** Only `raw > 0` plus a bottomed-out page on every
|
||||
relay that answered may set it. An all-empty page sets `failed`, so the next
|
||||
@@ -366,8 +377,8 @@ Rules carried over from phase 1, none of which are negotiable:
|
||||
as refused, and it is surfaced.
|
||||
- **One cursor, one filter per page.** The database is not per relay, so per-relay
|
||||
cursors do not exist; a relay is a source that fills the database.
|
||||
- **`newest_ms` advances only on a complete round** (`!newest.failed && bridge.exhausted`),
|
||||
`oldest_ms` only walks down on a complete page, and `exhausted` is cleared by a
|
||||
- **`newest` advances only on a complete round** (`!newest.failed && bridge.exhausted`),
|
||||
`oldest` only walks down on a complete page, and `exhausted` is cleared by a
|
||||
rekey.
|
||||
|
||||
The cold/warm distinction is now the *window rule* rather than a special case:
|
||||
@@ -375,9 +386,9 @@ The cold/warm distinction is now the *window rule* rather than a special case:
|
||||
| State of the channel | Window |
|
||||
| -------------------- | ------ |
|
||||
| no cursor, nothing cached (cold open) | `Window::opening` → `since = None`: subscribe for everything the relays have, then page down |
|
||||
| cursor with `newest_ms` (warm open) | `Window::opening` → `since = newest - CURSOR_OVERLAP`: new data only |
|
||||
| cursor with `oldest_ms` | `Window::older_than(oldest_ms)`: older data, on demand |
|
||||
| a hole between `saved.newest_ms` and the newest seen | `Window::between(..)`: the bridge |
|
||||
| cursor with `newest` (warm open) | `Window::opening` → `since = newest - CURSOR_OVERLAP`: new data only |
|
||||
| cursor with `oldest` | `Window::older_than(oldest)`: older data, on demand |
|
||||
| a hole between `saved.newest` and the newest seen | `Window::between(..)`: the bridge |
|
||||
|
||||
So an open does two things, and they follow the same rule: it makes sure the live
|
||||
REQ is installed — wide when nothing is held, which is where "subscribe to get all
|
||||
@@ -390,9 +401,9 @@ And the passes fall out of that rule:
|
||||
- **Newest pass** (`CatchUp`): one page at `Window::opening(saved)`. Cold, this is
|
||||
"get all data"; warm, this is "get only new data".
|
||||
- **Bridge**: while the previous page was *full* and still above
|
||||
`saved.newest_ms`, keep walking down with `until = oldest - 1`, bounded by
|
||||
`CATCH_UP_PAGES`. A short page ends it, which is what lets `newest_ms` advance.
|
||||
- **Older pass**: resume at `saved.oldest_ms.or(oldest_seen)` and walk down,
|
||||
`saved.newest`, keep walking down with `until = oldest - 1`, bounded by
|
||||
`CATCH_UP_PAGES`. A short page ends it, which is what lets `newest` advance.
|
||||
- **Older pass**: resume at `saved.oldest.or(oldest_seen)` and walk down,
|
||||
bounded by `CATCH_UP_PAGES` on a catch-up and `LOAD_OLDER_PAGES` on a scroll-up.
|
||||
|
||||
Sizes stay the reference numbers: `PAGE_WRAPS = 50`, `CATCH_UP_PAGES = 20`,
|
||||
@@ -452,7 +463,7 @@ the next ones:
|
||||
|
||||
- `ChannelKeyRef` gains
|
||||
`#[serde(default, skip_serializing_if = "Vec::is_empty")] priors: Vec<HeldKey>`
|
||||
where `HeldKey { epoch: Epoch, key: [u8; 32], retired_at: Option<u64> }`, and
|
||||
where `HeldKey { epoch: Epoch, key: [u8; 32], retired_at: Option<Timestamp> }`, and
|
||||
`CommunityState` gains the same shape for roots (`HeldRoot { epoch, key,
|
||||
`control_pk, retired_at }`). `channel_secret` returns every held epoch,
|
||||
`history::page` already takes `&[(Epoch, [u8; 32])]`, and `sync::refresh` stops
|
||||
@@ -521,9 +532,49 @@ Three deviations from the sketch above, all of them smaller than planned:
|
||||
adoptable, so a member who missed several rotations catches up in one database
|
||||
read instead of one pass per poll. The lookahead window is what feeds it.
|
||||
|
||||
Still deferred, and named here so it is not mistaken for landed: a rotation this
|
||||
client published itself is not adopted locally (no rekey writer exists yet, so the
|
||||
watch only ever adopts another member's).
|
||||
#### The writer (phase 6) — **landed**
|
||||
|
||||
Phase 3 gave the client a receiver; phase 6 gives it a hand. `rekey::rotate`
|
||||
builds the rotation from the key the client actually holds and publishes it, and
|
||||
`Community::rotate` is the entry a moderation surface will call:
|
||||
|
||||
- **One plan, drawn in the protocol crate.** `cord06::plan_rotation(scope, epoch)`
|
||||
mints what the rotation delivers — a `Refounding` (new root and control pair)
|
||||
for a base scope, a fresh key for a channel — beside `plan_refounding`, which is
|
||||
now the base arm of it. No caller ever holds a rotation secret before the
|
||||
rotation exists.
|
||||
- **Authority first, and the same authority the receiver applies.** `Rewrite::authorized`
|
||||
runs `cord06::rekey_authorized` under `permissions(scope)` — the same list
|
||||
`adopt` now walks with, shared instead of written twice — so the client cannot
|
||||
publish a rotation it would not itself adopt. `Community::rotate` adds the three
|
||||
states the role fold cannot see: a removal, a strand and a ban.
|
||||
- **One blob per recipient, and the rotator is one of them.** A rotation that
|
||||
delivered no blob to its own rotator would strand them, so the writer refuses
|
||||
one. Staff get the new Control Plane root beside the key, everyone else gets the
|
||||
key alone.
|
||||
- **A refounding carries its heads.** `carry_heads` reads the control editions
|
||||
back out of the store, picks the ones the settled floors name, and `compact`s
|
||||
them onto the new epoch's groups. Without it the new Control Plane starts empty
|
||||
and the next reader that does not hold the old root folds no roles, metadata or
|
||||
banlist at all. (`compact` re-signs nothing, so this only works on the plaintext
|
||||
seals the Control Plane already uses.)
|
||||
- **Then it adopts itself, through the ordinary receiver.** The chunks are saved
|
||||
into the local database as they are sent, so `Community::rotate` calls the same
|
||||
`rekey::adopt` the watch calls instead of adopting by construction: one path for
|
||||
every rotation, whoever wrote it. Publish failures are logged per relay and do
|
||||
not change the held state, so a rotation that only reached some relays still
|
||||
leaves this client consistent — which is the same asymmetry the reference has.
|
||||
- **The receiver now keeps the signing root it is handed.** `BaseAdoption`
|
||||
carries the delivered `control_root` through to `state.control_root`; before
|
||||
this, a refounding's root was dropped at the receiver and a staff member went on
|
||||
signing under the epoch they had left.
|
||||
|
||||
What is still not here: the trigger. Kicking a member, choosing a channel's
|
||||
remaining audience, and rendering any of it is the moderation and
|
||||
community-management surface §8 keeps out of scope, so `Community::rotate` is an
|
||||
API with no caller in the app yet — and `Rewrite.recipients` is the caller's to
|
||||
name, because a private channel's audience is in each member's own invite and not
|
||||
in the local state.
|
||||
|
||||
### 7. Honest states (phase 4) — **landed**
|
||||
|
||||
@@ -659,6 +710,50 @@ NIP-44-open them all. The plan's per-channel work queue in the pump turned out t
|
||||
be unnecessary for that: the first fold after a live wrap already opens exactly
|
||||
the uncached wraps.
|
||||
|
||||
### 10. Time is a type: `Timestamp`, not padded milliseconds (phase 5) — **landed**
|
||||
|
||||
The read path compared times through a bare `u64` in three units, and each
|
||||
boundary paid for the confusion:
|
||||
|
||||
| Value | Held | What it cost |
|
||||
| ----- | ---- | ------------ |
|
||||
| `HeldKey.retired_at`, `HeldRoot.retired_at`, `Plane.retired_at` | seconds | compared with `wrap.created_at.as_secs()`, written as `at_ms / 1000` |
|
||||
| `ChannelCursor.newest_ms`/`oldest_ms`, `WrapPage`, `Window`, the `Walk` bounds | milliseconds, always `secs * 1000` | `* 1000` on every accepted page, `/ 1000` on every filter |
|
||||
| `CommunityState.channel_cuts` | epochs | `Epoch` unpacked to `u64` and rewrapped at the boundary |
|
||||
| `added_at_ms`, a rumor's `at_ms` | milliseconds, genuinely | — |
|
||||
|
||||
What landed:
|
||||
|
||||
- `HeldKey`/`HeldRoot.retired_at`, `ChannelCursor.{newest,oldest}`,
|
||||
`WrapPage.{newest,oldest}`, `Plane.retired_at`, `Window.{until,since}` and the
|
||||
`Walk` bounds are `Timestamp`s. A window bound and a wrap's `created_at` are now
|
||||
the same type, so `read_under` and `Plane::accepts` compare them directly,
|
||||
`wrap_filter`/`live_filter` hand them to `until`/`since` unchanged, and the
|
||||
page's exclusive boundary is written `oldest - 1` instead of `oldest_ms - 1`
|
||||
truncated back to seconds.
|
||||
- `CURSOR_OVERLAP` is a `Duration` (`60s`), not `60_000`, so
|
||||
`newest - CURSOR_OVERLAP` reads as what it is.
|
||||
- `channel_cuts` is `BTreeMap<ChannelId, Epoch>`, which is what
|
||||
`rekey::Adoptions::cuts` already carried.
|
||||
- **The two millisecond fields stay milliseconds.** A rumor's time is genuinely
|
||||
finer than a wrap's second-granular `created_at`: CORD-01 carries the sub-second
|
||||
offset in an `ms` tag and `resolve_ms_strict` puts it back, so a reader's
|
||||
`at_ms` and `timeline`'s `before_ms` keep it. The cord02 list document's
|
||||
`added_at`/`removed_at` keep it too, and not merely for fidelity: `added_at` is
|
||||
an ordering key judged against a tombstone's `removed_at` under a strict `>`,
|
||||
so narrowing both to seconds could tie a rejoin with the leave that preceded it.
|
||||
- **A stored cursor is retired, not reinterpreted.** A document written when the
|
||||
boundaries were milliseconds would deserialize into a `Timestamp` far in the
|
||||
future, and its `exhausted` would then wedge the older pass for good. The map's
|
||||
serde key changed (`cursors` → `channel_cursors`), so such a record is dropped
|
||||
and the next round rebuilds the cursor from the wraps it reads;
|
||||
`a_cursor_stored_in_the_old_unit_is_dropped_rather_than_reinterpreted` pins
|
||||
both halves of that. `retired_at` needed no such treatment, because it was
|
||||
already written in seconds.
|
||||
|
||||
No behaviour changes with it: the walk paged the same regions before and after,
|
||||
because every millisecond it held was a second multiplied by a thousand.
|
||||
|
||||
## Order of work
|
||||
|
||||
### Phase 2a — move the code (no behaviour change) — **landed**
|
||||
@@ -777,13 +872,42 @@ Five recorded deviations:
|
||||
|
||||
Phase 4 leaves the client honest about what it can see and what it can write.
|
||||
What it does not do is make it see more: the rekey writer, and with it adopting a
|
||||
rotation this client published itself, remains open (see §6).
|
||||
rotation this client published itself, was still open there (§6, closed in
|
||||
phase 6).
|
||||
|
||||
Each phase leaves the client consistent on its own. Phase 2a was invisible; phase
|
||||
2b is what makes "open a community" a subscription and a database read; phase 3
|
||||
is what keeps a rekey from stranding history; phase 4 is what makes an empty or
|
||||
unreadable room tell the truth.
|
||||
|
||||
### Phase 5 — typed time — **landed**
|
||||
|
||||
§10. `HeldKey`/`HeldRoot.retired_at`, `ChannelCursor`, `WrapPage`, `Plane`,
|
||||
`Window` and the `Walk` bounds are `Timestamp`s; `channel_cuts` is `Epoch`;
|
||||
`CURSOR_OVERLAP` is a `Duration`; and the cursor map's serde key was retired so no
|
||||
stored millisecond cursor is read as seconds. No behaviour change, no relay
|
||||
contact, no new dependency — the units the read path already compared by hand are
|
||||
now the types it compares.
|
||||
|
||||
Gate, all green: `cargo test -p concord -p community` (51 + 32),
|
||||
`cargo clippy -p concord -p community -p community_ui --all-targets`,
|
||||
`cargo +nightly fmt -p concord -p community -p community_ui --check`,
|
||||
`cargo check -p workspace --all-targets`.
|
||||
|
||||
### Phase 6 — the rekey writer — **landed**
|
||||
|
||||
§6's writer: `cord06::plan_rotation`, `rekey::{Rewrite, rotate}`, `carry_heads`,
|
||||
and `Community::rotate`, which publishes a rotation and then adopts it through the
|
||||
receiver phase 3 built. The receiver also keeps the signing root a refounding
|
||||
hands it now, instead of dropping it.
|
||||
|
||||
Gate, all green: `cargo test -p concord -p community` (51 + 32),
|
||||
`cargo clippy -p concord -p community -p community_ui --all-targets`,
|
||||
`cargo +nightly fmt -p concord -p community -p community_ui --check`,
|
||||
`cargo check -p workspace --all-targets`. No test was added for the writer itself:
|
||||
the publish half needs a relay, and the same reason the reference's own rekey
|
||||
paths are exercised by hand applies here — see `Tests` below.
|
||||
|
||||
## Phase 1 status (landed)
|
||||
|
||||
What phase 1 delivered, and what phase 2 replaces:
|
||||
@@ -832,6 +956,10 @@ outstanding are the ones that need a GPUI harness or two live accounts.
|
||||
`Window::opening(saved)` starts at `newest - CURSOR_OVERLAP`; `Window::older_than`
|
||||
never includes the boundary event; `sync::live_window` is wide cold and resumes
|
||||
at the oldest held cursor warm.
|
||||
- The cursor's unit — **landed in phase 5**: a merge only moves forward and never
|
||||
earns `exhausted`; a document whose cursors were stored in the old millisecond
|
||||
encoding reads as no cursors at all, while a typed one round-trips
|
||||
(`concord/src/state.rs`).
|
||||
- The subscription plan — **landed in 2b**: a private channel's plane appears when
|
||||
the key is held and is absent when it is not; `plane_filter` asks for both wrap
|
||||
kinds and addresses every readable plane. The page-REQ half (a warm open's REQ
|
||||
@@ -845,8 +973,15 @@ outstanding are the ones that need a GPUI harness or two live accounts.
|
||||
rotator who outranks us, published after we joined, reads as a removal; a
|
||||
channel rotation replaces the key and keeps the prior. The pump's half
|
||||
(`community/src/lib.rs`) is that a rekey watch's event wakes its community.
|
||||
- The page registry — **landed in 2b**: a page that has already unregistered
|
||||
receives nothing.
|
||||
- The rekey writer — **landed in phase 6, structurally only.** No test covers it:
|
||||
the half that could be tested without a relay (that `plan_rotation` mints two
|
||||
unrelated keys, that `Rewrite::authorized` agrees with the receiver) would not
|
||||
catch the failures that matter (a blob addressed to the wrong epoch, a chunk set
|
||||
the receiver cannot collect, a refounding that carries no head), and the half
|
||||
that would — publish, re-read, adopt — needs a real relay and a second account.
|
||||
What stands in for it is that the writer cannot take a path the receiver does
|
||||
not: same `permissions`, same `build_rekey_chunks`, and adoption goes through
|
||||
`rekey::adopt` rather than beside it.
|
||||
- The read path: the side-event budget folds an edit/delete/reaction older than
|
||||
the row window onto its message.
|
||||
- What cannot be read — **landed in phase 4**: a wrap sealed after the rotation
|
||||
@@ -885,6 +1020,9 @@ outstanding are the ones that need a GPUI harness or two live accounts.
|
||||
Unread badges, notifications, message threads, pins, typing indicators and
|
||||
presence (21059 wraps are wired for routing here, not for those features), file
|
||||
and media rendering, moderation actions, and the community-management surfaces.
|
||||
The rekey writer is the one exception, and only its mechanism: `Community::rotate`
|
||||
exists to be called, but nothing in the app calls it. Kicking a member, choosing
|
||||
the audience a rotation keeps, and showing any of it stay out.
|
||||
`crates/chat`'s DM path shares none of this code and is not touched; a later
|
||||
change can lift the page walk/cursor into a shared module if DMs grow the same
|
||||
paging.
|
||||
|
||||
+48
-26
@@ -245,7 +245,7 @@ let page = history::page(
|
||||
let cached = cache::query_rumors(&client, &channel, None, 50, Some(&cord03::ROW_KINDS)).await?;
|
||||
```
|
||||
|
||||
A key's `retired_at` (epoch seconds, set when a rotation supersedes it) is a read
|
||||
A key's `retired_at` (a `Timestamp`, set when a rotation supersedes it) is a read
|
||||
cutoff: a wrap at that epoch with a later `created_at` is refused, so a retired
|
||||
epoch is history and never a live plane an ejected holder can keep writing into.
|
||||
|
||||
@@ -271,21 +271,24 @@ the handshake completes, so the page waits for the resubscribed answer rather
|
||||
than writing off a relay that only wanted to authenticate.
|
||||
|
||||
`history::page` walks newest-first across every held epoch, caches what it opens, and
|
||||
reports what it saw: `oldest_ms`/`newest_ms` feed the caller's `ChannelCursor`,
|
||||
reports what it saw: `oldest`/`newest` feed the caller's `ChannelCursor`,
|
||||
`exhausted` is earned only by a short page *after* history was seen, and an
|
||||
all-empty answer sets `failed` so a later round re-asks instead of sealing the
|
||||
channel at "no more history". `unreadable` counts the wraps the page reached that
|
||||
no held key could open — sealed past the cutoff their key's rotation set, or bound
|
||||
to another channel — because those are history the reader is missing, not history
|
||||
that is not there. Page down with `Window::older_than(seen.oldest_ms)`,
|
||||
open a channel with `Window::opening(cursor)` (wide cold, `newest_ms - 60s` warm),
|
||||
and read the region between two cursors with `Window::between(..)`. `query_rumors`
|
||||
is the read path when the group keys are gone; pass `kinds` to budget rows apart
|
||||
from the events that only decorate them. `cache::wrapper_index` reads the cached
|
||||
rows back keyed by the wrap they came from, which is what lets `sync::fold` observe
|
||||
author and message times without re-opening a wrap it already cached, and
|
||||
`cache::purge_expired(client, &channel, now)` runs at the top of every round — the
|
||||
timer is cooperative, so the local store is the artifact that has to forget.
|
||||
that is not there. Page down with `Window::older_than(seen.oldest)`,
|
||||
open a channel with `Window::opening(cursor)` (wide cold, `newest - 60s` warm),
|
||||
and read the region between two cursors with `Window::between(..)`. A `Window`
|
||||
carries `Timestamp`s, so its bounds go straight into a NIP-01 filter with no unit
|
||||
conversion; only a reader's millisecond `at_ms` narrows to a second at the query.
|
||||
`query_rumors` is the read path when the group keys are gone; pass `kinds` to
|
||||
budget rows apart from the events that only decorate them. `cache::wrapper_index`
|
||||
reads the cached rows back keyed by the wrap they came from, which is what lets
|
||||
`sync::fold` observe author and message times without re-opening a wrap it already
|
||||
cached, and `cache::purge_expired(client, &channel, now)` runs at the top of every
|
||||
round — the timer is cooperative, so the local store is the artifact that has to
|
||||
forget.
|
||||
|
||||
`sync::fold` counts the same thing over the whole store, per channel, in
|
||||
`Snapshot.unreadable`. `sync_round` sums a round's pages into
|
||||
@@ -415,25 +418,25 @@ whether a link still stands, and `fits()` is the write gate.
|
||||
## Rekeys, refounding and dissolution
|
||||
|
||||
A rotation is authority plus delivery: `rekey_authorized(&control.roles, &owner, &me, permission, &removed)`
|
||||
gates it, `plan_refounding(epoch)` mints the new pair, and `build_rekey_chunks`
|
||||
seals one blob per remaining member:
|
||||
gates it, `plan_rotation(scope, epoch)` mints what it delivers, and
|
||||
`build_rekey_chunks` seals one blob per remaining member:
|
||||
|
||||
```rust
|
||||
use concord::derive::epoch_key_commitment;
|
||||
use concord::cord06::{self, RekeyScope};
|
||||
use concord::cord06::{self, RekeyScope, RotationPlan};
|
||||
|
||||
let scope = RekeyScope::Channel(channel_id); // or RekeyScope::Base
|
||||
let plan = cord06::plan_refounding(Epoch(epoch + 1))?;
|
||||
let plan = cord06::plan_rotation(scope, Epoch(epoch + 1))?;
|
||||
let new_key = plan.new_key();
|
||||
|
||||
// A base rotation delivers the new control-plane keys beside the root; a channel
|
||||
// rotation delivers only that channel's fresh key.
|
||||
let new_key = plan.new_root;
|
||||
let (control_pk, control_root) = match scope {
|
||||
RekeyScope::Base => {
|
||||
let pk = plan.signer(&community_id)?.pk().to_bytes();
|
||||
(Some(pk), is_staff.then_some(&plan.new_control_root))
|
||||
let (control_pk, control_root) = match &plan {
|
||||
RotationPlan::Base(refounding) => {
|
||||
let pk = refounding.signer(&community_id)?.pk().to_bytes();
|
||||
(Some(pk), is_staff.then_some(&refounding.new_control_root))
|
||||
}
|
||||
RekeyScope::Channel(_) => (None, None),
|
||||
RotationPlan::Channel { .. } => (None, None),
|
||||
};
|
||||
|
||||
let mut blobs = Vec::with_capacity(members.len());
|
||||
@@ -441,18 +444,24 @@ let mut blobs = Vec::with_capacity(members.len());
|
||||
for member in &members {
|
||||
blobs.push(
|
||||
cord06::build_blob(
|
||||
&my_keys, member, scope, plan.epoch, &new_key, control_pk.as_ref(), control_root,
|
||||
&my_keys,
|
||||
member,
|
||||
scope,
|
||||
plan.epoch(),
|
||||
&new_key,
|
||||
control_pk.as_ref(),
|
||||
control_root,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
let rekey_group = cord06::rekey_group(scope, &community_root, &community_id, plan.epoch)?;
|
||||
let rekey_group = cord06::rekey_group(scope, &community_root, &community_id, plan.epoch())?;
|
||||
let wraps = cord06::build_rekey_chunks(
|
||||
&my_keys,
|
||||
&rekey_group,
|
||||
scope,
|
||||
plan.epoch,
|
||||
plan.epoch(),
|
||||
Epoch(epoch),
|
||||
&epoch_key_commitment(Epoch(epoch), &community_root),
|
||||
&blobs,
|
||||
@@ -476,6 +485,18 @@ waiving a gap, never adopting a fork), keeps each stepped-off key as a prior
|
||||
with the rotation's publish time as its read cutoff, and reports a removal or a
|
||||
strand when a complete rotation carries no blob for the member.
|
||||
|
||||
`community::rekey::rotate` is the sender, run against the held state: it resolves
|
||||
the epoch and key the scope is stepping off, refuses a rotation that would skip or
|
||||
cut off its own rotator, delivers one blob per recipient (`Rewrite.recipients`,
|
||||
which the caller names because a private channel's audience is not in the local
|
||||
state), marks the rotation severed when it excludes somebody, and — for a base
|
||||
scope — carries the settled Control Plane heads onto the new epoch's groups with
|
||||
`cord06::compact`. `Community::rotate` checks `Rewrite::authorized` first, the same
|
||||
`rekey_authorized` under the same permissions `adopt` applies, publishes to the
|
||||
community's relays, and then adopts what it published through `rekey::adopt`
|
||||
rather than by construction: a rotation this client wrote and one it received take
|
||||
one path.
|
||||
|
||||
The blob plaintext is a fixed-width binary record, but a signer's NIP-44 is
|
||||
text-only, so `build_blob` carries it base64-encoded inside the envelope.
|
||||
`open_blob` mirrors that, so the record layout and the `locator` are unchanged.
|
||||
@@ -737,5 +758,6 @@ client.subscribe(filter).with_id(sub_id).await?;
|
||||
rendered as a notice **and** enforced at write time: `Community::send` refuses
|
||||
when `channel_secret` is `None` (a removal, a strand, a channel cut, or a key we
|
||||
never held), because a wrap sealed under a superseded root would reach nobody who
|
||||
rotated. The residual gap: this client does not adopt a rotation it published
|
||||
itself, because there is no rekey writer yet.
|
||||
rotated. A rotation this client publishes itself (`Community::rotate`) is adopted
|
||||
through the same `rekey::adopt` an arriving one goes through, so the held state
|
||||
and the wire cannot diverge.
|
||||
|
||||
Reference in New Issue
Block a user