refactor concord
This commit is contained in:
@@ -300,9 +300,6 @@ async fn subscribe(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Concord wraps share kind 1059 with NIP-59 gift wraps, so an automatic
|
|
||||||
// target sends gossip after the plane authors as if they were DM peers.
|
|
||||||
// The community's own relays are the routing relays, so target them.
|
|
||||||
let target = if relays.is_empty() {
|
let target = if relays.is_empty() {
|
||||||
ReqTarget::auto(vec![filter])
|
ReqTarget::auto(vec![filter])
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use std::fmt;
|
|||||||
use data_encoding::BASE64;
|
use data_encoding::BASE64;
|
||||||
use nostr::nips::nip44::v2::{ConversationKey, decrypt_to_bytes, encrypt_to_bytes_with_nonce};
|
use nostr::nips::nip44::v2::{ConversationKey, decrypt_to_bytes, encrypt_to_bytes_with_nonce};
|
||||||
use nostr_sdk::prelude::{
|
use nostr_sdk::prelude::{
|
||||||
Event, EventBuilder, EventId, FinalizeEvent, Keys, Kind, PublicKey, Tag, Timestamp,
|
AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, EventBuilder, EventId, FinalizeEvent,
|
||||||
UnsignedEvent,
|
FinalizeEventAsync, Keys, Kind, PublicKey, Tag, Timestamp, UnsignedEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::derive::GroupKey;
|
use crate::derive::GroupKey;
|
||||||
@@ -198,32 +198,52 @@ pub fn open_bytes(conversation: &ConversationKey, content: &str) -> Result<Vec<u
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A member's own document (the Community List, the Invite List): NIP-44 to self.
|
/// A member's own document (the Community List, the Invite List): NIP-44 to self.
|
||||||
pub fn seal_to_self(keys: &Keys, plaintext: &[u8]) -> Result<String, StreamError> {
|
pub async fn seal_to_self<S>(signer: &S, plaintext: &str) -> Result<String, StreamError>
|
||||||
seal_bytes(
|
where
|
||||||
&ConversationKey::derive(keys.secret_key(), &keys.public_key())
|
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||||
.map_err(|error| StreamError::Encrypt(error.to_string()))?,
|
{
|
||||||
plaintext,
|
check_plaintext_cap(plaintext.len())?;
|
||||||
)
|
|
||||||
|
let address = signer
|
||||||
|
.get_public_key_async()
|
||||||
|
.await
|
||||||
|
.map_err(|error| StreamError::Encrypt(error.to_string()))?;
|
||||||
|
|
||||||
|
signer
|
||||||
|
.nip44_encrypt_async(&address, plaintext)
|
||||||
|
.await
|
||||||
|
.map_err(|error| StreamError::Encrypt(error.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn open_to_self(keys: &Keys, content: &str) -> Result<Vec<u8>, StreamError> {
|
pub async fn open_to_self<S>(signer: &S, content: &str) -> Result<String, StreamError>
|
||||||
open_bytes(
|
where
|
||||||
&ConversationKey::derive(keys.secret_key(), &keys.public_key())
|
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||||
.map_err(|error| StreamError::Decrypt(error.to_string()))?,
|
{
|
||||||
content,
|
let address = signer
|
||||||
)
|
.get_public_key_async()
|
||||||
|
.await
|
||||||
|
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
|
||||||
|
|
||||||
|
signer
|
||||||
|
.nip44_decrypt_async(&address, content)
|
||||||
|
.await
|
||||||
|
.map_err(|error| StreamError::Decrypt(error.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_seal(
|
pub async fn build_seal<S>(
|
||||||
rumor: &UnsignedEvent,
|
rumor: &UnsignedEvent,
|
||||||
form: SealForm,
|
form: SealForm,
|
||||||
group: &GroupKey,
|
group: &GroupKey,
|
||||||
author: &Keys,
|
author: &S,
|
||||||
) -> Result<Event, StreamError> {
|
) -> Result<Event, StreamError>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||||
|
{
|
||||||
let content = seal_content(rumor, form, group)?;
|
let content = seal_content(rumor, form, group)?;
|
||||||
EventBuilder::new(Kind::Custom(form.kind()), content)
|
EventBuilder::new(Kind::Custom(form.kind()), content)
|
||||||
.custom_created_at(rumor.created_at)
|
.custom_created_at(rumor.created_at)
|
||||||
.finalize(author)
|
.finalize_async(author)
|
||||||
|
.await
|
||||||
.map_err(|error| StreamError::Sign(error.to_string()))
|
.map_err(|error| StreamError::Sign(error.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,7 +470,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn sealed(rumor: &UnsignedEvent, form: SealForm, author: &Keys) -> Event {
|
fn sealed(rumor: &UnsignedEvent, form: SealForm, author: &Keys) -> Event {
|
||||||
build_seal(rumor, form, &group(0), author).expect("seals")
|
smol::block_on(build_seal(rumor, form, &group(0), author)).expect("seals")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn wrapped(seal: &Event, kind: u16, at_secs: u64) -> Event {
|
fn wrapped(seal: &Event, kind: u16, at_secs: u64) -> Event {
|
||||||
|
|||||||
@@ -183,18 +183,21 @@ pub fn build_snapshot_chunks(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn seal_rumor(
|
pub async fn seal_rumor<S>(
|
||||||
rumor: &UnsignedEvent,
|
rumor: &UnsignedEvent,
|
||||||
group: &GroupKey,
|
group: &GroupKey,
|
||||||
author: &Keys,
|
author: &S,
|
||||||
) -> Result<(Event, Keys), GuestbookError> {
|
) -> Result<(Event, Keys), GuestbookError>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||||
|
{
|
||||||
let kind = rumor.kind.as_u16();
|
let kind = rumor.kind.as_u16();
|
||||||
|
|
||||||
if !is_guestbook_kind(kind) {
|
if !is_guestbook_kind(kind) {
|
||||||
return Err(GuestbookError::UnknownKind(kind));
|
return Err(GuestbookError::UnknownKind(kind));
|
||||||
}
|
}
|
||||||
|
|
||||||
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?;
|
let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?;
|
||||||
|
|
||||||
Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?)
|
Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?)
|
||||||
}
|
}
|
||||||
@@ -556,7 +559,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn publish(rumor: &UnsignedEvent, author: &Keys) -> GuestbookRumor {
|
fn publish(rumor: &UnsignedEvent, author: &Keys) -> GuestbookRumor {
|
||||||
let wrap = seal_rumor(rumor, &group(), author).expect("seals").0;
|
let wrap = smol::block_on(seal_rumor(rumor, &group(), author))
|
||||||
|
.expect("seals")
|
||||||
|
.0;
|
||||||
|
|
||||||
open(&wrap, &group()).expect("opens").1
|
open(&wrap, &group()).expect("opens").1
|
||||||
}
|
}
|
||||||
@@ -826,7 +831,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
open(
|
open(
|
||||||
&seal_rumor(&bad_ms, &group(), &member).expect("seals").0,
|
&smol::block_on(seal_rumor(&bad_ms, &group(), &member))
|
||||||
|
.expect("seals")
|
||||||
|
.0,
|
||||||
&group()
|
&group()
|
||||||
),
|
),
|
||||||
Err(GuestbookError::Stream(StreamError::BadMs))
|
Err(GuestbookError::Stream(StreamError::BadMs))
|
||||||
@@ -835,7 +842,9 @@ mod tests {
|
|||||||
let bad_verb = build_rumor_ms(KIND_JOIN_LEAVE, member.public_key(), "maybe", vec![], AT);
|
let bad_verb = build_rumor_ms(KIND_JOIN_LEAVE, member.public_key(), "maybe", vec![], AT);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
open(
|
open(
|
||||||
&seal_rumor(&bad_verb, &group(), &member).expect("seals").0,
|
&smol::block_on(seal_rumor(&bad_verb, &group(), &member))
|
||||||
|
.expect("seals")
|
||||||
|
.0,
|
||||||
&group()
|
&group()
|
||||||
),
|
),
|
||||||
Err(GuestbookError::BadTag(TAG_CONTENT))
|
Err(GuestbookError::BadTag(TAG_CONTENT))
|
||||||
@@ -854,7 +863,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
open(
|
open(
|
||||||
&seal_rumor(&ambiguous, &group(), &moderator)
|
&smol::block_on(seal_rumor(&ambiguous, &group(), &moderator))
|
||||||
.expect("seals")
|
.expect("seals")
|
||||||
.0,
|
.0,
|
||||||
&group()
|
&group()
|
||||||
@@ -876,7 +885,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
open(
|
open(
|
||||||
&seal_rumor(&rumor, &group(), &moderator).expect("seals").0,
|
&smol::block_on(seal_rumor(&rumor, &group(), &moderator))
|
||||||
|
.expect("seals")
|
||||||
|
.0,
|
||||||
&group()
|
&group()
|
||||||
),
|
),
|
||||||
Err(GuestbookError::BadTag(TAG_SNAP))
|
Err(GuestbookError::BadTag(TAG_SNAP))
|
||||||
|
|||||||
@@ -183,25 +183,32 @@ pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_list_event(keys: &Keys, list: &CommunityList) -> Result<Event, ListError> {
|
pub async fn build_list_event<S>(keys: &S, list: &CommunityList) -> Result<Event, ListError>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||||
|
{
|
||||||
list.fits()?;
|
list.fits()?;
|
||||||
|
|
||||||
let json = serde_json::to_string(list).map_err(json_error)?;
|
let json = serde_json::to_string(list).map_err(json_error)?;
|
||||||
let content = cord01::seal_to_self(keys, json.as_bytes())?;
|
let content = cord01::seal_to_self(keys, &json).await?;
|
||||||
|
|
||||||
EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content)
|
EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content)
|
||||||
.finalize(keys)
|
.finalize_async(keys)
|
||||||
|
.await
|
||||||
.map_err(crypto_error)
|
.map_err(crypto_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_list_event(keys: &Keys, event: &Event) -> Result<CommunityList, ListError> {
|
pub async fn parse_list_event<S>(keys: &S, event: &Event) -> Result<CommunityList, ListError>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||||
|
{
|
||||||
if event.kind.as_u16() != KIND_COMMUNITY_LIST {
|
if event.kind.as_u16() != KIND_COMMUNITY_LIST {
|
||||||
return Err(ListError::Kind(event.kind.as_u16()));
|
return Err(ListError::Kind(event.kind.as_u16()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let json = cord01::open_to_self(keys, &event.content)?;
|
let json = cord01::open_to_self(keys, &event.content).await?;
|
||||||
|
|
||||||
serde_json::from_slice(&json).map_err(json_error)
|
serde_json::from_str(&json).map_err(json_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -419,18 +426,21 @@ mod tests {
|
|||||||
extra: Extra::default(),
|
extra: Extra::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let event = build_list_event(&me, &mine).expect("builds");
|
let event = smol::block_on(build_list_event(&me, &mine)).expect("builds");
|
||||||
assert_eq!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST));
|
assert_eq!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST));
|
||||||
assert_eq!(parse_list_event(&me, &event).expect("parses"), mine);
|
assert_eq!(
|
||||||
|
smol::block_on(parse_list_event(&me, &event)).expect("parses"),
|
||||||
|
mine
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!parse_list_event(&me, &event)
|
!smol::block_on(parse_list_event(&me, &event))
|
||||||
.expect("parses")
|
.expect("parses")
|
||||||
.is_live(&id(0x33))
|
.is_live(&id(0x33))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only the member's own keys open it, and an unreadable list is "no news".
|
// Only the member's own keys open it, and an unreadable list is "no news".
|
||||||
let stranger = Keys::generate();
|
let stranger = Keys::generate();
|
||||||
assert!(parse_list_event(&stranger, &event).is_err());
|
assert!(smol::block_on(parse_list_event(&stranger, &event)).is_err());
|
||||||
|
|
||||||
// Unknown fields survive the round trip, so a republish cannot wipe them.
|
// Unknown fields survive the round trip, so a republish cannot wipe them.
|
||||||
let mut held = mine.clone();
|
let mut held = mine.clone();
|
||||||
@@ -440,8 +450,11 @@ mod tests {
|
|||||||
.current
|
.current
|
||||||
.extra
|
.extra
|
||||||
.insert("held_roots".to_owned(), serde_json::json!([{"epoch": 1}]));
|
.insert("held_roots".to_owned(), serde_json::json!([{"epoch": 1}]));
|
||||||
let rebuilt =
|
let rebuilt = smol::block_on(parse_list_event(
|
||||||
parse_list_event(&me, &build_list_event(&me, &held).expect("builds")).expect("parses");
|
&me,
|
||||||
|
&smol::block_on(build_list_event(&me, &held)).expect("builds"),
|
||||||
|
))
|
||||||
|
.expect("parses");
|
||||||
assert_eq!(rebuilt, held);
|
assert_eq!(rebuilt, held);
|
||||||
|
|
||||||
// The write gate refuses an over-cap or oversized List before publishing.
|
// The write gate refuses an over-cap or oversized List before publishing.
|
||||||
@@ -459,7 +472,7 @@ mod tests {
|
|||||||
.collect(),
|
.collect(),
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
build_list_event(&me, &crowded),
|
smol::block_on(build_list_event(&me, &crowded)),
|
||||||
Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1
|
Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ pub mod list;
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent};
|
use nostr_sdk::prelude::{
|
||||||
|
AsyncGetPublicKey, AsyncSignEvent, Event, PublicKey, Timestamp, UnsignedEvent,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::cord01::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
|
use crate::cord01::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
|
||||||
@@ -114,17 +116,24 @@ pub struct CommunityGenesis {
|
|||||||
pub wraps: Vec<Event>,
|
pub wraps: Vec<Event>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn genesis(
|
pub async fn genesis<S>(
|
||||||
owner: &Keys,
|
owner: &S,
|
||||||
metadata: &CommunityMetadata,
|
metadata: &CommunityMetadata,
|
||||||
at_secs: u64,
|
at_secs: u64,
|
||||||
) -> Result<CommunityGenesis> {
|
) -> Result<CommunityGenesis>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||||
|
{
|
||||||
let metadata_content = encode_metadata(metadata)?;
|
let metadata_content = encode_metadata(metadata)?;
|
||||||
let owner_salt = random_32()?;
|
let owner_salt = random_32()?;
|
||||||
|
let owner_key = owner
|
||||||
|
.get_public_key_async()
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("signer: {error}"))?;
|
||||||
|
|
||||||
let identity = CommunityIdentity {
|
let identity = CommunityIdentity {
|
||||||
community_id: community_id_of(&owner.public_key().to_bytes(), &owner_salt),
|
community_id: community_id_of(&owner_key.to_bytes(), &owner_salt),
|
||||||
owner: owner.public_key(),
|
owner: owner_key,
|
||||||
owner_salt,
|
owner_salt,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -167,7 +176,7 @@ pub fn genesis(
|
|||||||
let mut wraps = Vec::with_capacity(editions.len());
|
let mut wraps = Vec::with_capacity(editions.len());
|
||||||
|
|
||||||
for edition in &editions {
|
for edition in &editions {
|
||||||
wraps.push(seal_edition(edition, owner, &read, &signer, at_secs)?);
|
wraps.push(seal_edition(edition, owner, &read, &signer, at_secs).await?);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(CommunityGenesis {
|
Ok(CommunityGenesis {
|
||||||
@@ -211,12 +220,15 @@ pub struct Edition<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ControlWriter {
|
impl ControlWriter {
|
||||||
pub fn publish(
|
pub async fn publish<S>(
|
||||||
&self,
|
&self,
|
||||||
keys: &Keys,
|
keys: &S,
|
||||||
edition: Edition<'_>,
|
edition: Edition<'_>,
|
||||||
at_secs: u64,
|
at_secs: u64,
|
||||||
) -> Result<(Event, EntityHead)> {
|
) -> Result<(Event, EntityHead)>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||||
|
{
|
||||||
let rumor = build_edition(EditionFields {
|
let rumor = build_edition(EditionFields {
|
||||||
author: self.author,
|
author: self.author,
|
||||||
subkind: edition.subkind,
|
subkind: edition.subkind,
|
||||||
@@ -231,20 +243,23 @@ impl ControlWriter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let parsed = parse_edition(&rumor)?;
|
let parsed = parse_edition(&rumor)?;
|
||||||
let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs)?;
|
let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs).await?;
|
||||||
|
|
||||||
Ok((wrap, EntityHead::from(&parsed)))
|
Ok((wrap, EntityHead::from(&parsed)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_community_metadata(
|
pub async fn set_community_metadata<S>(
|
||||||
&self,
|
&self,
|
||||||
keys: &Keys,
|
keys: &S,
|
||||||
community_id: &CommunityId,
|
community_id: &CommunityId,
|
||||||
metadata: &CommunityMetadata,
|
metadata: &CommunityMetadata,
|
||||||
head: Option<&EntityHead>,
|
head: Option<&EntityHead>,
|
||||||
citation: Option<AuthorityCitation>,
|
citation: Option<AuthorityCitation>,
|
||||||
at_secs: u64,
|
at_secs: u64,
|
||||||
) -> Result<(Event, EntityHead)> {
|
) -> Result<(Event, EntityHead)>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||||
|
{
|
||||||
let content = encode_metadata(metadata)?;
|
let content = encode_metadata(metadata)?;
|
||||||
|
|
||||||
self.publish(
|
self.publish(
|
||||||
@@ -258,17 +273,21 @@ impl ControlWriter {
|
|||||||
},
|
},
|
||||||
at_secs,
|
at_secs,
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_channel_metadata(
|
pub async fn set_channel_metadata<S>(
|
||||||
&self,
|
&self,
|
||||||
keys: &Keys,
|
keys: &S,
|
||||||
channel: &ChannelId,
|
channel: &ChannelId,
|
||||||
metadata: &ChannelMetadata,
|
metadata: &ChannelMetadata,
|
||||||
head: Option<&EntityHead>,
|
head: Option<&EntityHead>,
|
||||||
citation: Option<AuthorityCitation>,
|
citation: Option<AuthorityCitation>,
|
||||||
at_secs: u64,
|
at_secs: u64,
|
||||||
) -> Result<(Event, EntityHead)> {
|
) -> Result<(Event, EntityHead)>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||||
|
{
|
||||||
if metadata.name.len() > MAX_NAME_BYTES {
|
if metadata.name.len() > MAX_NAME_BYTES {
|
||||||
bail!("channel name exceeds {MAX_NAME_BYTES} bytes");
|
bail!("channel name exceeds {MAX_NAME_BYTES} bytes");
|
||||||
}
|
}
|
||||||
@@ -286,16 +305,20 @@ impl ControlWriter {
|
|||||||
},
|
},
|
||||||
at_secs,
|
at_secs,
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_role(
|
pub async fn set_role<S>(
|
||||||
&self,
|
&self,
|
||||||
keys: &Keys,
|
keys: &S,
|
||||||
role: &Role,
|
role: &Role,
|
||||||
head: Option<&EntityHead>,
|
head: Option<&EntityHead>,
|
||||||
citation: Option<AuthorityCitation>,
|
citation: Option<AuthorityCitation>,
|
||||||
at_secs: u64,
|
at_secs: u64,
|
||||||
) -> Result<(Event, EntityHead)> {
|
) -> Result<(Event, EntityHead)>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||||
|
{
|
||||||
if role.name.len() > MAX_NAME_BYTES {
|
if role.name.len() > MAX_NAME_BYTES {
|
||||||
bail!("role name exceeds {MAX_NAME_BYTES} bytes");
|
bail!("role name exceeds {MAX_NAME_BYTES} bytes");
|
||||||
}
|
}
|
||||||
@@ -313,17 +336,21 @@ impl ControlWriter {
|
|||||||
},
|
},
|
||||||
at_secs,
|
at_secs,
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_grant(
|
pub async fn set_grant<S>(
|
||||||
&self,
|
&self,
|
||||||
keys: &Keys,
|
keys: &S,
|
||||||
community_id: &CommunityId,
|
community_id: &CommunityId,
|
||||||
grant: &Grant,
|
grant: &Grant,
|
||||||
head: Option<&EntityHead>,
|
head: Option<&EntityHead>,
|
||||||
citation: Option<AuthorityCitation>,
|
citation: Option<AuthorityCitation>,
|
||||||
at_secs: u64,
|
at_secs: u64,
|
||||||
) -> Result<(Event, EntityHead)> {
|
) -> Result<(Event, EntityHead)>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||||
|
{
|
||||||
let content = grant.to_content()?;
|
let content = grant.to_content()?;
|
||||||
|
|
||||||
self.publish(
|
self.publish(
|
||||||
@@ -337,17 +364,21 @@ impl ControlWriter {
|
|||||||
},
|
},
|
||||||
at_secs,
|
at_secs,
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_banlist(
|
pub async fn set_banlist<S>(
|
||||||
&self,
|
&self,
|
||||||
keys: &Keys,
|
keys: &S,
|
||||||
community_id: &CommunityId,
|
community_id: &CommunityId,
|
||||||
banned: &BTreeSet<PublicKey>,
|
banned: &BTreeSet<PublicKey>,
|
||||||
head: Option<&EntityHead>,
|
head: Option<&EntityHead>,
|
||||||
citation: Option<AuthorityCitation>,
|
citation: Option<AuthorityCitation>,
|
||||||
at_secs: u64,
|
at_secs: u64,
|
||||||
) -> Result<(Event, EntityHead)> {
|
) -> Result<(Event, EntityHead)>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||||
|
{
|
||||||
if banned.len() > MAX_BANLIST {
|
if banned.len() > MAX_BANLIST {
|
||||||
bail!("banlist exceeds {MAX_BANLIST} entries");
|
bail!("banlist exceeds {MAX_BANLIST} entries");
|
||||||
}
|
}
|
||||||
@@ -366,19 +397,23 @@ impl ControlWriter {
|
|||||||
},
|
},
|
||||||
at_secs,
|
at_secs,
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn set_registry(
|
pub async fn set_registry<S>(
|
||||||
&self,
|
&self,
|
||||||
keys: &Keys,
|
keys: &S,
|
||||||
community_id: &CommunityId,
|
community_id: &CommunityId,
|
||||||
creator: &PublicKey,
|
creator: &PublicKey,
|
||||||
links: &[PublicKey],
|
links: &[PublicKey],
|
||||||
head: Option<&EntityHead>,
|
head: Option<&EntityHead>,
|
||||||
citation: Option<AuthorityCitation>,
|
citation: Option<AuthorityCitation>,
|
||||||
at_secs: u64,
|
at_secs: u64,
|
||||||
) -> Result<(Event, EntityHead)> {
|
) -> Result<(Event, EntityHead)>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||||
|
{
|
||||||
let entries: Vec<String> = links
|
let entries: Vec<String> = links
|
||||||
.iter()
|
.iter()
|
||||||
.take(MAX_REGISTRY_LINKS)
|
.take(MAX_REGISTRY_LINKS)
|
||||||
@@ -397,20 +432,24 @@ impl ControlWriter {
|
|||||||
},
|
},
|
||||||
at_secs,
|
at_secs,
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The whole Pin List, in whichever of CORD-04 §7's two forms the Channel calls for.
|
/// The whole Pin List, in whichever of CORD-04 §7's two forms the Channel calls for.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn set_pin_list(
|
pub async fn set_pin_list<S>(
|
||||||
&self,
|
&self,
|
||||||
keys: &Keys,
|
keys: &S,
|
||||||
community_id: &CommunityId,
|
community_id: &CommunityId,
|
||||||
channel: &ChannelId,
|
channel: &ChannelId,
|
||||||
content: &str,
|
content: &str,
|
||||||
head: Option<&EntityHead>,
|
head: Option<&EntityHead>,
|
||||||
citation: Option<AuthorityCitation>,
|
citation: Option<AuthorityCitation>,
|
||||||
at_secs: u64,
|
at_secs: u64,
|
||||||
) -> Result<(Event, EntityHead)> {
|
) -> Result<(Event, EntityHead)>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||||
|
{
|
||||||
self.publish(
|
self.publish(
|
||||||
keys,
|
keys,
|
||||||
Edition {
|
Edition {
|
||||||
@@ -422,6 +461,7 @@ impl ControlWriter {
|
|||||||
},
|
},
|
||||||
at_secs,
|
at_secs,
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,14 +748,17 @@ fn authorized_head<'a>(
|
|||||||
selection.head.map(|index| authorized[index])
|
selection.head.map(|index| authorized[index])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn seal_edition(
|
async fn seal_edition<S>(
|
||||||
edition: &UnsignedEvent,
|
edition: &UnsignedEvent,
|
||||||
owner: &Keys,
|
owner: &S,
|
||||||
read: &GroupKey,
|
read: &GroupKey,
|
||||||
signer: &GroupKey,
|
signer: &GroupKey,
|
||||||
at_secs: u64,
|
at_secs: u64,
|
||||||
) -> Result<Event> {
|
) -> Result<Event>
|
||||||
let seal = build_seal(edition, SealForm::Plaintext, read, owner)?;
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||||
|
{
|
||||||
|
let seal = build_seal(edition, SealForm::Plaintext, read, owner).await?;
|
||||||
|
|
||||||
let (wrap, _) = wrap_seal_with(
|
let (wrap, _) = wrap_seal_with(
|
||||||
&seal,
|
&seal,
|
||||||
@@ -731,6 +774,8 @@ fn seal_edition(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use nostr_sdk::prelude::Keys;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::cord03::{self, build_message, seal_rumor};
|
use crate::cord03::{self, build_message, seal_rumor};
|
||||||
use crate::cord04::pins;
|
use crate::cord04::pins;
|
||||||
@@ -768,7 +813,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn metadata_and_channel_edits_reach_a_second_client() {
|
fn metadata_and_channel_edits_reach_a_second_client() {
|
||||||
let owner = Keys::generate();
|
let owner = Keys::generate();
|
||||||
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
|
let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
|
||||||
let community_id = minted.identity.community_id;
|
let community_id = minted.identity.community_id;
|
||||||
let owner_pk = owner.public_key();
|
let owner_pk = owner.public_key();
|
||||||
let (read, signer) = holder(&minted);
|
let (read, signer) = holder(&minted);
|
||||||
@@ -797,33 +842,31 @@ mod tests {
|
|||||||
.get(minted.channel_id.as_bytes())
|
.get(minted.channel_id.as_bytes())
|
||||||
.expect("head");
|
.expect("head");
|
||||||
|
|
||||||
let (community_wrap, _) = writer
|
let (community_wrap, _) = smol::block_on(writer.set_community_metadata(
|
||||||
.set_community_metadata(
|
&owner,
|
||||||
&owner,
|
&community_id,
|
||||||
&community_id,
|
&CommunityMetadata {
|
||||||
&CommunityMetadata {
|
relays: vec!["wss://relay.example".to_owned()],
|
||||||
relays: vec!["wss://relay.example".to_owned()],
|
..metadata("coop two")
|
||||||
..metadata("coop two")
|
},
|
||||||
},
|
Some(community_head),
|
||||||
Some(community_head),
|
None,
|
||||||
None,
|
AT + 1,
|
||||||
AT + 1,
|
))
|
||||||
)
|
.expect("publishes");
|
||||||
.expect("publishes");
|
let (channel_wrap, _) = smol::block_on(writer.set_channel_metadata(
|
||||||
let (channel_wrap, _) = writer
|
&owner,
|
||||||
.set_channel_metadata(
|
&minted.channel_id,
|
||||||
&owner,
|
&ChannelMetadata {
|
||||||
&minted.channel_id,
|
name: "lobby".to_owned(),
|
||||||
&ChannelMetadata {
|
private: false,
|
||||||
name: "lobby".to_owned(),
|
..ChannelMetadata::default()
|
||||||
private: false,
|
},
|
||||||
..ChannelMetadata::default()
|
Some(channel_head),
|
||||||
},
|
None,
|
||||||
Some(channel_head),
|
AT + 2,
|
||||||
None,
|
))
|
||||||
AT + 2,
|
.expect("publishes");
|
||||||
)
|
|
||||||
.expect("publishes");
|
|
||||||
|
|
||||||
let mut edited = genesis_editions.clone();
|
let mut edited = genesis_editions.clone();
|
||||||
edited.extend(open_all(
|
edited.extend(open_all(
|
||||||
@@ -875,7 +918,7 @@ mod tests {
|
|||||||
fn a_delegated_member_edits_metadata_only_under_its_own_grant() {
|
fn a_delegated_member_edits_metadata_only_under_its_own_grant() {
|
||||||
let owner = Keys::generate();
|
let owner = Keys::generate();
|
||||||
let member = Keys::generate();
|
let member = Keys::generate();
|
||||||
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
|
let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
|
||||||
let community_id = minted.identity.community_id;
|
let community_id = minted.identity.community_id;
|
||||||
let owner_pk = owner.public_key();
|
let owner_pk = owner.public_key();
|
||||||
let (read, signer) = holder(&minted);
|
let (read, signer) = holder(&minted);
|
||||||
@@ -896,21 +939,20 @@ mod tests {
|
|||||||
extra: Extra::default(),
|
extra: Extra::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let (role_wrap, _) = writer
|
let (role_wrap, _) = smol::block_on(writer.publish(
|
||||||
.publish(
|
&owner,
|
||||||
&owner,
|
Edition {
|
||||||
Edition {
|
subkind: vsk::ROLE,
|
||||||
subkind: vsk::ROLE,
|
entity: *role_id.as_bytes(),
|
||||||
entity: *role_id.as_bytes(),
|
content: &role.to_content().expect("serializes"),
|
||||||
content: &role.to_content().expect("serializes"),
|
head: None,
|
||||||
head: None,
|
citation: None,
|
||||||
citation: None,
|
},
|
||||||
},
|
AT + 1,
|
||||||
AT + 1,
|
))
|
||||||
)
|
.expect("publishes");
|
||||||
.expect("publishes");
|
let (grant_wrap, _) = smol::block_on(
|
||||||
let (grant_wrap, _) = writer
|
writer.publish(
|
||||||
.publish(
|
|
||||||
&owner,
|
&owner,
|
||||||
Edition {
|
Edition {
|
||||||
subkind: vsk::GRANT,
|
subkind: vsk::GRANT,
|
||||||
@@ -927,8 +969,9 @@ mod tests {
|
|||||||
citation: None,
|
citation: None,
|
||||||
},
|
},
|
||||||
AT + 2,
|
AT + 2,
|
||||||
)
|
),
|
||||||
.expect("publishes");
|
)
|
||||||
|
.expect("publishes");
|
||||||
|
|
||||||
let mut base = open_all(&minted.wraps, &read, &signer.pk());
|
let mut base = open_all(&minted.wraps, &read, &signer.pk());
|
||||||
base.extend(open_all(&[role_wrap, grant_wrap], &read, &signer.pk()));
|
base.extend(open_all(&[role_wrap, grant_wrap], &read, &signer.pk()));
|
||||||
@@ -959,36 +1002,34 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let content = serde_json::to_string(&metadata("coop by mod")).expect("serializes");
|
let content = serde_json::to_string(&metadata("coop by mod")).expect("serializes");
|
||||||
|
|
||||||
let (uncited, _) = member_writer
|
let (uncited, _) = smol::block_on(member_writer.publish(
|
||||||
.publish(
|
&member,
|
||||||
&member,
|
Edition {
|
||||||
Edition {
|
subkind: vsk::COMMUNITY_METADATA,
|
||||||
subkind: vsk::COMMUNITY_METADATA,
|
entity: *community_id.as_bytes(),
|
||||||
entity: *community_id.as_bytes(),
|
content: &content,
|
||||||
content: &content,
|
head: Some(head),
|
||||||
head: Some(head),
|
citation: None,
|
||||||
citation: None,
|
},
|
||||||
},
|
AT + 3,
|
||||||
AT + 3,
|
))
|
||||||
)
|
.expect("publishes");
|
||||||
.expect("publishes");
|
let (cited, _) = smol::block_on(member_writer.publish(
|
||||||
let (cited, _) = member_writer
|
&member,
|
||||||
.publish(
|
Edition {
|
||||||
&member,
|
subkind: vsk::COMMUNITY_METADATA,
|
||||||
Edition {
|
entity: *community_id.as_bytes(),
|
||||||
subkind: vsk::COMMUNITY_METADATA,
|
content: &content,
|
||||||
entity: *community_id.as_bytes(),
|
head: Some(head),
|
||||||
content: &content,
|
citation: Some(AuthorityCitation {
|
||||||
head: Some(head),
|
entity: grant.entity,
|
||||||
citation: Some(AuthorityCitation {
|
version: grant.version,
|
||||||
entity: grant.entity,
|
hash: grant.self_hash,
|
||||||
version: grant.version,
|
}),
|
||||||
hash: grant.self_hash,
|
},
|
||||||
}),
|
AT + 4,
|
||||||
},
|
))
|
||||||
AT + 4,
|
.expect("publishes");
|
||||||
)
|
|
||||||
.expect("publishes");
|
|
||||||
|
|
||||||
// Uncited, the edit claims an authority the member never showed.
|
// Uncited, the edit claims an authority the member never showed.
|
||||||
let mut forged = base.clone();
|
let mut forged = base.clone();
|
||||||
@@ -1023,7 +1064,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_pin_list_folds_under_its_coordinate_for_a_second_client() {
|
fn a_pin_list_folds_under_its_coordinate_for_a_second_client() {
|
||||||
let owner = Keys::generate();
|
let owner = Keys::generate();
|
||||||
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
|
let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
|
||||||
let community_id = minted.identity.community_id;
|
let community_id = minted.identity.community_id;
|
||||||
let owner_pk = owner.public_key();
|
let owner_pk = owner.public_key();
|
||||||
let (read, signer) = holder(&minted);
|
let (read, signer) = holder(&minted);
|
||||||
@@ -1042,7 +1083,7 @@ mod tests {
|
|||||||
AT * 1_000,
|
AT * 1_000,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let (wrap, _) = seal_rumor(&rumor, &group, &author, false).expect("seals");
|
let (wrap, _) = smol::block_on(seal_rumor(&rumor, &group, &author, false)).expect("seals");
|
||||||
let opened = cord03::open(&wrap, &group, &channel, ROOT_EPOCH)
|
let opened = cord03::open(&wrap, &group, &channel, ROOT_EPOCH)
|
||||||
.expect("opens")
|
.expect("opens")
|
||||||
.0;
|
.0;
|
||||||
@@ -1064,17 +1105,16 @@ mod tests {
|
|||||||
read: read.clone(),
|
read: read.clone(),
|
||||||
signer: signer.clone(),
|
signer: signer.clone(),
|
||||||
};
|
};
|
||||||
let (pin_wrap, _) = writer
|
let (pin_wrap, _) = smol::block_on(writer.set_pin_list(
|
||||||
.set_pin_list(
|
&owner,
|
||||||
&owner,
|
&community_id,
|
||||||
&community_id,
|
&channel,
|
||||||
&channel,
|
&content,
|
||||||
&content,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
AT + 1,
|
||||||
AT + 1,
|
))
|
||||||
)
|
.expect("publishes");
|
||||||
.expect("publishes");
|
|
||||||
|
|
||||||
let mut editions = open_all(&minted.wraps, &read, &signer.pk());
|
let mut editions = open_all(&minted.wraps, &read, &signer.pk());
|
||||||
editions.extend(open_all(&[pin_wrap], &read, &signer.pk()));
|
editions.extend(open_all(&[pin_wrap], &read, &signer.pk()));
|
||||||
@@ -1109,7 +1149,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn the_timer_is_never_guessed_and_the_write_caps_hold() {
|
fn the_timer_is_never_guessed_and_the_write_caps_hold() {
|
||||||
let owner = Keys::generate();
|
let owner = Keys::generate();
|
||||||
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
|
let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
|
||||||
let community_id = minted.identity.community_id;
|
let community_id = minted.identity.community_id;
|
||||||
let owner_pk = owner.public_key();
|
let owner_pk = owner.public_key();
|
||||||
let (read, signer) = holder(&minted);
|
let (read, signer) = holder(&minted);
|
||||||
@@ -1124,10 +1164,16 @@ mod tests {
|
|||||||
&owner_pk,
|
&owner_pk,
|
||||||
&community_id,
|
&community_id,
|
||||||
&open_all(
|
&open_all(
|
||||||
&[writer
|
&[smol::block_on(writer.set_community_metadata(
|
||||||
.set_community_metadata(&owner, &community_id, metadata, None, None, AT + 1)
|
&owner,
|
||||||
.expect("publishes")
|
&community_id,
|
||||||
.0],
|
metadata,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
AT + 1,
|
||||||
|
))
|
||||||
|
.expect("publishes")
|
||||||
|
.0],
|
||||||
&writer.read,
|
&writer.read,
|
||||||
&writer.signer.pk(),
|
&writer.signer.pk(),
|
||||||
),
|
),
|
||||||
@@ -1163,8 +1209,7 @@ mod tests {
|
|||||||
.map(|_| Keys::generate().public_key())
|
.map(|_| Keys::generate().public_key())
|
||||||
.collect();
|
.collect();
|
||||||
assert!(
|
assert!(
|
||||||
writer
|
smol::block_on(writer.set_banlist(&owner, &community_id, &banned, None, None, AT + 2))
|
||||||
.set_banlist(&owner, &community_id, &banned, None, None, AT + 2)
|
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1179,20 +1224,19 @@ mod tests {
|
|||||||
assert!(grant.to_content().is_err());
|
assert!(grant.to_content().is_err());
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
writer
|
smol::block_on(writer.set_channel_metadata(
|
||||||
.set_channel_metadata(
|
&owner,
|
||||||
&owner,
|
&minted.channel_id,
|
||||||
&minted.channel_id,
|
&ChannelMetadata {
|
||||||
&ChannelMetadata {
|
name: "x".repeat(MAX_NAME_BYTES + 1),
|
||||||
name: "x".repeat(MAX_NAME_BYTES + 1),
|
private: false,
|
||||||
private: false,
|
..ChannelMetadata::default()
|
||||||
..ChannelMetadata::default()
|
},
|
||||||
},
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
AT + 3,
|
||||||
AT + 3,
|
))
|
||||||
)
|
.is_err()
|
||||||
.is_err()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,19 +292,22 @@ pub fn build_typing(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `ephemeral` picks the 21059 wrap, which relays must not store.
|
/// `ephemeral` picks the 21059 wrap, which relays must not store.
|
||||||
pub fn seal_rumor(
|
pub async fn seal_rumor<S>(
|
||||||
rumor: &UnsignedEvent,
|
rumor: &UnsignedEvent,
|
||||||
group: &GroupKey,
|
group: &GroupKey,
|
||||||
author: &Keys,
|
author: &S,
|
||||||
ephemeral: bool,
|
ephemeral: bool,
|
||||||
) -> Result<(Event, Keys), ChatError> {
|
) -> Result<(Event, Keys), ChatError>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||||
|
{
|
||||||
let kind = rumor.kind.as_u16();
|
let kind = rumor.kind.as_u16();
|
||||||
|
|
||||||
if !is_chat_kind(kind) {
|
if !is_chat_kind(kind) {
|
||||||
return Err(ChatError::UnknownKind(kind));
|
return Err(ChatError::UnknownKind(kind));
|
||||||
}
|
}
|
||||||
|
|
||||||
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?;
|
let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?;
|
||||||
let wrap_kind = if ephemeral {
|
let wrap_kind = if ephemeral {
|
||||||
KIND_WRAP_EPHEMERAL
|
KIND_WRAP_EPHEMERAL
|
||||||
} else {
|
} else {
|
||||||
@@ -674,7 +677,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn sealed(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys) -> Event {
|
fn sealed(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys) -> Event {
|
||||||
seal_rumor(rumor, group, author, false).expect("seals").0
|
smol::block_on(seal_rumor(rumor, group, author, false))
|
||||||
|
.expect("seals")
|
||||||
|
.0
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, epoch: Epoch) -> ChatRumor {
|
fn read(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, epoch: Epoch) -> ChatRumor {
|
||||||
@@ -949,7 +954,8 @@ mod tests {
|
|||||||
|
|
||||||
// Chat is encrypted-seal only (CORD-02 §5), and a retired kind is not a
|
// Chat is encrypted-seal only (CORD-02 §5), and a retired kind is not a
|
||||||
// chat rumor however well-formed it looks.
|
// chat rumor however well-formed it looks.
|
||||||
let seal = build_seal(&plain, SealForm::Plaintext, &group, &alice).expect("seals");
|
let seal =
|
||||||
|
smol::block_on(build_seal(&plain, SealForm::Plaintext, &group, &alice)).expect("seals");
|
||||||
let (wrap, _) = wrap_seal(
|
let (wrap, _) = wrap_seal(
|
||||||
&seal,
|
&seal,
|
||||||
&group,
|
&group,
|
||||||
@@ -971,7 +977,7 @@ mod tests {
|
|||||||
AT,
|
AT,
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
seal_rumor(&ghost, &group, &alice, false),
|
smol::block_on(seal_rumor(&ghost, &group, &alice, false)),
|
||||||
Err(ChatError::UnknownKind(3300))
|
Err(ChatError::UnknownKind(3300))
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
@@ -583,7 +583,7 @@ mod tests {
|
|||||||
at_ms,
|
at_ms,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let (wrap, _) = seal_rumor(&rumor, &group(), author, false).expect("seals");
|
let (wrap, _) = smol::block_on(seal_rumor(&rumor, &group(), author, false)).expect("seals");
|
||||||
|
|
||||||
open(&wrap, &group(), &channel(), Epoch(0)).expect("opens")
|
open(&wrap, &group(), &channel(), Epoch(0)).expect("opens")
|
||||||
}
|
}
|
||||||
@@ -699,7 +699,7 @@ mod tests {
|
|||||||
AT_MS + 5_000,
|
AT_MS + 5_000,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let (wrap, _) = seal_rumor(&edit, &group(), &author, false).expect("seals");
|
let (wrap, _) = smol::block_on(seal_rumor(&edit, &group(), &author, false)).expect("seals");
|
||||||
let (edit_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
|
let (edit_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
|
||||||
|
|
||||||
let refreshed = with_proven_edit(&entry, &edit_opened, &group(), &channel());
|
let refreshed = with_proven_edit(&entry, &edit_opened, &group(), &channel());
|
||||||
@@ -718,7 +718,8 @@ mod tests {
|
|||||||
AT_MS + 6_000,
|
AT_MS + 6_000,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let (wrap, _) = seal_rumor(&hijack, &group(), &stranger, false).expect("seals");
|
let (wrap, _) =
|
||||||
|
smol::block_on(seal_rumor(&hijack, &group(), &stranger, false)).expect("seals");
|
||||||
let (hijack_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
|
let (hijack_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
|
||||||
|
|
||||||
let unchanged = with_proven_edit(&entry, &hijack_opened, &group(), &channel());
|
let unchanged = with_proven_edit(&entry, &hijack_opened, &group(), &channel());
|
||||||
@@ -805,7 +806,8 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
AT_MS + 1_000,
|
AT_MS + 1_000,
|
||||||
);
|
);
|
||||||
let (wrap, _) = seal_rumor(&delete, &group(), author_keys, false).expect("seals");
|
let (wrap, _) =
|
||||||
|
smol::block_on(seal_rumor(&delete, &group(), author_keys, false)).expect("seals");
|
||||||
let (_, rumor) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
|
let (_, rumor) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -590,25 +590,32 @@ pub fn merge_invite_lists(held: InviteList, incoming: InviteList) -> InviteList
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_invite_list(keys: &Keys, list: &InviteList) -> Result<Event, InviteError> {
|
pub async fn build_invite_list<S>(keys: &S, list: &InviteList) -> Result<Event, InviteError>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||||
|
{
|
||||||
list.fits()?;
|
list.fits()?;
|
||||||
|
|
||||||
let json = serde_json::to_string(list).map_err(json_error)?;
|
let json = serde_json::to_string(list).map_err(json_error)?;
|
||||||
let content = cord01::seal_to_self(keys, json.as_bytes())?;
|
let content = cord01::seal_to_self(keys, &json).await?;
|
||||||
|
|
||||||
EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content)
|
EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content)
|
||||||
.finalize(keys)
|
.finalize_async(keys)
|
||||||
|
.await
|
||||||
.map_err(crypto_error)
|
.map_err(crypto_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_invite_list(keys: &Keys, event: &Event) -> Result<InviteList, InviteError> {
|
pub async fn parse_invite_list<S>(keys: &S, event: &Event) -> Result<InviteList, InviteError>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||||
|
{
|
||||||
if event.kind.as_u16() != KIND_INVITE_LIST {
|
if event.kind.as_u16() != KIND_INVITE_LIST {
|
||||||
return Err(InviteError::Kind(event.kind.as_u16()));
|
return Err(InviteError::Kind(event.kind.as_u16()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let json = cord01::open_to_self(keys, &event.content)?;
|
let json = cord01::open_to_self(keys, &event.content).await?;
|
||||||
|
|
||||||
serde_json::from_slice(&json).map_err(json_error)
|
serde_json::from_str(&json).map_err(json_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An entry is immutable once minted, so two copies should agree.
|
/// An entry is immutable once minted, so two copies should agree.
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ use std::fmt;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use data_encoding::HEXLOWER;
|
use data_encoding::HEXLOWER;
|
||||||
use nostr::nips::nip44::v2::ConversationKey;
|
use nostr::nips::nip44::v2::ConversationKey;
|
||||||
use nostr_sdk::prelude::{Event, Keys, PublicKey, SecretKey, Tag, Timestamp, UnsignedEvent};
|
use nostr_sdk::prelude::{
|
||||||
|
AsyncGetPublicKey, AsyncSignEvent, Event, Keys, PublicKey, SecretKey, Tag, Timestamp,
|
||||||
|
UnsignedEvent,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::cord01::{self, KIND_SEAL_PLAINTEXT, OpenedStream, SealForm, StreamError};
|
use crate::cord01::{self, KIND_SEAL_PLAINTEXT, OpenedStream, SealForm, StreamError};
|
||||||
@@ -598,8 +601,8 @@ pub fn build_rekey_rumor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_rekey_chunks(
|
pub async fn build_rekey_chunks<S>(
|
||||||
rotator: &Keys,
|
rotator: &S,
|
||||||
group: &GroupKey,
|
group: &GroupKey,
|
||||||
scope: RekeyScope,
|
scope: RekeyScope,
|
||||||
new_epoch: Epoch,
|
new_epoch: Epoch,
|
||||||
@@ -609,7 +612,11 @@ pub fn build_rekey_chunks(
|
|||||||
citation: Option<&AuthorityCitation>,
|
citation: Option<&AuthorityCitation>,
|
||||||
severed: bool,
|
severed: bool,
|
||||||
at_secs: u64,
|
at_secs: u64,
|
||||||
) -> Result<Vec<Event>, RekeyError> {
|
) -> Result<Vec<Event>, RekeyError>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||||
|
{
|
||||||
|
let rotator_key = rotator.get_public_key_async().await.map_err(crypto_error)?;
|
||||||
let mut groups: Vec<&[RekeyBlob]> = blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect();
|
let mut groups: Vec<&[RekeyBlob]> = blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect();
|
||||||
|
|
||||||
if groups.is_empty() {
|
if groups.is_empty() {
|
||||||
@@ -621,7 +628,7 @@ pub fn build_rekey_chunks(
|
|||||||
|
|
||||||
for (index, group_blobs) in groups.into_iter().enumerate() {
|
for (index, group_blobs) in groups.into_iter().enumerate() {
|
||||||
let rumor = build_rekey_rumor(
|
let rumor = build_rekey_rumor(
|
||||||
rotator.public_key(),
|
rotator_key,
|
||||||
scope,
|
scope,
|
||||||
new_epoch,
|
new_epoch,
|
||||||
prev_epoch,
|
prev_epoch,
|
||||||
@@ -633,7 +640,7 @@ pub fn build_rekey_chunks(
|
|||||||
at_secs,
|
at_secs,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let seal = cord01::build_seal(&rumor, SealForm::Encrypted, group, rotator)?;
|
let seal = cord01::build_seal(&rumor, SealForm::Encrypted, group, rotator).await?;
|
||||||
let (wrap, _) = cord01::wrap_seal(
|
let (wrap, _) = cord01::wrap_seal(
|
||||||
&seal,
|
&seal,
|
||||||
group,
|
group,
|
||||||
@@ -731,14 +738,17 @@ pub fn dissolved_tombstone_rumor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn seal_dissolved(
|
pub async fn seal_dissolved<S>(
|
||||||
rumor: &UnsignedEvent,
|
rumor: &UnsignedEvent,
|
||||||
community_id: &CommunityId,
|
community_id: &CommunityId,
|
||||||
owner: &Keys,
|
owner: &S,
|
||||||
at_secs: u64,
|
at_secs: u64,
|
||||||
) -> Result<Event, RekeyError> {
|
) -> Result<Event, RekeyError>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||||
|
{
|
||||||
let group = dissolved_group_key(community_id).map_err(crypto_error)?;
|
let group = dissolved_group_key(community_id).map_err(crypto_error)?;
|
||||||
let seal = cord01::build_seal(rumor, SealForm::Plaintext, &group, owner)?;
|
let seal = cord01::build_seal(rumor, SealForm::Plaintext, &group, owner).await?;
|
||||||
let (wrap, _) = cord01::wrap_seal(
|
let (wrap, _) = cord01::wrap_seal(
|
||||||
&seal,
|
&seal,
|
||||||
&group,
|
&group,
|
||||||
@@ -1059,7 +1069,7 @@ mod tests {
|
|||||||
|
|
||||||
let group = rekey_group(scope, &ROOT, &community_id, epoch).expect("derives");
|
let group = rekey_group(scope, &ROOT, &community_id, epoch).expect("derives");
|
||||||
let prior_commit = epoch_key_commitment(Epoch(0), &PRIOR_KEY);
|
let prior_commit = epoch_key_commitment(Epoch(0), &PRIOR_KEY);
|
||||||
let chunks = build_rekey_chunks(
|
let chunks = smol::block_on(build_rekey_chunks(
|
||||||
&rotator,
|
&rotator,
|
||||||
&group,
|
&group,
|
||||||
scope,
|
scope,
|
||||||
@@ -1070,7 +1080,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
false,
|
false,
|
||||||
AT,
|
AT,
|
||||||
)
|
))
|
||||||
.expect("builds");
|
.expect("builds");
|
||||||
assert_eq!(chunks.len(), 1);
|
assert_eq!(chunks.len(), 1);
|
||||||
|
|
||||||
@@ -1269,26 +1279,26 @@ mod tests {
|
|||||||
content: String,
|
content: String,
|
||||||
at_secs: u64,
|
at_secs: u64,
|
||||||
) -> Event {
|
) -> Event {
|
||||||
writer
|
smol::block_on(writer.publish(
|
||||||
.publish(
|
owner,
|
||||||
owner,
|
Edition {
|
||||||
Edition {
|
subkind,
|
||||||
subkind,
|
entity,
|
||||||
entity,
|
content: &content,
|
||||||
content: &content,
|
head: None,
|
||||||
head: None,
|
citation: None,
|
||||||
citation: None,
|
},
|
||||||
},
|
at_secs,
|
||||||
at_secs,
|
))
|
||||||
)
|
.expect("publishes")
|
||||||
.expect("publishes")
|
.0
|
||||||
.0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_rotation_needs_the_permission_and_must_strictly_outrank_every_target() {
|
fn a_rotation_needs_the_permission_and_must_strictly_outrank_every_target() {
|
||||||
let owner = Keys::generate();
|
let owner = Keys::generate();
|
||||||
let minted = genesis(&owner, &CommunityMetadata::default(), AT).expect("mints");
|
let minted =
|
||||||
|
smol::block_on(genesis(&owner, &CommunityMetadata::default(), AT)).expect("mints");
|
||||||
let community_id = minted.identity.community_id;
|
let community_id = minted.identity.community_id;
|
||||||
let read =
|
let read =
|
||||||
control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives");
|
control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives");
|
||||||
@@ -1426,7 +1436,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let chunks = build_rekey_chunks(
|
let chunks = smol::block_on(build_rekey_chunks(
|
||||||
&rotator,
|
&rotator,
|
||||||
&group,
|
&group,
|
||||||
scope,
|
scope,
|
||||||
@@ -1437,7 +1447,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
false,
|
false,
|
||||||
AT,
|
AT,
|
||||||
)
|
))
|
||||||
.expect("builds");
|
.expect("builds");
|
||||||
|
|
||||||
assert_eq!(chunks.len(), 1, "a full send chunk is one event");
|
assert_eq!(chunks.len(), 1, "a full send chunk is one event");
|
||||||
@@ -1452,7 +1462,7 @@ mod tests {
|
|||||||
wrapped: "x".to_owned(),
|
wrapped: "x".to_owned(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let chunks = build_rekey_chunks(
|
let chunks = smol::block_on(build_rekey_chunks(
|
||||||
&rotator,
|
&rotator,
|
||||||
&group,
|
&group,
|
||||||
scope,
|
scope,
|
||||||
@@ -1463,7 +1473,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
false,
|
false,
|
||||||
AT,
|
AT,
|
||||||
)
|
))
|
||||||
.expect("builds");
|
.expect("builds");
|
||||||
|
|
||||||
assert_eq!(chunks.len(), 2, "one over the cap splits across two events");
|
assert_eq!(chunks.len(), 2, "one over the cap splits across two events");
|
||||||
@@ -1485,8 +1495,13 @@ mod tests {
|
|||||||
content: "{}",
|
content: "{}",
|
||||||
at_secs: AT,
|
at_secs: AT,
|
||||||
});
|
});
|
||||||
let seal =
|
let seal = smol::block_on(cord01::build_seal(
|
||||||
cord01::build_seal(&rumor, SealForm::Plaintext, &prior_read, &owner).expect("seals");
|
&rumor,
|
||||||
|
SealForm::Plaintext,
|
||||||
|
&prior_read,
|
||||||
|
&owner,
|
||||||
|
))
|
||||||
|
.expect("seals");
|
||||||
|
|
||||||
let refounding = plan_refounding(Epoch(1)).expect("plans");
|
let refounding = plan_refounding(Epoch(1)).expect("plans");
|
||||||
let read = refounding.read(&community_id).expect("derives");
|
let read = refounding.read(&community_id).expect("derives");
|
||||||
@@ -1507,8 +1522,13 @@ mod tests {
|
|||||||
assert_eq!(reopened.author, owner.public_key());
|
assert_eq!(reopened.author, owner.public_key());
|
||||||
|
|
||||||
// Only a plaintext seal can be carried forward.
|
// Only a plaintext seal can be carried forward.
|
||||||
let encrypted =
|
let encrypted = smol::block_on(cord01::build_seal(
|
||||||
cord01::build_seal(&rumor, SealForm::Encrypted, &prior_read, &owner).expect("seals");
|
&rumor,
|
||||||
|
SealForm::Encrypted,
|
||||||
|
&prior_read,
|
||||||
|
&owner,
|
||||||
|
))
|
||||||
|
.expect("seals");
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
compact(&[encrypted], &read, &signer, AT + 1),
|
compact(&[encrypted], &read, &signer, AT + 1),
|
||||||
Err(RekeyError::Stream(StreamError::NotRewrappable))
|
Err(RekeyError::Stream(StreamError::NotRewrappable))
|
||||||
@@ -1527,7 +1547,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let rumor = dissolved_tombstone_rumor(owner.public_key(), &community_id, AT);
|
let rumor = dissolved_tombstone_rumor(owner.public_key(), &community_id, AT);
|
||||||
let wrap = seal_dissolved(&rumor, &community_id, &owner, AT).expect("seals");
|
let wrap =
|
||||||
|
smol::block_on(seal_dissolved(&rumor, &community_id, &owner, AT)).expect("seals");
|
||||||
|
|
||||||
assert!(verify_dissolved(&wrap, &identity));
|
assert!(verify_dissolved(&wrap, &identity));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1538,18 +1559,18 @@ mod tests {
|
|||||||
// Anyone holding the community id finds the address, but only the committed
|
// Anyone holding the community id finds the address, but only the committed
|
||||||
// owner's signature counts.
|
// owner's signature counts.
|
||||||
let impostor = Keys::generate();
|
let impostor = Keys::generate();
|
||||||
let forged = seal_dissolved(
|
let forged = smol::block_on(seal_dissolved(
|
||||||
&dissolved_tombstone_rumor(impostor.public_key(), &community_id, AT),
|
&dissolved_tombstone_rumor(impostor.public_key(), &community_id, AT),
|
||||||
&community_id,
|
&community_id,
|
||||||
&impostor,
|
&impostor,
|
||||||
AT,
|
AT,
|
||||||
)
|
))
|
||||||
.expect("seals");
|
.expect("seals");
|
||||||
assert!(!verify_dissolved(&forged, &identity));
|
assert!(!verify_dissolved(&forged, &identity));
|
||||||
|
|
||||||
// The spec's all-zero `eid` is refused: it would let one owner's genuine
|
// The spec's all-zero `eid` is refused: it would let one owner's genuine
|
||||||
// tombstone be re-wrapped at another of their communities and kill it.
|
// tombstone be re-wrapped at another of their communities and kill it.
|
||||||
let zeroed = seal_dissolved(
|
let zeroed = smol::block_on(seal_dissolved(
|
||||||
&cord01::build_rumor_secs(
|
&cord01::build_rumor_secs(
|
||||||
KIND_CONTROL,
|
KIND_CONTROL,
|
||||||
owner.public_key(),
|
owner.public_key(),
|
||||||
@@ -1563,7 +1584,7 @@ mod tests {
|
|||||||
&community_id,
|
&community_id,
|
||||||
&owner,
|
&owner,
|
||||||
AT,
|
AT,
|
||||||
)
|
))
|
||||||
.expect("seals");
|
.expect("seals");
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
open_dissolved(&zeroed, &community_id),
|
open_dissolved(&zeroed, &community_id),
|
||||||
|
|||||||
@@ -461,7 +461,11 @@ mod tests {
|
|||||||
at_ms,
|
at_ms,
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
relay.insert(seal_rumor(&rumor, &group, &author, false).expect("seals").0);
|
relay.insert(
|
||||||
|
smol::block_on(seal_rumor(&rumor, &group, &author, false))
|
||||||
|
.expect("seals")
|
||||||
|
.0,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut seen = BTreeSet::new();
|
let mut seen = BTreeSet::new();
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
# Concord backend audit and simplification plan
|
||||||
|
|
||||||
|
Audit of `crates/concord`, triggered by `CommunityRegistry` never reaching
|
||||||
|
`subscribe`: `sync::load` found zero community state documents. Tracing that
|
||||||
|
surfaced two separate things: the app only uses a fraction of the crate, and the
|
||||||
|
crate's writers take a concrete `nostr::Keys`, which the app's signer can never
|
||||||
|
produce.
|
||||||
|
|
||||||
|
Sizes: ~10,500 lines total — ~6,750 production, ~3,750 tests.
|
||||||
|
|
||||||
|
## Decisions taken
|
||||||
|
|
||||||
|
- **D1 — Keep the unwired protocol surface.** `cord05`/`cord06`/`pins`/paging
|
||||||
|
stay in the tree for future use. No mass deletion. (Findings are recorded in
|
||||||
|
§4 for reference only.)
|
||||||
|
- **D2 — Replace `&Keys` with a signer boundary** for account-key operations.
|
||||||
|
Verified feasible against the pinned SDK; design in §2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. `&Keys` cannot be replaced by a public key — but it can be replaced by a signer
|
||||||
|
|
||||||
|
The original question was whether functions like `genesis` only need
|
||||||
|
`signer.get_public_key_async()`. They do not: they sign.
|
||||||
|
|
||||||
|
- `cord02::genesis` (`cords/cord02/mod.rs:117`) → `seal_edition` (`:711`) →
|
||||||
|
`build_seal` (`cord01.rs:217`), which signs the seal (`.finalize(author)`,
|
||||||
|
`cord01.rs:226`), and `wrap_seal_with` (`:247`), which signs the wrap.
|
||||||
|
- Self-addressed documents use NIP-44 to self: `seal_to_self`
|
||||||
|
(`cord01.rs:201`) derives a conversation key from `keys.secret_key()`.
|
||||||
|
|
||||||
|
A public key can produce neither a Schnorr signature nor an ECDH key, so
|
||||||
|
"public-key-only" is impossible. The real defect is the **concrete type**: the
|
||||||
|
app holds `state::UniversalSigner` (async, possibly NIP-46), and a `nostr::Keys`
|
||||||
|
can never be conjured from it. `docs/concord-usage.md:535-536` already records
|
||||||
|
this as a deliberate migration pass.
|
||||||
|
|
||||||
|
### What the pinned SDK actually provides
|
||||||
|
|
||||||
|
Pinned rev `b230cec` (`nostr` 0.45.4 / `nostr-sdk` 0.45.2):
|
||||||
|
|
||||||
|
- There is **no `NostrSigner` trait in this revision.** The async signer surface
|
||||||
|
is three traits, all in the `nostr` crate:
|
||||||
|
- `AsyncGetPublicKey` — `nostr/src/key/public_key.rs:39`
|
||||||
|
- `AsyncSignEvent` — `nostr/src/event/mod.rs:366`
|
||||||
|
- `AsyncNip44` — `nostr/src/nips/nip44/traits.rs:30`
|
||||||
|
- `Keys` implements all three (`nostr/src/key/mod.rs:298,309,342`), so tests and
|
||||||
|
local key holders keep working.
|
||||||
|
- `UniversalSigner` already implements all three with
|
||||||
|
`Error = UniversalSignerError` (`crates/state/src/signer.rs:148-191`).
|
||||||
|
- SDK helpers accept them:
|
||||||
|
- `EventBuilder::finalize_async` — `S: AsyncGetPublicKey + AsyncSignEvent + ?Sized`
|
||||||
|
(`nostr/src/event/builder.rs:171-193`)
|
||||||
|
- `GiftWrapBuilder::finalize_async` — `S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44`
|
||||||
|
(`nostr/src/nips/nip59.rs:334-355`)
|
||||||
|
- `UnwrappedGift::from_gift_wrap_async` — `T: AsyncNip44` (`nip59.rs:84-90`)
|
||||||
|
|
||||||
|
So the answer is yes: pass a signer. `UniversalSigner` works as-is.
|
||||||
|
|
||||||
|
### Per-function bounds, not a bundle
|
||||||
|
|
||||||
|
Each function should request only the capabilities it uses. The SDK itself is
|
||||||
|
designed this way (`UnsignedEvent::finalize_async` takes only `AsyncSignEvent`,
|
||||||
|
`EventBuilder::finalize_async` takes `AsyncGetPublicKey + AsyncSignEvent`,
|
||||||
|
NIP-59 takes all three).
|
||||||
|
|
||||||
|
| Operation | Bounds |
|
||||||
|
| --- | --- |
|
||||||
|
| Sign a seal/edition/rekey wrap, author already known | `AsyncSignEvent` |
|
||||||
|
| Build an event where the author comes from the signer | `AsyncGetPublicKey + AsyncSignEvent` |
|
||||||
|
| To-self documents (Community List, Invite List) | `AsyncGetPublicKey + AsyncNip44`, plus `AsyncSignEvent` when the document is itself an event |
|
||||||
|
| Decrypt-only (`parse_list_event`, `unwrap_direct_invite`) | `AsyncNip44` |
|
||||||
|
| Rekey blob encrypt (`build_blob`) | `AsyncGetPublicKey + AsyncNip44` (no signing) |
|
||||||
|
| Rekey blob open (`open_blob`) | `AsyncNip44` |
|
||||||
|
| Direct invite build (`GiftWrapBuilder`) | all three |
|
||||||
|
|
||||||
|
Use generics (`S: AsyncSignEvent + ?Sized`), never `&dyn`: the traits carry
|
||||||
|
associated `Error` types, so `dyn AsyncSignEvent` would force the concrete error
|
||||||
|
at every call site (`dyn AsyncSignEvent<Error = UniversalSignerError>`),
|
||||||
|
defeating the abstraction. The SDK uses generics throughout for this reason.
|
||||||
|
|
||||||
|
Do **not** define a supertrait bundle
|
||||||
|
`trait Signer: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 {}`: all three
|
||||||
|
supertraits declare an associated `Error`, so `Self::Error` becomes ambiguous,
|
||||||
|
and the bundle forces NIP-44 onto purely-signing callers (and vice versa).
|
||||||
|
|
||||||
|
Inside concord, replace `builder.finalize(&keys)` with
|
||||||
|
`builder.finalize_async(signer).await`. `finalize_async` fetches the signer's
|
||||||
|
public key and uses it as the event author, exactly as `finalize` did, so the
|
||||||
|
bytes are unchanged for every caller that passes a matching signer.
|
||||||
|
|
||||||
|
We deliberately **do not** pre-check the author against `rumor.pubkey` inside
|
||||||
|
`build_seal`. The seal's author is the signer's own public key, matching the old
|
||||||
|
`finalize` semantics; a signer that does not match the rumor is still caught by
|
||||||
|
`open_wrap_at` as `AuthorMismatch` (`cord01.rs:328`). Pre-checking would also
|
||||||
|
make it impossible to construct the hostile seals the cord suite relies on as
|
||||||
|
test vectors (`cord01.rs` `hostile_wraps_are_dropped_in_order`).
|
||||||
|
|
||||||
|
If the repeated `<S as ...>::Error: Error + Send + Sync + 'static` bounds
|
||||||
|
become too noisy, the only stable-Rust way to shorten them is an owned
|
||||||
|
error-erased trait (as the app already does with
|
||||||
|
`crates/state/src/signer.rs:64-138`). That trades precision for brevity; keep
|
||||||
|
per-function bounds unless the noise proves unmanageable.
|
||||||
|
|
||||||
|
### What must NOT go through the signer
|
||||||
|
|
||||||
|
- **Group-key NIP-44.** `cord01::{seal_bytes, open_bytes, wrap_seal,
|
||||||
|
wrap_seal_with, rewrap_seal}` encrypt under a `ConversationKey` derived from
|
||||||
|
HKDF group secrets. `AsyncNip44` can only ECDH against a public key, so group
|
||||||
|
encryption stays on `ConversationKey` / `GroupKey::keys()`.
|
||||||
|
- **Wrap signatures.** Wraps are signed by the derived group signer key
|
||||||
|
(`GroupKey::keys()`), not the account.
|
||||||
|
- **Locally held raw secrets.** `cord05::{build_bundle_event, build_revocation}`
|
||||||
|
take a generated `link_signer` whose secret the app stores as
|
||||||
|
`signer_sk` (`docs/concord-usage.md:306-321`). `&Keys` is correct there; the
|
||||||
|
app has the secret itself.
|
||||||
|
- **Local database artifacts.** `store::{cache_rumor, save_state}` sign with the
|
||||||
|
internal random `LOCAL_KEYS` (`store.rs:18`). No user signer involved.
|
||||||
|
|
||||||
|
### Call-site inventory
|
||||||
|
|
||||||
|
Account-key sites to migrate:
|
||||||
|
|
||||||
|
| Site | Today | After | Bounds |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `cord02::genesis` (`cord02/mod.rs:117`) | `owner: &Keys` | `owner: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
|
||||||
|
| `ControlWriter::{publish, set_*}` (`cord02/mod.rs:214-425`) | `keys: &Keys` | `keys: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
|
||||||
|
| `seal_edition` (`cord02/mod.rs:711`, internal) | `owner: &Keys` | `owner: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
|
||||||
|
| `cord01::build_seal` (`cord01.rs:217`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
|
||||||
|
| `cord01::{seal_to_self, open_to_self}` (`:201,209`) | `keys: &Keys` | `&S`, async | `AsyncGetPublicKey + AsyncNip44` |
|
||||||
|
| `guestbook::seal_rumor` (`guestbook.rs:186`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
|
||||||
|
| `cord03::seal_rumor` (`cord03.rs:295`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
|
||||||
|
| `list::build_list_event` (`list.rs:186`) | `keys: &Keys` | `keys: &S` | all three |
|
||||||
|
| `list::parse_list_event` (`list.rs:197`) | `keys: &Keys` | `keys: &S` | `AsyncGetPublicKey + AsyncNip44` |
|
||||||
|
| `cord05::{build_direct_invite, unwrap_direct_invite}` (`:451,478`) | `inviter`/`recipient: &Keys` | `&S` (stage 3) | build: all three; unwrap: `AsyncNip44` |
|
||||||
|
| `cord05::{build_invite_list, parse_invite_list}` (`:593,604`) | `keys: &Keys` | **done** | build: all three; parse: `AsyncGetPublicKey + AsyncNip44` |
|
||||||
|
| `cord06::build_blob` (`:302`) | `rotator: &Keys` | `rotator: &S` (stage 3) | `AsyncGetPublicKey + AsyncNip44` |
|
||||||
|
| `cord06::open_blob` (`:319`) | `recipient: &Keys` | `recipient: &S` (stage 3) | `AsyncNip44` |
|
||||||
|
| `cord06::{build_rekey_chunks, seal_dissolved}` (`:602,737`) | actor `&Keys` | **done** | `AsyncGetPublicKey + AsyncSignEvent` |
|
||||||
|
|
||||||
|
Leave unchanged: `cord05::{build_bundle_event, build_revocation}`, all
|
||||||
|
`cord01` wrap functions, `GroupKey::keys()`, `store::LOCAL_KEYS`.
|
||||||
|
|
||||||
|
### Async ripple and tests
|
||||||
|
|
||||||
|
Every migrated function becomes `async`. `smol` is already a dev-dependency of
|
||||||
|
concord (`crates/concord/Cargo.toml:21-23`), so affected `#[test]`s become
|
||||||
|
`smol::block_on(...)` wrappers. The app's call sites are already async
|
||||||
|
background tasks.
|
||||||
|
|
||||||
|
### Known constraints
|
||||||
|
|
||||||
|
- `GiftWrapBuilder::finalize_async` and `UnwrappedGift::from_gift_wrap_async`
|
||||||
|
are generic over `S: Sized` (no `?Sized`), so those functions must stay
|
||||||
|
generic, never `&dyn`.
|
||||||
|
- Every converted `S::Error` must be `Error + Send + Sync + 'static` for the
|
||||||
|
SDK helpers' `Error::other` (`nostr/src/error.rs:100-105`) and for
|
||||||
|
`anyhow`; `Keys::AsyncGetPublicKey::Error = Infallible`,
|
||||||
|
`Keys::AsyncSignEvent::Error = nostr::Error`, `UniversalSignerError`
|
||||||
|
(`crates/state/src/signer.rs:10-32`) all qualify.
|
||||||
|
- `AsyncGetPublicKey` is worth requiring alongside `AsyncSignEvent` wherever the
|
||||||
|
author is embedded in the payload: `sign_event` signs the id of the given
|
||||||
|
unsigned event without rewriting its pubkey, so a mismatched signer is only
|
||||||
|
caught later by signature verification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Migration plan
|
||||||
|
|
||||||
|
### Phase 1 — replace `&Keys` with per-function signer bounds (no behavior change) — DONE
|
||||||
|
|
||||||
|
1. No new module: change the signatures listed in the inventory table to
|
||||||
|
generics over the SDK traits (`S: AsyncGetPublicKey + AsyncSignEvent`,
|
||||||
|
`S: AsyncSignEvent`, `S: AsyncGetPublicKey + AsyncNip44`, or `S: AsyncNip44`).
|
||||||
|
2. Migrate the live path only: `cord01::build_seal`, `cord01::{seal_to_self,
|
||||||
|
open_to_self}`, `seal_edition`, `genesis`, `ControlWriter`, `guestbook::
|
||||||
|
seal_rumor`, `cord03::seal_rumor`, `list::{build,parse}_list_event`.
|
||||||
|
3. Update `docs/concord-usage.md` examples to take a signer.
|
||||||
|
4. Update concord tests to `smol::block_on`; `&Keys` keeps working because it
|
||||||
|
implements all three traits.
|
||||||
|
|
||||||
|
Validation: `cargo test -p concord` — 46 passed, 0 failed. The one behavior
|
||||||
|
change from the plan sketch is the dropped up-front author check in §1.
|
||||||
|
`cord01::{seal_to_self, open_to_self}` now take `&str` and return `String`
|
||||||
|
(NIP-44 is UTF-8 text), so the `list` and invite-list callers read the plaintext
|
||||||
|
with `serde_json::from_str`.
|
||||||
|
|
||||||
|
**Unplanned but forced:** `cord01::{build_seal, seal_to_self, open_to_self}` are
|
||||||
|
shared helpers, so the unwired callers had to be migrated in the same pass to
|
||||||
|
keep the crate compiling: `cord05::{build_invite_list, parse_invite_list}` and
|
||||||
|
`cord06::{build_rekey_chunks, seal_dissolved}` (Phase 3's mechanical part).
|
||||||
|
`cord05::{build_direct_invite, unwrap_direct_invite}` and
|
||||||
|
`cord06::{build_blob, open_blob}` are untouched — they use the NIP-59 and
|
||||||
|
group-key paths, not the migrated helpers — and remain `&Keys` for Phase 3.
|
||||||
|
|
||||||
|
### Phase 2 — app uses the signer
|
||||||
|
|
||||||
|
1. `CommunityRegistry::create(&signer, …)` works with `UniversalSigner` directly
|
||||||
|
— no secret exposure. This is the change that makes `subscribe` fire.
|
||||||
|
2. Remove the app-side reimplementation of `list::parse_list_event`
|
||||||
|
(`crates/community/src/sync.rs:154-156`) now that it accepts a signer.
|
||||||
|
|
||||||
|
### Phase 3 — migrate the remaining unwired writers
|
||||||
|
|
||||||
|
`cord05` direct invite / invite list, `cord06` blob/rekey/dissolved, when (or
|
||||||
|
before) the flows that use them are wired. The helpers already force the
|
||||||
|
`cord05` invite-list and `cord06` rekey/dissolved writers to be generic and
|
||||||
|
`async` (see Phase 1); what remains is `cord05::{build_direct_invite,
|
||||||
|
unwrap_direct_invite}` and `cord06::{build_blob, open_blob}`, plus keeping the
|
||||||
|
`Sized` generics (no `&dyn`) for the NIP-59 paths.
|
||||||
|
|
||||||
|
### Phase 4 — duplication and hygiene (independent, low risk)
|
||||||
|
|
||||||
|
1. Add `store::load_states(client)` and delete the app-side state-document scan
|
||||||
|
(`crates/community/src/sync.rs:100-137`).
|
||||||
|
2. Export the `concord/` state prefix from concord; delete the app-side copies
|
||||||
|
(`store.rs:26`, `sync.rs:15-16`).
|
||||||
|
3. Collapse the duplicated tag parsers (`cord03.rs:614-654` vs
|
||||||
|
`guestbook.rs:496-530`) and the identical `ChatError`/`GuestbookError`
|
||||||
|
enums.
|
||||||
|
4. Remove never-varied parameters where the change is local: `banned_at` from
|
||||||
|
`complete_memberlist` (doc admits "empty today"), `cache_rumor -> Result<()>`
|
||||||
|
once nothing reads the bool, `snapshot_authority`/`ephemeral`/`query_rumors
|
||||||
|
(until)` if no scheduled flow needs them.
|
||||||
|
5. Tighten visibility of internal-only `pub` items in `cord04`
|
||||||
|
(`edition_hash`, `fold`, `FoldResult`, `bootstrap_head`, `HeadSelection`,
|
||||||
|
`parse_banlist`, `Role::parse`, `Grant::parse`).
|
||||||
|
6. Fix doc drift: `backfill` arity (`docs/concord-usage.md:212`), `save_state`
|
||||||
|
parameter (`:487`), `init` signature (`:431-432`), and refresh the "Not wired
|
||||||
|
up yet" section (`:528-545`) once Phase 2 lands.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Retained-by-decision surface (reference only)
|
||||||
|
|
||||||
|
Per D1 these stay, but they should be understood as unwired, not live:
|
||||||
|
|
||||||
|
| Module | Approx. prod LOC | App use |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `cord06` rotation/refounding/dissolution | ~850 | none |
|
||||||
|
| `cord05` invites/links/direct/list | ~650 | none (types only, via unused `list::join_material`) |
|
||||||
|
| `cord04::pins` | ~550 | none |
|
||||||
|
| `cord03` write path + `fold` + `plane_keys` | ~340 | only `open` / `expiration_of` |
|
||||||
|
| guestbook / list write paths | ~240 | `open`, `coalesce`, `complete_memberlist`, `is_live` |
|
||||||
|
| `store` paging / purge / query / load_state | ~180 | `cache_rumor`, `save_state` |
|
||||||
|
|
||||||
|
Truly unreferenced even by tests (safe candidates, but kept per D1):
|
||||||
|
`CommunityInvite::expired`, `GroupKey::pk_hex`, `From<[u8; 32]>` impls,
|
||||||
|
`CommunityRoles::{roles, is_empty}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-goals
|
||||||
|
|
||||||
|
- No mass deletion of unwired modules (D1).
|
||||||
|
- No changes to frozen HKDF derivations, locators, golden vectors, or `cord01`
|
||||||
|
envelope semantics.
|
||||||
|
- No group-key encryption through the signer.
|
||||||
|
- Tests move only alongside the code they cover.
|
||||||
|
|
||||||
|
## 5. Validation
|
||||||
|
|
||||||
|
- `cargo test -p concord` after each phase; `cargo test --workspace` before
|
||||||
|
landing.
|
||||||
|
- Phase 1 is behavior-preserving: the existing cord test suite is the oracle.
|
||||||
|
- Phase 2 adds the app-level test: seed a `CommunityState` via
|
||||||
|
`store::save_state`, drive `CommunityRegistry`, assert a subscription is made
|
||||||
|
and an inbound wrap folds into the community.
|
||||||
|
|
||||||
|
## 6. Immediate unblock
|
||||||
|
|
||||||
|
Two options, both app-side:
|
||||||
|
|
||||||
|
1. Smallest (no concord change): expose the local `Keys` the account path
|
||||||
|
already constructs (`crates/state/src/lib.rs:254`) and add
|
||||||
|
`CommunityRegistry::create` around it.
|
||||||
|
2. Clean (needs Phase 1): `CommunityRegistry::create(&UniversalSigner, …)` with
|
||||||
|
no secret exposure, working for NIP-46 accounts too.
|
||||||
|
|
||||||
|
Option 2 is the reason to do Phase 1.
|
||||||
+16
-10
@@ -47,7 +47,7 @@ use concord::cord02::{self, CommunityMetadata};
|
|||||||
use concord::store::{self, CommunityState, save_state};
|
use concord::store::{self, CommunityState, save_state};
|
||||||
|
|
||||||
let metadata = CommunityMetadata { name: "Room".into(), ..Default::default() };
|
let metadata = CommunityMetadata { name: "Room".into(), ..Default::default() };
|
||||||
let minted = cord02::genesis(&owner_keys, &metadata, now_secs)?;
|
let minted = cord02::genesis(&owner_keys, &metadata, now_secs).await?;
|
||||||
|
|
||||||
// minted.identity — community_id, owner, owner_salt (verify() recomputes it)
|
// minted.identity — community_id, owner, owner_salt (verify() recomputes it)
|
||||||
// minted.wraps — the two owner-signed genesis editions, already sealed
|
// minted.wraps — the two owner-signed genesis editions, already sealed
|
||||||
@@ -114,7 +114,7 @@ use concord::cord02::guestbook;
|
|||||||
|
|
||||||
let guestbook = guestbook_group_key(&invite.community_root, &invite.community_id, invite.root_epoch)?;
|
let guestbook = guestbook_group_key(&invite.community_root, &invite.community_id, invite.root_epoch)?;
|
||||||
let rumor = cord02::guestbook::build_join(my_pk, Some((creator_npub, label)), now_ms);
|
let rumor = cord02::guestbook::build_join(my_pk, Some((creator_npub, label)), now_ms);
|
||||||
let (wrap, _) = cord02::guestbook::seal_rumor(&rumor, &guestbook, &my_keys)?;
|
let (wrap, _) = cord02::guestbook::seal_rumor(&rumor, &guestbook, &my_keys).await?;
|
||||||
client.send_event(&wrap).to(&relays).await?;
|
client.send_event(&wrap).to(&relays).await?;
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ use concord::derive::channel_group_key;
|
|||||||
|
|
||||||
let plane = channel_group_key(&community_root, &channel, epoch)?; // public channel
|
let plane = channel_group_key(&community_root, &channel, epoch)?; // public channel
|
||||||
let rumor = build_message(my_pk, &channel, epoch, text, None, at_ms, timer);
|
let rumor = build_message(my_pk, &channel, epoch, text, None, at_ms, timer);
|
||||||
let (wrap, wrap_key) = cord03::seal_rumor(&rumor, &plane, &my_keys, false)?;
|
let (wrap, wrap_key) = cord03::seal_rumor(&rumor, &plane, &my_keys, false).await?;
|
||||||
client.send_event(&wrap).to(&relays).await?;
|
client.send_event(&wrap).to(&relays).await?;
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -253,7 +253,7 @@ let writer = ControlWriter { author: my_pk, read: read.clone(), signer: signer.c
|
|||||||
let head = control.floors.get(entity).cloned();
|
let head = control.floors.get(entity).cloned();
|
||||||
|
|
||||||
let (wrap, new_head) = writer.set_community_metadata(
|
let (wrap, new_head) = writer.set_community_metadata(
|
||||||
&my_keys, &community_id, &metadata, head.as_ref(), citation, now_secs)?;
|
&my_keys, &community_id, &metadata, head.as_ref(), citation, now_secs).await?;
|
||||||
```
|
```
|
||||||
|
|
||||||
`citation` is the `vac` the actor acts under — `None` only for the owner. Build it
|
`citation` is the `vac` the actor acts under — `None` only for the owner. Build it
|
||||||
@@ -405,12 +405,12 @@ A member's own memberships, synced across their devices:
|
|||||||
use concord::cord02::list;
|
use concord::cord02::list;
|
||||||
|
|
||||||
let material = cord02::list::join_material(&invite, staff.then_some(&control_root));
|
let material = cord02::list::join_material(&invite, staff.then_some(&control_root));
|
||||||
let mut mine = cord02::list::parse_list_event(&my_keys, &event)?;
|
let mut mine = cord02::list::parse_list_event(&my_keys, &event).await?;
|
||||||
mine = cord02::list::merge(mine, cord02::list::CommunityList {
|
mine = cord02::list::merge(mine, cord02::list::CommunityList {
|
||||||
entries: vec![cord02::list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: Default::default() }],
|
entries: vec![cord02::list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: Default::default() }],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
let event = cord02::list::build_list_event(&my_keys, &mine)?; // kind 13302, NIP-44 to self
|
let event = cord02::list::build_list_event(&my_keys, &mine).await?; // kind 13302, NIP-44 to self
|
||||||
```
|
```
|
||||||
|
|
||||||
`is_live(&id)` answers joined-versus-left: a tombstone is terminal until a
|
`is_live(&id)` answers joined-versus-left: a tombstone is terminal until a
|
||||||
@@ -493,8 +493,9 @@ self.consumer = Some(cx.spawn(async move |this, cx| {
|
|||||||
- Do the first load in `cx.defer_in(window, ...)` so `init` returns before the
|
- Do the first load in `cx.defer_in(window, ...)` so `init` returns before the
|
||||||
first relay request.
|
first relay request.
|
||||||
- NIP-46 signing is async: call `signer.get_public_key_async()` /
|
- NIP-46 signing is async: call `signer.get_public_key_async()` /
|
||||||
`sign_event_async` inside the background task. The builders still take
|
`sign_event_async` inside the background task. Every account-key writer takes
|
||||||
`&Keys`, so run them where device keys are available.
|
any signer (`Keys` or the app's `UniversalSigner`) and is `async`, so `await`
|
||||||
|
it there rather than requiring device keys.
|
||||||
|
|
||||||
### Subscriptions
|
### Subscriptions
|
||||||
|
|
||||||
@@ -532,8 +533,13 @@ client.subscribe(filter).with_id(sub_id).await?;
|
|||||||
the plane whose address it carries, and rebuilding a subscription when a plane's
|
the plane whose address it carries, and rebuilding a subscription when a plane's
|
||||||
address changes (join, channel added, rekey folded). GPUI integration above is
|
address changes (join, channel added, rekey folded). GPUI integration above is
|
||||||
the shape to build, not code that exists.
|
the shape to build, not code that exists.
|
||||||
- **Every writer takes `&Keys`, not a `NostrSigner`.** NIP-46 is one deliberate
|
- **Account-key writers take any signer, not `&Keys`.** `genesis`,
|
||||||
pass over the builders, not a per-call patch.
|
`ControlWriter`, the guestbook and chat `seal_rumor`s and the `list` builders are
|
||||||
|
`async` and generic over the SDK's `AsyncGetPublicKey` / `AsyncSignEvent` /
|
||||||
|
`AsyncNip44` traits, so a `Keys` and an app `UniversalSigner` both work.
|
||||||
|
Group-key and locally-held-secret writers (`cord01` wrap functions,
|
||||||
|
`cord05::build_bundle_event`, `store`) still take the raw key material they
|
||||||
|
genuinely need.
|
||||||
- **`crates/chat/src/lib.rs::handle_notifications` treats every kind 1059 event as
|
- **`crates/chat/src/lib.rs::handle_notifications` treats every kind 1059 event as
|
||||||
a NIP-59 gift wrap for the current user.** Concord wraps are kind 1059 too, so
|
a NIP-59 gift wrap for the current user.** Concord wraps are kind 1059 too, so
|
||||||
that handler must route by subscription id before any concord subscription goes
|
that handler must route by subscription id before any concord subscription goes
|
||||||
|
|||||||
Reference in New Issue
Block a user