feat: v0.8 Iteration A — ID-class fixes, manifest privacy, registry + greetings
A1 — ID-class bug family (v0.6.1 persona split left network-NodeId comparisons
where posting IDs live). ~14 sites fixed with multi-persona semantics ("author is
one of MY posting identities", not == default):
- revoke_post_access / revoke_circle_access (revocation was broken for every post)
- group key create/add-member/rotate (add-member distribution now works)
- circle profile set/delete/get, encrypted-attachment CEK unwrap
- receipt/comment slot authorship, replication cycle, blob eviction own-tier
- orphaned GroupKeyRequest/Response (0xA1/0xA2) deleted (no send side existed)
A2 — Manifest privacy: AuthorManifest.author_addresses removed from struct AND
signature digest; CdnManifest tree-era fields dropped. Startup migrations re-sign
own manifests, purge unverifiable foreign copies, strip persona fields from legacy
network-id profile rows. Posting identities are now location-anonymous.
A3 — Discovery & first contact:
- Open-slot comments: derivable slot V_x (blake3 from author + slot_binder_nonce)
so strangers pass the CDN comment-verification gate; OpenSlotDecl in FoF gating
- Greetings: HPKE-style sealed GreetingBody (return_path + fresh reply_pubkey),
throwaway outer ID, size-bucketed; messaging-first (Reply/Dismiss, no vouch)
- Registry: frozen canonical registry post + REGISTRY_POST_ID, signed registration
comments (self-certifying deletes), newest-wins per persona, --publish-registry
- Comment TTL: expires_at_ms inside signed digest v2 (context bump), rand 30-365d
ordinary / fixed 30d registrations, expiry sweep on the eviction cycle
- Single accept_incoming_comment() gate wired into all three ingest sites, closing
a pre-existing hole where both pull paths stored comments with no verification
- UI: consent panel (visible/listed/greetings, active choice at first publish),
registry search in Discover, sealed "Say hi" compose, greetings inbox
Security fixes from review: forged-tombstone injection, delete-by-sender-trust,
remote SetPolicy poisoning, stored XSS (escapeHtml quotes, 7 pre-existing sites),
greeting off-switch now revokes prior bios' slots.
No PoW (rejected: inverted cost). Session-relay gating untouched.
201 core tests pass; CLI + desktop build; 3-node integration green.
DEPLOY GATE: wire-incompatible with v0.7.3 — ALPN bump to itsgoin/4 lands in
Iteration B. Do not build/deploy/anchor-swap before that.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
This commit is contained in:
parent
66c9851061
commit
17dbf076cb
19 changed files with 5237 additions and 524 deletions
|
|
@ -27,6 +27,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
let mut web_port: Option<u16> = None;
|
let mut web_port: Option<u16> = None;
|
||||||
let mut print_identity = false;
|
let mut print_identity = false;
|
||||||
let mut announce = false;
|
let mut announce = false;
|
||||||
|
let mut publish_registry = false;
|
||||||
let mut ann_category: Option<String> = None;
|
let mut ann_category: Option<String> = None;
|
||||||
let mut ann_title: Option<String> = None;
|
let mut ann_title: Option<String> = None;
|
||||||
let mut ann_body: Option<String> = None;
|
let mut ann_body: Option<String> = None;
|
||||||
|
|
@ -76,6 +77,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
"--print-identity" => { print_identity = true; i += 1; }
|
"--print-identity" => { print_identity = true; i += 1; }
|
||||||
"--announce" => { announce = true; i += 1; }
|
"--announce" => { announce = true; i += 1; }
|
||||||
|
"--publish-registry" => { publish_registry = true; i += 1; }
|
||||||
"--ann-category" => { ann_category = args.get(i + 1).cloned(); i += 2; }
|
"--ann-category" => { ann_category = args.get(i + 1).cloned(); i += 2; }
|
||||||
"--ann-title" => { ann_title = args.get(i + 1).cloned(); i += 2; }
|
"--ann-title" => { ann_title = args.get(i + 1).cloned(); i += 2; }
|
||||||
"--ann-body" => { ann_body = args.get(i + 1).cloned(); i += 2; }
|
"--ann-body" => { ann_body = args.get(i + 1).cloned(); i += 2; }
|
||||||
|
|
@ -152,6 +154,29 @@ async fn main() -> anyhow::Result<()> {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-shot: --publish-registry — genesis publish of the network
|
||||||
|
// registry post. Refuses unless this node's default posting identity
|
||||||
|
// is the bootstrap anchor's (debug builds may override for tests via
|
||||||
|
// ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS=1). Prints the registry post ID
|
||||||
|
// for verification against the shipped constant.
|
||||||
|
if publish_registry {
|
||||||
|
let node = Node::open_with_bind(&data_dir, bind_addr, profile).await?;
|
||||||
|
match node.publish_registry_genesis().await {
|
||||||
|
Ok(post_id) => {
|
||||||
|
println!("registry_post_id: {}", hex::encode(post_id));
|
||||||
|
println!(
|
||||||
|
"shipped_constant: {}",
|
||||||
|
hex::encode(itsgoin_core::registry::REGISTRY_POST_ID)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to publish registry genesis: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
println!("Starting ItsGoin node (data: {}, profile: {:?})...", data_dir, profile);
|
println!("Starting ItsGoin node (data: {}, profile: {:?})...", data_dir, profile);
|
||||||
let node = Arc::new(Node::open_with_bind(&data_dir, bind_addr, profile).await?);
|
let node = Arc::new(Node::open_with_bind(&data_dir, bind_addr, profile).await?);
|
||||||
|
|
||||||
|
|
@ -162,8 +187,9 @@ async fn main() -> anyhow::Result<()> {
|
||||||
let addr = node.endpoint_addr();
|
let addr = node.endpoint_addr();
|
||||||
let sockets: Vec<_> = addr.ip_addrs().collect();
|
let sockets: Vec<_> = addr.ip_addrs().collect();
|
||||||
|
|
||||||
// Show our display name if set
|
// Show our display name if set (profiles are keyed by POSTING identity,
|
||||||
let my_name = node.get_display_name(&node.node_id).await.unwrap_or(None);
|
// not the network NodeId).
|
||||||
|
let my_name = node.get_display_name(&node.default_posting_id).await.unwrap_or(None);
|
||||||
let name_display = my_name.as_deref().unwrap_or("(not set)");
|
let name_display = my_name.as_deref().unwrap_or("(not set)");
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
|
|
@ -206,6 +232,14 @@ async fn main() -> anyhow::Result<()> {
|
||||||
println!(" connections Show mesh connections");
|
println!(" connections Show mesh connections");
|
||||||
println!(" social-routes Show social routing cache");
|
println!(" social-routes Show social routing cache");
|
||||||
println!(" name <display_name> Set your display name");
|
println!(" name <display_name> Set your display name");
|
||||||
|
println!(" register <name> [keywords...] Register default persona in the network registry (30d, re-run to renew)");
|
||||||
|
println!(" unregister Remove your registry entry (signed delete)");
|
||||||
|
println!(" search <keywords> Search the registry (pulls chain, queries locally)");
|
||||||
|
println!(" greet <bio_post_id_hex> <text> Send a sealed greeting to a bio post's author");
|
||||||
|
println!(" greetings List unsealed greetings on your bio (with reply/dismiss index)");
|
||||||
|
println!(" reply <greeting_index> <text> Sealed reply to a greeting's return path (fresh reply key)");
|
||||||
|
println!(" dismiss <greeting_index> Dismiss a greeting (local only)");
|
||||||
|
println!(" greetings-open <on|off> Toggle the greeting slot on your bio (republishes bio)");
|
||||||
println!(" stats Show node stats");
|
println!(" stats Show node stats");
|
||||||
println!(" export-key Export identity key (KEEP SECRET)");
|
println!(" export-key Export identity key (KEEP SECRET)");
|
||||||
println!(" id Show this node's ID");
|
println!(" id Show this node's ID");
|
||||||
|
|
@ -254,6 +288,10 @@ async fn main() -> anyhow::Result<()> {
|
||||||
let stdin = io::stdin();
|
let stdin = io::stdin();
|
||||||
let reader = stdin.lock();
|
let reader = stdin.lock();
|
||||||
|
|
||||||
|
// A3: cached greeting list so `reply <n>` / `dismiss <n>` indices
|
||||||
|
// refer to the last `greetings` output.
|
||||||
|
let mut last_greetings: Vec<itsgoin_core::node::GreetingRecord> = Vec::new();
|
||||||
|
|
||||||
print!("> ");
|
print!("> ");
|
||||||
io::stdout().flush()?;
|
io::stdout().flush()?;
|
||||||
|
|
||||||
|
|
@ -342,9 +380,13 @@ async fn main() -> anyhow::Result<()> {
|
||||||
if follows.is_empty() {
|
if follows.is_empty() {
|
||||||
println!("(not following anyone)");
|
println!("(not following anyone)");
|
||||||
}
|
}
|
||||||
|
let own_ids: Vec<itsgoin_core::types::NodeId> = node
|
||||||
|
.list_posting_identities().await.unwrap_or_default()
|
||||||
|
.into_iter().map(|p| p.node_id).collect();
|
||||||
for nid in follows {
|
for nid in follows {
|
||||||
let name = node.get_display_name(&nid).await.unwrap_or(None);
|
let name = node.get_display_name(&nid).await.unwrap_or(None);
|
||||||
let label = if nid == node.node_id { " (you)" } else { "" };
|
// Follows are posting ids — "you" = any of our personas.
|
||||||
|
let label = if own_ids.contains(&nid) { " (you)" } else { "" };
|
||||||
if let Some(name) = name {
|
if let Some(name) = name {
|
||||||
println!(" {} ({}){}", name, &hex::encode(nid)[..12], label);
|
println!(" {} ({}){}", name, &hex::encode(nid)[..12], label);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -697,7 +739,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
|
|
||||||
"create-persona" => {
|
"create-persona" => {
|
||||||
let name = arg.unwrap_or("").to_string();
|
let name = arg.unwrap_or("").to_string();
|
||||||
match node.create_posting_identity(name).await {
|
match node.create_posting_identity(name, None).await {
|
||||||
Ok(id) => {
|
Ok(id) => {
|
||||||
println!("Created posting identity: {}", hex::encode(id.node_id));
|
println!("Created posting identity: {}", hex::encode(id.node_id));
|
||||||
}
|
}
|
||||||
|
|
@ -861,6 +903,166 @@ async fn main() -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"register" => {
|
||||||
|
if let Some(rest) = arg {
|
||||||
|
let mut words = rest.split_whitespace();
|
||||||
|
let name = words.next().unwrap_or("").to_string();
|
||||||
|
let keywords: Vec<String> = words.map(|w| w.to_string()).collect();
|
||||||
|
if name.is_empty() {
|
||||||
|
println!("Usage: register <name> [keywords...]");
|
||||||
|
} else {
|
||||||
|
let pid = node.default_posting_id;
|
||||||
|
match node.register_persona(&pid, &name, &keywords).await {
|
||||||
|
Ok(()) => println!(
|
||||||
|
"Registered '{}' ({} keywords). Listed for 30 days; auto-renews while listed.",
|
||||||
|
name, keywords.len()
|
||||||
|
),
|
||||||
|
Err(e) => println!("Error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("Usage: register <name> [keywords...]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"unregister" => {
|
||||||
|
let pid = node.default_posting_id;
|
||||||
|
match node.unregister_persona(&pid).await {
|
||||||
|
Ok(()) => println!("Registry entry removed (signed delete propagated)."),
|
||||||
|
Err(e) => println!("Error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"search" => {
|
||||||
|
let query = arg.unwrap_or("");
|
||||||
|
match node.search_registry(query).await {
|
||||||
|
Ok(hits) => {
|
||||||
|
if hits.is_empty() {
|
||||||
|
println!("(no registry matches)");
|
||||||
|
}
|
||||||
|
for h in hits {
|
||||||
|
println!(
|
||||||
|
" {} {} [{}]",
|
||||||
|
h.name,
|
||||||
|
hex::encode(h.author),
|
||||||
|
h.keywords.join(", "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => println!("Error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"greet" => {
|
||||||
|
if let Some(rest) = arg {
|
||||||
|
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
|
||||||
|
if parts.len() < 2 {
|
||||||
|
println!("Usage: greet <bio_post_id_hex> <text>");
|
||||||
|
} else {
|
||||||
|
match itsgoin_core::parse_node_id_hex(parts[0]) {
|
||||||
|
Ok(bio_post_id) => {
|
||||||
|
match node.send_greeting(bio_post_id, parts[1].to_string()).await {
|
||||||
|
Ok(()) => println!("Greeting sent (sealed — only the bio author can read it)."),
|
||||||
|
Err(e) => println!("Error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => println!("Invalid post ID: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("Usage: greet <bio_post_id_hex> <text>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"greetings" => {
|
||||||
|
match node.list_greetings().await {
|
||||||
|
Ok(greetings) => {
|
||||||
|
if greetings.is_empty() {
|
||||||
|
println!("(no greetings)");
|
||||||
|
}
|
||||||
|
for (i, g) in greetings.iter().enumerate() {
|
||||||
|
let name = if g.sender_name.is_empty() {
|
||||||
|
hex::encode(g.sender_persona)[..12].to_string()
|
||||||
|
} else {
|
||||||
|
g.sender_name.clone()
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" [{}] {} ({}): {}",
|
||||||
|
i, name, &hex::encode(g.sender_persona)[..12], g.text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
last_greetings = greetings;
|
||||||
|
}
|
||||||
|
Err(e) => println!("Error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"reply" => {
|
||||||
|
if let Some(rest) = arg {
|
||||||
|
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
|
||||||
|
match (parts.first().and_then(|s| s.parse::<usize>().ok()), parts.get(1)) {
|
||||||
|
(Some(idx), Some(text)) => {
|
||||||
|
match last_greetings.get(idx) {
|
||||||
|
Some(g) => {
|
||||||
|
match node.reply_to_greeting(
|
||||||
|
g.comment_author, g.post_id, g.timestamp_ms,
|
||||||
|
text.to_string(),
|
||||||
|
).await {
|
||||||
|
Ok(()) => println!("Reply sent (sealed to the greeting's fresh reply key)."),
|
||||||
|
Err(e) => println!("Error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => println!("No greeting at index {} — run `greetings` first.", idx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => println!("Usage: reply <greeting_index> <text>"),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("Usage: reply <greeting_index> <text>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"dismiss" => {
|
||||||
|
match arg.and_then(|a| a.trim().parse::<usize>().ok()) {
|
||||||
|
Some(idx) => match last_greetings.get(idx) {
|
||||||
|
Some(g) => {
|
||||||
|
match node.dismiss_greeting(g.comment_author, g.post_id, g.timestamp_ms).await {
|
||||||
|
Ok(()) => println!("Greeting dismissed (local only)."),
|
||||||
|
Err(e) => println!("Error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => println!("No greeting at index {} — run `greetings` first.", idx),
|
||||||
|
},
|
||||||
|
None => println!("Usage: dismiss <greeting_index>"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"greetings-open" => {
|
||||||
|
match arg.map(|a| a.trim()) {
|
||||||
|
Some("on") | Some("off") => {
|
||||||
|
let open = arg.map(|a| a.trim()) == Some("on");
|
||||||
|
let pid = node.default_posting_id;
|
||||||
|
match node.set_greetings_open(&pid, open).await {
|
||||||
|
Ok(()) => println!(
|
||||||
|
"Greetings {} (bio republished).",
|
||||||
|
if open { "OPEN — strangers can send sealed greetings" } else { "CLOSED" }
|
||||||
|
),
|
||||||
|
Err(e) => println!("Error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let pid = node.default_posting_id;
|
||||||
|
match node.get_greetings_open(&pid).await {
|
||||||
|
Ok(open) => println!(
|
||||||
|
"Greetings are {}. Usage: greetings-open <on|off>",
|
||||||
|
if open { "open" } else { "closed" }
|
||||||
|
),
|
||||||
|
Err(e) => println!("Error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
"quit" | "exit" | "q" => {
|
"quit" | "exit" | "q" => {
|
||||||
println!("Shutting down...");
|
println!("Shutting down...");
|
||||||
break;
|
break;
|
||||||
|
|
@ -887,7 +1089,9 @@ async fn print_post(
|
||||||
) {
|
) {
|
||||||
let author_hex = hex::encode(post.author);
|
let author_hex = hex::encode(post.author);
|
||||||
let author_short = &author_hex[..12];
|
let author_short = &author_hex[..12];
|
||||||
let is_me = &post.author == &node.node_id;
|
// Posts are authored by posting identities — "me" = any of our personas.
|
||||||
|
let is_me = node.list_posting_identities().await.unwrap_or_default()
|
||||||
|
.iter().any(|p| p.node_id == post.author);
|
||||||
|
|
||||||
let author_name = node.get_display_name(&post.author).await.unwrap_or(None);
|
let author_name = node.get_display_name(&post.author).await.unwrap_or(None);
|
||||||
let author_label = match (author_name, is_me) {
|
let author_label = match (author_name, is_me) {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -923,15 +923,15 @@ pub fn rotate_group_key(
|
||||||
// --- CDN Manifest Signing ---
|
// --- CDN Manifest Signing ---
|
||||||
|
|
||||||
/// Compute the canonical digest for an AuthorManifest (for signing/verification).
|
/// Compute the canonical digest for an AuthorManifest (for signing/verification).
|
||||||
/// Digest = BLAKE3(post_id ‖ author ‖ created_at_le ‖ updated_at_le ‖ author_addresses_json ‖ previous_posts_json ‖ following_posts_json)
|
/// Digest = BLAKE3(post_id ‖ author ‖ created_at_le ‖ updated_at_le ‖ previous_posts_json ‖ following_posts_json)
|
||||||
|
/// v0.8: author_addresses removed from AuthorManifest (and from this digest) —
|
||||||
|
/// posting-identity manifests must not carry device addresses.
|
||||||
fn manifest_digest(manifest: &crate::types::AuthorManifest) -> [u8; 32] {
|
fn manifest_digest(manifest: &crate::types::AuthorManifest) -> [u8; 32] {
|
||||||
let mut hasher = blake3::Hasher::new();
|
let mut hasher = blake3::Hasher::new();
|
||||||
hasher.update(&manifest.post_id);
|
hasher.update(&manifest.post_id);
|
||||||
hasher.update(&manifest.author);
|
hasher.update(&manifest.author);
|
||||||
hasher.update(&manifest.created_at.to_le_bytes());
|
hasher.update(&manifest.created_at.to_le_bytes());
|
||||||
hasher.update(&manifest.updated_at.to_le_bytes());
|
hasher.update(&manifest.updated_at.to_le_bytes());
|
||||||
let addrs_json = serde_json::to_string(&manifest.author_addresses).unwrap_or_default();
|
|
||||||
hasher.update(addrs_json.as_bytes());
|
|
||||||
let prev_json = serde_json::to_string(&manifest.previous_posts).unwrap_or_default();
|
let prev_json = serde_json::to_string(&manifest.previous_posts).unwrap_or_default();
|
||||||
hasher.update(prev_json.as_bytes());
|
hasher.update(prev_json.as_bytes());
|
||||||
let next_json = serde_json::to_string(&manifest.following_posts).unwrap_or_default();
|
let next_json = serde_json::to_string(&manifest.following_posts).unwrap_or_default();
|
||||||
|
|
@ -1010,8 +1010,20 @@ pub fn random_slot_noise(size: usize) -> Vec<u8> {
|
||||||
// --- Engagement crypto ---
|
// --- Engagement crypto ---
|
||||||
|
|
||||||
const REACTION_WRAP_CONTEXT: &str = "itsgoin/private-reaction/v1";
|
const REACTION_WRAP_CONTEXT: &str = "itsgoin/private-reaction/v1";
|
||||||
const COMMENT_SIGN_CONTEXT: &str = "itsgoin/comment-sig/v1";
|
/// v0.8 (A3): digest v2 — expires_at_ms enters the signed digest so a
|
||||||
|
/// comment's TTL can never be silently extended by holders. v0.8 has
|
||||||
|
/// ZERO wire-compat obligations (zero-users ruling), so v1 is gone.
|
||||||
|
const COMMENT_SIGN_CONTEXT: &str = "itsgoin/comment-sig/v2";
|
||||||
const REACTION_SIGN_CONTEXT: &str = "itsgoin/reaction-sig/v1";
|
const REACTION_SIGN_CONTEXT: &str = "itsgoin/reaction-sig/v1";
|
||||||
|
/// v0.8 (A3): self-certifying comment-delete signature context.
|
||||||
|
const COMMENT_DELETE_CONTEXT: &str = "itsgoin/comment-delete/v1";
|
||||||
|
/// v0.8 (A3): derivable open-slot V_x context (design §27). Anyone can
|
||||||
|
/// compute V_open from the post alone: author pubkey + slot_binder_nonce.
|
||||||
|
const OPEN_SLOT_VX_CONTEXT: &str = "itsgoin/open-slot-vx/v1";
|
||||||
|
/// v0.8 (A3): greeting-body seal contexts. The bio post id is baked into
|
||||||
|
/// the derivation (same domain-separation style as vouch grants).
|
||||||
|
const GREETING_KEY_CONTEXT: &str = "itsgoin/greeting/v1/key";
|
||||||
|
const GREETING_NONCE_CONTEXT: &str = "itsgoin/greeting/v1/nonce";
|
||||||
|
|
||||||
/// Encrypt a private reaction payload (only the post author can decrypt).
|
/// Encrypt a private reaction payload (only the post author can decrypt).
|
||||||
/// Uses X25519 DH between reactor and author, then ChaCha20-Poly1305.
|
/// Uses X25519 DH between reactor and author, then ChaCha20-Poly1305.
|
||||||
|
|
@ -1067,25 +1079,31 @@ pub fn decrypt_private_reaction(
|
||||||
String::from_utf8(plaintext).map_err(|e| anyhow::anyhow!("invalid utf8: {}", e))
|
String::from_utf8(plaintext).map_err(|e| anyhow::anyhow!("invalid utf8: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sign a comment: ed25519 over BLAKE3(author || post_id || content || timestamp_ms).
|
/// Sign a comment: ed25519 over BLAKE3(author || post_id || content ||
|
||||||
|
/// timestamp_ms [|| ref:ref_post_id] || expires:expires_at_ms).
|
||||||
|
///
|
||||||
|
/// Digest v2 (A3): `expires_at_ms` is part of the comment's identity —
|
||||||
|
/// holders cannot extend a comment's life without invalidating the sig.
|
||||||
fn comment_digest(
|
fn comment_digest(
|
||||||
author: &NodeId,
|
author: &NodeId,
|
||||||
post_id: &PostId,
|
post_id: &PostId,
|
||||||
content: &str,
|
content: &str,
|
||||||
timestamp_ms: u64,
|
timestamp_ms: u64,
|
||||||
ref_post_id: Option<&PostId>,
|
ref_post_id: Option<&PostId>,
|
||||||
|
expires_at_ms: u64,
|
||||||
) -> blake3::Hash {
|
) -> blake3::Hash {
|
||||||
let mut hasher = blake3::Hasher::new_derive_key(COMMENT_SIGN_CONTEXT);
|
let mut hasher = blake3::Hasher::new_derive_key(COMMENT_SIGN_CONTEXT);
|
||||||
hasher.update(author);
|
hasher.update(author);
|
||||||
hasher.update(post_id);
|
hasher.update(post_id);
|
||||||
hasher.update(content.as_bytes());
|
hasher.update(content.as_bytes());
|
||||||
hasher.update(×tamp_ms.to_le_bytes());
|
hasher.update(×tamp_ms.to_le_bytes());
|
||||||
// Domain-separated append: `None` yields the same digest as the v0.6.1
|
// Domain-separated appends (same pattern as the v0.6.2 `ref:` field).
|
||||||
// scheme, so plain comments keep verifying; `Some(ref)` adds the ref id.
|
|
||||||
if let Some(rid) = ref_post_id {
|
if let Some(rid) = ref_post_id {
|
||||||
hasher.update(b"ref:");
|
hasher.update(b"ref:");
|
||||||
hasher.update(rid);
|
hasher.update(rid);
|
||||||
}
|
}
|
||||||
|
hasher.update(b"expires:");
|
||||||
|
hasher.update(&expires_at_ms.to_le_bytes());
|
||||||
hasher.finalize()
|
hasher.finalize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1096,13 +1114,14 @@ pub fn sign_comment(
|
||||||
content: &str,
|
content: &str,
|
||||||
timestamp_ms: u64,
|
timestamp_ms: u64,
|
||||||
ref_post_id: Option<&PostId>,
|
ref_post_id: Option<&PostId>,
|
||||||
|
expires_at_ms: u64,
|
||||||
) -> Vec<u8> {
|
) -> Vec<u8> {
|
||||||
let signing_key = SigningKey::from_bytes(seed);
|
let signing_key = SigningKey::from_bytes(seed);
|
||||||
let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id);
|
let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id, expires_at_ms);
|
||||||
signing_key.sign(digest.as_bytes()).to_bytes().to_vec()
|
signing_key.sign(digest.as_bytes()).to_bytes().to_vec()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify a comment's ed25519 signature.
|
/// Verify a comment's ed25519 signature (digest v2, expiry included).
|
||||||
pub fn verify_comment_signature(
|
pub fn verify_comment_signature(
|
||||||
author: &NodeId,
|
author: &NodeId,
|
||||||
post_id: &PostId,
|
post_id: &PostId,
|
||||||
|
|
@ -1110,6 +1129,7 @@ pub fn verify_comment_signature(
|
||||||
timestamp_ms: u64,
|
timestamp_ms: u64,
|
||||||
signature: &[u8],
|
signature: &[u8],
|
||||||
ref_post_id: Option<&PostId>,
|
ref_post_id: Option<&PostId>,
|
||||||
|
expires_at_ms: u64,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let Ok(verifying_key) = VerifyingKey::from_bytes(author) else {
|
let Ok(verifying_key) = VerifyingKey::from_bytes(author) else {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -1117,10 +1137,177 @@ pub fn verify_comment_signature(
|
||||||
let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else {
|
let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id);
|
let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id, expires_at_ms);
|
||||||
verifying_key.verify(digest.as_bytes(), &sig).is_ok()
|
verifying_key.verify(digest.as_bytes(), &sig).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convenience: verify an `InlineComment`'s identity signature.
|
||||||
|
pub fn verify_inline_comment_signature(comment: &crate::types::InlineComment) -> bool {
|
||||||
|
verify_comment_signature(
|
||||||
|
&comment.author,
|
||||||
|
&comment.post_id,
|
||||||
|
&comment.content,
|
||||||
|
comment.timestamp_ms,
|
||||||
|
&comment.signature,
|
||||||
|
comment.ref_post_id.as_ref(),
|
||||||
|
comment.expires_at_ms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- v0.8 (A3): self-certifying comment deletes ---
|
||||||
|
|
||||||
|
fn comment_delete_digest(author: &NodeId, post_id: &PostId, timestamp_ms: u64) -> blake3::Hash {
|
||||||
|
let mut hasher = blake3::Hasher::new_derive_key(COMMENT_DELETE_CONTEXT);
|
||||||
|
hasher.update(author);
|
||||||
|
hasher.update(post_id);
|
||||||
|
hasher.update(×tamp_ms.to_le_bytes());
|
||||||
|
hasher.finalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign a comment delete: ed25519 by the comment author's posting key
|
||||||
|
/// over BLAKE3(context="itsgoin/comment-delete/v1", author || post_id ||
|
||||||
|
/// timestamp_ms). Verifiable from the delete op alone — works on holders
|
||||||
|
/// that never met the persona (registry unregister path).
|
||||||
|
pub fn sign_comment_delete(
|
||||||
|
seed: &[u8; 32],
|
||||||
|
author: &NodeId,
|
||||||
|
post_id: &PostId,
|
||||||
|
timestamp_ms: u64,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let signing_key = SigningKey::from_bytes(seed);
|
||||||
|
let digest = comment_delete_digest(author, post_id, timestamp_ms);
|
||||||
|
signing_key.sign(digest.as_bytes()).to_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a self-certifying comment-delete signature.
|
||||||
|
pub fn verify_comment_delete(
|
||||||
|
author: &NodeId,
|
||||||
|
post_id: &PostId,
|
||||||
|
timestamp_ms: u64,
|
||||||
|
signature: &[u8],
|
||||||
|
) -> bool {
|
||||||
|
let Ok(verifying_key) = VerifyingKey::from_bytes(author) else { return false; };
|
||||||
|
let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else { return false; };
|
||||||
|
let digest = comment_delete_digest(author, post_id, timestamp_ms);
|
||||||
|
verifying_key.verify(digest.as_bytes(), &sig).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- v0.8 (A3): derivable open-slot V_x (design §27) ---
|
||||||
|
|
||||||
|
/// Derive the open slot's V_x from the post alone:
|
||||||
|
/// `V_open = blake3::derive_key("itsgoin/open-slot-vx/v1",
|
||||||
|
/// author_posting_pubkey || slot_binder_nonce)`.
|
||||||
|
/// Computable by any stranger (author is on the Post, nonce is in the
|
||||||
|
/// gating); distinct per publish. The CEK recovered through an open slot
|
||||||
|
/// is PUBLIC — the outer CEK layer is camouflage only; confidentiality
|
||||||
|
/// (greetings) comes from the inner HPKE-style seal.
|
||||||
|
pub fn derive_open_slot_vx(
|
||||||
|
author_posting_pubkey: &NodeId,
|
||||||
|
slot_binder_nonce: &[u8; 32],
|
||||||
|
) -> [u8; 32] {
|
||||||
|
let mut input = [0u8; 64];
|
||||||
|
input[..32].copy_from_slice(author_posting_pubkey);
|
||||||
|
input[32..].copy_from_slice(slot_binder_nonce);
|
||||||
|
blake3::derive_key(OPEN_SLOT_VX_CONTEXT, &input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- v0.8 (A3): sealed greeting bodies (bucketed) ---
|
||||||
|
|
||||||
|
fn derive_greeting_key_nonce(
|
||||||
|
shared_secret: &[u8; 32],
|
||||||
|
bio_post_id: &PostId,
|
||||||
|
) -> ([u8; 32], [u8; 12]) {
|
||||||
|
let key_ctx = format!("{}/{}", GREETING_KEY_CONTEXT, hex_lower(bio_post_id));
|
||||||
|
let nonce_ctx = format!("{}/{}", GREETING_NONCE_CONTEXT, hex_lower(bio_post_id));
|
||||||
|
let key = blake3::derive_key(&key_ctx, shared_secret);
|
||||||
|
let nonce_full = blake3::derive_key(&nonce_ctx, shared_secret);
|
||||||
|
let mut nonce = [0u8; 12];
|
||||||
|
nonce.copy_from_slice(&nonce_full[..12]);
|
||||||
|
(key, nonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a fresh x25519 keypair `(priv_scalar, pub)` for greeting
|
||||||
|
/// reply keys / ephemeral seal keys. Same ed25519→x25519 derivation path
|
||||||
|
/// the rest of the codebase uses.
|
||||||
|
pub fn generate_x25519_keypair() -> ([u8; 32], [u8; 32]) {
|
||||||
|
generate_vouch_batch_ephemeral()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seal a greeting body to `recipient_x25519_pub`, padded to exactly
|
||||||
|
/// `body_bucket` plaintext bytes. Output layout:
|
||||||
|
/// `eph_x25519_pub(32) || ChaCha20-Poly1305(len_u32_le || body || pad)`.
|
||||||
|
/// Total ciphertext length is `32 + 4 + body_bucket + 16` for every
|
||||||
|
/// greeting in the same bucket — all greetings on a bio are
|
||||||
|
/// size-identical ciphertexts.
|
||||||
|
///
|
||||||
|
/// The recipient key is the bio author's posting key converted via
|
||||||
|
/// `ed25519_pubkey_to_x25519_public` for original greetings, or the raw
|
||||||
|
/// per-greeting `reply_pubkey` for replies. `bio_post_id` is the post the
|
||||||
|
/// comment is placed ON (binds the seal to that post).
|
||||||
|
pub fn seal_greeting_body(
|
||||||
|
recipient_x25519_pub: &[u8; 32],
|
||||||
|
bio_post_id: &PostId,
|
||||||
|
plaintext: &[u8],
|
||||||
|
body_bucket: usize,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
if plaintext.len() > body_bucket {
|
||||||
|
bail!(
|
||||||
|
"greeting body too large: {} > bucket {}",
|
||||||
|
plaintext.len(),
|
||||||
|
body_bucket
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral();
|
||||||
|
let shared = x25519_dh(&eph_priv, recipient_x25519_pub);
|
||||||
|
let (key, nonce) = derive_greeting_key_nonce(&shared, bio_post_id);
|
||||||
|
|
||||||
|
// len prefix + body + random pad to the bucket.
|
||||||
|
let mut padded = Vec::with_capacity(4 + body_bucket);
|
||||||
|
padded.extend_from_slice(&(plaintext.len() as u32).to_le_bytes());
|
||||||
|
padded.extend_from_slice(plaintext);
|
||||||
|
let mut pad = vec![0u8; body_bucket - plaintext.len()];
|
||||||
|
rand::rng().fill_bytes(&mut pad);
|
||||||
|
padded.extend_from_slice(&pad);
|
||||||
|
|
||||||
|
let cipher = ChaCha20Poly1305::new_from_slice(&key)
|
||||||
|
.map_err(|e| anyhow::anyhow!("greeting cipher init: {}", e))?;
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(Nonce::from_slice(&nonce), padded.as_slice())
|
||||||
|
.map_err(|e| anyhow::anyhow!("greeting seal: {}", e))?;
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(32 + ciphertext.len());
|
||||||
|
out.extend_from_slice(&eph_pub);
|
||||||
|
out.extend_from_slice(&ciphertext);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to open a sealed greeting body with the recipient's x25519
|
||||||
|
/// private scalar. Returns `None` on any shape/AEAD failure (not sealed
|
||||||
|
/// to this key, or tampered).
|
||||||
|
pub fn open_greeting_body(
|
||||||
|
recipient_x25519_priv: &[u8; 32],
|
||||||
|
bio_post_id: &PostId,
|
||||||
|
sealed: &[u8],
|
||||||
|
) -> Option<Vec<u8>> {
|
||||||
|
if sealed.len() < 32 + 4 + 16 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut eph_pub = [0u8; 32];
|
||||||
|
eph_pub.copy_from_slice(&sealed[..32]);
|
||||||
|
let shared = x25519_dh(recipient_x25519_priv, &eph_pub);
|
||||||
|
let (key, nonce) = derive_greeting_key_nonce(&shared, bio_post_id);
|
||||||
|
let cipher = ChaCha20Poly1305::new_from_slice(&key).ok()?;
|
||||||
|
let padded = cipher.decrypt(Nonce::from_slice(&nonce), &sealed[32..]).ok()?;
|
||||||
|
if padded.len() < 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let real_len = u32::from_le_bytes(padded[..4].try_into().ok()?) as usize;
|
||||||
|
if 4 + real_len > padded.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(padded[4..4 + real_len].to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
/// Sign a reaction: ed25519 over BLAKE3(reactor || post_id || emoji || timestamp_ms).
|
/// Sign a reaction: ed25519 over BLAKE3(reactor || post_id || emoji || timestamp_ms).
|
||||||
pub fn sign_reaction(
|
pub fn sign_reaction(
|
||||||
seed: &[u8; 32],
|
seed: &[u8; 32],
|
||||||
|
|
@ -1359,20 +1546,133 @@ mod tests {
|
||||||
let ref_post = [2u8; 32];
|
let ref_post = [2u8; 32];
|
||||||
let content = "preview";
|
let content = "preview";
|
||||||
let ts = 1000u64;
|
let ts = 1000u64;
|
||||||
|
let exp = 5000u64;
|
||||||
|
|
||||||
// Signature including ref_post_id.
|
// Signature including ref_post_id.
|
||||||
let sig_with_ref = sign_comment(&seed, &nid, &post_id, content, ts, Some(&ref_post));
|
let sig_with_ref = sign_comment(&seed, &nid, &post_id, content, ts, Some(&ref_post), exp);
|
||||||
// Verifies only when the ref is supplied.
|
// Verifies only when the ref is supplied.
|
||||||
assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&ref_post)));
|
assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&ref_post), exp));
|
||||||
// Same signature must NOT verify when the ref is dropped (binding).
|
// Same signature must NOT verify when the ref is dropped (binding).
|
||||||
assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, None));
|
assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, None, exp));
|
||||||
// Nor when the ref is swapped.
|
// Nor when the ref is swapped.
|
||||||
let other_ref = [3u8; 32];
|
let other_ref = [3u8; 32];
|
||||||
assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&other_ref)));
|
assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&other_ref), exp));
|
||||||
|
|
||||||
// Plain-comment signature still works (backward compat with v0.6.1).
|
// Plain-comment signature still works.
|
||||||
let sig_plain = sign_comment(&seed, &nid, &post_id, content, ts, None);
|
let sig_plain = sign_comment(&seed, &nid, &post_id, content, ts, None, exp);
|
||||||
assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_plain, None));
|
assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_plain, None, exp));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A3: digest v2 covers expires_at_ms — mutating the expiry flips
|
||||||
|
/// verification (no silent TTL extension possible).
|
||||||
|
#[test]
|
||||||
|
fn comment_signature_binds_expiry() {
|
||||||
|
let (seed, nid) = make_keypair(8);
|
||||||
|
let post_id = [1u8; 32];
|
||||||
|
let ts = 1000u64;
|
||||||
|
let exp = 90_000_000u64;
|
||||||
|
|
||||||
|
let sig = sign_comment(&seed, &nid, &post_id, "hi", ts, None, exp);
|
||||||
|
assert!(verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, exp));
|
||||||
|
// Extended expiry must fail.
|
||||||
|
assert!(!verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, exp + 1));
|
||||||
|
// Stripped expiry (0) must fail.
|
||||||
|
assert!(!verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A3: self-certifying comment-delete roundtrip + wrong-key reject.
|
||||||
|
#[test]
|
||||||
|
fn comment_delete_sign_verify() {
|
||||||
|
let (seed, nid) = make_keypair(9);
|
||||||
|
let (_mseed, mallory) = make_keypair(10);
|
||||||
|
let post_id = [4u8; 32];
|
||||||
|
let ts = 123_456u64;
|
||||||
|
|
||||||
|
let sig = sign_comment_delete(&seed, &nid, &post_id, ts);
|
||||||
|
assert_eq!(sig.len(), 64);
|
||||||
|
assert!(verify_comment_delete(&nid, &post_id, ts, &sig));
|
||||||
|
// Wrong author key.
|
||||||
|
assert!(!verify_comment_delete(&mallory, &post_id, ts, &sig));
|
||||||
|
// Wrong tuple.
|
||||||
|
assert!(!verify_comment_delete(&nid, &post_id, ts + 1, &sig));
|
||||||
|
assert!(!verify_comment_delete(&nid, &[5u8; 32], ts, &sig));
|
||||||
|
// Garbage signature.
|
||||||
|
assert!(!verify_comment_delete(&nid, &post_id, ts, &[0u8; 64]));
|
||||||
|
assert!(!verify_comment_delete(&nid, &post_id, ts, &[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A3: derive_open_slot_vx determinism + distinctness across nonces
|
||||||
|
/// and authors.
|
||||||
|
#[test]
|
||||||
|
fn open_slot_vx_deterministic_and_distinct() {
|
||||||
|
let (_s1, a1) = make_keypair(21);
|
||||||
|
let (_s2, a2) = make_keypair(22);
|
||||||
|
let n1 = [0xAA; 32];
|
||||||
|
let n2 = [0xBB; 32];
|
||||||
|
|
||||||
|
let v = derive_open_slot_vx(&a1, &n1);
|
||||||
|
assert_eq!(v, derive_open_slot_vx(&a1, &n1), "deterministic");
|
||||||
|
assert_ne!(v, derive_open_slot_vx(&a1, &n2), "distinct per nonce");
|
||||||
|
assert_ne!(v, derive_open_slot_vx(&a2, &n1), "distinct per author");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A3: seal/open_greeting_body roundtrip, exact-bucket ciphertext
|
||||||
|
/// length equality across different plaintext lengths, tamper reject,
|
||||||
|
/// wrong-key reject.
|
||||||
|
#[test]
|
||||||
|
fn greeting_body_roundtrip_and_bucketing() {
|
||||||
|
let (bob_priv, bob_pub) = make_persona_x25519(30);
|
||||||
|
let (carol_priv, _carol_pub) = make_persona_x25519(31);
|
||||||
|
let bio_post_id: PostId = [0x77; 32];
|
||||||
|
const BUCKET: usize = 1024;
|
||||||
|
|
||||||
|
let body = br#"{"v":1,"sender_persona":"aa","sender_name":"Al","text":"hi","return_path":"bb","reply_pubkey":"cc"}"#;
|
||||||
|
let sealed = seal_greeting_body(&bob_pub, &bio_post_id, body, BUCKET).unwrap();
|
||||||
|
assert_eq!(sealed.len(), 32 + 4 + BUCKET + 16, "fixed-size ciphertext");
|
||||||
|
|
||||||
|
// Different plaintext length → same ciphertext length.
|
||||||
|
let sealed2 = seal_greeting_body(&bob_pub, &bio_post_id, b"x", BUCKET).unwrap();
|
||||||
|
assert_eq!(sealed.len(), sealed2.len(), "bucket hides length");
|
||||||
|
|
||||||
|
// Recipient opens.
|
||||||
|
let opened = open_greeting_body(&bob_priv, &bio_post_id, &sealed).unwrap();
|
||||||
|
assert_eq!(opened, body.to_vec());
|
||||||
|
|
||||||
|
// Non-recipient cannot.
|
||||||
|
assert!(open_greeting_body(&carol_priv, &bio_post_id, &sealed).is_none());
|
||||||
|
|
||||||
|
// Wrong bio post id cannot.
|
||||||
|
assert!(open_greeting_body(&bob_priv, &[0x88; 32], &sealed).is_none());
|
||||||
|
|
||||||
|
// Tampered ciphertext fails AEAD.
|
||||||
|
let mut tampered = sealed.clone();
|
||||||
|
let last = tampered.len() - 1;
|
||||||
|
tampered[last] ^= 0x01;
|
||||||
|
assert!(open_greeting_body(&bob_priv, &bio_post_id, &tampered).is_none());
|
||||||
|
|
||||||
|
// Oversized body refused.
|
||||||
|
assert!(seal_greeting_body(&bob_pub, &bio_post_id, &vec![0u8; BUCKET + 1], BUCKET).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A3: a reply sealed to a fresh `reply_pubkey` opens with the stored
|
||||||
|
/// reply private key and NOT with the recipient's long-term key.
|
||||||
|
#[test]
|
||||||
|
fn greeting_reply_sealed_to_fresh_key_only() {
|
||||||
|
// Long-term persona key of the greeting's original sender.
|
||||||
|
let (longterm_priv, _longterm_pub) = make_persona_x25519(40);
|
||||||
|
// Fresh per-greeting reply keypair minted by that sender.
|
||||||
|
let (reply_priv, reply_pub) = generate_x25519_keypair();
|
||||||
|
let return_path_post: PostId = [0x99; 32];
|
||||||
|
|
||||||
|
let reply_body = b"hello back";
|
||||||
|
let sealed = seal_greeting_body(&reply_pub, &return_path_post, reply_body, 1024).unwrap();
|
||||||
|
|
||||||
|
// Stored fresh reply key opens it.
|
||||||
|
let opened = open_greeting_body(&reply_priv, &return_path_post, &sealed).unwrap();
|
||||||
|
assert_eq!(opened, reply_body.to_vec());
|
||||||
|
|
||||||
|
// Long-term key does NOT.
|
||||||
|
assert!(open_greeting_body(&longterm_priv, &return_path_post, &sealed).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1383,7 +1683,6 @@ mod tests {
|
||||||
let mut manifest = AuthorManifest {
|
let mut manifest = AuthorManifest {
|
||||||
post_id: [42u8; 32],
|
post_id: [42u8; 32],
|
||||||
author: node_id,
|
author: node_id,
|
||||||
author_addresses: vec!["10.0.0.1:4433".to_string()],
|
|
||||||
created_at: 1000,
|
created_at: 1000,
|
||||||
updated_at: 2000,
|
updated_at: 2000,
|
||||||
previous_posts: vec![ManifestEntry {
|
previous_posts: vec![ManifestEntry {
|
||||||
|
|
@ -1400,6 +1699,97 @@ mod tests {
|
||||||
assert!(verify_manifest_signature(&manifest));
|
assert!(verify_manifest_signature(&manifest));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v0.8 (A2): manifests must never carry device addresses — neither the
|
||||||
|
/// author-signed part nor the CdnManifest wire wrapper.
|
||||||
|
#[test]
|
||||||
|
fn test_manifest_carries_no_addresses() {
|
||||||
|
use crate::types::{AuthorManifest, CdnManifest, ManifestEntry};
|
||||||
|
let (seed, node_id) = make_keypair(1);
|
||||||
|
let (_hseed, host_id) = make_keypair(2);
|
||||||
|
|
||||||
|
let mut manifest = AuthorManifest {
|
||||||
|
post_id: [42u8; 32],
|
||||||
|
author: node_id,
|
||||||
|
created_at: 1000,
|
||||||
|
updated_at: 2000,
|
||||||
|
previous_posts: vec![ManifestEntry {
|
||||||
|
post_id: [1u8; 32],
|
||||||
|
timestamp_ms: 900,
|
||||||
|
has_attachments: true,
|
||||||
|
}],
|
||||||
|
following_posts: vec![],
|
||||||
|
signature: vec![],
|
||||||
|
};
|
||||||
|
manifest.signature = sign_manifest(&seed, &manifest);
|
||||||
|
assert!(verify_manifest_signature(&manifest));
|
||||||
|
|
||||||
|
let author_json = serde_json::to_string(&manifest).unwrap();
|
||||||
|
assert!(
|
||||||
|
!author_json.contains("addresses"),
|
||||||
|
"AuthorManifest JSON must not contain any address field: {author_json}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let cdn = CdnManifest {
|
||||||
|
author_manifest: manifest,
|
||||||
|
host: host_id,
|
||||||
|
};
|
||||||
|
let cdn_json = serde_json::to_string(&cdn).unwrap();
|
||||||
|
assert!(
|
||||||
|
!cdn_json.contains("addresses"),
|
||||||
|
"CdnManifest JSON must not contain any address field: {cdn_json}"
|
||||||
|
);
|
||||||
|
assert!(!cdn_json.contains("downstream_count"));
|
||||||
|
assert!(!cdn_json.contains("\"source\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A1: crypto pairings must use the MATCHED persona's (id, seed) tuple.
|
||||||
|
/// The historical bug paired the default posting secret with the NETWORK
|
||||||
|
/// NodeId — a pair that can never unwrap anything.
|
||||||
|
#[test]
|
||||||
|
fn test_cek_unwrap_requires_matching_persona_pair() {
|
||||||
|
let (author_seed, author_id) = make_keypair(1);
|
||||||
|
let (persona_seed, persona_id) = make_keypair(2); // recipient persona
|
||||||
|
let (network_seed, network_id) = make_keypair(9); // device network key
|
||||||
|
|
||||||
|
let (_encrypted, wrapped_keys) =
|
||||||
|
encrypt_post("hi", &author_seed, &author_id, &[persona_id]).unwrap();
|
||||||
|
|
||||||
|
// Mismatched pair (persona seed + network id): recipient lookup fails.
|
||||||
|
let r = unwrap_cek_for_recipient(&persona_seed, &network_id, &author_id, &wrapped_keys).unwrap();
|
||||||
|
assert!(r.is_none(), "network id must not appear in recipients");
|
||||||
|
|
||||||
|
// Network pair entirely: also fails.
|
||||||
|
let r = unwrap_cek_for_recipient(&network_seed, &network_id, &author_id, &wrapped_keys).unwrap();
|
||||||
|
assert!(r.is_none());
|
||||||
|
|
||||||
|
// Matched persona pair: succeeds.
|
||||||
|
let r = unwrap_cek_for_recipient(&persona_seed, &persona_id, &author_id, &wrapped_keys).unwrap();
|
||||||
|
assert!(r.is_some(), "matched persona (id, seed) pair must unwrap the CEK");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A1 (revocation): rewrap_visibility works with the AUTHOR persona's
|
||||||
|
/// (seed, id) pair — and cannot work with the network pair the old code
|
||||||
|
/// passed.
|
||||||
|
#[test]
|
||||||
|
fn test_rewrap_visibility_uses_author_persona_pair() {
|
||||||
|
let (author_seed, author_id) = make_keypair(3); // non-default persona
|
||||||
|
let (_b_seed, bob_id) = make_keypair(4);
|
||||||
|
let (_c_seed, carol_id) = make_keypair(5);
|
||||||
|
let (network_seed, network_id) = make_keypair(9);
|
||||||
|
|
||||||
|
let (_encrypted, wrapped_keys) =
|
||||||
|
encrypt_post("secret", &author_seed, &author_id, &[bob_id, carol_id]).unwrap();
|
||||||
|
|
||||||
|
// Old bug shape: (default/other secret, network id) — must fail.
|
||||||
|
assert!(rewrap_visibility(&network_seed, &network_id, &wrapped_keys, &[author_id, bob_id]).is_err());
|
||||||
|
|
||||||
|
// Correct: the author persona's own pair — succeeds and re-wraps
|
||||||
|
// for the reduced recipient set.
|
||||||
|
let new_wrapped = rewrap_visibility(&author_seed, &author_id, &wrapped_keys, &[author_id, bob_id]).unwrap();
|
||||||
|
assert!(new_wrapped.iter().any(|wk| wk.recipient == bob_id));
|
||||||
|
assert!(!new_wrapped.iter().any(|wk| wk.recipient == carol_id), "revoked recipient must be gone");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_forged_manifest_rejected() {
|
fn test_forged_manifest_rejected() {
|
||||||
use crate::types::AuthorManifest;
|
use crate::types::AuthorManifest;
|
||||||
|
|
@ -1409,7 +1799,6 @@ mod tests {
|
||||||
let mut manifest = AuthorManifest {
|
let mut manifest = AuthorManifest {
|
||||||
post_id: [42u8; 32],
|
post_id: [42u8; 32],
|
||||||
author: node_id,
|
author: node_id,
|
||||||
author_addresses: vec![],
|
|
||||||
created_at: 1000,
|
created_at: 1000,
|
||||||
updated_at: 2000,
|
updated_at: 2000,
|
||||||
previous_posts: vec![],
|
previous_posts: vec![],
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,14 @@ pub async fn export_data(
|
||||||
let (posts, blob_cids) = if scope == ExportScope::IdentityOnly {
|
let (posts, blob_cids) = if scope == ExportScope::IdentityOnly {
|
||||||
(vec![], vec![])
|
(vec![], vec![])
|
||||||
} else {
|
} else {
|
||||||
gather_posts(storage, node_id).await?
|
// Own posts are authored by POSTING identities (personas) — the
|
||||||
|
// network NodeId never authors posts. Export across all personas.
|
||||||
|
let author_ids: Vec<NodeId> = {
|
||||||
|
let s = storage.get().await;
|
||||||
|
s.list_posting_identities()?
|
||||||
|
.into_iter().map(|p| p.node_id).collect()
|
||||||
|
};
|
||||||
|
gather_posts(storage, &author_ids).await?
|
||||||
};
|
};
|
||||||
|
|
||||||
let (follows, profiles, settings) = if scope == ExportScope::Everything {
|
let (follows, profiles, settings) = if scope == ExportScope::Everything {
|
||||||
|
|
@ -245,10 +252,10 @@ pub async fn export_data(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gather own posts and their blob CIDs.
|
/// Gather own posts (authored by any of our posting identities) and their blob CIDs.
|
||||||
async fn gather_posts(
|
async fn gather_posts(
|
||||||
storage: &StoragePool,
|
storage: &StoragePool,
|
||||||
node_id: &NodeId,
|
author_ids: &[NodeId],
|
||||||
) -> anyhow::Result<(Vec<ExportedPost>, Vec<[u8; 32]>)> {
|
) -> anyhow::Result<(Vec<ExportedPost>, Vec<[u8; 32]>)> {
|
||||||
let s = storage.get().await;
|
let s = storage.get().await;
|
||||||
let posts_with_vis = s.list_posts_with_visibility()?;
|
let posts_with_vis = s.list_posts_with_visibility()?;
|
||||||
|
|
@ -257,7 +264,7 @@ async fn gather_posts(
|
||||||
|
|
||||||
for (id, post, vis) in &posts_with_vis {
|
for (id, post, vis) in &posts_with_vis {
|
||||||
// Only export our own posts
|
// Only export our own posts
|
||||||
if post.author != *node_id {
|
if !author_ids.contains(&post.author) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,11 @@ use rand::RngCore;
|
||||||
|
|
||||||
use crate::crypto::{seal_wrap_slot, SealedWrapSlot};
|
use crate::crypto::{seal_wrap_slot, SealedWrapSlot};
|
||||||
use crate::storage::Storage;
|
use crate::storage::Storage;
|
||||||
use crate::types::{FoFCommentGating, NodeId, WrapSlot};
|
use crate::types::{FoFCommentGating, NodeId, OpenSlotDecl, OpenSlotKind, WrapSlot};
|
||||||
|
|
||||||
|
/// v0.8 (A3): hard cap on a declared open-slot body bucket. Anything
|
||||||
|
/// larger on receive is presumed attacker-shaped.
|
||||||
|
pub const MAX_OPEN_SLOT_BODY_BUCKET: u16 = 4096;
|
||||||
|
|
||||||
/// Build the `FoFCommentGating` block for a post about to be published
|
/// Build the `FoFCommentGating` block for a post about to be published
|
||||||
/// under `CommentPermission::FriendsOfFriends`. The author's keyring
|
/// under `CommentPermission::FriendsOfFriends`. The author's keyring
|
||||||
|
|
@ -33,9 +37,16 @@ use crate::types::{FoFCommentGating, NodeId, WrapSlot};
|
||||||
///
|
///
|
||||||
/// Side effect: this function is pure; no storage writes. The caller
|
/// Side effect: this function is pure; no storage writes. The caller
|
||||||
/// owns persisting the resulting Post.
|
/// owns persisting the resulting Post.
|
||||||
|
/// `open_slot`: when `Some((kind, body_bucket))`, one EXTRA real slot is
|
||||||
|
/// sealed under the derivable `V_open =
|
||||||
|
/// derive_open_slot_vx(author, slot_binder_nonce)` and declared in
|
||||||
|
/// `FoFCommentGating.open_slot` (A3 — greeting/registry open slots).
|
||||||
|
/// The open slot is a REAL wrap slot sealed by the normal path; what
|
||||||
|
/// makes it open is only that its V_x is derivable by anyone.
|
||||||
pub fn build_fof_comment_gating(
|
pub fn build_fof_comment_gating(
|
||||||
storage: &Storage,
|
storage: &Storage,
|
||||||
author_persona_id: &NodeId,
|
author_persona_id: &NodeId,
|
||||||
|
open_slot: Option<(OpenSlotKind, u16)>,
|
||||||
) -> Result<Option<FoFCommentGatingBuilt>> {
|
) -> Result<Option<FoFCommentGatingBuilt>> {
|
||||||
// Gather the author's keyring with provenance: (V_x, owner, epoch).
|
// Gather the author's keyring with provenance: (V_x, owner, epoch).
|
||||||
// The author's own V_me appears with owner=author_persona_id.
|
// The author's own V_me appears with owner=author_persona_id.
|
||||||
|
|
@ -70,6 +81,9 @@ pub fn build_fof_comment_gating(
|
||||||
// (slot_index, owner, epoch, pub_x) afterward for provenance.
|
// (slot_index, owner, epoch, pub_x) afterward for provenance.
|
||||||
enum EntryKind {
|
enum EntryKind {
|
||||||
Real { v_x_owner: NodeId, v_x_epoch: u32 },
|
Real { v_x_owner: NodeId, v_x_epoch: u32 },
|
||||||
|
/// A3: the derivable open slot (greeting/registry). Not part of
|
||||||
|
/// V_x provenance — its key is public by construction.
|
||||||
|
Open,
|
||||||
Dummy,
|
Dummy,
|
||||||
}
|
}
|
||||||
let mut entries: Vec<(EntryKind, [u8; 32], WrapSlot)> = Vec::with_capacity(tagged_keys.len());
|
let mut entries: Vec<(EntryKind, [u8; 32], WrapSlot)> = Vec::with_capacity(tagged_keys.len());
|
||||||
|
|
@ -88,6 +102,25 @@ pub fn build_fof_comment_gating(
|
||||||
entries.push((EntryKind::Real { v_x_owner: *owner, v_x_epoch: *epoch }, pub_x, slot));
|
entries.push((EntryKind::Real { v_x_owner: *owner, v_x_epoch: *epoch }, pub_x, slot));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A3: seal the open slot (if requested) under the derivable V_open.
|
||||||
|
if open_slot.is_some() {
|
||||||
|
let v_open = crate::crypto::derive_open_slot_vx(author_persona_id, &slot_binder_nonce);
|
||||||
|
let mut seed = [0u8; 32];
|
||||||
|
rand::rng().fill_bytes(&mut seed);
|
||||||
|
let signing_key = SigningKey::from_bytes(&seed);
|
||||||
|
let pub_x = *signing_key.verifying_key().as_bytes();
|
||||||
|
let sealed: SealedWrapSlot = seal_wrap_slot(&v_open, &slot_binder_nonce, &cek, &seed)?;
|
||||||
|
entries.push((
|
||||||
|
EntryKind::Open,
|
||||||
|
pub_x,
|
||||||
|
WrapSlot {
|
||||||
|
prefilter_tag: sealed.prefilter_tag,
|
||||||
|
read_ciphertext: sealed.read_ciphertext,
|
||||||
|
sign_ciphertext: sealed.sign_ciphertext,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Pad to bucket with dummies.
|
// Pad to bucket with dummies.
|
||||||
let bucket = crate::profile::next_vouch_batch_bucket(entries.len());
|
let bucket = crate::profile::next_vouch_batch_bucket(entries.len());
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::rng();
|
||||||
|
|
@ -117,8 +150,10 @@ pub fn build_fof_comment_gating(
|
||||||
let mut pub_post_set: Vec<[u8; 32]> = Vec::with_capacity(entries.len());
|
let mut pub_post_set: Vec<[u8; 32]> = Vec::with_capacity(entries.len());
|
||||||
let mut wrap_slots: Vec<WrapSlot> = Vec::with_capacity(entries.len());
|
let mut wrap_slots: Vec<WrapSlot> = Vec::with_capacity(entries.len());
|
||||||
let mut real_slot_provenance: Vec<RealSlotProvenance> = Vec::new();
|
let mut real_slot_provenance: Vec<RealSlotProvenance> = Vec::new();
|
||||||
|
let mut open_slot_index: Option<u32> = None;
|
||||||
for (idx, (kind, pub_x, slot)) in entries.into_iter().enumerate() {
|
for (idx, (kind, pub_x, slot)) in entries.into_iter().enumerate() {
|
||||||
if let EntryKind::Real { v_x_owner, v_x_epoch } = kind {
|
match kind {
|
||||||
|
EntryKind::Real { v_x_owner, v_x_epoch } => {
|
||||||
real_slot_provenance.push(RealSlotProvenance {
|
real_slot_provenance.push(RealSlotProvenance {
|
||||||
slot_index: idx as u32,
|
slot_index: idx as u32,
|
||||||
v_x_owner,
|
v_x_owner,
|
||||||
|
|
@ -126,15 +161,28 @@ pub fn build_fof_comment_gating(
|
||||||
pub_x,
|
pub_x,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
EntryKind::Open => open_slot_index = Some(idx as u32),
|
||||||
|
EntryKind::Dummy => {}
|
||||||
|
}
|
||||||
pub_post_set.push(pub_x);
|
pub_post_set.push(pub_x);
|
||||||
wrap_slots.push(slot);
|
wrap_slots.push(slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let open_slot_decl = match (open_slot, open_slot_index) {
|
||||||
|
(Some((kind, body_bucket)), Some(slot_index)) => Some(OpenSlotDecl {
|
||||||
|
slot_index,
|
||||||
|
kind,
|
||||||
|
body_bucket,
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
let gating = FoFCommentGating {
|
let gating = FoFCommentGating {
|
||||||
slot_binder_nonce,
|
slot_binder_nonce,
|
||||||
pub_post_set,
|
pub_post_set,
|
||||||
wrap_slots,
|
wrap_slots,
|
||||||
revocation_list: Vec::new(),
|
revocation_list: Vec::new(),
|
||||||
|
open_slot: open_slot_decl,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Some(FoFCommentGatingBuilt {
|
Ok(Some(FoFCommentGatingBuilt {
|
||||||
|
|
@ -142,6 +190,7 @@ pub fn build_fof_comment_gating(
|
||||||
cek,
|
cek,
|
||||||
slot_binder_nonce,
|
slot_binder_nonce,
|
||||||
real_slot_provenance,
|
real_slot_provenance,
|
||||||
|
open_slot_index,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,6 +213,9 @@ pub struct FoFCommentGatingBuilt {
|
||||||
/// `own_post_slot_provenance` for later cascade revocations.
|
/// `own_post_slot_provenance` for later cascade revocations.
|
||||||
/// Each entry: (slot_index, v_x_owner, v_x_epoch, pub_x).
|
/// Each entry: (slot_index, v_x_owner, v_x_epoch, pub_x).
|
||||||
pub real_slot_provenance: Vec<RealSlotProvenance>,
|
pub real_slot_provenance: Vec<RealSlotProvenance>,
|
||||||
|
/// A3: index of the derivable open slot, when one was requested.
|
||||||
|
/// Mirrors `gating.open_slot.slot_index`.
|
||||||
|
pub open_slot_index: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One entry per real (non-dummy) slot in a published FoF post.
|
/// One entry per real (non-dummy) slot in a published FoF post.
|
||||||
|
|
@ -314,6 +366,37 @@ pub fn sweep_unreadable_on_new_v_x(
|
||||||
Ok(unlocked)
|
Ok(unlocked)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v0.8 (A3): the stranger-side derive+open helper. Derives the post's
|
||||||
|
/// open-slot `V_open` from the post alone and opens the DECLARED slot,
|
||||||
|
/// returning a `PostUnlock` usable with [`build_fof_comment`].
|
||||||
|
/// `persona_id` is filled with the caller-provided commenter id
|
||||||
|
/// (typically a throwaway identity minted per greeting).
|
||||||
|
///
|
||||||
|
/// Returns `None` when the post has no gating, no open-slot declaration,
|
||||||
|
/// or the declared slot doesn't open under the derived key (malformed or
|
||||||
|
/// key-burned open slot = the author's global off-switch).
|
||||||
|
pub fn derive_open_slot_unlock(
|
||||||
|
post: &crate::types::Post,
|
||||||
|
commenter_persona_id: &NodeId,
|
||||||
|
) -> Option<PostUnlock> {
|
||||||
|
let gating = post.fof_gating.as_ref()?;
|
||||||
|
let decl = gating.open_slot.as_ref()?;
|
||||||
|
let slot = gating.wrap_slots.get(decl.slot_index as usize)?;
|
||||||
|
let v_open = crate::crypto::derive_open_slot_vx(&post.author, &gating.slot_binder_nonce);
|
||||||
|
let opened = crate::crypto::open_wrap_slot(
|
||||||
|
&v_open,
|
||||||
|
&gating.slot_binder_nonce,
|
||||||
|
&slot.read_ciphertext,
|
||||||
|
&slot.sign_ciphertext,
|
||||||
|
)?;
|
||||||
|
Some(PostUnlock {
|
||||||
|
persona_id: *commenter_persona_id,
|
||||||
|
slot_index: decl.slot_index,
|
||||||
|
cek: opened.cek,
|
||||||
|
priv_x_seed: opened.priv_x_seed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Inner helper: prefilter + AEAD-open against a single V_x.
|
/// Inner helper: prefilter + AEAD-open against a single V_x.
|
||||||
fn try_unlock_with_v_x(
|
fn try_unlock_with_v_x(
|
||||||
gating: &crate::types::FoFCommentGating,
|
gating: &crate::types::FoFCommentGating,
|
||||||
|
|
@ -376,6 +459,7 @@ pub fn build_fof_comment(
|
||||||
body: &str,
|
body: &str,
|
||||||
parent_comment_id: Option<[u8; 32]>,
|
parent_comment_id: Option<[u8; 32]>,
|
||||||
now_ms: u64,
|
now_ms: u64,
|
||||||
|
expires_at_ms: u64,
|
||||||
) -> Result<crate::types::InlineComment> {
|
) -> Result<crate::types::InlineComment> {
|
||||||
use ed25519_dalek::{Signer, SigningKey};
|
use ed25519_dalek::{Signer, SigningKey};
|
||||||
|
|
||||||
|
|
@ -411,6 +495,7 @@ pub fn build_fof_comment(
|
||||||
"",
|
"",
|
||||||
now_ms,
|
now_ms,
|
||||||
None,
|
None,
|
||||||
|
expires_at_ms,
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(crate::types::InlineComment {
|
Ok(crate::types::InlineComment {
|
||||||
|
|
@ -424,6 +509,7 @@ pub fn build_fof_comment(
|
||||||
pub_x_index: Some(unlock.slot_index),
|
pub_x_index: Some(unlock.slot_index),
|
||||||
group_sig: Some(group_sig),
|
group_sig: Some(group_sig),
|
||||||
encrypted_payload: Some(encrypted),
|
encrypted_payload: Some(encrypted),
|
||||||
|
expires_at_ms,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -439,7 +525,14 @@ pub fn verify_fof_group_sig(
|
||||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||||
let Some(pub_x_index) = comment.pub_x_index else { return false; };
|
let Some(pub_x_index) = comment.pub_x_index else { return false; };
|
||||||
let Some(group_sig) = comment.group_sig.as_ref() else { return false; };
|
let Some(group_sig) = comment.group_sig.as_ref() else { return false; };
|
||||||
let Some(encrypted_payload) = comment.encrypted_payload.as_ref() else { return false; };
|
// A3: plaintext open-slot comments (registry entries) carry their
|
||||||
|
// body in `content` with no encrypted_payload — the group_sig then
|
||||||
|
// covers the content bytes instead. FoF/greeting comments always
|
||||||
|
// carry Some(encrypted_payload) as before.
|
||||||
|
let encrypted_payload: &[u8] = match comment.encrypted_payload.as_deref() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => comment.content.as_bytes(),
|
||||||
|
};
|
||||||
let idx = pub_x_index as usize;
|
let idx = pub_x_index as usize;
|
||||||
if idx >= gating.pub_post_set.len() { return false; }
|
if idx >= gating.pub_post_set.len() { return false; }
|
||||||
if group_sig.len() != 64 { return false; }
|
if group_sig.len() != 64 { return false; }
|
||||||
|
|
@ -552,6 +645,24 @@ pub fn validate_fof_gating_on_receive(post: &crate::types::Post) -> Result<()> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A3: open-slot declaration sanity.
|
||||||
|
if let Some(decl) = gating.open_slot.as_ref() {
|
||||||
|
if decl.slot_index as usize >= gating.wrap_slots.len() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"open_slot.slot_index {} out of bounds ({} slots)",
|
||||||
|
decl.slot_index,
|
||||||
|
gating.wrap_slots.len(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if decl.body_bucket == 0 || decl.body_bucket > MAX_OPEN_SLOT_BODY_BUCKET {
|
||||||
|
anyhow::bail!(
|
||||||
|
"open_slot.body_bucket {} out of range (1..={})",
|
||||||
|
decl.body_bucket,
|
||||||
|
MAX_OPEN_SLOT_BODY_BUCKET,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -955,7 +1066,7 @@ mod tests {
|
||||||
rand::rng().fill_bytes(&mut v_x_bob);
|
rand::rng().fill_bytes(&mut v_x_bob);
|
||||||
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
|
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
|
||||||
|
|
||||||
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("gating built");
|
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("gating built");
|
||||||
// Real count = 2 (own + Bob). Bucket = 8 (minimum floor).
|
// Real count = 2 (own + Bob). Bucket = 8 (minimum floor).
|
||||||
assert_eq!(built.gating.pub_post_set.len(), 8);
|
assert_eq!(built.gating.pub_post_set.len(), 8);
|
||||||
assert_eq!(built.gating.wrap_slots.len(), 8);
|
assert_eq!(built.gating.wrap_slots.len(), 8);
|
||||||
|
|
@ -1009,7 +1120,7 @@ mod tests {
|
||||||
let s = temp_storage();
|
let s = temp_storage();
|
||||||
let (alice_id, _) = make_persona(5);
|
let (alice_id, _) = make_persona(5);
|
||||||
// No V_me inserted.
|
// No V_me inserted.
|
||||||
let built = build_fof_comment_gating(&s, &alice_id).unwrap();
|
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap();
|
||||||
assert!(built.is_none(), "no V_me → no gating block");
|
assert!(built.is_none(), "no V_me → no gating block");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1031,7 +1142,7 @@ mod tests {
|
||||||
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &same_key, 2000, None).unwrap();
|
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &same_key, 2000, None).unwrap();
|
||||||
s.insert_received_vouch_key(&alice_id, &carol_id, 1, &same_key, 3000, None).unwrap();
|
s.insert_received_vouch_key(&alice_id, &carol_id, 1, &same_key, 3000, None).unwrap();
|
||||||
|
|
||||||
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
|
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
|
||||||
// Unique-key count = 2 (V_me_alice + same_key). Bucket = 8.
|
// Unique-key count = 2 (V_me_alice + same_key). Bucket = 8.
|
||||||
// We can't assert real count directly without exposing internals,
|
// We can't assert real count directly without exposing internals,
|
||||||
// but we can confirm exactly two distinct successful unwraps:
|
// but we can confirm exactly two distinct successful unwraps:
|
||||||
|
|
@ -1088,7 +1199,7 @@ mod tests {
|
||||||
alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
|
alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
|
||||||
|
|
||||||
// Alice publishes the gating block.
|
// Alice publishes the gating block.
|
||||||
let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built");
|
let built = build_fof_comment_gating(&alice_storage, &alice_id, None).unwrap().expect("built");
|
||||||
let parent_post_id = [0xCC; 32];
|
let parent_post_id = [0xCC; 32];
|
||||||
|
|
||||||
// Bob's device: has his V_me (which is v_x_bob since he handed it out).
|
// Bob's device: has his V_me (which is v_x_bob since he handed it out).
|
||||||
|
|
@ -1128,6 +1239,7 @@ mod tests {
|
||||||
"great post alice",
|
"great post alice",
|
||||||
None,
|
None,
|
||||||
4000,
|
4000,
|
||||||
|
4_000_000_000_000,
|
||||||
).unwrap();
|
).unwrap();
|
||||||
assert!(comment.content.is_empty(), "FoF comment body is encrypted, not in content");
|
assert!(comment.content.is_empty(), "FoF comment body is encrypted, not in content");
|
||||||
assert!(comment.pub_x_index.is_some());
|
assert!(comment.pub_x_index.is_some());
|
||||||
|
|
@ -1183,7 +1295,7 @@ mod tests {
|
||||||
rand::rng().fill_bytes(&mut v_x_bob);
|
rand::rng().fill_bytes(&mut v_x_bob);
|
||||||
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
|
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
|
||||||
|
|
||||||
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
|
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
|
||||||
let post_id = [0xDE; 32];
|
let post_id = [0xDE; 32];
|
||||||
|
|
||||||
// Persist the post so apply_fof_revocation_locally can resolve
|
// Persist the post so apply_fof_revocation_locally can resolve
|
||||||
|
|
@ -1228,7 +1340,7 @@ mod tests {
|
||||||
};
|
};
|
||||||
let comment = build_fof_comment(
|
let comment = build_fof_comment(
|
||||||
&post_id, &bob_unlock, &built.slot_binder_nonce,
|
&post_id, &bob_unlock, &built.slot_binder_nonce,
|
||||||
&bob_id, &bob_seed, "hello", None, 4000,
|
&bob_id, &bob_seed, "hello", None, 4000, 4_000_000_000_000,
|
||||||
).unwrap();
|
).unwrap();
|
||||||
s.store_comment(&comment).unwrap();
|
s.store_comment(&comment).unwrap();
|
||||||
assert_eq!(s.get_comments(&post_id).unwrap().len(), 1, "Bob's comment stored");
|
assert_eq!(s.get_comments(&post_id).unwrap().len(), 1, "Bob's comment stored");
|
||||||
|
|
@ -1277,7 +1389,7 @@ mod tests {
|
||||||
s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();
|
s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();
|
||||||
|
|
||||||
// Initial gating: Alice only.
|
// Initial gating: Alice only.
|
||||||
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
|
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
|
||||||
let post_id = [0xBC; 32];
|
let post_id = [0xBC; 32];
|
||||||
let post = crate::types::Post {
|
let post = crate::types::Post {
|
||||||
author: alice_id, content: "alice".into(), attachments: vec![],
|
author: alice_id, content: "alice".into(), attachments: vec![],
|
||||||
|
|
@ -1379,7 +1491,7 @@ mod tests {
|
||||||
rand::rng().fill_bytes(&mut v_me_old);
|
rand::rng().fill_bytes(&mut v_me_old);
|
||||||
s.insert_own_vouch_key(&alice_id, 1, &v_me_old, 1000).unwrap();
|
s.insert_own_vouch_key(&alice_id, 1, &v_me_old, 1000).unwrap();
|
||||||
|
|
||||||
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
|
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
|
||||||
let post_id = [0xAB; 32];
|
let post_id = [0xAB; 32];
|
||||||
let post = crate::types::Post {
|
let post = crate::types::Post {
|
||||||
author: alice_id, content: "x".into(), attachments: vec![],
|
author: alice_id, content: "x".into(), attachments: vec![],
|
||||||
|
|
@ -1469,6 +1581,7 @@ mod tests {
|
||||||
pub_post_set: (0..slot_count).map(|_| [0u8; 32]).collect(),
|
pub_post_set: (0..slot_count).map(|_| [0u8; 32]).collect(),
|
||||||
wrap_slots: (0..slot_count).map(|_| dummy_wrap_slot()).collect(),
|
wrap_slots: (0..slot_count).map(|_| dummy_wrap_slot()).collect(),
|
||||||
revocation_list: vec![],
|
revocation_list: vec![],
|
||||||
|
open_slot: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1574,7 +1687,7 @@ mod tests {
|
||||||
rand::rng().fill_bytes(&mut v_me_alice);
|
rand::rng().fill_bytes(&mut v_me_alice);
|
||||||
s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();
|
s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();
|
||||||
|
|
||||||
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
|
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
|
||||||
let post_id = [0xEE; 32];
|
let post_id = [0xEE; 32];
|
||||||
let post = crate::types::Post {
|
let post = crate::types::Post {
|
||||||
author: alice_id, content: String::new(), attachments: vec![],
|
author: alice_id, content: String::new(), attachments: vec![],
|
||||||
|
|
@ -1706,7 +1819,7 @@ mod tests {
|
||||||
rand::rng().fill_bytes(&mut v_me_alice);
|
rand::rng().fill_bytes(&mut v_me_alice);
|
||||||
alice_storage.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 500).unwrap();
|
alice_storage.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 500).unwrap();
|
||||||
alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 600, None).unwrap();
|
alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 600, None).unwrap();
|
||||||
let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built");
|
let built = build_fof_comment_gating(&alice_storage, &alice_id, None).unwrap().expect("built");
|
||||||
let post = crate::types::Post {
|
let post = crate::types::Post {
|
||||||
author: alice_id, content: String::new(), attachments: vec![],
|
author: alice_id, content: String::new(), attachments: vec![],
|
||||||
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
|
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
|
||||||
|
|
@ -1750,7 +1863,7 @@ mod tests {
|
||||||
rand::rng().fill_bytes(&mut v_x_carol);
|
rand::rng().fill_bytes(&mut v_x_carol);
|
||||||
alice_storage.insert_received_vouch_key(&alice_id, &carol_id, 1, &v_x_carol, 200, None).unwrap();
|
alice_storage.insert_received_vouch_key(&alice_id, &carol_id, 1, &v_x_carol, 200, None).unwrap();
|
||||||
|
|
||||||
let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built");
|
let built = build_fof_comment_gating(&alice_storage, &alice_id, None).unwrap().expect("built");
|
||||||
|
|
||||||
// Bob's storage: holds his own V_me only (no Carol-V_x). The post
|
// Bob's storage: holds his own V_me only (no Carol-V_x). The post
|
||||||
// shouldn't unlock for him yet.
|
// shouldn't unlock for him yet.
|
||||||
|
|
@ -1826,7 +1939,7 @@ mod tests {
|
||||||
rand::rng().fill_bytes(&mut v_x_bob);
|
rand::rng().fill_bytes(&mut v_x_bob);
|
||||||
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
|
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
|
||||||
|
|
||||||
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
|
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
|
||||||
let body_plaintext = "secret to the FoF set only";
|
let body_plaintext = "secret to the FoF set only";
|
||||||
let body_ct = encrypt_fof_body(body_plaintext, &built.cek, &built.slot_binder_nonce).unwrap();
|
let body_ct = encrypt_fof_body(body_plaintext, &built.cek, &built.slot_binder_nonce).unwrap();
|
||||||
|
|
||||||
|
|
@ -1905,7 +2018,7 @@ mod tests {
|
||||||
s.insert_received_vouch_key(&alice_id, &bob_id, 3, &v_x_bob, 2000, None).unwrap();
|
s.insert_received_vouch_key(&alice_id, &bob_id, 3, &v_x_bob, 2000, None).unwrap();
|
||||||
s.insert_received_vouch_key(&alice_id, &carol_id, 5, &v_x_carol, 3000, None).unwrap();
|
s.insert_received_vouch_key(&alice_id, &carol_id, 5, &v_x_carol, 3000, None).unwrap();
|
||||||
|
|
||||||
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
|
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
|
||||||
// 3 unique V_x's → 3 real slots, padded to bucket 8.
|
// 3 unique V_x's → 3 real slots, padded to bucket 8.
|
||||||
assert_eq!(built.real_slot_provenance.len(), 3);
|
assert_eq!(built.real_slot_provenance.len(), 3);
|
||||||
assert_eq!(built.gating.pub_post_set.len(), 8);
|
assert_eq!(built.gating.pub_post_set.len(), 8);
|
||||||
|
|
@ -1962,4 +2075,105 @@ mod tests {
|
||||||
assert!(verify_fof_revocation(&mallory_id, &post_id, &revoked_pub_x, 1000, 0, &sig));
|
assert!(verify_fof_revocation(&mallory_id, &post_id, &revoked_pub_x, 1000, 0, &sig));
|
||||||
let _ = alice_seed;
|
let _ = alice_seed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- v0.8 (A3): open-slot tests ---
|
||||||
|
|
||||||
|
/// Gating built with an open slot: declaration present, validates on
|
||||||
|
/// receive; a stranger (no vouch keys at all) derives V_open from the
|
||||||
|
/// post alone, recovers CEK + signing seed, and a comment built with
|
||||||
|
/// the derived key passes the CDN group_sig check unmodified.
|
||||||
|
#[test]
|
||||||
|
fn open_slot_stranger_derive_and_comment() {
|
||||||
|
use crate::types::{OpenSlotKind, PostingIdentity};
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
|
||||||
|
let s = temp_storage();
|
||||||
|
let (alice_id, alice_seed) = make_persona(140);
|
||||||
|
s.upsert_posting_identity(&PostingIdentity {
|
||||||
|
node_id: alice_id, secret_seed: alice_seed,
|
||||||
|
display_name: "Alice".into(), created_at: 1000,
|
||||||
|
}).unwrap();
|
||||||
|
let mut v_me_alice = [0u8; 32];
|
||||||
|
rand::rng().fill_bytes(&mut v_me_alice);
|
||||||
|
s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();
|
||||||
|
|
||||||
|
let built = build_fof_comment_gating(&s, &alice_id, Some((OpenSlotKind::Greeting, 1024)))
|
||||||
|
.unwrap().expect("built");
|
||||||
|
let decl = built.gating.open_slot.as_ref().expect("open slot declared");
|
||||||
|
assert_eq!(decl.kind, OpenSlotKind::Greeting);
|
||||||
|
assert_eq!(decl.body_bucket, 1024);
|
||||||
|
assert_eq!(Some(decl.slot_index), built.open_slot_index);
|
||||||
|
assert!((decl.slot_index as usize) < built.gating.wrap_slots.len());
|
||||||
|
|
||||||
|
let post = crate::types::Post {
|
||||||
|
author: alice_id, content: String::new(), attachments: vec![],
|
||||||
|
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
|
||||||
|
supersedes_post_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wire-shape validation passes.
|
||||||
|
validate_fof_gating_on_receive(&post).unwrap();
|
||||||
|
|
||||||
|
// A stranger mints a throwaway identity, derives the unlock.
|
||||||
|
let (stranger_id, stranger_seed) = make_persona(141);
|
||||||
|
let unlock = derive_open_slot_unlock(&post, &stranger_id)
|
||||||
|
.expect("stranger derives the open slot unlock");
|
||||||
|
assert_eq!(unlock.cek, built.cek, "stranger recovers the (public) CEK");
|
||||||
|
assert_eq!(unlock.slot_index, decl.slot_index);
|
||||||
|
|
||||||
|
// The derived signing seed matches the published pub_x.
|
||||||
|
let derived_pub = SigningKey::from_bytes(&unlock.priv_x_seed).verifying_key().to_bytes();
|
||||||
|
assert_eq!(built.gating.pub_post_set[decl.slot_index as usize], derived_pub);
|
||||||
|
|
||||||
|
// Stranger authors a comment through the normal FoF path — must
|
||||||
|
// pass the CDN group_sig gate unmodified.
|
||||||
|
let comment = build_fof_comment(
|
||||||
|
&crate::content::compute_post_id(&post), &unlock,
|
||||||
|
&built.slot_binder_nonce, &stranger_id, &stranger_seed,
|
||||||
|
"hello, stranger here", None, 4000, 4_000_000_000_000,
|
||||||
|
).unwrap();
|
||||||
|
assert!(verify_fof_group_sig(&comment, &built.gating));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open-slot declaration bounds are enforced on receive.
|
||||||
|
#[test]
|
||||||
|
fn open_slot_validation_bounds() {
|
||||||
|
use crate::types::{OpenSlotDecl, OpenSlotKind};
|
||||||
|
|
||||||
|
// slot_index out of bounds.
|
||||||
|
let mut g = dummy_gating(8);
|
||||||
|
g.open_slot = Some(OpenSlotDecl { slot_index: 8, kind: OpenSlotKind::Greeting, body_bucket: 1024 });
|
||||||
|
let p = dummy_post(Some(g));
|
||||||
|
let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("out of bounds"), "got: {}", err);
|
||||||
|
|
||||||
|
// body_bucket too big.
|
||||||
|
let mut g = dummy_gating(8);
|
||||||
|
g.open_slot = Some(OpenSlotDecl { slot_index: 0, kind: OpenSlotKind::Registry, body_bucket: 4097 });
|
||||||
|
let p = dummy_post(Some(g));
|
||||||
|
let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("out of range"), "got: {}", err);
|
||||||
|
|
||||||
|
// body_bucket zero.
|
||||||
|
let mut g = dummy_gating(8);
|
||||||
|
g.open_slot = Some(OpenSlotDecl { slot_index: 0, kind: OpenSlotKind::Greeting, body_bucket: 0 });
|
||||||
|
let p = dummy_post(Some(g));
|
||||||
|
assert!(validate_fof_gating_on_receive(&p).is_err());
|
||||||
|
|
||||||
|
// Well-formed decl passes.
|
||||||
|
let mut g = dummy_gating(8);
|
||||||
|
g.open_slot = Some(OpenSlotDecl { slot_index: 3, kind: OpenSlotKind::Greeting, body_bucket: 1024 });
|
||||||
|
let p = dummy_post(Some(g));
|
||||||
|
validate_fof_gating_on_receive(&p).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No open slot declared → derive_open_slot_unlock returns None even
|
||||||
|
/// though the gating exists.
|
||||||
|
#[test]
|
||||||
|
fn open_slot_unlock_requires_declaration() {
|
||||||
|
let g = dummy_gating(8);
|
||||||
|
let p = dummy_post(Some(g));
|
||||||
|
let (stranger, _) = make_persona(150);
|
||||||
|
assert!(derive_open_slot_unlock(&p, &stranger).is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -297,4 +297,40 @@ mod tests {
|
||||||
assert!(!applied2);
|
assert!(!applied2);
|
||||||
assert!(s2.get_group_key(&group_id).unwrap().is_none());
|
assert!(s2.get_group_key(&group_id).unwrap().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A1 (multi-persona): a distribution post addressed to our SECOND
|
||||||
|
/// persona must still be applied — the trial-unwrap iterates ALL held
|
||||||
|
/// posting identities, not just the default one.
|
||||||
|
#[test]
|
||||||
|
fn second_persona_member_applies_distribution() {
|
||||||
|
let s = temp_storage();
|
||||||
|
let (admin_sec, admin_id) = make_keypair(1);
|
||||||
|
let (default_sec, default_id) = make_keypair(2); // default persona (not a member)
|
||||||
|
let (second_sec, second_id) = make_keypair(3); // second persona (the member)
|
||||||
|
|
||||||
|
let group_id = [43u8; 32];
|
||||||
|
let record = GroupKeyRecord {
|
||||||
|
group_id,
|
||||||
|
circle_name: "second".to_string(),
|
||||||
|
epoch: 1,
|
||||||
|
group_public_key: [8u8; 32],
|
||||||
|
admin: admin_id,
|
||||||
|
created_at: 100,
|
||||||
|
canonical_root_post_id: None,
|
||||||
|
};
|
||||||
|
let group_seed = [11u8; 32];
|
||||||
|
|
||||||
|
// Addressed ONLY to the second persona.
|
||||||
|
let (_pid, post, visibility) = build_distribution_post(
|
||||||
|
&admin_id, &admin_sec, &record, &group_seed, &[second_id],
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
let personas = vec![
|
||||||
|
mk_persona(default_sec, default_id),
|
||||||
|
mk_persona(second_sec, second_id),
|
||||||
|
];
|
||||||
|
let applied = try_apply_distribution_post(&s, &post, &visibility, &personas).unwrap();
|
||||||
|
assert!(applied, "second persona must unwrap the group seed");
|
||||||
|
assert_eq!(s.get_group_seed(&group_id, 1).unwrap().unwrap(), group_seed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ pub mod network;
|
||||||
pub mod node;
|
pub mod node;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
|
pub mod registry;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod stun;
|
pub mod stun;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
|
||||||
|
|
@ -2291,6 +2291,22 @@ impl Network {
|
||||||
sent += 1;
|
sent += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// v0.8 (A3): no-known-holders fallback — seed the diff through
|
||||||
|
// up to 3 current mesh connections. Without this, a first
|
||||||
|
// engagement on a post with no recorded file_holders (e.g. a
|
||||||
|
// fresh registration on the self-materialized registry post, or
|
||||||
|
// a greeting on a bio just pulled) goes nowhere until someone
|
||||||
|
// pulls the header on the slow cadence.
|
||||||
|
if sent == 0 {
|
||||||
|
for peer in self.connected_peers().await.into_iter().take(3) {
|
||||||
|
if &peer == exclude_peer {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if self.send_to_peer_uni(&peer, MessageType::BlobHeaderDiff, payload).await.is_ok() {
|
||||||
|
sent += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
sent
|
sent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -175,6 +175,9 @@ pub fn scan_vouch_grants_for_all_personas(
|
||||||
/// batch distributing the persona's current `V_me` to vouched personas.
|
/// batch distributing the persona's current `V_me` to vouched personas.
|
||||||
/// `bio_epoch` is a monotonic per-persona counter that lets receivers
|
/// `bio_epoch` is a monotonic per-persona counter that lets receivers
|
||||||
/// short-circuit re-scanning unchanged bios.
|
/// short-circuit re-scanning unchanged bios.
|
||||||
|
/// v0.8 (A3): `fof_gating` carries the bio's comment gating — including
|
||||||
|
/// the Greeting open slot when the persona's `greetings_open` consent is
|
||||||
|
/// on. `None` publishes a bio that accepts no greetings.
|
||||||
pub fn build_profile_post(
|
pub fn build_profile_post(
|
||||||
author: &NodeId,
|
author: &NodeId,
|
||||||
author_secret: &[u8; 32],
|
author_secret: &[u8; 32],
|
||||||
|
|
@ -183,6 +186,7 @@ pub fn build_profile_post(
|
||||||
avatar_cid: Option<[u8; 32]>,
|
avatar_cid: Option<[u8; 32]>,
|
||||||
vouch_grants: Option<crate::types::VouchGrantBatch>,
|
vouch_grants: Option<crate::types::VouchGrantBatch>,
|
||||||
bio_epoch: u32,
|
bio_epoch: u32,
|
||||||
|
fof_gating: Option<crate::types::FoFCommentGating>,
|
||||||
) -> Post {
|
) -> Post {
|
||||||
let timestamp_ms = std::time::SystemTime::now()
|
let timestamp_ms = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|
@ -203,7 +207,7 @@ pub fn build_profile_post(
|
||||||
content: serde_json::to_string(&content).unwrap_or_default(),
|
content: serde_json::to_string(&content).unwrap_or_default(),
|
||||||
attachments: vec![],
|
attachments: vec![],
|
||||||
timestamp_ms,
|
timestamp_ms,
|
||||||
fof_gating: None,
|
fof_gating,
|
||||||
supersedes_post_id: None,
|
supersedes_post_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -345,7 +349,7 @@ mod tests {
|
||||||
let s = temp_storage();
|
let s = temp_storage();
|
||||||
let (sec, pub_id) = make_keypair(11);
|
let (sec, pub_id) = make_keypair(11);
|
||||||
|
|
||||||
let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None, None, 0);
|
let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None, None, 0, None);
|
||||||
apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap();
|
apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap();
|
||||||
|
|
||||||
let stored = s.get_profile(&pub_id).unwrap().expect("profile stored");
|
let stored = s.get_profile(&pub_id).unwrap().expect("profile stored");
|
||||||
|
|
@ -360,7 +364,7 @@ mod tests {
|
||||||
let (sec_b, _pub_b) = make_keypair(2);
|
let (sec_b, _pub_b) = make_keypair(2);
|
||||||
|
|
||||||
// Build a post claiming `pub_a` but signing with `sec_b`.
|
// Build a post claiming `pub_a` but signing with `sec_b`.
|
||||||
let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None, None, 0);
|
let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None, None, 0, None);
|
||||||
let res = apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile));
|
let res = apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile));
|
||||||
assert!(res.is_err());
|
assert!(res.is_err());
|
||||||
assert!(s.get_profile(&pub_a).unwrap().is_none());
|
assert!(s.get_profile(&pub_a).unwrap().is_none());
|
||||||
|
|
@ -372,7 +376,7 @@ mod tests {
|
||||||
let (sec, pub_id) = make_keypair(3);
|
let (sec, pub_id) = make_keypair(3);
|
||||||
|
|
||||||
// Seed with a newer profile.
|
// Seed with a newer profile.
|
||||||
let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None, None, 0);
|
let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None, None, 0, None);
|
||||||
// Hack the timestamp to make it clearly newer.
|
// Hack the timestamp to make it clearly newer.
|
||||||
let mut content: ProfilePostContent = serde_json::from_str(&newer.content).unwrap();
|
let mut content: ProfilePostContent = serde_json::from_str(&newer.content).unwrap();
|
||||||
content.timestamp_ms = 10_000;
|
content.timestamp_ms = 10_000;
|
||||||
|
|
@ -382,7 +386,7 @@ mod tests {
|
||||||
apply_profile_post_if_applicable(&s, &newer, Some(&VisibilityIntent::Profile)).unwrap();
|
apply_profile_post_if_applicable(&s, &newer, Some(&VisibilityIntent::Profile)).unwrap();
|
||||||
|
|
||||||
// Apply an older profile — should be ignored.
|
// Apply an older profile — should be ignored.
|
||||||
let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None, None, 0);
|
let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None, None, 0, None);
|
||||||
let mut content_o: ProfilePostContent = serde_json::from_str(&older.content).unwrap();
|
let mut content_o: ProfilePostContent = serde_json::from_str(&older.content).unwrap();
|
||||||
content_o.timestamp_ms = 5_000;
|
content_o.timestamp_ms = 5_000;
|
||||||
content_o.signature = crypto::sign_profile(&sec, &content_o.display_name, &content_o.bio, &content_o.avatar_cid, content_o.timestamp_ms);
|
content_o.signature = crypto::sign_profile(&sec, &content_o.display_name, &content_o.bio, &content_o.avatar_cid, content_o.timestamp_ms);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
BlobHeaderDiffOp, CdnManifest, DeleteRecord, GroupEpoch, GroupId, GroupMemberKey, NodeId,
|
BlobHeaderDiffOp, CdnManifest, DeleteRecord, GroupEpoch, GroupId, NodeId,
|
||||||
PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, VisibilityUpdate, WormId,
|
PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, VisibilityUpdate, WormId,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Single ALPN for Discovery Protocol v3 (N1/N2/N3 architecture)
|
/// Single ALPN for Discovery Protocol v3 (N1/N2/N3 architecture)
|
||||||
|
///
|
||||||
|
/// DEPLOY GATE (v0.8): the manifest wire format + signature digest changed
|
||||||
|
/// incompatibly in this tree (AuthorManifest.author_addresses removed from
|
||||||
|
/// struct AND digest; CdnManifest reduced to 2 fields; GroupKeyRequest
|
||||||
|
/// 0xA1/0xA2 removed). Mixed-version nodes half-interop: old-signed
|
||||||
|
/// manifests fail the new digest check, new CdnManifest JSON fails old
|
||||||
|
/// deserialization. Scott ruled ALPN -> b"itsgoin/4" for v0.8 (2026-07-29,
|
||||||
|
/// scheduled Iteration B). Bump it BEFORE any build/deploy of this change
|
||||||
|
/// set so mixed generations refuse cleanly at handshake — ask Scott first
|
||||||
|
/// (feedback_ask_before_release).
|
||||||
pub const ALPN_V2: &[u8] = b"itsgoin/3";
|
pub const ALPN_V2: &[u8] = b"itsgoin/3";
|
||||||
|
|
||||||
/// A post bundled with its visibility metadata for sync
|
/// A post bundled with its visibility metadata for sync
|
||||||
|
|
@ -52,8 +62,6 @@ pub enum MessageType {
|
||||||
// 0x95 (BlobDeleteNotice) retired in v0.6.2 — remote holders evict via LRU.
|
// 0x95 (BlobDeleteNotice) retired in v0.6.2 — remote holders evict via LRU.
|
||||||
// 0xA0 (GroupKeyDistribute) retired in v0.6.2 — group seeds now travel
|
// 0xA0 (GroupKeyDistribute) retired in v0.6.2 — group seeds now travel
|
||||||
// as encrypted posts via the CDN. See `group_key_distribution` module.
|
// as encrypted posts via the CDN. See `group_key_distribution` module.
|
||||||
GroupKeyRequest = 0xA1,
|
|
||||||
GroupKeyResponse = 0xA2,
|
|
||||||
RelayIntroduce = 0xB0,
|
RelayIntroduce = 0xB0,
|
||||||
RelayIntroduceResult = 0xB1,
|
RelayIntroduceResult = 0xB1,
|
||||||
SessionRelay = 0xB2,
|
SessionRelay = 0xB2,
|
||||||
|
|
@ -103,8 +111,6 @@ impl MessageType {
|
||||||
0x92 => Some(Self::ManifestRefreshRequest),
|
0x92 => Some(Self::ManifestRefreshRequest),
|
||||||
0x93 => Some(Self::ManifestRefreshResponse),
|
0x93 => Some(Self::ManifestRefreshResponse),
|
||||||
0x94 => Some(Self::ManifestPush),
|
0x94 => Some(Self::ManifestPush),
|
||||||
0xA1 => Some(Self::GroupKeyRequest),
|
|
||||||
0xA2 => Some(Self::GroupKeyResponse),
|
|
||||||
0xB0 => Some(Self::RelayIntroduce),
|
0xB0 => Some(Self::RelayIntroduce),
|
||||||
0xB1 => Some(Self::RelayIntroduceResult),
|
0xB1 => Some(Self::RelayIntroduceResult),
|
||||||
0xB2 => Some(Self::SessionRelay),
|
0xB2 => Some(Self::SessionRelay),
|
||||||
|
|
@ -390,23 +396,6 @@ pub struct ManifestPushEntry {
|
||||||
// encrypted posts (`VisibilityIntent::GroupKeyDistribute`). See
|
// encrypted posts (`VisibilityIntent::GroupKeyDistribute`). See
|
||||||
// `crate::group_key_distribution` and `types::GroupKeyDistributionContent`.
|
// `crate::group_key_distribution` and `types::GroupKeyDistributionContent`.
|
||||||
|
|
||||||
/// Member requests current group key (bi-stream request)
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct GroupKeyRequestPayload {
|
|
||||||
pub group_id: GroupId,
|
|
||||||
pub known_epoch: GroupEpoch,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Admin responds with wrapped key (bi-stream response)
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct GroupKeyResponsePayload {
|
|
||||||
pub group_id: GroupId,
|
|
||||||
pub epoch: GroupEpoch,
|
|
||||||
pub group_public_key: [u8; 32],
|
|
||||||
pub admin: NodeId,
|
|
||||||
pub member_key: Option<GroupMemberKey>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Relay introduction payloads ---
|
// --- Relay introduction payloads ---
|
||||||
|
|
||||||
/// Relay introduction identifier for deduplication
|
/// Relay introduction identifier for deduplication
|
||||||
|
|
@ -745,8 +734,6 @@ mod tests {
|
||||||
MessageType::ManifestRefreshRequest,
|
MessageType::ManifestRefreshRequest,
|
||||||
MessageType::ManifestRefreshResponse,
|
MessageType::ManifestRefreshResponse,
|
||||||
MessageType::ManifestPush,
|
MessageType::ManifestPush,
|
||||||
MessageType::GroupKeyRequest,
|
|
||||||
MessageType::GroupKeyResponse,
|
|
||||||
MessageType::RelayIntroduce,
|
MessageType::RelayIntroduce,
|
||||||
MessageType::RelayIntroduceResult,
|
MessageType::RelayIntroduceResult,
|
||||||
MessageType::SessionRelay,
|
MessageType::SessionRelay,
|
||||||
|
|
|
||||||
474
crates/core/src/registry.rs
Normal file
474
crates/core/src/registry.rs
Normal file
|
|
@ -0,0 +1,474 @@
|
||||||
|
//! v0.8 (A3): the network registry post — a well-known "registrations
|
||||||
|
//! here" post whose comment chain is the opt-in, searchable linkage of a
|
||||||
|
//! public persona name/keywords to a posting-identity author ID
|
||||||
|
//! (design.html §27 `id="discovery"`).
|
||||||
|
//!
|
||||||
|
//! - The post's canonical bytes are FROZEN in [`REGISTRY_POST_JSON`];
|
||||||
|
//! its id is [`REGISTRY_POST_ID`] = BLAKE3(bytes). The constant is the
|
||||||
|
//! authority — never re-serialize the `Post` struct at runtime to
|
||||||
|
//! derive the id (serde field order = declaration order; any future
|
||||||
|
//! field would silently change the bytes).
|
||||||
|
//! - Every v0.8 node self-materializes the post at startup
|
||||||
|
//! ([`materialize_registry_post`]); only the comment-chain pull is
|
||||||
|
//! network work.
|
||||||
|
//! - Registration = the SAME open-slot comment implementation as
|
||||||
|
//! greetings (round-6 ruling), aimed at this post: plaintext JSON
|
||||||
|
//! entry, self-certified by the standard comment signature under the
|
||||||
|
//! persona's REAL posting key, fixed 30-day TTL, one active entry per
|
||||||
|
//! persona (newest wins).
|
||||||
|
//! - Sharding: `shard(seed, k)` derivation exists from day one via the
|
||||||
|
//! content string embedding the seed + k. k=1 forever in A3; adding
|
||||||
|
//! shard k+1 is a new content string = threshold change, not a
|
||||||
|
//! migration.
|
||||||
|
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::storage::Storage;
|
||||||
|
use crate::types::{NodeId, Post, PostId};
|
||||||
|
|
||||||
|
/// Shard seed + index (design ruling: derivable shard identity).
|
||||||
|
pub const REGISTRY_SHARD_SEED: &str = "itsgoin-registry-v1";
|
||||||
|
pub const REGISTRY_SHARD_K: u32 = 1;
|
||||||
|
|
||||||
|
/// Fixed genesis timestamp baked into the canonical bytes
|
||||||
|
/// (2026-07-30T00:00:00Z).
|
||||||
|
pub const REGISTRY_GENESIS_TIMESTAMP_MS: u64 = 1_785_369_600_000;
|
||||||
|
|
||||||
|
/// Single padded bucket for registration entries (plaintext JSON).
|
||||||
|
pub const REGISTRY_BODY_BUCKET: u16 = 512;
|
||||||
|
|
||||||
|
/// Registrations live exactly 30 days; re-sign to stay listed.
|
||||||
|
pub const REGISTRATION_TTL_MS: u64 = 30 * 24 * 3600 * 1000;
|
||||||
|
/// Holder-side slack when checking the fixed TTL (clock skew).
|
||||||
|
pub const REGISTRATION_TTL_SLACK_MS: u64 = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/// Entry shape limits (holder-enforced).
|
||||||
|
pub const MAX_REGISTRY_NAME_CHARS: usize = 64;
|
||||||
|
pub const MAX_REGISTRY_KEYWORDS: usize = 8;
|
||||||
|
pub const MAX_REGISTRY_KEYWORD_CHARS: usize = 32;
|
||||||
|
|
||||||
|
/// Ordinary comment TTL window: rand(30..=365 days), drawn BEFORE
|
||||||
|
/// signing (the expiry is inside the signed digest).
|
||||||
|
pub const ORDINARY_TTL_MIN_MS: u64 = 30 * 24 * 3600 * 1000;
|
||||||
|
pub const ORDINARY_TTL_MAX_MS: u64 = 365 * 24 * 3600 * 1000;
|
||||||
|
|
||||||
|
/// Draw the expiry for an ordinary comment: `now + rand(30..=365 days)`.
|
||||||
|
/// The randomness serves identity hygiene — throwaway commenter IDs
|
||||||
|
/// vanish at unpredictable times.
|
||||||
|
///
|
||||||
|
/// Debug/test override: `ITSGOIN_TEST_TTL_SECS=<n>` makes every ordinary
|
||||||
|
/// comment expire in n seconds (integration-test hook; debug builds only).
|
||||||
|
pub fn draw_ordinary_comment_expiry(now_ms: u64) -> u64 {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
{
|
||||||
|
if let Ok(secs) = std::env::var("ITSGOIN_TEST_TTL_SECS") {
|
||||||
|
if let Ok(secs) = secs.parse::<u64>() {
|
||||||
|
return now_ms + secs * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
use rand::Rng;
|
||||||
|
let ttl = rand::rng().random_range(ORDINARY_TTL_MIN_MS..=ORDINARY_TTL_MAX_MS);
|
||||||
|
now_ms + ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The frozen canonical serialized bytes of the registry post. Generated
|
||||||
|
/// once (see `build_canonical_registry_post` + the `frozen_bytes_*`
|
||||||
|
/// tests) and pasted here as a literal. DO NOT regenerate at runtime.
|
||||||
|
pub const REGISTRY_POST_JSON: &str = include_str!("registry_post_v1.json");
|
||||||
|
|
||||||
|
/// `BLAKE3(REGISTRY_POST_JSON)` — the registry post's id. Asserted
|
||||||
|
/// against the frozen bytes in CI (`frozen_bytes_triple_assertion`).
|
||||||
|
pub const REGISTRY_POST_ID: PostId = [
|
||||||
|
0x95, 0x28, 0x55, 0x78, 0x0d, 0xaa, 0x7c, 0xcc,
|
||||||
|
0x1c, 0xe1, 0x2d, 0x18, 0x10, 0x3f, 0x77, 0x1d,
|
||||||
|
0x6b, 0xe0, 0xd8, 0x81, 0x33, 0x18, 0x7b, 0xbf,
|
||||||
|
0xec, 0x64, 0x60, 0xda, 0x24, 0x21, 0x90, 0xb4,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Deterministic builder for the canonical registry post. All seal
|
||||||
|
/// inputs are blake3-derived from the shard seed + k, so the output
|
||||||
|
/// bytes are reproducible bit-for-bit — this is what generated (and what
|
||||||
|
/// CI re-checks against) [`REGISTRY_POST_JSON`].
|
||||||
|
pub fn build_canonical_registry_post() -> Result<Post> {
|
||||||
|
use crate::types::{FoFCommentGating, OpenSlotDecl, OpenSlotKind, WrapSlot};
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
|
||||||
|
let shard_input = format!("{}/k={}", REGISTRY_SHARD_SEED, REGISTRY_SHARD_K);
|
||||||
|
let derive = |ctx: &str| -> [u8; 32] { blake3::derive_key(ctx, shard_input.as_bytes()) };
|
||||||
|
|
||||||
|
let slot_binder_nonce = derive("itsgoin/registry/v1/slot-binder");
|
||||||
|
let cek = derive("itsgoin/registry/v1/cek");
|
||||||
|
let priv_x_seed = derive("itsgoin/registry/v1/priv-x-seed");
|
||||||
|
let pub_x = SigningKey::from_bytes(&priv_x_seed).verifying_key().to_bytes();
|
||||||
|
|
||||||
|
let v_open = crate::crypto::derive_open_slot_vx(
|
||||||
|
&crate::DEFAULT_ANCHOR_POSTING_ID,
|
||||||
|
&slot_binder_nonce,
|
||||||
|
);
|
||||||
|
let sealed = crate::crypto::seal_wrap_slot(&v_open, &slot_binder_nonce, &cek, &priv_x_seed)?;
|
||||||
|
|
||||||
|
let gating = FoFCommentGating {
|
||||||
|
slot_binder_nonce,
|
||||||
|
pub_post_set: vec![pub_x],
|
||||||
|
wrap_slots: vec![WrapSlot {
|
||||||
|
prefilter_tag: sealed.prefilter_tag,
|
||||||
|
read_ciphertext: sealed.read_ciphertext,
|
||||||
|
sign_ciphertext: sealed.sign_ciphertext,
|
||||||
|
}],
|
||||||
|
revocation_list: vec![],
|
||||||
|
open_slot: Some(OpenSlotDecl {
|
||||||
|
slot_index: 0,
|
||||||
|
kind: OpenSlotKind::Registry,
|
||||||
|
body_bucket: REGISTRY_BODY_BUCKET,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Post {
|
||||||
|
author: crate::DEFAULT_ANCHOR_POSTING_ID,
|
||||||
|
content: format!(
|
||||||
|
"ItsGoin Network Registry — shard({}, k={}). Register here with a signed \
|
||||||
|
public comment {{name, keywords}} authored by your persona's posting key. \
|
||||||
|
Entries are self-certifying, expire after 30 days, and are newest-wins per \
|
||||||
|
persona. Delete = signed DeleteComment.",
|
||||||
|
REGISTRY_SHARD_SEED, REGISTRY_SHARD_K,
|
||||||
|
),
|
||||||
|
attachments: vec![],
|
||||||
|
timestamp_ms: REGISTRY_GENESIS_TIMESTAMP_MS,
|
||||||
|
fof_gating: Some(gating),
|
||||||
|
supersedes_post_id: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the frozen constant into a `Post`. `verify_post_id` passes by
|
||||||
|
/// construction everywhere.
|
||||||
|
pub fn registry_post() -> Post {
|
||||||
|
serde_json::from_str(REGISTRY_POST_JSON)
|
||||||
|
.expect("frozen REGISTRY_POST_JSON must deserialize — build is broken otherwise")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Self-materialize the registry post (store-if-absent, Public). Every
|
||||||
|
/// v0.8 node can therefore hold/serve the chain; no fetch needed.
|
||||||
|
/// Returns `true` if the post was newly stored.
|
||||||
|
pub fn materialize_registry_post(storage: &Storage) -> Result<bool> {
|
||||||
|
let post = registry_post();
|
||||||
|
debug_assert!(crate::content::verify_post_id(®ISTRY_POST_ID, &post));
|
||||||
|
storage.store_post_with_intent(
|
||||||
|
®ISTRY_POST_ID,
|
||||||
|
&post,
|
||||||
|
&crate::types::PostVisibility::Public,
|
||||||
|
&crate::types::VisibilityIntent::Public,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A registration entry — plaintext JSON in the comment `content`.
|
||||||
|
/// Public by intent: no encryption, no anonymity.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct RegistrationEntry {
|
||||||
|
pub v: u32,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub keywords: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse + shape-validate a registration entry from comment content.
|
||||||
|
pub fn parse_registration(content: &str) -> Result<RegistrationEntry> {
|
||||||
|
if content.len() > REGISTRY_BODY_BUCKET as usize {
|
||||||
|
bail!("registration entry exceeds body bucket ({} bytes)", content.len());
|
||||||
|
}
|
||||||
|
let entry: RegistrationEntry = serde_json::from_str(content)
|
||||||
|
.map_err(|e| anyhow::anyhow!("registration entry is not valid JSON: {}", e))?;
|
||||||
|
if entry.v != 1 {
|
||||||
|
bail!("unsupported registration version {}", entry.v);
|
||||||
|
}
|
||||||
|
if entry.name.is_empty() || entry.name.chars().count() > MAX_REGISTRY_NAME_CHARS {
|
||||||
|
bail!("registration name empty or over {} chars", MAX_REGISTRY_NAME_CHARS);
|
||||||
|
}
|
||||||
|
if entry.keywords.len() > MAX_REGISTRY_KEYWORDS {
|
||||||
|
bail!("more than {} keywords", MAX_REGISTRY_KEYWORDS);
|
||||||
|
}
|
||||||
|
for kw in &entry.keywords {
|
||||||
|
if kw.is_empty() || kw.chars().count() > MAX_REGISTRY_KEYWORD_CHARS {
|
||||||
|
bail!("keyword empty or over {} chars", MAX_REGISTRY_KEYWORD_CHARS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fixed-30d TTL check for registrations: `expires_at_ms − timestamp_ms`
|
||||||
|
/// must equal 30 days within ±5 minutes of slack.
|
||||||
|
pub fn registration_ttl_ok(timestamp_ms: u64, expires_at_ms: u64) -> bool {
|
||||||
|
let Some(ttl) = expires_at_ms.checked_sub(timestamp_ms) else { return false; };
|
||||||
|
ttl.abs_diff(REGISTRATION_TTL_MS) <= REGISTRATION_TTL_SLACK_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One search hit from the local registry chain.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RegistryMatch {
|
||||||
|
pub author: NodeId,
|
||||||
|
pub name: String,
|
||||||
|
pub keywords: Vec<String>,
|
||||||
|
pub timestamp_ms: u64,
|
||||||
|
pub expires_at_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Case-insensitive substring search over name + keywords of the
|
||||||
|
/// locally-held, unexpired, newest-wins registry entries. Search cost
|
||||||
|
/// lands on the searcher (design §27) — LOCAL query only; chain refresh
|
||||||
|
/// is the caller's job (`Node::search_registry`).
|
||||||
|
///
|
||||||
|
/// An empty query returns every live entry (browse mode).
|
||||||
|
pub fn search_entries(storage: &Storage, query: &str) -> Result<Vec<RegistryMatch>> {
|
||||||
|
let comments = storage.get_comments(®ISTRY_POST_ID)?;
|
||||||
|
let needle = query.trim().to_lowercase();
|
||||||
|
|
||||||
|
// Newest-wins per author (storage enforces at ingest; dedupe
|
||||||
|
// defensively for locally-authored duplicates).
|
||||||
|
let mut newest: std::collections::HashMap<NodeId, RegistryMatch> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for c in &comments {
|
||||||
|
let Ok(entry) = parse_registration(&c.content) else { continue; };
|
||||||
|
let m = RegistryMatch {
|
||||||
|
author: c.author,
|
||||||
|
name: entry.name,
|
||||||
|
keywords: entry.keywords,
|
||||||
|
timestamp_ms: c.timestamp_ms,
|
||||||
|
expires_at_ms: c.expires_at_ms,
|
||||||
|
};
|
||||||
|
match newest.get(&c.author) {
|
||||||
|
Some(existing) if existing.timestamp_ms >= m.timestamp_ms => {}
|
||||||
|
_ => {
|
||||||
|
newest.insert(c.author, m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut hits: Vec<RegistryMatch> = newest
|
||||||
|
.into_values()
|
||||||
|
.filter(|m| {
|
||||||
|
if needle.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
m.name.to_lowercase().contains(&needle)
|
||||||
|
|| m.keywords.iter().any(|k| k.to_lowercase().contains(&needle))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
hits.sort_by(|a, b| b.timestamp_ms.cmp(&a.timestamp_ms));
|
||||||
|
Ok(hits)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::types::InlineComment;
|
||||||
|
|
||||||
|
fn temp_storage() -> Storage {
|
||||||
|
Storage::open(":memory:").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generator (dev-time): rebuild the canonical bytes and print them.
|
||||||
|
/// Run with `cargo test -p itsgoin-core registry_generator -- --ignored --nocapture`
|
||||||
|
/// when (and only when) the canonical definition intentionally changes,
|
||||||
|
/// then paste the output into `registry_post_v1.json` + REGISTRY_POST_ID.
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn registry_generator() {
|
||||||
|
let post = build_canonical_registry_post().unwrap();
|
||||||
|
let json = serde_json::to_string(&post).unwrap();
|
||||||
|
let id = blake3::hash(json.as_bytes());
|
||||||
|
println!("JSON:\n{}", json);
|
||||||
|
println!("ID: {:?}", id.as_bytes());
|
||||||
|
println!("ID hex: {}", hex::encode(id.as_bytes()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CI: the frozen literal, its BLAKE3, the shipped constant, and the
|
||||||
|
/// deterministic builder must all agree — forever.
|
||||||
|
#[test]
|
||||||
|
fn frozen_bytes_triple_assertion() {
|
||||||
|
// 1. blake3(frozen bytes) == REGISTRY_POST_ID.
|
||||||
|
let hash = blake3::hash(REGISTRY_POST_JSON.as_bytes());
|
||||||
|
assert_eq!(
|
||||||
|
hash.as_bytes(),
|
||||||
|
®ISTRY_POST_ID,
|
||||||
|
"REGISTRY_POST_ID must equal blake3 of the frozen bytes",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. compute_post_id(parse(frozen bytes)) == REGISTRY_POST_ID —
|
||||||
|
// i.e., re-serialization is currently byte-identical, so
|
||||||
|
// verify_post_id passes everywhere.
|
||||||
|
let post = registry_post();
|
||||||
|
assert_eq!(
|
||||||
|
crate::content::compute_post_id(&post),
|
||||||
|
REGISTRY_POST_ID,
|
||||||
|
"round-trip serialization must reproduce the frozen bytes",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. The deterministic builder still reproduces the constant
|
||||||
|
// (all seal inputs derived — no drift in crypto derivations).
|
||||||
|
let rebuilt = build_canonical_registry_post().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&rebuilt).unwrap(),
|
||||||
|
REGISTRY_POST_JSON,
|
||||||
|
"deterministic builder must reproduce the frozen bytes",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sanity: canonical fields.
|
||||||
|
assert_eq!(post.author, crate::DEFAULT_ANCHOR_POSTING_ID);
|
||||||
|
assert_eq!(post.timestamp_ms, REGISTRY_GENESIS_TIMESTAMP_MS);
|
||||||
|
let gating = post.fof_gating.as_ref().expect("gating");
|
||||||
|
let decl = gating.open_slot.as_ref().expect("open slot");
|
||||||
|
assert_eq!(decl.slot_index, 0);
|
||||||
|
assert_eq!(decl.kind, crate::types::OpenSlotKind::Registry);
|
||||||
|
assert_eq!(decl.body_bucket, REGISTRY_BODY_BUCKET);
|
||||||
|
assert_eq!(gating.wrap_slots.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The registry open slot must actually open under the derivable key
|
||||||
|
/// and yield the signing seed matching pub_post_set[0].
|
||||||
|
#[test]
|
||||||
|
fn registry_open_slot_derivable() {
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
let post = registry_post();
|
||||||
|
let unlock = crate::fof::derive_open_slot_unlock(&post, &[0u8; 32])
|
||||||
|
.expect("registry open slot must open under the derived key");
|
||||||
|
assert_eq!(unlock.slot_index, 0);
|
||||||
|
let derived_pub = SigningKey::from_bytes(&unlock.priv_x_seed).verifying_key().to_bytes();
|
||||||
|
assert_eq!(post.fof_gating.unwrap().pub_post_set[0], derived_pub);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn materialize_is_idempotent() {
|
||||||
|
let s = temp_storage();
|
||||||
|
assert!(materialize_registry_post(&s).unwrap(), "first store inserts");
|
||||||
|
assert!(!materialize_registry_post(&s).unwrap(), "second store no-ops");
|
||||||
|
let (post, vis) = s.get_post_with_visibility(®ISTRY_POST_ID).unwrap().unwrap();
|
||||||
|
assert_eq!(post.author, crate::DEFAULT_ANCHOR_POSTING_ID);
|
||||||
|
assert!(matches!(vis, crate::types::PostVisibility::Public));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registration_shape_limits() {
|
||||||
|
// Good.
|
||||||
|
parse_registration(r#"{"v":1,"name":"Alice","keywords":["rust","p2p"]}"#).unwrap();
|
||||||
|
// No keywords is fine.
|
||||||
|
parse_registration(r#"{"v":1,"name":"Bob"}"#).unwrap();
|
||||||
|
// Wrong version.
|
||||||
|
assert!(parse_registration(r#"{"v":2,"name":"X"}"#).is_err());
|
||||||
|
// Empty name.
|
||||||
|
assert!(parse_registration(r#"{"v":1,"name":""}"#).is_err());
|
||||||
|
// Name too long.
|
||||||
|
let long_name = "x".repeat(65);
|
||||||
|
assert!(parse_registration(&format!(r#"{{"v":1,"name":"{}"}}"#, long_name)).is_err());
|
||||||
|
// Too many keywords.
|
||||||
|
let kws: Vec<String> = (0..9).map(|i| format!("k{}", i)).collect();
|
||||||
|
let json = serde_json::json!({"v":1,"name":"A","keywords":kws}).to_string();
|
||||||
|
assert!(parse_registration(&json).is_err());
|
||||||
|
// Keyword too long.
|
||||||
|
let json = serde_json::json!({"v":1,"name":"A","keywords":["x".repeat(33)]}).to_string();
|
||||||
|
assert!(parse_registration(&json).is_err());
|
||||||
|
// Not JSON.
|
||||||
|
assert!(parse_registration("hello").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registration_ttl_fixed_30d() {
|
||||||
|
let ts = 1_000_000u64;
|
||||||
|
// Exact 30d.
|
||||||
|
assert!(registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS));
|
||||||
|
// Inside slack.
|
||||||
|
assert!(registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS + REGISTRATION_TTL_SLACK_MS));
|
||||||
|
assert!(registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS - REGISTRATION_TTL_SLACK_MS));
|
||||||
|
// Outside slack.
|
||||||
|
assert!(!registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS + REGISTRATION_TTL_SLACK_MS + 1));
|
||||||
|
assert!(!registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS - REGISTRATION_TTL_SLACK_MS - 1));
|
||||||
|
// Degenerate.
|
||||||
|
assert!(!registration_ttl_ok(ts, ts));
|
||||||
|
assert!(!registration_ttl_ok(ts, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reg_comment(author: NodeId, ts: u64, name: &str, kws: &[&str]) -> InlineComment {
|
||||||
|
InlineComment {
|
||||||
|
author,
|
||||||
|
post_id: REGISTRY_POST_ID,
|
||||||
|
content: serde_json::json!({"v":1,"name":name,"keywords":kws}).to_string(),
|
||||||
|
timestamp_ms: ts,
|
||||||
|
signature: vec![0u8; 64],
|
||||||
|
deleted_at: None,
|
||||||
|
ref_post_id: None,
|
||||||
|
pub_x_index: Some(0),
|
||||||
|
group_sig: None,
|
||||||
|
encrypted_payload: None,
|
||||||
|
expires_at_ms: ts + REGISTRATION_TTL_MS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_now() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_millis() as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn newest_wins_upsert() {
|
||||||
|
let s = temp_storage();
|
||||||
|
let author = [7u8; 32];
|
||||||
|
let t0 = test_now();
|
||||||
|
|
||||||
|
// First entry at t0.
|
||||||
|
assert!(s.upsert_registry_entry_newest_wins(®ISTRY_POST_ID, &author, t0).unwrap());
|
||||||
|
s.store_comment(®_comment(author, t0, "Old", &["a"])).unwrap();
|
||||||
|
|
||||||
|
// Newer entry at t0+1000: accepted, older row hard-deleted.
|
||||||
|
assert!(s.upsert_registry_entry_newest_wins(®ISTRY_POST_ID, &author, t0 + 1000).unwrap());
|
||||||
|
s.store_comment(®_comment(author, t0 + 1000, "New", &["b"])).unwrap();
|
||||||
|
let comments = s.get_comments(®ISTRY_POST_ID).unwrap();
|
||||||
|
assert_eq!(comments.len(), 1, "older row hard-deleted");
|
||||||
|
assert_eq!(comments[0].timestamp_ms, t0 + 1000);
|
||||||
|
|
||||||
|
// Older-than-stored entry: rejected, nothing changes.
|
||||||
|
assert!(!s.upsert_registry_entry_newest_wins(®ISTRY_POST_ID, &author, t0 + 500).unwrap());
|
||||||
|
assert_eq!(s.get_comments(®ISTRY_POST_ID).unwrap().len(), 1);
|
||||||
|
|
||||||
|
// Same-timestamp re-ingest: idempotent accept.
|
||||||
|
assert!(s.upsert_registry_entry_newest_wins(®ISTRY_POST_ID, &author, t0 + 1000).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_matches_name_and_keywords() {
|
||||||
|
let s = temp_storage();
|
||||||
|
let alice = [1u8; 32];
|
||||||
|
let bob = [2u8; 32];
|
||||||
|
let t0 = test_now();
|
||||||
|
s.store_comment(®_comment(alice, t0, "Alice", &["rust", "p2p"])).unwrap();
|
||||||
|
s.store_comment(®_comment(bob, t0 + 1, "Bob", &["gardening"])).unwrap();
|
||||||
|
|
||||||
|
// Keyword hit (case-insensitive).
|
||||||
|
let hits = search_entries(&s, "RUST").unwrap();
|
||||||
|
assert_eq!(hits.len(), 1);
|
||||||
|
assert_eq!(hits[0].author, alice);
|
||||||
|
assert_eq!(hits[0].name, "Alice");
|
||||||
|
|
||||||
|
// Name substring hit.
|
||||||
|
let hits = search_entries(&s, "bo").unwrap();
|
||||||
|
assert_eq!(hits.len(), 1);
|
||||||
|
assert_eq!(hits[0].author, bob);
|
||||||
|
|
||||||
|
// Empty query = browse all.
|
||||||
|
assert_eq!(search_entries(&s, "").unwrap().len(), 2);
|
||||||
|
|
||||||
|
// No hit.
|
||||||
|
assert!(search_entries(&s, "zzz").unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_skips_expired_entries() {
|
||||||
|
let s = temp_storage();
|
||||||
|
let alice = [1u8; 32];
|
||||||
|
let mut c = reg_comment(alice, 1000, "Alice", &["rust"]);
|
||||||
|
c.expires_at_ms = 2000; // long past
|
||||||
|
s.store_comment(&c).unwrap();
|
||||||
|
assert!(search_entries(&s, "rust").unwrap().is_empty(), "expired entries filtered");
|
||||||
|
}
|
||||||
|
}
|
||||||
1
crates/core/src/registry_post_v1.json
Normal file
1
crates/core/src/registry_post_v1.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"author":[23,175,20,25,86,174,11,80,220,28,185,36,140,173,245,252,163,113,234,45,133,49,172,154,221,60,3,202,255,198,20,65],"content":"ItsGoin Network Registry — shard(itsgoin-registry-v1, k=1). Register here with a signed public comment {name, keywords} authored by your persona's posting key. Entries are self-certifying, expire after 30 days, and are newest-wins per persona. Delete = signed DeleteComment.","attachments":[],"timestamp_ms":1785369600000,"fof_gating":{"slot_binder_nonce":[194,138,144,176,11,137,185,140,117,108,61,163,60,83,166,44,35,228,74,53,76,175,67,116,175,175,49,54,140,206,82,147],"pub_post_set":[[247,219,138,10,17,157,246,133,230,232,228,97,55,195,255,125,108,212,226,71,102,202,26,31,78,233,76,47,236,66,71,144]],"wrap_slots":[{"prefilter_tag":[95,128],"read_ciphertext":[216,233,169,245,192,77,2,116,19,155,45,0,222,219,59,237,100,86,247,42,2,101,141,2,131,40,39,236,29,158,172,66,8,45,135,137,219,4,159,255,148,101,61,120,3,253,1,220],"sign_ciphertext":[42,203,235,211,8,208,10,246,77,37,158,207,8,214,152,49,15,110,220,5,190,177,105,164,146,117,190,211,12,13,32,36,109,105,156,33,42,133,118,213,248,33,74,182,178,235,176,204]}],"revocation_list":[],"open_slot":{"slot_index":0,"kind":"Registry","body_bucket":512}}}
|
||||||
|
|
@ -366,9 +366,28 @@ impl Storage {
|
||||||
group_sig BLOB,
|
group_sig BLOB,
|
||||||
encrypted_payload BLOB,
|
encrypted_payload BLOB,
|
||||||
deleted_at INTEGER,
|
deleted_at INTEGER,
|
||||||
|
-- v0.8 (A3): absolute expiry (ms). NULL/0 = legacy no-TTL.
|
||||||
|
expires_at INTEGER,
|
||||||
PRIMARY KEY (author, post_id, timestamp_ms)
|
PRIMARY KEY (author, post_id, timestamp_ms)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id);
|
CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id);
|
||||||
|
-- v0.8 (A3): local-only greeting inbox dismissals.
|
||||||
|
CREATE TABLE IF NOT EXISTS greeting_dismissals (
|
||||||
|
comment_author BLOB NOT NULL,
|
||||||
|
post_id BLOB NOT NULL,
|
||||||
|
timestamp_ms INTEGER NOT NULL,
|
||||||
|
dismissed_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (comment_author, post_id, timestamp_ms)
|
||||||
|
);
|
||||||
|
-- v0.8 (A3): private halves of per-greeting reply x25519 keys,
|
||||||
|
-- keyed by the reply pubkey we told the recipient to seal to.
|
||||||
|
-- return_post_id = our return-path post (own bio) at mint time.
|
||||||
|
CREATE TABLE IF NOT EXISTS greeting_reply_keys (
|
||||||
|
reply_pubkey BLOB PRIMARY KEY,
|
||||||
|
reply_privkey BLOB NOT NULL,
|
||||||
|
return_post_id BLOB NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
CREATE TABLE IF NOT EXISTS comment_policies (
|
CREATE TABLE IF NOT EXISTS comment_policies (
|
||||||
post_id BLOB PRIMARY KEY,
|
post_id BLOB PRIMARY KEY,
|
||||||
policy_json TEXT NOT NULL
|
policy_json TEXT NOT NULL
|
||||||
|
|
@ -800,6 +819,17 @@ impl Storage {
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.8 (A3): add expires_at for comment TTL (rand 30–365d ordinary,
|
||||||
|
// fixed 30d registry entries). NULL/0 = legacy comment without TTL.
|
||||||
|
let has_expires_at = self.conn.prepare(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='expires_at'"
|
||||||
|
)?.query_row([], |row| row.get::<_, i64>(0))?;
|
||||||
|
if has_expires_at == 0 {
|
||||||
|
self.conn.execute_batch(
|
||||||
|
"ALTER TABLE comments ADD COLUMN expires_at INTEGER DEFAULT NULL;"
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
// v0.6.2: add canonical_root_post_id to group_keys. When set, the
|
// v0.6.2: add canonical_root_post_id to group_keys. When set, the
|
||||||
// record is a group (many-way, anchored at a public root post);
|
// record is a group (many-way, anchored at a public root post);
|
||||||
// when NULL it's a traditional circle (one-way, admin-only).
|
// when NULL it's a traditional circle (one-way, admin-only).
|
||||||
|
|
@ -1249,20 +1279,29 @@ impl Storage {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Batch: reaction counts for multiple posts at once
|
/// Batch: reaction counts for multiple posts at once
|
||||||
pub fn get_reaction_counts_batch(&self, post_ids: &[PostId], our_node_id: &NodeId) -> anyhow::Result<std::collections::HashMap<PostId, Vec<(String, u64, bool)>>> {
|
/// `our_ids` = ALL of the local node's posting identities (reactors are
|
||||||
|
/// posting ids — the network NodeId never reacts).
|
||||||
|
pub fn get_reaction_counts_batch(&self, post_ids: &[PostId], our_ids: &[NodeId]) -> anyhow::Result<std::collections::HashMap<PostId, Vec<(String, u64, bool)>>> {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
let mut result: HashMap<PostId, Vec<(String, u64, bool)>> = HashMap::new();
|
let mut result: HashMap<PostId, Vec<(String, u64, bool)>> = HashMap::new();
|
||||||
if post_ids.is_empty() { return Ok(result); }
|
if post_ids.is_empty() { return Ok(result); }
|
||||||
let placeholders: String = (0..post_ids.len()).map(|i| format!("?{}", i + 1)).collect::<Vec<_>>().join(",");
|
let placeholders: String = (0..post_ids.len()).map(|i| format!("?{}", i + 1)).collect::<Vec<_>>().join(",");
|
||||||
|
let mine_placeholders: String = if our_ids.is_empty() {
|
||||||
|
"NULL".to_string()
|
||||||
|
} else {
|
||||||
|
(0..our_ids.len()).map(|i| format!("?{}", post_ids.len() + 1 + i)).collect::<Vec<_>>().join(",")
|
||||||
|
};
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT post_id, emoji, COUNT(*) as cnt, SUM(CASE WHEN reactor = ?{} THEN 1 ELSE 0 END) as my_count
|
"SELECT post_id, emoji, COUNT(*) as cnt, SUM(CASE WHEN reactor IN ({}) THEN 1 ELSE 0 END) as my_count
|
||||||
FROM reactions WHERE post_id IN ({}) AND deleted_at IS NULL
|
FROM reactions WHERE post_id IN ({}) AND deleted_at IS NULL
|
||||||
GROUP BY post_id, emoji ORDER BY cnt DESC",
|
GROUP BY post_id, emoji ORDER BY cnt DESC",
|
||||||
post_ids.len() + 1, placeholders
|
mine_placeholders, placeholders
|
||||||
);
|
);
|
||||||
let mut stmt = self.conn.prepare(&sql)?;
|
let mut stmt = self.conn.prepare(&sql)?;
|
||||||
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = post_ids.iter().map(|id| Box::new(id.to_vec()) as Box<dyn rusqlite::types::ToSql>).collect();
|
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = post_ids.iter().map(|id| Box::new(id.to_vec()) as Box<dyn rusqlite::types::ToSql>).collect();
|
||||||
params.push(Box::new(our_node_id.to_vec()));
|
for oid in our_ids {
|
||||||
|
params.push(Box::new(oid.to_vec()));
|
||||||
|
}
|
||||||
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||||
let rows = stmt.query_map(param_refs.as_slice(), |row| {
|
let rows = stmt.query_map(param_refs.as_slice(), |row| {
|
||||||
let pid: Vec<u8> = row.get(0)?;
|
let pid: Vec<u8> = row.get(0)?;
|
||||||
|
|
@ -2557,15 +2596,18 @@ impl Storage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve display info for a peer: check circle profiles the viewer belongs to,
|
/// Resolve display info for a peer: check circle profiles any of the
|
||||||
/// then fall back to public profile.
|
/// viewer's posting identities belongs to, then fall back to public
|
||||||
|
/// profile. `viewers` = ALL of the local node's posting identities
|
||||||
|
/// (circle_members holds posting ids, never the network NodeId).
|
||||||
/// Returns (display_name, bio, avatar_cid).
|
/// Returns (display_name, bio, avatar_cid).
|
||||||
pub fn resolve_display_for_peer(
|
pub fn resolve_display_for_peer(
|
||||||
&self,
|
&self,
|
||||||
author: &NodeId,
|
author: &NodeId,
|
||||||
viewer: &NodeId,
|
viewers: &[NodeId],
|
||||||
) -> anyhow::Result<(String, String, Option<[u8; 32]>)> {
|
) -> anyhow::Result<(String, String, Option<[u8; 32]>)> {
|
||||||
// Find circles where viewer is a member and author has a circle profile
|
// Find circles where a viewer persona is a member and author has a
|
||||||
|
// circle profile; take the freshest match across all personas.
|
||||||
let mut stmt = self.conn.prepare(
|
let mut stmt = self.conn.prepare(
|
||||||
"SELECT cp.display_name, cp.bio, cp.avatar_cid, cp.updated_at
|
"SELECT cp.display_name, cp.bio, cp.avatar_cid, cp.updated_at
|
||||||
FROM circle_profiles cp
|
FROM circle_profiles cp
|
||||||
|
|
@ -2574,6 +2616,8 @@ impl Storage {
|
||||||
ORDER BY cp.updated_at DESC
|
ORDER BY cp.updated_at DESC
|
||||||
LIMIT 1",
|
LIMIT 1",
|
||||||
)?;
|
)?;
|
||||||
|
let mut best: Option<(String, String, Option<[u8; 32]>, u64)> = None;
|
||||||
|
for viewer in viewers {
|
||||||
let mut rows = stmt.query(params![author.as_slice(), viewer.as_slice()])?;
|
let mut rows = stmt.query(params![author.as_slice(), viewer.as_slice()])?;
|
||||||
if let Some(row) = rows.next()? {
|
if let Some(row) = rows.next()? {
|
||||||
let dn: String = row.get(0)?;
|
let dn: String = row.get(0)?;
|
||||||
|
|
@ -2582,9 +2626,16 @@ impl Storage {
|
||||||
let bio: String = row.get(1)?;
|
let bio: String = row.get(1)?;
|
||||||
let avatar_cid = row.get::<_, Option<Vec<u8>>>(2).unwrap_or(None)
|
let avatar_cid = row.get::<_, Option<Vec<u8>>>(2).unwrap_or(None)
|
||||||
.and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok());
|
.and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok());
|
||||||
return Ok((dn, bio, avatar_cid));
|
let updated_at = row.get::<_, i64>(3).unwrap_or(0) as u64;
|
||||||
|
if best.as_ref().map(|b| updated_at > b.3).unwrap_or(true) {
|
||||||
|
best = Some((dn, bio, avatar_cid, updated_at));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((dn, bio, avatar_cid, _)) = best {
|
||||||
|
return Ok((dn, bio, avatar_cid));
|
||||||
|
}
|
||||||
|
|
||||||
// Fall back to public profile
|
// Fall back to public profile
|
||||||
if let Some(profile) = self.get_profile(author)? {
|
if let Some(profile) = self.get_profile(author)? {
|
||||||
|
|
@ -4508,6 +4559,36 @@ impl Storage {
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List ALL stored CDN manifests: (cid, manifest_json). Used by the v0.8
|
||||||
|
/// startup migration to re-sign own manifests / purge stale foreign ones.
|
||||||
|
pub fn list_all_cdn_manifests(&self) -> anyhow::Result<Vec<([u8; 32], String)>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT cid, manifest_json FROM cdn_manifests"
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
let cid_bytes: Vec<u8> = row.get(0)?;
|
||||||
|
let json: String = row.get(1)?;
|
||||||
|
Ok((cid_bytes, json))
|
||||||
|
})?;
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
let (cid_bytes, json) = row?;
|
||||||
|
let cid: [u8; 32] = cid_bytes.try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid cid in cdn_manifests"))?;
|
||||||
|
result.push((cid, json));
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a single CDN manifest row by CID (file_holders untouched).
|
||||||
|
pub fn delete_cdn_manifest(&self, cid: &[u8; 32]) -> anyhow::Result<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM cdn_manifests WHERE cid = ?1",
|
||||||
|
params![cid.as_slice()],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Get CIDs of manifests older than a cutoff. Callers look up holders
|
/// Get CIDs of manifests older than a cutoff. Callers look up holders
|
||||||
/// via file_holders to pick a refresh source.
|
/// via file_holders to pick a refresh source.
|
||||||
pub fn get_stale_manifest_cids(&self, older_than_ms: u64) -> anyhow::Result<Vec<[u8; 32]>> {
|
pub fn get_stale_manifest_cids(&self, older_than_ms: u64) -> anyhow::Result<Vec<[u8; 32]>> {
|
||||||
|
|
@ -5908,13 +5989,27 @@ impl Storage {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get reaction counts grouped by emoji for a post (excludes tombstoned reactions).
|
/// Get reaction counts grouped by emoji for a post (excludes tombstoned reactions).
|
||||||
pub fn get_reaction_counts(&self, post_id: &PostId, my_node_id: &NodeId) -> anyhow::Result<Vec<(String, u64, bool)>> {
|
/// `my_ids` = ALL of the local node's posting identities (reactors are
|
||||||
let mut stmt = self.conn.prepare(
|
/// posting ids — the network NodeId never reacts).
|
||||||
|
pub fn get_reaction_counts(&self, post_id: &PostId, my_ids: &[NodeId]) -> anyhow::Result<Vec<(String, u64, bool)>> {
|
||||||
|
let mine_placeholders: String = if my_ids.is_empty() {
|
||||||
|
"NULL".to_string()
|
||||||
|
} else {
|
||||||
|
(0..my_ids.len()).map(|i| format!("?{}", i + 2)).collect::<Vec<_>>().join(",")
|
||||||
|
};
|
||||||
|
let sql = format!(
|
||||||
"SELECT emoji, COUNT(*) as cnt,
|
"SELECT emoji, COUNT(*) as cnt,
|
||||||
SUM(CASE WHEN reactor = ?2 THEN 1 ELSE 0 END) as my_count
|
SUM(CASE WHEN reactor IN ({}) THEN 1 ELSE 0 END) as my_count
|
||||||
FROM reactions WHERE post_id = ?1 AND deleted_at IS NULL GROUP BY emoji ORDER BY cnt DESC"
|
FROM reactions WHERE post_id = ?1 AND deleted_at IS NULL GROUP BY emoji ORDER BY cnt DESC",
|
||||||
)?;
|
mine_placeholders
|
||||||
let rows = stmt.query_map(params![post_id.as_slice(), my_node_id.as_slice()], |row| {
|
);
|
||||||
|
let mut stmt = self.conn.prepare(&sql)?;
|
||||||
|
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(post_id.to_vec())];
|
||||||
|
for id in my_ids {
|
||||||
|
params.push(Box::new(id.to_vec()));
|
||||||
|
}
|
||||||
|
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||||
|
let rows = stmt.query_map(param_refs.as_slice(), |row| {
|
||||||
let emoji: String = row.get(0)?;
|
let emoji: String = row.get(0)?;
|
||||||
let count: i64 = row.get(1)?;
|
let count: i64 = row.get(1)?;
|
||||||
let my_count: i64 = row.get(2)?;
|
let my_count: i64 = row.get(2)?;
|
||||||
|
|
@ -5935,15 +6030,18 @@ impl Storage {
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"INSERT INTO comments
|
"INSERT INTO comments
|
||||||
(author, post_id, content, timestamp_ms, signature, deleted_at,
|
(author, post_id, content, timestamp_ms, signature, deleted_at,
|
||||||
ref_post_id, pub_x_index, group_sig, encrypted_payload)
|
ref_post_id, pub_x_index, group_sig, encrypted_payload, expires_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
|
||||||
ON CONFLICT(author, post_id, timestamp_ms) DO UPDATE SET
|
ON CONFLICT(author, post_id, timestamp_ms) DO UPDATE SET
|
||||||
content = CASE WHEN excluded.deleted_at IS NOT NULL THEN content ELSE excluded.content END,
|
content = CASE WHEN excluded.deleted_at IS NOT NULL THEN content ELSE excluded.content END,
|
||||||
|
signature = CASE WHEN excluded.deleted_at IS NOT NULL THEN signature ELSE excluded.signature END,
|
||||||
deleted_at = CASE WHEN excluded.deleted_at IS NOT NULL THEN excluded.deleted_at ELSE deleted_at END,
|
deleted_at = CASE WHEN excluded.deleted_at IS NOT NULL THEN excluded.deleted_at ELSE deleted_at END,
|
||||||
ref_post_id = COALESCE(excluded.ref_post_id, ref_post_id),
|
ref_post_id = COALESCE(excluded.ref_post_id, ref_post_id),
|
||||||
pub_x_index = COALESCE(excluded.pub_x_index, pub_x_index),
|
pub_x_index = COALESCE(excluded.pub_x_index, pub_x_index),
|
||||||
group_sig = COALESCE(excluded.group_sig, group_sig),
|
group_sig = COALESCE(excluded.group_sig, group_sig),
|
||||||
encrypted_payload = COALESCE(excluded.encrypted_payload, encrypted_payload)",
|
encrypted_payload = COALESCE(excluded.encrypted_payload, encrypted_payload),
|
||||||
|
expires_at = CASE WHEN excluded.expires_at IS NOT NULL AND excluded.expires_at > 0
|
||||||
|
THEN excluded.expires_at ELSE expires_at END",
|
||||||
params![
|
params![
|
||||||
comment.author.as_slice(),
|
comment.author.as_slice(),
|
||||||
comment.post_id.as_slice(),
|
comment.post_id.as_slice(),
|
||||||
|
|
@ -5955,6 +6053,7 @@ impl Storage {
|
||||||
comment.pub_x_index.map(|i| i as i64),
|
comment.pub_x_index.map(|i| i as i64),
|
||||||
comment.group_sig.as_ref().map(|b| b.as_slice()),
|
comment.group_sig.as_ref().map(|b| b.as_slice()),
|
||||||
comment.encrypted_payload.as_ref().map(|b| b.as_slice()),
|
comment.encrypted_payload.as_ref().map(|b| b.as_slice()),
|
||||||
|
if comment.expires_at_ms > 0 { Some(comment.expires_at_ms as i64) } else { None },
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -5978,14 +6077,18 @@ impl Storage {
|
||||||
Ok(updated > 0)
|
Ok(updated > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get live (non-tombstoned) comments for a post. Used for UI display.
|
/// Get live (non-tombstoned, unexpired) comments for a post. Used for
|
||||||
|
/// UI display. The expiry filter is belt-and-suspenders between
|
||||||
|
/// `expire_comments` sweeps.
|
||||||
pub fn get_comments(&self, post_id: &PostId) -> anyhow::Result<Vec<InlineComment>> {
|
pub fn get_comments(&self, post_id: &PostId) -> anyhow::Result<Vec<InlineComment>> {
|
||||||
let mut stmt = self.conn.prepare(
|
let mut stmt = self.conn.prepare(
|
||||||
"SELECT author, post_id, content, timestamp_ms, signature, ref_post_id,
|
"SELECT author, post_id, content, timestamp_ms, signature, ref_post_id,
|
||||||
pub_x_index, group_sig, encrypted_payload
|
pub_x_index, group_sig, encrypted_payload, expires_at
|
||||||
FROM comments WHERE post_id = ?1 AND deleted_at IS NULL ORDER BY timestamp_ms ASC"
|
FROM comments WHERE post_id = ?1 AND deleted_at IS NULL
|
||||||
|
AND (expires_at IS NULL OR expires_at = 0 OR expires_at > ?2)
|
||||||
|
ORDER BY timestamp_ms ASC"
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map(params![post_id.as_slice()], |row| {
|
let rows = stmt.query_map(params![post_id.as_slice(), now_ms()], |row| {
|
||||||
let author: Vec<u8> = row.get(0)?;
|
let author: Vec<u8> = row.get(0)?;
|
||||||
let pid: Vec<u8> = row.get(1)?;
|
let pid: Vec<u8> = row.get(1)?;
|
||||||
let content: String = row.get(2)?;
|
let content: String = row.get(2)?;
|
||||||
|
|
@ -5995,11 +6098,12 @@ impl Storage {
|
||||||
let pxi: Option<i64> = row.get(6)?;
|
let pxi: Option<i64> = row.get(6)?;
|
||||||
let gsig: Option<Vec<u8>> = row.get(7)?;
|
let gsig: Option<Vec<u8>> = row.get(7)?;
|
||||||
let epl: Option<Vec<u8>> = row.get(8)?;
|
let epl: Option<Vec<u8>> = row.get(8)?;
|
||||||
Ok((author, pid, content, ts, sig, ref_post, pxi, gsig, epl))
|
let exp: Option<i64> = row.get(9)?;
|
||||||
|
Ok((author, pid, content, ts, sig, ref_post, pxi, gsig, epl, exp))
|
||||||
})?;
|
})?;
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let (author_bytes, pid_bytes, content, ts, sig, ref_post, pxi, gsig, epl) = row?;
|
let (author_bytes, pid_bytes, content, ts, sig, ref_post, pxi, gsig, epl, exp) = row?;
|
||||||
let author = blob_to_nodeid(author_bytes)?;
|
let author = blob_to_nodeid(author_bytes)?;
|
||||||
let post_id = blob_to_postid(pid_bytes)?;
|
let post_id = blob_to_postid(pid_bytes)?;
|
||||||
let ref_post_id = match ref_post {
|
let ref_post_id = match ref_post {
|
||||||
|
|
@ -6017,19 +6121,30 @@ impl Storage {
|
||||||
pub_x_index: pxi.map(|v| v as u32),
|
pub_x_index: pxi.map(|v| v as u32),
|
||||||
group_sig: gsig,
|
group_sig: gsig,
|
||||||
encrypted_payload: epl,
|
encrypted_payload: epl,
|
||||||
|
expires_at_ms: exp.unwrap_or(0) as u64,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get ALL comments for a post, including tombstoned ones. Used for header rebuild
|
/// Get ALL comments for a post, including tombstoned ones (but never
|
||||||
/// so tombstones propagate through pull-based sync.
|
/// expired ones — expiry is the forgetting mechanism and expired
|
||||||
|
/// comments must not be re-served). Used for header rebuild so
|
||||||
|
/// tombstones propagate through pull-based sync.
|
||||||
|
///
|
||||||
|
/// v0.8 (A3): also carries the FoF fields (pub_x_index / group_sig /
|
||||||
|
/// encrypted_payload) + expires_at — without them, FoF/greeting
|
||||||
|
/// comments served via full-header sync would lose their group_sig
|
||||||
|
/// and be dropped by the receive gate.
|
||||||
pub fn get_comments_with_tombstones(&self, post_id: &PostId) -> anyhow::Result<Vec<InlineComment>> {
|
pub fn get_comments_with_tombstones(&self, post_id: &PostId) -> anyhow::Result<Vec<InlineComment>> {
|
||||||
let mut stmt = self.conn.prepare(
|
let mut stmt = self.conn.prepare(
|
||||||
"SELECT author, post_id, content, timestamp_ms, signature, deleted_at, ref_post_id
|
"SELECT author, post_id, content, timestamp_ms, signature, deleted_at, ref_post_id,
|
||||||
FROM comments WHERE post_id = ?1 ORDER BY timestamp_ms ASC"
|
pub_x_index, group_sig, encrypted_payload, expires_at
|
||||||
|
FROM comments WHERE post_id = ?1
|
||||||
|
AND (expires_at IS NULL OR expires_at = 0 OR expires_at > ?2)
|
||||||
|
ORDER BY timestamp_ms ASC"
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map(params![post_id.as_slice()], |row| {
|
let rows = stmt.query_map(params![post_id.as_slice(), now_ms()], |row| {
|
||||||
let author: Vec<u8> = row.get(0)?;
|
let author: Vec<u8> = row.get(0)?;
|
||||||
let pid: Vec<u8> = row.get(1)?;
|
let pid: Vec<u8> = row.get(1)?;
|
||||||
let content: String = row.get(2)?;
|
let content: String = row.get(2)?;
|
||||||
|
|
@ -6037,11 +6152,15 @@ impl Storage {
|
||||||
let sig: Vec<u8> = row.get(4)?;
|
let sig: Vec<u8> = row.get(4)?;
|
||||||
let del: Option<i64> = row.get(5)?;
|
let del: Option<i64> = row.get(5)?;
|
||||||
let ref_post: Option<Vec<u8>> = row.get(6)?;
|
let ref_post: Option<Vec<u8>> = row.get(6)?;
|
||||||
Ok((author, pid, content, ts, sig, del, ref_post))
|
let pxi: Option<i64> = row.get(7)?;
|
||||||
|
let gsig: Option<Vec<u8>> = row.get(8)?;
|
||||||
|
let epl: Option<Vec<u8>> = row.get(9)?;
|
||||||
|
let exp: Option<i64> = row.get(10)?;
|
||||||
|
Ok((author, pid, content, ts, sig, del, ref_post, pxi, gsig, epl, exp))
|
||||||
})?;
|
})?;
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let (author_bytes, pid_bytes, content, ts, sig, del, ref_post) = row?;
|
let (author_bytes, pid_bytes, content, ts, sig, del, ref_post, pxi, gsig, epl, exp) = row?;
|
||||||
let author = blob_to_nodeid(author_bytes)?;
|
let author = blob_to_nodeid(author_bytes)?;
|
||||||
let post_id = blob_to_postid(pid_bytes)?;
|
let post_id = blob_to_postid(pid_bytes)?;
|
||||||
let ref_post_id = match ref_post {
|
let ref_post_id = match ref_post {
|
||||||
|
|
@ -6056,22 +6175,319 @@ impl Storage {
|
||||||
signature: sig,
|
signature: sig,
|
||||||
deleted_at: del.map(|v| v as u64),
|
deleted_at: del.map(|v| v as u64),
|
||||||
ref_post_id,
|
ref_post_id,
|
||||||
pub_x_index: None,
|
pub_x_index: pxi.map(|v| v as u32),
|
||||||
group_sig: None,
|
group_sig: gsig,
|
||||||
encrypted_payload: None,
|
encrypted_payload: epl,
|
||||||
|
expires_at_ms: exp.unwrap_or(0) as u64,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get comment count for a post (excludes tombstoned comments).
|
/// Get comment count for a post (excludes tombstoned + expired comments).
|
||||||
pub fn get_comment_count(&self, post_id: &PostId) -> anyhow::Result<u64> {
|
pub fn get_comment_count(&self, post_id: &PostId) -> anyhow::Result<u64> {
|
||||||
let count: i64 = self.conn.prepare(
|
let count: i64 = self.conn.prepare(
|
||||||
"SELECT COUNT(*) FROM comments WHERE post_id = ?1 AND deleted_at IS NULL"
|
"SELECT COUNT(*) FROM comments WHERE post_id = ?1 AND deleted_at IS NULL
|
||||||
)?.query_row(params![post_id.as_slice()], |row| row.get(0))?;
|
AND (expires_at IS NULL OR expires_at = 0 OR expires_at > ?2)"
|
||||||
|
)?.query_row(params![post_id.as_slice(), now_ms()], |row| row.get(0))?;
|
||||||
Ok(count as u64)
|
Ok(count as u64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v0.8 (A3): rebuild the post's aggregated blob header from vetted
|
||||||
|
/// DB state (reactions + comments + policy), preserving slot fields
|
||||||
|
/// from any previously-stored header JSON. Used after local comment
|
||||||
|
/// creation (so pulls serve our own engagement) and after pull-path
|
||||||
|
/// ingestion (so we never re-serve peer-supplied comments our accept
|
||||||
|
/// gate rejected).
|
||||||
|
pub fn rebuild_blob_header_from_db(
|
||||||
|
&self,
|
||||||
|
post_id: &PostId,
|
||||||
|
fallback_author: &NodeId,
|
||||||
|
now: u64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let reactions = self.get_reactions_with_tombstones(post_id).unwrap_or_default();
|
||||||
|
let comments = self.get_comments_with_tombstones(post_id).unwrap_or_default();
|
||||||
|
let policy = self.get_comment_policy(post_id).ok().flatten().unwrap_or_default();
|
||||||
|
let (existing_json, _) = self.get_blob_header(post_id).ok().flatten()
|
||||||
|
.unwrap_or((String::new(), 0));
|
||||||
|
let mut header: crate::types::BlobHeader = serde_json::from_str(&existing_json)
|
||||||
|
.unwrap_or_else(|_| crate::types::BlobHeader {
|
||||||
|
post_id: *post_id,
|
||||||
|
author: *fallback_author,
|
||||||
|
reactions: vec![],
|
||||||
|
comments: vec![],
|
||||||
|
policy: CommentPolicy::default(),
|
||||||
|
updated_at: 0,
|
||||||
|
thread_splits: vec![],
|
||||||
|
receipt_slots: vec![],
|
||||||
|
comment_slots: vec![],
|
||||||
|
prior_author: None,
|
||||||
|
});
|
||||||
|
header.post_id = *post_id;
|
||||||
|
header.reactions = reactions;
|
||||||
|
header.comments = comments;
|
||||||
|
header.policy = policy;
|
||||||
|
header.updated_at = now;
|
||||||
|
// Trust the locally-stored post for authorship when available.
|
||||||
|
let author = self.get_post(post_id).ok().flatten()
|
||||||
|
.map(|p| p.author)
|
||||||
|
.unwrap_or(*fallback_author);
|
||||||
|
header.author = author;
|
||||||
|
let json = serde_json::to_string(&header)?;
|
||||||
|
self.store_blob_header(post_id, &author, &json, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- v0.8 (A3): comment TTL sweep + open-slot helpers ---
|
||||||
|
|
||||||
|
/// Hard-DELETE (not tombstone) all comments whose expiry has passed.
|
||||||
|
/// Expiry is the forgetting mechanism — tombstones would keep
|
||||||
|
/// throwaway IDs alive. Returns the number of rows deleted.
|
||||||
|
pub fn expire_comments(&self, now_ms_val: u64) -> anyhow::Result<usize> {
|
||||||
|
let deleted = self.conn.execute(
|
||||||
|
"DELETE FROM comments
|
||||||
|
WHERE expires_at IS NOT NULL AND expires_at > 0 AND expires_at <= ?1",
|
||||||
|
params![now_ms_val as i64],
|
||||||
|
)?;
|
||||||
|
Ok(deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Newest-wins registry rule: one active entry per persona (`author`)
|
||||||
|
/// on the registry post. Returns `true` if the incoming row (at
|
||||||
|
/// `incoming_ts`) is the newest for this author — in which case any
|
||||||
|
/// strictly-older rows are HARD-deleted — and `false` when a newer
|
||||||
|
/// entry is already stored (caller drops the incoming one).
|
||||||
|
pub fn upsert_registry_entry_newest_wins(
|
||||||
|
&self,
|
||||||
|
post_id: &PostId,
|
||||||
|
author: &NodeId,
|
||||||
|
incoming_ts: u64,
|
||||||
|
) -> anyhow::Result<bool> {
|
||||||
|
if !self.registry_entry_is_newest(post_id, author, incoming_ts)? {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
self.prune_older_registry_entries(post_id, author, incoming_ts)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// READ-ONLY newest-wins check: is `incoming_ts` at least as new as
|
||||||
|
/// every stored entry for this persona? Used by the accept gate,
|
||||||
|
/// which must stay side-effect free — the destructive prune happens
|
||||||
|
/// in the storing callers AFTER a successful store_comment.
|
||||||
|
pub fn registry_entry_is_newest(
|
||||||
|
&self,
|
||||||
|
post_id: &PostId,
|
||||||
|
author: &NodeId,
|
||||||
|
incoming_ts: u64,
|
||||||
|
) -> anyhow::Result<bool> {
|
||||||
|
let newest: Option<i64> = self.conn.query_row(
|
||||||
|
"SELECT MAX(timestamp_ms) FROM comments WHERE post_id = ?1 AND author = ?2",
|
||||||
|
params![post_id.as_slice(), author.as_slice()],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
if let Some(newest) = newest {
|
||||||
|
if (newest as u64) > incoming_ts {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hard-delete this persona's registry entries older than `newest_ts`.
|
||||||
|
/// Call only after the newest entry has been successfully stored, so
|
||||||
|
/// a store failure never leaves the persona entry-less.
|
||||||
|
pub fn prune_older_registry_entries(
|
||||||
|
&self,
|
||||||
|
post_id: &PostId,
|
||||||
|
author: &NodeId,
|
||||||
|
newest_ts: u64,
|
||||||
|
) -> anyhow::Result<usize> {
|
||||||
|
let n = self.conn.execute(
|
||||||
|
"DELETE FROM comments WHERE post_id = ?1 AND author = ?2 AND timestamp_ms < ?3",
|
||||||
|
params![post_id.as_slice(), author.as_slice(), newest_ts as i64],
|
||||||
|
)?;
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count live (unexpired, non-tombstoned) comments on a post's open
|
||||||
|
/// slot — the per-bio greeting flood cap input.
|
||||||
|
pub fn count_unexpired_open_slot_comments(
|
||||||
|
&self,
|
||||||
|
post_id: &PostId,
|
||||||
|
pub_x_index: u32,
|
||||||
|
) -> anyhow::Result<u64> {
|
||||||
|
let count: i64 = self.conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM comments
|
||||||
|
WHERE post_id = ?1 AND pub_x_index = ?2 AND deleted_at IS NULL
|
||||||
|
AND (expires_at IS NULL OR expires_at = 0 OR expires_at > ?3)",
|
||||||
|
params![post_id.as_slice(), pub_x_index as i64, now_ms()],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
Ok(count as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local-only: mark a greeting as dismissed in the inbox.
|
||||||
|
pub fn add_greeting_dismissal(
|
||||||
|
&self,
|
||||||
|
comment_author: &NodeId,
|
||||||
|
post_id: &PostId,
|
||||||
|
timestamp_ms: u64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO greeting_dismissals
|
||||||
|
(comment_author, post_id, timestamp_ms, dismissed_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
params![comment_author.as_slice(), post_id.as_slice(), timestamp_ms as i64, now_ms()],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local-only: was this greeting dismissed?
|
||||||
|
pub fn is_greeting_dismissed(
|
||||||
|
&self,
|
||||||
|
comment_author: &NodeId,
|
||||||
|
post_id: &PostId,
|
||||||
|
timestamp_ms: u64,
|
||||||
|
) -> anyhow::Result<bool> {
|
||||||
|
let count: i64 = self.conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM greeting_dismissals
|
||||||
|
WHERE comment_author = ?1 AND post_id = ?2 AND timestamp_ms = ?3",
|
||||||
|
params![comment_author.as_slice(), post_id.as_slice(), timestamp_ms as i64],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the private half of a per-greeting reply x25519 keypair.
|
||||||
|
pub fn store_greeting_reply_key(
|
||||||
|
&self,
|
||||||
|
reply_pubkey: &[u8; 32],
|
||||||
|
reply_privkey: &[u8; 32],
|
||||||
|
return_post_id: &PostId,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO greeting_reply_keys
|
||||||
|
(reply_pubkey, reply_privkey, return_post_id, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
params![
|
||||||
|
reply_pubkey.as_slice(),
|
||||||
|
reply_privkey.as_slice(),
|
||||||
|
return_post_id.as_slice(),
|
||||||
|
now_ms(),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all stored reply private keys (privkey, return_post_id).
|
||||||
|
/// Used to unseal replies arriving on our return-path posts.
|
||||||
|
pub fn list_greeting_reply_keys(&self) -> anyhow::Result<Vec<([u8; 32], PostId)>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT reply_privkey, return_post_id FROM greeting_reply_keys ORDER BY created_at DESC"
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
let privkey: Vec<u8> = row.get(0)?;
|
||||||
|
let post: Vec<u8> = row.get(1)?;
|
||||||
|
Ok((privkey, post))
|
||||||
|
})?;
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
let (privkey, post) = row?;
|
||||||
|
let pk: [u8; 32] = privkey.as_slice().try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid reply privkey length"))?;
|
||||||
|
result.push((pk, blob_to_postid(post)?));
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All FoF-gated posts authored by `author` (fof_gating_json set),
|
||||||
|
/// reconstructed with their gating. Used by the greeting inbox to
|
||||||
|
/// scan own bio posts (current + previous revisions) for open-slot
|
||||||
|
/// comments.
|
||||||
|
pub fn list_gated_posts_by_author(
|
||||||
|
&self,
|
||||||
|
author: &NodeId,
|
||||||
|
) -> anyhow::Result<Vec<(PostId, Post)>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT id, author, content, attachments, timestamp_ms, fof_gating_json
|
||||||
|
FROM posts WHERE author = ?1 AND fof_gating_json IS NOT NULL
|
||||||
|
ORDER BY timestamp_ms DESC",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(params![author.as_slice()], |row| {
|
||||||
|
let id: Vec<u8> = row.get(0)?;
|
||||||
|
let author: Vec<u8> = row.get(1)?;
|
||||||
|
let content: String = row.get(2)?;
|
||||||
|
let attachments: String = row.get(3)?;
|
||||||
|
let ts: i64 = row.get(4)?;
|
||||||
|
let fof_json: Option<String> = row.get(5)?;
|
||||||
|
Ok((id, author, content, attachments, ts, fof_json))
|
||||||
|
})?;
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
let (id, author_bytes, content, attachments_json, ts, fof_json) = row?;
|
||||||
|
let attachments: Vec<Attachment> =
|
||||||
|
serde_json::from_str(&attachments_json).unwrap_or_default();
|
||||||
|
let fof_gating = fof_json
|
||||||
|
.and_then(|s| serde_json::from_str::<crate::types::FoFCommentGating>(&s).ok());
|
||||||
|
result.push((
|
||||||
|
blob_to_postid(id)?,
|
||||||
|
Post {
|
||||||
|
author: blob_to_nodeid(author_bytes)?,
|
||||||
|
content,
|
||||||
|
attachments,
|
||||||
|
timestamp_ms: ts as u64,
|
||||||
|
fof_gating,
|
||||||
|
supersedes_post_id: None,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Newest live registry entry for `author`:
|
||||||
|
/// `(timestamp_ms, expires_at_ms)`. Used by unregister + auto-renew.
|
||||||
|
pub fn get_newest_registry_entry(
|
||||||
|
&self,
|
||||||
|
registry_post_id: &PostId,
|
||||||
|
author: &NodeId,
|
||||||
|
) -> anyhow::Result<Option<(u64, u64)>> {
|
||||||
|
let result = self.conn.query_row(
|
||||||
|
"SELECT timestamp_ms, expires_at FROM comments
|
||||||
|
WHERE post_id = ?1 AND author = ?2 AND deleted_at IS NULL
|
||||||
|
ORDER BY timestamp_ms DESC LIMIT 1",
|
||||||
|
params![registry_post_id.as_slice(), author.as_slice()],
|
||||||
|
|row| {
|
||||||
|
let ts: i64 = row.get(0)?;
|
||||||
|
let exp: Option<i64> = row.get(1)?;
|
||||||
|
Ok((ts as u64, exp.unwrap_or(0) as u64))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok(pair) => Ok(Some(pair)),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Latest `VisibilityIntent::Profile` post authored by `author` —
|
||||||
|
/// the persona's current bio post (the v1 greeting return path).
|
||||||
|
pub fn get_latest_profile_post_id_by_author(
|
||||||
|
&self,
|
||||||
|
author: &NodeId,
|
||||||
|
) -> anyhow::Result<Option<PostId>> {
|
||||||
|
let result = self.conn.query_row(
|
||||||
|
"SELECT id FROM posts
|
||||||
|
WHERE author = ?1 AND visibility_intent = '\"Profile\"'
|
||||||
|
ORDER BY timestamp_ms DESC LIMIT 1",
|
||||||
|
params![author.as_slice()],
|
||||||
|
|row| row.get::<_, Vec<u8>>(0),
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok(bytes) => Ok(Some(blob_to_postid(bytes)?)),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Engagement: comment policies ---
|
// --- Engagement: comment policies ---
|
||||||
|
|
||||||
/// Store or update a comment policy for a post.
|
/// Store or update a comment policy for a post.
|
||||||
|
|
@ -7293,13 +7709,13 @@ mod tests {
|
||||||
s.set_circle_profile(&cp).unwrap();
|
s.set_circle_profile(&cp).unwrap();
|
||||||
|
|
||||||
// Viewer in circle sees circle profile
|
// Viewer in circle sees circle profile
|
||||||
let (dn, bio, avatar) = s.resolve_display_for_peer(&author, &viewer).unwrap();
|
let (dn, bio, avatar) = s.resolve_display_for_peer(&author, &[viewer]).unwrap();
|
||||||
assert_eq!(dn, "Alice (CF)");
|
assert_eq!(dn, "Alice (CF)");
|
||||||
assert_eq!(bio, "Circle bio");
|
assert_eq!(bio, "Circle bio");
|
||||||
assert_eq!(avatar, Some([99u8; 32]));
|
assert_eq!(avatar, Some([99u8; 32]));
|
||||||
|
|
||||||
// Stranger sees public profile
|
// Stranger sees public profile
|
||||||
let (dn2, bio2, avatar2) = s.resolve_display_for_peer(&author, &stranger).unwrap();
|
let (dn2, bio2, avatar2) = s.resolve_display_for_peer(&author, &[stranger]).unwrap();
|
||||||
assert_eq!(dn2, "Alice Public");
|
assert_eq!(dn2, "Alice Public");
|
||||||
assert_eq!(bio2, "Public bio");
|
assert_eq!(bio2, "Public bio");
|
||||||
assert!(avatar2.is_none());
|
assert!(avatar2.is_none());
|
||||||
|
|
@ -7325,7 +7741,7 @@ mod tests {
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|
||||||
// Stranger sees nothing
|
// Stranger sees nothing
|
||||||
let (dn, bio, avatar) = s.resolve_display_for_peer(&author, &stranger).unwrap();
|
let (dn, bio, avatar) = s.resolve_display_for_peer(&author, &[stranger]).unwrap();
|
||||||
assert!(dn.is_empty());
|
assert!(dn.is_empty());
|
||||||
assert!(bio.is_empty());
|
assert!(bio.is_empty());
|
||||||
assert!(avatar.is_none());
|
assert!(avatar.is_none());
|
||||||
|
|
@ -7488,7 +7904,7 @@ mod tests {
|
||||||
let reactions = s.get_reactions(&post_id).unwrap();
|
let reactions = s.get_reactions(&post_id).unwrap();
|
||||||
assert_eq!(reactions.len(), 3);
|
assert_eq!(reactions.len(), 3);
|
||||||
|
|
||||||
let counts = s.get_reaction_counts(&post_id, &me).unwrap();
|
let counts = s.get_reaction_counts(&post_id, &[me]).unwrap();
|
||||||
assert_eq!(counts.len(), 2); // 👍 and ❤️
|
assert_eq!(counts.len(), 2); // 👍 and ❤️
|
||||||
// 👍 has 2 reactions, one from me
|
// 👍 has 2 reactions, one from me
|
||||||
let thumbs = counts.iter().find(|(e, _, _)| e == "👍").unwrap();
|
let thumbs = counts.iter().find(|(e, _, _)| e == "👍").unwrap();
|
||||||
|
|
@ -7520,6 +7936,7 @@ mod tests {
|
||||||
pub_x_index: None,
|
pub_x_index: None,
|
||||||
group_sig: None,
|
group_sig: None,
|
||||||
encrypted_payload: None,
|
encrypted_payload: None,
|
||||||
|
expires_at_ms: 0,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|
||||||
s.store_comment(&InlineComment {
|
s.store_comment(&InlineComment {
|
||||||
|
|
@ -7533,6 +7950,7 @@ mod tests {
|
||||||
pub_x_index: None,
|
pub_x_index: None,
|
||||||
group_sig: None,
|
group_sig: None,
|
||||||
encrypted_payload: None,
|
encrypted_payload: None,
|
||||||
|
expires_at_ms: 0,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|
||||||
let comments = s.get_comments(&post_id).unwrap();
|
let comments = s.get_comments(&post_id).unwrap();
|
||||||
|
|
@ -7561,6 +7979,7 @@ mod tests {
|
||||||
pub_x_index: None,
|
pub_x_index: None,
|
||||||
group_sig: None,
|
group_sig: None,
|
||||||
encrypted_payload: None,
|
encrypted_payload: None,
|
||||||
|
expires_at_ms: 0,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|
||||||
let live = s.get_comments(&post_id).unwrap();
|
let live = s.get_comments(&post_id).unwrap();
|
||||||
|
|
@ -7649,4 +8068,287 @@ mod tests {
|
||||||
|
|
||||||
assert!(s.get_thread_parent(&parent).unwrap().is_none());
|
assert!(s.get_thread_parent(&parent).unwrap().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- A1 ID-class fixes: multi-persona coverage ---
|
||||||
|
|
||||||
|
fn make_post(author: NodeId, ts: u64) -> Post {
|
||||||
|
Post {
|
||||||
|
author,
|
||||||
|
content: format!("post at {ts}"),
|
||||||
|
attachments: vec![],
|
||||||
|
timestamp_ms: ts,
|
||||||
|
fof_gating: None,
|
||||||
|
supersedes_post_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_persona(s: &Storage, byte: u8) -> NodeId {
|
||||||
|
let id = make_node_id(byte);
|
||||||
|
s.upsert_posting_identity(&PostingIdentity {
|
||||||
|
node_id: id,
|
||||||
|
secret_seed: [byte; 32],
|
||||||
|
display_name: String::new(),
|
||||||
|
created_at: byte as u64,
|
||||||
|
}).unwrap();
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replication (bug A1-3): own posts are found by unioning over ALL
|
||||||
|
/// posting identities; a network-NodeId query finds nothing.
|
||||||
|
#[test]
|
||||||
|
fn own_recent_posts_found_across_personas_not_network_id() {
|
||||||
|
let s = temp_storage();
|
||||||
|
let network_id = make_node_id(9);
|
||||||
|
let persona1 = add_persona(&s, 1);
|
||||||
|
let persona2 = add_persona(&s, 2);
|
||||||
|
|
||||||
|
s.store_post(&make_post_id(11), &make_post(persona1, 1000)).unwrap();
|
||||||
|
s.store_post(&make_post_id(12), &make_post(persona2, 2000)).unwrap();
|
||||||
|
|
||||||
|
// The old bug: querying by the network id → always empty.
|
||||||
|
assert!(s.get_own_recent_post_ids(&network_id, 0).unwrap().is_empty());
|
||||||
|
|
||||||
|
// The fix pattern: union across all posting identities.
|
||||||
|
let mut found = Vec::new();
|
||||||
|
for pi in s.list_posting_identities().unwrap() {
|
||||||
|
found.extend(s.get_own_recent_post_ids(&pi.node_id, 0).unwrap());
|
||||||
|
}
|
||||||
|
assert_eq!(found.len(), 2, "posts from BOTH personas must be found");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reaction "mine" flag (caller-side bug): reactors are posting ids;
|
||||||
|
/// the flag must light up for a reaction from ANY persona and never for
|
||||||
|
/// the network id.
|
||||||
|
#[test]
|
||||||
|
fn reaction_mine_flag_multi_persona() {
|
||||||
|
let s = temp_storage();
|
||||||
|
let network_id = make_node_id(9);
|
||||||
|
let persona1 = make_node_id(1);
|
||||||
|
let persona2 = make_node_id(2);
|
||||||
|
let post_id = make_post_id(21);
|
||||||
|
|
||||||
|
s.store_reaction(&Reaction {
|
||||||
|
reactor: persona2,
|
||||||
|
emoji: "👍".to_string(),
|
||||||
|
post_id,
|
||||||
|
timestamp_ms: 1000,
|
||||||
|
encrypted_payload: None,
|
||||||
|
deleted_at: None,
|
||||||
|
signature: vec![],
|
||||||
|
}).unwrap();
|
||||||
|
|
||||||
|
// Old bug shape: network id as "me" → never mine.
|
||||||
|
let counts = s.get_reaction_counts(&post_id, &[network_id]).unwrap();
|
||||||
|
assert!(!counts[0].2, "network id must never own a reaction");
|
||||||
|
|
||||||
|
// Fix: all personas → reaction by the SECOND persona is mine.
|
||||||
|
let counts = s.get_reaction_counts(&post_id, &[persona1, persona2]).unwrap();
|
||||||
|
assert!(counts[0].2, "reaction by any persona must count as mine");
|
||||||
|
|
||||||
|
// Batch variant behaves identically.
|
||||||
|
let batch = s.get_reaction_counts_batch(&[post_id], &[persona1, persona2]).unwrap();
|
||||||
|
assert!(batch.get(&post_id).unwrap()[0].2);
|
||||||
|
let batch = s.get_reaction_counts_batch(&[post_id], &[network_id]).unwrap();
|
||||||
|
assert!(!batch.get(&post_id).unwrap()[0].2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Circle display resolution (bug A1-11): the viewer join is against
|
||||||
|
/// posting-class circle members — a second persona's membership must
|
||||||
|
/// resolve, the network id must not.
|
||||||
|
#[test]
|
||||||
|
fn resolve_display_for_second_persona_viewer() {
|
||||||
|
let s = temp_storage();
|
||||||
|
let network_id = make_node_id(9);
|
||||||
|
let persona1 = make_node_id(1);
|
||||||
|
let persona2 = make_node_id(2);
|
||||||
|
let author = make_node_id(5);
|
||||||
|
|
||||||
|
s.create_circle("friends").unwrap();
|
||||||
|
// Only our SECOND persona is a member of the author's circle mirror.
|
||||||
|
s.add_circle_member("friends", &persona2).unwrap();
|
||||||
|
s.set_circle_profile(&CircleProfile {
|
||||||
|
author,
|
||||||
|
circle_name: "friends".to_string(),
|
||||||
|
display_name: "Circle Name".to_string(),
|
||||||
|
bio: "circle bio".to_string(),
|
||||||
|
avatar_cid: None,
|
||||||
|
updated_at: 1000,
|
||||||
|
}).unwrap();
|
||||||
|
|
||||||
|
// Old bug shape: network id as viewer → no circle match.
|
||||||
|
let (dn, _, _) = s.resolve_display_for_peer(&author, &[network_id]).unwrap();
|
||||||
|
assert_eq!(dn, "", "network-id viewer must not resolve circle profiles");
|
||||||
|
|
||||||
|
// Fix: all personas as viewers → second persona matches.
|
||||||
|
let (dn, bio, _) = s.resolve_display_for_peer(&author, &[persona1, persona2]).unwrap();
|
||||||
|
assert_eq!(dn, "Circle Name");
|
||||||
|
assert_eq!(bio, "circle bio");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Circle revocation (bug A1-9): posts stored under per-persona authors
|
||||||
|
/// are found by per-persona circle-intent queries, not by the network id.
|
||||||
|
#[test]
|
||||||
|
fn circle_intent_posts_found_per_persona() {
|
||||||
|
let s = temp_storage();
|
||||||
|
let network_id = make_node_id(9);
|
||||||
|
let persona1 = add_persona(&s, 1);
|
||||||
|
let persona2 = add_persona(&s, 2);
|
||||||
|
|
||||||
|
let vis = PostVisibility::Encrypted { recipients: vec![] };
|
||||||
|
s.store_post_with_intent(
|
||||||
|
&make_post_id(31),
|
||||||
|
&make_post(persona1, 1000),
|
||||||
|
&vis,
|
||||||
|
&VisibilityIntent::Circle("crew".to_string()),
|
||||||
|
).unwrap();
|
||||||
|
s.store_post_with_intent(
|
||||||
|
&make_post_id(32),
|
||||||
|
&make_post(persona2, 2000),
|
||||||
|
&vis,
|
||||||
|
&VisibilityIntent::Circle("crew".to_string()),
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
// Old bug shape: query by network id → nothing.
|
||||||
|
assert!(s.find_posts_by_circle_intent("crew", &network_id).unwrap().is_empty());
|
||||||
|
|
||||||
|
// Fix pattern: union across personas finds both posts.
|
||||||
|
let mut found = 0;
|
||||||
|
for pi in s.list_posting_identities().unwrap() {
|
||||||
|
found += s.find_posts_by_circle_intent("crew", &pi.node_id).unwrap().len();
|
||||||
|
}
|
||||||
|
assert_eq!(found, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- v0.8 (A3): comment TTL + greeting storage ---
|
||||||
|
|
||||||
|
fn ttl_comment(author: NodeId, post_id: PostId, ts: u64, expires: u64) -> InlineComment {
|
||||||
|
InlineComment {
|
||||||
|
author,
|
||||||
|
post_id,
|
||||||
|
content: "hello".to_string(),
|
||||||
|
timestamp_ms: ts,
|
||||||
|
signature: vec![0u8; 64],
|
||||||
|
deleted_at: None,
|
||||||
|
ref_post_id: None,
|
||||||
|
pub_x_index: None,
|
||||||
|
group_sig: None,
|
||||||
|
encrypted_payload: None,
|
||||||
|
expires_at_ms: expires,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_now_ms() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_millis() as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The expires_at migration must be idempotent across re-opens of a
|
||||||
|
/// file-backed DB (pragma-guarded ALTER).
|
||||||
|
#[test]
|
||||||
|
fn expires_at_migration_idempotent() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("itsgoin-a3-mig-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let db_path = dir.join("test.db");
|
||||||
|
let db_str = db_path.to_str().unwrap().to_string();
|
||||||
|
{
|
||||||
|
let s = Storage::open(&db_str).unwrap();
|
||||||
|
let now = test_now_ms();
|
||||||
|
s.store_comment(&ttl_comment(make_node_id(1), make_post_id(1), now, now + 60_000)).unwrap();
|
||||||
|
}
|
||||||
|
// Re-open: migration runs again, must not fail or lose data.
|
||||||
|
{
|
||||||
|
let s = Storage::open(&db_str).unwrap();
|
||||||
|
let comments = s.get_comments(&make_post_id(1)).unwrap();
|
||||||
|
assert_eq!(comments.len(), 1);
|
||||||
|
assert!(comments[0].expires_at_ms > 0, "expiry survived re-open");
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// expire_comments hard-deletes only due rows; legacy (NULL/0) rows
|
||||||
|
/// are never touched.
|
||||||
|
#[test]
|
||||||
|
fn expire_comments_deletes_only_due_rows() {
|
||||||
|
let s = temp_storage();
|
||||||
|
let post_id = make_post_id(2);
|
||||||
|
let now = test_now_ms();
|
||||||
|
|
||||||
|
s.store_comment(&ttl_comment(make_node_id(1), post_id, 1000, now - 1)).unwrap(); // due
|
||||||
|
s.store_comment(&ttl_comment(make_node_id(2), post_id, 1001, now + 60_000)).unwrap(); // live
|
||||||
|
s.store_comment(&ttl_comment(make_node_id(3), post_id, 1002, 0)).unwrap(); // legacy no-TTL
|
||||||
|
|
||||||
|
assert_eq!(s.expire_comments(now).unwrap(), 1, "only the due row deleted");
|
||||||
|
// Hard delete: gone even from the with-tombstones view.
|
||||||
|
let all = s.get_comments_with_tombstones(&post_id).unwrap();
|
||||||
|
assert_eq!(all.len(), 2);
|
||||||
|
assert!(all.iter().all(|c| c.author != make_node_id(1)));
|
||||||
|
// Idempotent.
|
||||||
|
assert_eq!(s.expire_comments(now).unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read filters exclude expired-but-unswept rows (belt-and-suspenders
|
||||||
|
/// between sweeps).
|
||||||
|
#[test]
|
||||||
|
fn read_filters_exclude_expired_unswept() {
|
||||||
|
let s = temp_storage();
|
||||||
|
let post_id = make_post_id(3);
|
||||||
|
let now = test_now_ms();
|
||||||
|
|
||||||
|
s.store_comment(&ttl_comment(make_node_id(1), post_id, 1000, now - 1)).unwrap(); // expired, unswept
|
||||||
|
s.store_comment(&ttl_comment(make_node_id(2), post_id, 1001, now + 60_000)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(s.get_comments(&post_id).unwrap().len(), 1);
|
||||||
|
assert_eq!(s.get_comments_with_tombstones(&post_id).unwrap().len(), 1);
|
||||||
|
assert_eq!(s.get_comment_count(&post_id).unwrap(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_slot_comment_count_helper() {
|
||||||
|
let s = temp_storage();
|
||||||
|
let post_id = make_post_id(4);
|
||||||
|
let now = test_now_ms();
|
||||||
|
|
||||||
|
for i in 0..3u8 {
|
||||||
|
let mut c = ttl_comment(make_node_id(10 + i), post_id, 1000 + i as u64, now + 60_000);
|
||||||
|
c.pub_x_index = Some(5);
|
||||||
|
s.store_comment(&c).unwrap();
|
||||||
|
}
|
||||||
|
// One on a different slot; one expired on slot 5.
|
||||||
|
let mut other = ttl_comment(make_node_id(20), post_id, 2000, now + 60_000);
|
||||||
|
other.pub_x_index = Some(6);
|
||||||
|
s.store_comment(&other).unwrap();
|
||||||
|
let mut expired = ttl_comment(make_node_id(21), post_id, 2001, now - 1);
|
||||||
|
expired.pub_x_index = Some(5);
|
||||||
|
s.store_comment(&expired).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(s.count_unexpired_open_slot_comments(&post_id, 5).unwrap(), 3);
|
||||||
|
assert_eq!(s.count_unexpired_open_slot_comments(&post_id, 6).unwrap(), 1);
|
||||||
|
assert_eq!(s.count_unexpired_open_slot_comments(&post_id, 7).unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn greeting_dismissals_and_reply_keys() {
|
||||||
|
let s = temp_storage();
|
||||||
|
let author = make_node_id(1);
|
||||||
|
let post_id = make_post_id(5);
|
||||||
|
|
||||||
|
assert!(!s.is_greeting_dismissed(&author, &post_id, 1000).unwrap());
|
||||||
|
s.add_greeting_dismissal(&author, &post_id, 1000).unwrap();
|
||||||
|
assert!(s.is_greeting_dismissed(&author, &post_id, 1000).unwrap());
|
||||||
|
// Different timestamp = different greeting, not dismissed.
|
||||||
|
assert!(!s.is_greeting_dismissed(&author, &post_id, 1001).unwrap());
|
||||||
|
// Idempotent re-dismiss.
|
||||||
|
s.add_greeting_dismissal(&author, &post_id, 1000).unwrap();
|
||||||
|
|
||||||
|
// Reply keys roundtrip.
|
||||||
|
let privkey = [7u8; 32];
|
||||||
|
let pubkey = [8u8; 32];
|
||||||
|
s.store_greeting_reply_key(&pubkey, &privkey, &post_id).unwrap();
|
||||||
|
let keys = s.list_greeting_reply_keys().unwrap();
|
||||||
|
assert_eq!(keys.len(), 1);
|
||||||
|
assert_eq!(keys[0].0, privkey);
|
||||||
|
assert_eq!(keys[0].1, post_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,22 @@ pub struct Attachment {
|
||||||
pub size_bytes: u64,
|
pub size_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public profile — plaintext, synced to all peers
|
/// Public profile — plaintext, synced to all peers.
|
||||||
|
///
|
||||||
|
/// v0.8 keying (dual-purpose struct — the `node_id` decides which class):
|
||||||
|
/// - NETWORK-id rows carry TOPOLOGY ONLY (anchors, recent_peers,
|
||||||
|
/// preferred_peers); persona fields stay empty. They travel via
|
||||||
|
/// ProfileUpdate / InitialExchange, always through
|
||||||
|
/// `sanitized_for_network_broadcast()`.
|
||||||
|
/// - POSTING-id rows carry PERSONA fields (display_name, bio, avatar_cid,
|
||||||
|
/// public_visible); topology fields stay empty. They travel ONLY as
|
||||||
|
/// signed profile posts (`ProfilePostContent`), which have no topology
|
||||||
|
/// fields at all — so posting identities never advertise device
|
||||||
|
/// neighborhoods on the wire.
|
||||||
|
/// The topology NodeIds on network-id rows are the designed 11-needle
|
||||||
|
/// findability mechanism (device-to-device, no posting-id linkage) — an
|
||||||
|
/// accepted tradeoff, not a leak; see design.html v0.8 location-anonymity
|
||||||
|
/// ruling before reflagging in audits.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct PublicProfile {
|
pub struct PublicProfile {
|
||||||
pub node_id: NodeId,
|
pub node_id: NodeId,
|
||||||
|
|
@ -856,8 +871,6 @@ pub struct AuthorManifest {
|
||||||
/// The post this manifest is anchored to
|
/// The post this manifest is anchored to
|
||||||
pub post_id: PostId,
|
pub post_id: PostId,
|
||||||
pub author: NodeId,
|
pub author: NodeId,
|
||||||
/// Author's N+10:A (ip:port strings)
|
|
||||||
pub author_addresses: Vec<String>,
|
|
||||||
/// Original post creation time (ms)
|
/// Original post creation time (ms)
|
||||||
pub created_at: u64,
|
pub created_at: u64,
|
||||||
/// When manifest was last updated (ms)
|
/// When manifest was last updated (ms)
|
||||||
|
|
@ -870,20 +883,16 @@ pub struct AuthorManifest {
|
||||||
pub signature: Vec<u8>,
|
pub signature: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CDN manifest traveling with blobs (author-signed part + host metadata)
|
/// CDN manifest traveling with blobs (author-signed part + serving host).
|
||||||
|
/// v0.8: ALL device-address fields removed (host_addresses, source,
|
||||||
|
/// source_addresses, downstream_count) — posting-identity manifests must not
|
||||||
|
/// carry device addresses (location anonymity). Holder resolution goes via
|
||||||
|
/// file_holders / worm lookup / redirect peers instead.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CdnManifest {
|
pub struct CdnManifest {
|
||||||
pub author_manifest: AuthorManifest,
|
pub author_manifest: AuthorManifest,
|
||||||
/// Serving host's NodeId
|
/// Serving host's NodeId (the QUIC-authenticated device serving the blob)
|
||||||
pub host: NodeId,
|
pub host: NodeId,
|
||||||
/// Serving host's N+10:A
|
|
||||||
pub host_addresses: Vec<String>,
|
|
||||||
/// Who the host got it from
|
|
||||||
pub source: NodeId,
|
|
||||||
/// Source's N+10:A
|
|
||||||
pub source_addresses: Vec<String>,
|
|
||||||
/// How many downstream this host has
|
|
||||||
pub downstream_count: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cached routing info for a social contact
|
/// Cached routing info for a social contact
|
||||||
|
|
@ -978,6 +987,38 @@ pub struct InlineComment {
|
||||||
/// Non-FoF observers see only ciphertext + sigs — body is opaque.
|
/// Non-FoF observers see only ciphertext + sigs — body is opaque.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub encrypted_payload: Option<Vec<u8>>,
|
pub encrypted_payload: Option<Vec<u8>>,
|
||||||
|
/// v0.8 (A3): absolute expiry, ms since epoch. Fixed at creation and
|
||||||
|
/// covered by the comment signature (digest v2) — no silent extension
|
||||||
|
/// is possible. Ordinary comments: created_at + rand(30..=365 days).
|
||||||
|
/// Registry entries: created_at + exactly 30 days. `0` means "no TTL"
|
||||||
|
/// (pre-v0.8 comment); v0.8 receivers REJECT `0` at ingest.
|
||||||
|
#[serde(default)]
|
||||||
|
pub expires_at_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v0.8 (A3): plaintext JSON inside a sealed greeting blob (see
|
||||||
|
/// `crypto::seal_greeting_body`). Only the recipient (bio author for an
|
||||||
|
/// original greeting; holder of the per-greeting `reply_pubkey` private
|
||||||
|
/// half for a reply) can read this.
|
||||||
|
///
|
||||||
|
/// Messaging-first (rounds 8–9): `return_path` names the post whose
|
||||||
|
/// Greeting open slot the recipient replies into (v1: the sender's own
|
||||||
|
/// bio post), and `reply_pubkey` is a FRESH per-greeting x25519 key —
|
||||||
|
/// replies are sealed to it, never to a long-term key. No vouch is
|
||||||
|
/// involved anywhere in this flow.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GreetingBody {
|
||||||
|
pub v: u32,
|
||||||
|
/// hex32 — the sender's REAL posting pubkey (inside the seal only).
|
||||||
|
pub sender_persona: String,
|
||||||
|
/// <= 64 chars.
|
||||||
|
pub sender_name: String,
|
||||||
|
/// <= 600 chars.
|
||||||
|
pub text: String,
|
||||||
|
/// hex32 post id — where the sender watches for a reply.
|
||||||
|
pub return_path: String,
|
||||||
|
/// hex32 fresh x25519 pubkey — seal replies to THIS.
|
||||||
|
pub reply_pubkey: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Permission level for comments on a post
|
/// Permission level for comments on a post
|
||||||
|
|
@ -1076,6 +1117,34 @@ pub struct RevocationEntry {
|
||||||
pub author_sig: Vec<u8>,
|
pub author_sig: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v0.8 (A3): what an open slot on a post is FOR. One mechanism — a wrap
|
||||||
|
/// slot whose V_x is derivable by anyone — two features aimed at
|
||||||
|
/// different parent posts (round-6 ruling).
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum OpenSlotKind {
|
||||||
|
/// Sealed first-contact greetings on a bio post.
|
||||||
|
Greeting,
|
||||||
|
/// Plaintext self-certifying registration entries on the registry post.
|
||||||
|
Registry,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v0.8 (A3): author-signed declaration that one of the post's wrap
|
||||||
|
/// slots is OPEN — its V_x is derivable from the post alone via
|
||||||
|
/// `crypto::derive_open_slot_vx(author, slot_binder_nonce)`. Covered by
|
||||||
|
/// the PostId hash (lives inside `FoFCommentGating`), so it is immutable
|
||||||
|
/// per publish; a bio republish (new slot_binder_nonce) is the only way
|
||||||
|
/// to change it.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct OpenSlotDecl {
|
||||||
|
/// Index into `wrap_slots`; its pub_x is a normal member of
|
||||||
|
/// `pub_post_set`. Observable-by-design: greetings are identifiable
|
||||||
|
/// as a class, never by sender.
|
||||||
|
pub slot_index: u32,
|
||||||
|
pub kind: OpenSlotKind,
|
||||||
|
/// Padded plaintext bucket size in bytes (single bucket, design §27).
|
||||||
|
pub body_bucket: u16,
|
||||||
|
}
|
||||||
|
|
||||||
/// FoF Layer 2: the author-published gating block embedded in a
|
/// FoF Layer 2: the author-published gating block embedded in a
|
||||||
/// FoF-comment-policy post. Carries the wrap slots + the matching
|
/// FoF-comment-policy post. Carries the wrap slots + the matching
|
||||||
/// pub_post_set + the slot_binder_nonce. The `revocation_list` is
|
/// pub_post_set + the slot_binder_nonce. The `revocation_list` is
|
||||||
|
|
@ -1097,6 +1166,10 @@ pub struct FoFCommentGating {
|
||||||
/// arrive; the on-wire t=0 snapshot is empty.
|
/// arrive; the on-wire t=0 snapshot is empty.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub revocation_list: Vec<RevocationEntry>,
|
pub revocation_list: Vec<RevocationEntry>,
|
||||||
|
/// v0.8 (A3): optional open-slot declaration (greeting / registry).
|
||||||
|
/// `None` on ordinary FoF-gated posts.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub open_slot: Option<OpenSlotDecl>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Author-controlled engagement policy for a post
|
/// Author-controlled engagement policy for a post
|
||||||
|
|
@ -1128,7 +1201,19 @@ pub enum BlobHeaderDiffOp {
|
||||||
RemoveReaction { reactor: NodeId, emoji: String, post_id: PostId },
|
RemoveReaction { reactor: NodeId, emoji: String, post_id: PostId },
|
||||||
AddComment(InlineComment),
|
AddComment(InlineComment),
|
||||||
EditComment { author: NodeId, post_id: PostId, timestamp_ms: u64, new_content: String },
|
EditComment { author: NodeId, post_id: PostId, timestamp_ms: u64, new_content: String },
|
||||||
DeleteComment { author: NodeId, post_id: PostId, timestamp_ms: u64 },
|
/// v0.8 (A3): self-certifying delete. `signature` is
|
||||||
|
/// `crypto::sign_comment_delete` by the comment author's posting key
|
||||||
|
/// over (author || post_id || timestamp_ms). Receivers accept when the
|
||||||
|
/// signature verifies OR when the diff sender is the parent post's
|
||||||
|
/// author (moderation override). Empty signature = legacy shape,
|
||||||
|
/// only the sender-override path can accept it.
|
||||||
|
DeleteComment {
|
||||||
|
author: NodeId,
|
||||||
|
post_id: PostId,
|
||||||
|
timestamp_ms: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
signature: Vec<u8>,
|
||||||
|
},
|
||||||
SetPolicy(CommentPolicy),
|
SetPolicy(CommentPolicy),
|
||||||
ThreadSplit { new_post_id: PostId },
|
ThreadSplit { new_post_id: PostId },
|
||||||
/// Write an encrypted receipt slot (64 bytes encrypted data)
|
/// Write an encrypted receipt slot (64 bytes encrypted data)
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,8 @@ struct BadgeCountsDto {
|
||||||
unread_messages: usize,
|
unread_messages: usize,
|
||||||
new_reacts: usize,
|
new_reacts: usize,
|
||||||
new_comments: usize,
|
new_comments: usize,
|
||||||
|
/// v0.8 (A3): undismissed greetings in the inbox (Messages badge adds this).
|
||||||
|
greetings: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -279,7 +281,11 @@ async fn post_to_dto(
|
||||||
// Engagement data
|
// Engagement data
|
||||||
let reaction_counts = {
|
let reaction_counts = {
|
||||||
let storage = node.storage.get().await;
|
let storage = node.storage.get().await;
|
||||||
storage.get_reaction_counts(id, &node.node_id).unwrap_or_default()
|
// Reactors are posting ids — "reacted_by_me" = any of our personas.
|
||||||
|
let our_ids: Vec<itsgoin_core::types::NodeId> = storage.list_posting_identities()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter().map(|p| p.node_id).collect();
|
||||||
|
storage.get_reaction_counts(id, &our_ids).unwrap_or_default()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(emoji, count, reacted_by_me)| ReactionCountDto { emoji, count, reacted_by_me })
|
.map(|(emoji, count, reacted_by_me)| ReactionCountDto { emoji, count, reacted_by_me })
|
||||||
.collect()
|
.collect()
|
||||||
|
|
@ -555,9 +561,13 @@ async fn list_posting_identities(
|
||||||
async fn create_posting_identity(
|
async fn create_posting_identity(
|
||||||
state: State<'_, AppNode>,
|
state: State<'_, AppNode>,
|
||||||
display_name: String,
|
display_name: String,
|
||||||
|
greetings_open: Option<bool>,
|
||||||
) -> Result<PostingIdentityDto, String> {
|
) -> Result<PostingIdentityDto, String> {
|
||||||
let node = get_node(&state).await;
|
let node = get_node(&state).await;
|
||||||
let id = node.create_posting_identity(display_name).await.map_err(|e| e.to_string())?;
|
let id = node
|
||||||
|
.create_posting_identity(display_name, greetings_open)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(PostingIdentityDto {
|
Ok(PostingIdentityDto {
|
||||||
node_id: hex::encode(id.node_id),
|
node_id: hex::encode(id.node_id),
|
||||||
display_name: id.display_name,
|
display_name: id.display_name,
|
||||||
|
|
@ -863,10 +873,12 @@ async fn post_to_dto_batch(
|
||||||
// Batch queries — 3 queries total instead of 4 × N
|
// Batch queries — 3 queries total instead of 4 × N
|
||||||
let (reaction_map, comment_map, intent_map, posting_identities) = {
|
let (reaction_map, comment_map, intent_map, posting_identities) = {
|
||||||
let storage = node.storage.get().await;
|
let storage = node.storage.get().await;
|
||||||
let reactions = storage.get_reaction_counts_batch(&post_ids, &node.node_id).unwrap_or_default();
|
let identities = storage.list_posting_identities().unwrap_or_default();
|
||||||
|
// Reactors are posting ids — "reacted_by_me" = any of our personas.
|
||||||
|
let our_ids: Vec<itsgoin_core::types::NodeId> = identities.iter().map(|p| p.node_id).collect();
|
||||||
|
let reactions = storage.get_reaction_counts_batch(&post_ids, &our_ids).unwrap_or_default();
|
||||||
let comments = storage.get_comment_counts_batch(&post_ids).unwrap_or_default();
|
let comments = storage.get_comment_counts_batch(&post_ids).unwrap_or_default();
|
||||||
let intents = storage.get_post_intents_batch(&post_ids).unwrap_or_default();
|
let intents = storage.get_post_intents_batch(&post_ids).unwrap_or_default();
|
||||||
let identities = storage.list_posting_identities().unwrap_or_default();
|
|
||||||
(reactions, comments, intents, identities)
|
(reactions, comments, intents, identities)
|
||||||
};
|
};
|
||||||
// Map posting-id -> display-name so we can tag author=persona posts.
|
// Map posting-id -> display-name so we can tag author=persona posts.
|
||||||
|
|
@ -1729,6 +1741,264 @@ async fn set_session_relay_enabled(state: State<'_, AppNode>, enabled: bool) ->
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- v0.8 (A3): registry search + greetings ---
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct RegistryEntryDto {
|
||||||
|
author_id: String,
|
||||||
|
display_name: String,
|
||||||
|
keywords: Vec<String>,
|
||||||
|
updated_at_ms: u64,
|
||||||
|
expires_at_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct RegistryStatusDto {
|
||||||
|
listed: bool,
|
||||||
|
keywords: Vec<String>,
|
||||||
|
expires_at_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct GreetingDto {
|
||||||
|
/// Comment key part 1 (throwaway outer identity) — opaque to the UI,
|
||||||
|
/// passed back verbatim for reply/dismiss.
|
||||||
|
comment_author: String,
|
||||||
|
/// Comment key part 2.
|
||||||
|
post_id: String,
|
||||||
|
/// Comment key part 3.
|
||||||
|
timestamp_ms: u64,
|
||||||
|
/// The sender's REAL persona (recovered from inside the seal).
|
||||||
|
sender_persona_id: String,
|
||||||
|
sender_name: String,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct GreetingSlotDto {
|
||||||
|
bio_post_id: String,
|
||||||
|
accepts_greetings: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register the persona in the network registry (30d entry, auto-renews
|
||||||
|
/// while listed — the core eviction cycle re-signs ~every 25 days).
|
||||||
|
#[tauri::command]
|
||||||
|
async fn register_persona(
|
||||||
|
state: State<'_, AppNode>,
|
||||||
|
posting_id_hex: String,
|
||||||
|
name: String,
|
||||||
|
keywords: Vec<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let node = get_node(&state).await;
|
||||||
|
let posting_id = parse_node_id(&posting_id_hex)?;
|
||||||
|
node.register_persona(&posting_id, &name, &keywords)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the persona's registry entry (self-certifying signed delete).
|
||||||
|
#[tauri::command]
|
||||||
|
async fn unregister_persona(
|
||||||
|
state: State<'_, AppNode>,
|
||||||
|
posting_id_hex: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let node = get_node(&state).await;
|
||||||
|
let posting_id = parse_node_id(&posting_id_hex)?;
|
||||||
|
node.unregister_persona(&posting_id).await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current "Listed" state for the consent panel: listed flag + the
|
||||||
|
/// keywords used at last registration + entry expiry (0 if none held).
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_registry_status(
|
||||||
|
state: State<'_, AppNode>,
|
||||||
|
posting_id_hex: String,
|
||||||
|
) -> Result<RegistryStatusDto, String> {
|
||||||
|
let node = get_node(&state).await;
|
||||||
|
let posting_id = parse_node_id(&posting_id_hex)?;
|
||||||
|
let storage = node.storage.get().await;
|
||||||
|
let listed = storage
|
||||||
|
.get_setting(&format!("registry_listed.{}", posting_id_hex))
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|v| v == "1")
|
||||||
|
.unwrap_or(false);
|
||||||
|
let keywords = storage
|
||||||
|
.get_setting(&format!("registry_keywords.{}", posting_id_hex))
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|v| {
|
||||||
|
v.split(',')
|
||||||
|
.map(|k| k.trim().to_string())
|
||||||
|
.filter(|k| !k.is_empty())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let expires_at_ms = storage
|
||||||
|
.get_newest_registry_entry(&itsgoin_core::registry::REGISTRY_POST_ID, &posting_id)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|(_ts, exp)| exp)
|
||||||
|
.unwrap_or(0);
|
||||||
|
Ok(RegistryStatusDto { listed, keywords, expires_at_ms })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search the registry: refreshes the comment chain from peers, then
|
||||||
|
/// queries locally (search cost lands on the searcher, design §27).
|
||||||
|
#[tauri::command]
|
||||||
|
async fn search_registry(
|
||||||
|
state: State<'_, AppNode>,
|
||||||
|
query: String,
|
||||||
|
) -> Result<Vec<RegistryEntryDto>, String> {
|
||||||
|
let node = get_node(&state).await;
|
||||||
|
let matches = node.search_registry(&query).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(matches
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| RegistryEntryDto {
|
||||||
|
author_id: hex::encode(m.author),
|
||||||
|
display_name: m.name,
|
||||||
|
keywords: m.keywords,
|
||||||
|
updated_at_ms: m.timestamp_ms,
|
||||||
|
expires_at_ms: m.expires_at_ms,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locally-held greeting-slot info for an author's latest bio post.
|
||||||
|
async fn greeting_slot_local(node: &Node, author: &NodeId) -> Option<GreetingSlotDto> {
|
||||||
|
let storage = node.storage.get().await;
|
||||||
|
let bio_post_id = storage
|
||||||
|
.get_latest_profile_post_id_by_author(author)
|
||||||
|
.ok()
|
||||||
|
.flatten()?;
|
||||||
|
let post = storage.get_post(&bio_post_id).ok().flatten()?;
|
||||||
|
let accepts = post
|
||||||
|
.fof_gating
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|g| g.open_slot.as_ref())
|
||||||
|
.map(|d| d.kind == itsgoin_core::types::OpenSlotKind::Greeting)
|
||||||
|
.unwrap_or(false);
|
||||||
|
Some(GreetingSlotDto {
|
||||||
|
bio_post_id: hex::encode(bio_post_id),
|
||||||
|
accepts_greetings: accepts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does this author's bio declare a Greeting open slot? Drives the
|
||||||
|
/// "Say hi" button in the bio modal. On-demand (A3 §3.2): if the bio
|
||||||
|
/// isn't local yet (e.g. a registry search result), worm-locate the
|
||||||
|
/// author and sync their posts once before answering.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_greeting_slot(
|
||||||
|
state: State<'_, AppNode>,
|
||||||
|
node_id_hex: String,
|
||||||
|
) -> Result<Option<GreetingSlotDto>, String> {
|
||||||
|
let node = get_node(&state).await;
|
||||||
|
let nid = parse_node_id(&node_id_hex)?;
|
||||||
|
if let Some(dto) = greeting_slot_local(&node, &nid).await {
|
||||||
|
return Ok(Some(dto));
|
||||||
|
}
|
||||||
|
// Fetch-on-demand: existing worm lookup + one-shot sync, then retry.
|
||||||
|
if let Ok(Some(wr)) = node.worm_lookup(&nid).await {
|
||||||
|
let _ = node.sync_with(wr.node_id).await;
|
||||||
|
return Ok(greeting_slot_local(&node, &nid).await);
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a sealed first-contact greeting to a bio post's author.
|
||||||
|
/// Messaging-first: no vouch is involved anywhere in this flow.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn send_greeting(
|
||||||
|
state: State<'_, AppNode>,
|
||||||
|
bio_post_id_hex: String,
|
||||||
|
content: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let node = get_node(&state).await;
|
||||||
|
let bio_post_id = hex_to_postid(&bio_post_id_hex)?;
|
||||||
|
node.send_greeting(bio_post_id, content).await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reply to a received greeting — sealed to the greeting's fresh reply
|
||||||
|
/// key and dropped into its declared return path (never a long-term key).
|
||||||
|
#[tauri::command]
|
||||||
|
async fn reply_to_greeting(
|
||||||
|
state: State<'_, AppNode>,
|
||||||
|
comment_author_hex: String,
|
||||||
|
post_id_hex: String,
|
||||||
|
timestamp_ms: u64,
|
||||||
|
content: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let node = get_node(&state).await;
|
||||||
|
let comment_author = parse_node_id(&comment_author_hex)?;
|
||||||
|
let post_id = hex_to_postid(&post_id_hex)?;
|
||||||
|
node.reply_to_greeting(comment_author, post_id, timestamp_ms, content)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unsealed greetings (and replies) across all personas' bio posts,
|
||||||
|
/// newest first. Return-path/reply-key internals stay inside core.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn list_greetings(state: State<'_, AppNode>) -> Result<Vec<GreetingDto>, String> {
|
||||||
|
let node = get_node(&state).await;
|
||||||
|
let records = node.list_greetings().await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(records
|
||||||
|
.into_iter()
|
||||||
|
.map(|g| GreetingDto {
|
||||||
|
comment_author: hex::encode(g.comment_author),
|
||||||
|
post_id: hex::encode(g.post_id),
|
||||||
|
timestamp_ms: g.timestamp_ms,
|
||||||
|
sender_persona_id: hex::encode(g.sender_persona),
|
||||||
|
sender_name: g.sender_name,
|
||||||
|
content: g.text,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismiss a greeting (local only — nothing propagates).
|
||||||
|
#[tauri::command]
|
||||||
|
async fn dismiss_greeting(
|
||||||
|
state: State<'_, AppNode>,
|
||||||
|
comment_author_hex: String,
|
||||||
|
post_id_hex: String,
|
||||||
|
timestamp_ms: u64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let node = get_node(&state).await;
|
||||||
|
let comment_author = parse_node_id(&comment_author_hex)?;
|
||||||
|
let post_id = hex_to_postid(&post_id_hex)?;
|
||||||
|
node.dismiss_greeting(comment_author, post_id, timestamp_ms)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-persona greeting consent (republishes the bio on change).
|
||||||
|
#[tauri::command]
|
||||||
|
async fn set_greetings_open(
|
||||||
|
state: State<'_, AppNode>,
|
||||||
|
posting_id_hex: String,
|
||||||
|
open: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let node = get_node(&state).await;
|
||||||
|
let posting_id = parse_node_id(&posting_id_hex)?;
|
||||||
|
node.set_greetings_open(&posting_id, open).await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current greeting-consent state (unset = ON, the pre-checked default).
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_greetings_open(
|
||||||
|
state: State<'_, AppNode>,
|
||||||
|
posting_id_hex: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let node = get_node(&state).await;
|
||||||
|
let posting_id = parse_node_id(&posting_id_hex)?;
|
||||||
|
node.get_greetings_open(&posting_id).await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Open a URL in the user's default system browser.
|
/// Open a URL in the user's default system browser.
|
||||||
/// Desktop: spawns the platform opener (xdg-open / open / cmd start).
|
/// Desktop: spawns the platform opener (xdg-open / open / cmd start).
|
||||||
/// Only https:// URLs are accepted to avoid being a generic command exec.
|
/// Only https:// URLs are accepted to avoid being a generic command exec.
|
||||||
|
|
@ -2063,6 +2333,28 @@ async fn get_seen_engagement(
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Comment count for the engagement badges, EXCLUDING greeting open-slot
|
||||||
|
/// comments: greetings have their own dedicated badge/inbox, and counting
|
||||||
|
/// them here would double-count every greeting into "My Posts" while
|
||||||
|
/// pointing the user at a bio post where the comment renders as sealed
|
||||||
|
/// ciphertext instead of at the Greetings inbox.
|
||||||
|
fn non_greeting_comment_count(
|
||||||
|
storage: &itsgoin_core::storage::Storage,
|
||||||
|
post_id: &itsgoin_core::types::PostId,
|
||||||
|
post: &itsgoin_core::types::Post,
|
||||||
|
) -> u64 {
|
||||||
|
let mut count = storage.get_comment_count(post_id).unwrap_or(0);
|
||||||
|
if let Some(decl) = post.fof_gating.as_ref().and_then(|g| g.open_slot.as_ref()) {
|
||||||
|
if matches!(decl.kind, itsgoin_core::types::OpenSlotKind::Greeting) {
|
||||||
|
let greetings = storage
|
||||||
|
.count_unexpired_open_slot_comments(post_id, decl.slot_index)
|
||||||
|
.unwrap_or(0);
|
||||||
|
count = count.saturating_sub(greetings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn get_badge_counts(
|
async fn get_badge_counts(
|
||||||
state: State<'_, AppNode>,
|
state: State<'_, AppNode>,
|
||||||
|
|
@ -2071,11 +2363,17 @@ async fn get_badge_counts(
|
||||||
let node = get_node(&state).await;
|
let node = get_node(&state).await;
|
||||||
let storage = node.storage.get().await;
|
let storage = node.storage.get().await;
|
||||||
|
|
||||||
|
// "Own post" checks below are posting-class: posts are authored by our
|
||||||
|
// posting identities (personas), never the network NodeId.
|
||||||
|
let own_ids: Vec<itsgoin_core::types::NodeId> = storage.list_posting_identities()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter().map(|p| p.node_id).collect();
|
||||||
|
|
||||||
// Feed badge: count non-DM posts from others newer than last_feed_view_ms
|
// Feed badge: count non-DM posts from others newer than last_feed_view_ms
|
||||||
let feed_posts = storage.get_feed().map_err(|e| e.to_string())?;
|
let feed_posts = storage.get_feed().map_err(|e| e.to_string())?;
|
||||||
let new_feed = feed_posts.iter()
|
let new_feed = feed_posts.iter()
|
||||||
.filter(|(id, p, _vis)| {
|
.filter(|(id, p, _vis)| {
|
||||||
p.author != node.node_id
|
!own_ids.contains(&p.author)
|
||||||
&& p.timestamp_ms > last_feed_view_ms
|
&& p.timestamp_ms > last_feed_view_ms
|
||||||
&& !matches!(
|
&& !matches!(
|
||||||
storage.get_post_intent(id).ok().flatten(),
|
storage.get_post_intent(id).ok().flatten(),
|
||||||
|
|
@ -2088,18 +2386,20 @@ async fn get_badge_counts(
|
||||||
let all_posts = storage.list_posts_reverse_chron().map_err(|e| e.to_string())?;
|
let all_posts = storage.list_posts_reverse_chron().map_err(|e| e.to_string())?;
|
||||||
let mut new_engagement = 0usize;
|
let mut new_engagement = 0usize;
|
||||||
for (id, post, _vis) in &all_posts {
|
for (id, post, _vis) in &all_posts {
|
||||||
if post.author != node.node_id { continue; }
|
if !own_ids.contains(&post.author) { continue; }
|
||||||
// Skip DMs
|
// Skip DMs
|
||||||
if matches!(
|
if matches!(
|
||||||
storage.get_post_intent(id).ok().flatten(),
|
storage.get_post_intent(id).ok().flatten(),
|
||||||
Some(VisibilityIntent::Direct(_))
|
Some(VisibilityIntent::Direct(_))
|
||||||
) { continue; }
|
) { continue; }
|
||||||
let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id)
|
let total_reacts: u64 = storage.get_reaction_counts(id, &own_ids)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(_, count, _)| *count)
|
.map(|(_, count, _)| *count)
|
||||||
.sum();
|
.sum();
|
||||||
let total_comments = storage.get_comment_count(id).unwrap_or(0);
|
// Greeting open-slot comments are excluded — they have their own
|
||||||
|
// dedicated Greetings badge (see non_greeting_comment_count).
|
||||||
|
let total_comments = non_greeting_comment_count(&storage, id, post);
|
||||||
if total_reacts > 0 || total_comments > 0 {
|
if total_reacts > 0 || total_comments > 0 {
|
||||||
let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0));
|
let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0));
|
||||||
if total_reacts > seen_r as u64 || total_comments > seen_c as u64 {
|
if total_reacts > seen_r as u64 || total_comments > seen_c as u64 {
|
||||||
|
|
@ -2114,14 +2414,14 @@ async fn get_badge_counts(
|
||||||
matches!(
|
matches!(
|
||||||
storage.get_post_intent(id).ok().flatten(),
|
storage.get_post_intent(id).ok().flatten(),
|
||||||
Some(VisibilityIntent::Direct(_))
|
Some(VisibilityIntent::Direct(_))
|
||||||
) || (p.author != node.node_id && matches!(
|
) || (!own_ids.contains(&p.author) && matches!(
|
||||||
storage.get_post_with_visibility(id).ok().flatten(),
|
storage.get_post_with_visibility(id).ok().flatten(),
|
||||||
Some((_, PostVisibility::Encrypted { .. }))
|
Some((_, PostVisibility::Encrypted { .. }))
|
||||||
))
|
))
|
||||||
});
|
});
|
||||||
let mut seen_partners = std::collections::HashSet::new();
|
let mut seen_partners = std::collections::HashSet::new();
|
||||||
for (_id, post, _vis) in dm_posts {
|
for (_id, post, _vis) in dm_posts {
|
||||||
let partner = if post.author == node.node_id {
|
let partner = if own_ids.contains(&post.author) {
|
||||||
// sent DM — skip for unread count
|
// sent DM — skip for unread count
|
||||||
continue;
|
continue;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2139,16 +2439,22 @@ async fn get_badge_counts(
|
||||||
let mut new_reacts = 0usize;
|
let mut new_reacts = 0usize;
|
||||||
let mut new_comments = 0usize;
|
let mut new_comments = 0usize;
|
||||||
for (id, post, _vis) in &all_posts {
|
for (id, post, _vis) in &all_posts {
|
||||||
if post.author != node.node_id { continue; }
|
if !own_ids.contains(&post.author) { continue; }
|
||||||
let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id)
|
let total_reacts: u64 = storage.get_reaction_counts(id, &own_ids)
|
||||||
.unwrap_or_default().iter().map(|(_, c, _)| *c).sum();
|
.unwrap_or_default().iter().map(|(_, c, _)| *c).sum();
|
||||||
let total_comments = storage.get_comment_count(id).unwrap_or(0);
|
// Greeting open-slot comments excluded here too (dedicated badge).
|
||||||
|
let total_comments = non_greeting_comment_count(&storage, id, post);
|
||||||
let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0));
|
let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0));
|
||||||
if total_reacts > seen_r as u64 { new_reacts += (total_reacts - seen_r as u64) as usize; }
|
if total_reacts > seen_r as u64 { new_reacts += (total_reacts - seen_r as u64) as usize; }
|
||||||
if total_comments > seen_c as u64 { new_comments += (total_comments - seen_c as u64) as usize; }
|
if total_comments > seen_c as u64 { new_comments += (total_comments - seen_c as u64) as usize; }
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(BadgeCountsDto { new_feed, new_engagement, unread_messages, new_reacts, new_comments })
|
// v0.8 (A3): greetings inbox size. Release the storage slot first —
|
||||||
|
// list_greetings takes its own guard internally.
|
||||||
|
drop(storage);
|
||||||
|
let greetings = node.list_greetings().await.map(|g| g.len()).unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(BadgeCountsDto { new_feed, new_engagement, unread_messages, new_reacts, new_comments, greetings })
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -2994,6 +3300,8 @@ async fn export_data(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// node_id here is only used for the export file name; the post filter
|
||||||
|
// inside export_data now iterates all posting identities itself.
|
||||||
let result = itsgoin_core::export::export_data(
|
let result = itsgoin_core::export::export_data(
|
||||||
&node.data_dir,
|
&node.data_dir,
|
||||||
&node.storage,
|
&node.storage,
|
||||||
|
|
@ -3083,11 +3391,13 @@ async fn import_public_posts(
|
||||||
zip_path: String,
|
zip_path: String,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let node = get_node(&state).await;
|
let node = get_node(&state).await;
|
||||||
|
// Imported posts are re-authored as US — that must be a POSTING identity
|
||||||
|
// (the network NodeId never authors posts).
|
||||||
let result = itsgoin_core::import::import_public_posts(
|
let result = itsgoin_core::import::import_public_posts(
|
||||||
std::path::Path::new(&zip_path),
|
std::path::Path::new(&zip_path),
|
||||||
&node.storage,
|
&node.storage,
|
||||||
&node.blob_store,
|
&node.blob_store,
|
||||||
&node.node_id,
|
&node.default_posting_id,
|
||||||
).await.map_err(|e| e.to_string())?;
|
).await.map_err(|e| e.to_string())?;
|
||||||
Ok(result.message)
|
Ok(result.message)
|
||||||
}
|
}
|
||||||
|
|
@ -3141,12 +3451,14 @@ async fn import_merge_with_key(
|
||||||
key_hex: String,
|
key_hex: String,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let node = get_node(&state).await;
|
let node = get_node(&state).await;
|
||||||
|
// Merged content is authored under a POSTING identity, never the
|
||||||
|
// network NodeId.
|
||||||
let result = itsgoin_core::import::merge_with_key(
|
let result = itsgoin_core::import::merge_with_key(
|
||||||
std::path::Path::new(&zip_path),
|
std::path::Path::new(&zip_path),
|
||||||
&key_hex,
|
&key_hex,
|
||||||
&node.storage,
|
&node.storage,
|
||||||
&node.blob_store,
|
&node.blob_store,
|
||||||
&node.node_id,
|
&node.default_posting_id,
|
||||||
&node.secret_seed(),
|
&node.secret_seed(),
|
||||||
).await.map_err(|e| e.to_string())?;
|
).await.map_err(|e| e.to_string())?;
|
||||||
Ok(result.message)
|
Ok(result.message)
|
||||||
|
|
@ -3414,6 +3726,17 @@ pub fn run() {
|
||||||
exit_app,
|
exit_app,
|
||||||
get_session_relay_enabled,
|
get_session_relay_enabled,
|
||||||
set_session_relay_enabled,
|
set_session_relay_enabled,
|
||||||
|
register_persona,
|
||||||
|
unregister_persona,
|
||||||
|
get_registry_status,
|
||||||
|
search_registry,
|
||||||
|
get_greeting_slot,
|
||||||
|
send_greeting,
|
||||||
|
reply_to_greeting,
|
||||||
|
list_greetings,
|
||||||
|
dismiss_greeting,
|
||||||
|
set_greetings_open,
|
||||||
|
get_greetings_open,
|
||||||
])
|
])
|
||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application")
|
.expect("error while building tauri application")
|
||||||
|
|
|
||||||
411
frontend/app.js
411
frontend/app.js
|
|
@ -1090,7 +1090,117 @@ function setupMyPostsMediaObserver() {
|
||||||
mutObs.observe(myPostsList, { childList: true });
|
mutObs.observe(myPostsList, { childList: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.8 (A3): greetings inbox. Rows offer [Reply] (primary) and [Dismiss]
|
||||||
|
// ONLY — vouching is People-tab relationship management and must never be
|
||||||
|
// reachable from a Messages surface (round 9). The sender's name opens
|
||||||
|
// the ordinary bio modal, where the existing Friend flow lives.
|
||||||
|
let _greetingsCount = 0;
|
||||||
|
let _lastDmUnread = 0;
|
||||||
|
async function loadGreetings() {
|
||||||
|
const container = document.getElementById('greetings-list');
|
||||||
|
if (!container) return;
|
||||||
|
try {
|
||||||
|
const greetings = await invoke('list_greetings');
|
||||||
|
_greetingsCount = greetings.length;
|
||||||
|
|
||||||
|
// Notify once per greeting (localStorage-backed seen set, tag
|
||||||
|
// prefix `greet-` mirroring `msg-`).
|
||||||
|
try {
|
||||||
|
let notified = [];
|
||||||
|
try { notified = JSON.parse(localStorage.getItem('greetNotified') || '[]'); } catch (_) {}
|
||||||
|
const notifSet = new Set(notified);
|
||||||
|
const keys = [];
|
||||||
|
for (const g of greetings) {
|
||||||
|
const key = `greet-${g.commentAuthor}-${g.timestampMs}`;
|
||||||
|
keys.push(key);
|
||||||
|
if (_notifReady && !notifSet.has(key)) {
|
||||||
|
const who = g.senderName || g.senderPersonaId.slice(0, 8);
|
||||||
|
maybeNotify(`Greeting from ${who}`, (g.content || '').slice(0, 100), key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep only keys still in the inbox (dismissed/expired drop out).
|
||||||
|
localStorage.setItem('greetNotified', JSON.stringify(keys));
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
if (greetings.length === 0) {
|
||||||
|
container.innerHTML = `<p class="empty-hint">No greetings yet</p>`;
|
||||||
|
} else {
|
||||||
|
container.innerHTML = greetings.map(g => {
|
||||||
|
const icon = generateIdenticon(g.senderPersonaId, 18);
|
||||||
|
const label = escapeHtml(g.senderName || g.senderPersonaId.slice(0, 12));
|
||||||
|
return `<div class="peer-card greeting-item" data-comment-author="${g.commentAuthor}" data-post-id="${g.postId}" data-ts="${g.timestampMs}">
|
||||||
|
<div class="peer-card-row">${icon} <a class="peer-name-link greet-bio-link" data-node-id="${g.senderPersonaId}" data-name="${label}">${label}</a> <span class="last-seen">${formatTimeAgo(g.timestampMs)}</span></div>
|
||||||
|
<div style="font-size:0.85rem;line-height:1.4;margin-top:0.25rem;white-space:pre-wrap;word-break:break-word">${escapeHtml(g.content)}</div>
|
||||||
|
<div class="peer-card-actions">
|
||||||
|
<button class="btn btn-primary btn-sm greet-reply-btn">Reply</button>
|
||||||
|
<button class="btn btn-danger btn-sm greet-dismiss-btn">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
<div class="greet-reply-box hidden" style="margin-top:0.4rem;display:flex;gap:0.4rem;align-items:flex-end">
|
||||||
|
<textarea class="conv-reply-input" rows="2" maxlength="600" placeholder="Sealed reply..." style="flex:1"></textarea>
|
||||||
|
<button class="btn btn-primary btn-sm greet-reply-send">Send</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.querySelectorAll('.greet-bio-link').forEach(el => {
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openBioModal(el.dataset.nodeId, el.dataset.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
container.querySelectorAll('.greet-reply-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const box = btn.closest('.greeting-item').querySelector('.greet-reply-box');
|
||||||
|
box.classList.toggle('hidden');
|
||||||
|
if (!box.classList.contains('hidden')) box.querySelector('textarea').focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
container.querySelectorAll('.greet-reply-send').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const item = btn.closest('.greeting-item');
|
||||||
|
const input = item.querySelector('.greet-reply-box textarea');
|
||||||
|
const content = input.value.trim();
|
||||||
|
if (!content) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await invoke('reply_to_greeting', {
|
||||||
|
commentAuthorHex: item.dataset.commentAuthor,
|
||||||
|
postIdHex: item.dataset.postId,
|
||||||
|
timestampMs: Number(item.dataset.ts),
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
toast('Reply sent (sealed)');
|
||||||
|
input.value = '';
|
||||||
|
item.querySelector('.greet-reply-box').classList.add('hidden');
|
||||||
|
} catch (e) { toast('Error: ' + e); }
|
||||||
|
finally { btn.disabled = false; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
container.querySelectorAll('.greet-dismiss-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const item = btn.closest('.greeting-item');
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await invoke('dismiss_greeting', {
|
||||||
|
commentAuthorHex: item.dataset.commentAuthor,
|
||||||
|
postIdHex: item.dataset.postId,
|
||||||
|
timestampMs: Number(item.dataset.ts),
|
||||||
|
});
|
||||||
|
loadGreetings();
|
||||||
|
} catch (e) { toast('Error: ' + e); btn.disabled = false; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateTabBadge('messages', _lastDmUnread + _greetingsCount);
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMessages(force) {
|
async function loadMessages(force) {
|
||||||
|
// v0.8 (A3): refresh the greetings inbox alongside DMs (own IPC call,
|
||||||
|
// fire-and-forget — it updates the shared Messages badge itself).
|
||||||
|
loadGreetings().catch(() => {});
|
||||||
try {
|
try {
|
||||||
const [posts, follows] = await Promise.all([
|
const [posts, follows] = await Promise.all([
|
||||||
invoke('get_all_posts'),
|
invoke('get_all_posts'),
|
||||||
|
|
@ -1336,7 +1446,9 @@ async function loadMessages(force) {
|
||||||
const hasUnread = thread.posts.some(p => !p.isMe && p.timestampMs > lastReadMs);
|
const hasUnread = thread.posts.some(p => !p.isMe && p.timestampMs > lastReadMs);
|
||||||
if (hasUnread) unreadCount++;
|
if (hasUnread) unreadCount++;
|
||||||
}
|
}
|
||||||
updateTabBadge('messages', unreadCount);
|
// v0.8 (A3): the Messages badge = unread DMs + undismissed greetings.
|
||||||
|
_lastDmUnread = unreadCount;
|
||||||
|
updateTabBadge('messages', unreadCount + _greetingsCount);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
conversationsList.innerHTML = `<div class="section-card"><p class="status-err">Error: ${e}</p></div>`;
|
conversationsList.innerHTML = `<div class="section-card"><p class="status-err">Error: ${e}</p></div>`;
|
||||||
}
|
}
|
||||||
|
|
@ -1669,7 +1781,7 @@ async function openBioModal(nodeId, preloadedName) {
|
||||||
overlay.classList.remove('hidden');
|
overlay.classList.remove('hidden');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// `resolve_display` returns {name, bio, avatarCid} for any NodeId.
|
// `resolve_display` returns {displayName, bio, avatarCid} for any NodeId.
|
||||||
const resolved = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null);
|
const resolved = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null);
|
||||||
const follows = await invoke('list_follows').catch(() => []);
|
const follows = await invoke('list_follows').catch(() => []);
|
||||||
const ignored = await invoke('list_ignored_peers').catch(() => []);
|
const ignored = await invoke('list_ignored_peers').catch(() => []);
|
||||||
|
|
@ -1677,7 +1789,7 @@ async function openBioModal(nodeId, preloadedName) {
|
||||||
const following = follows.some(f => f.nodeId === nodeId);
|
const following = follows.some(f => f.nodeId === nodeId);
|
||||||
const isIgnored = ignored.some(i => i.nodeId === nodeId);
|
const isIgnored = ignored.some(i => i.nodeId === nodeId);
|
||||||
const isVouched = vouches.some(v => v.nodeId === nodeId);
|
const isVouched = vouches.some(v => v.nodeId === nodeId);
|
||||||
const name = (resolved && resolved.name) || preloadedName || nodeId.slice(0, 12);
|
const name = (resolved && resolved.displayName) || preloadedName || nodeId.slice(0, 12);
|
||||||
const bio = (resolved && resolved.bio) || '';
|
const bio = (resolved && resolved.bio) || '';
|
||||||
const icon = generateIdenticon(nodeId, 48);
|
const icon = generateIdenticon(nodeId, 48);
|
||||||
|
|
||||||
|
|
@ -1686,12 +1798,12 @@ async function openBioModal(nodeId, preloadedName) {
|
||||||
<div style="display:flex;gap:0.75rem;align-items:flex-start;margin-bottom:0.75rem">
|
<div style="display:flex;gap:0.75rem;align-items:flex-start;margin-bottom:0.75rem">
|
||||||
${icon}
|
${icon}
|
||||||
<div style="flex:1;min-width:0">
|
<div style="flex:1;min-width:0">
|
||||||
<div style="font-size:1.05rem;font-weight:600">${escapeHtml(name)}</div>
|
<div id="bio-modal-name" style="font-size:1.05rem;font-weight:600">${escapeHtml(name)}</div>
|
||||||
<div style="font-size:0.75rem;color:#888;word-break:break-all">${nodeId}</div>
|
<div style="font-size:0.75rem;color:#888;word-break:break-all">${nodeId}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${bio ? `<p style="font-size:0.9rem;line-height:1.45;margin:0 0 0.75rem">${escapeHtml(bio)}</p>` : '<p class="empty-hint" style="margin:0 0 0.75rem">No bio.</p>'}
|
${bio ? `<p id="bio-modal-bio" style="font-size:0.9rem;line-height:1.45;margin:0 0 0.75rem">${escapeHtml(bio)}</p>` : '<p id="bio-modal-bio" class="empty-hint" style="margin:0 0 0.75rem">No bio.</p>'}
|
||||||
<div style="display:flex;gap:0.4rem;flex-wrap:wrap">
|
<div id="bio-actions" style="display:flex;gap:0.4rem;flex-wrap:wrap">
|
||||||
<button id="bio-view-posts" class="btn btn-primary btn-sm">View Posts</button>
|
<button id="bio-view-posts" class="btn btn-primary btn-sm">View Posts</button>
|
||||||
${(following && isVouched)
|
${(following && isVouched)
|
||||||
? `<button id="bio-unfriend" class="btn btn-ghost btn-sm">Unfriend</button>`
|
? `<button id="bio-unfriend" class="btn btn-ghost btn-sm">Unfriend</button>`
|
||||||
|
|
@ -1785,11 +1897,90 @@ async function openBioModal(nodeId, preloadedName) {
|
||||||
} catch (e) { toast('Error: ' + e); }
|
} catch (e) { toast('Error: ' + e); }
|
||||||
finally { unfriend.disabled = false; }
|
finally { unfriend.disabled = false; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// v0.8 (A3): greeting affordance for strangers only (no existing
|
||||||
|
// relationship). The slot check may fetch the bio on-demand, so
|
||||||
|
// the button arrives asynchronously. Established peers keep the
|
||||||
|
// ordinary Message button.
|
||||||
|
if (!following && !isVouched) {
|
||||||
|
invoke('get_greeting_slot', { nodeIdHex: nodeId }).then(async slot => {
|
||||||
|
const row = document.getElementById('bio-actions');
|
||||||
|
if (!row || !bodyEl.contains(row)) return; // modal replaced/closed
|
||||||
|
// The slot check may have pulled the author's posts on-demand
|
||||||
|
// (registry search results start non-local) — if the first
|
||||||
|
// resolve came back empty, patch name/bio in place now.
|
||||||
|
if (!resolved || (!resolved.displayName && !resolved.bio)) {
|
||||||
|
const again = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null);
|
||||||
|
if (again && bodyEl.contains(row)) {
|
||||||
|
if (again.displayName) {
|
||||||
|
titleEl.textContent = again.displayName;
|
||||||
|
const nm = document.getElementById('bio-modal-name');
|
||||||
|
if (nm) nm.textContent = again.displayName;
|
||||||
|
}
|
||||||
|
if (again.bio) {
|
||||||
|
const bp = document.getElementById('bio-modal-bio');
|
||||||
|
if (bp) {
|
||||||
|
bp.textContent = again.bio;
|
||||||
|
bp.className = '';
|
||||||
|
bp.style.cssText = 'font-size:0.9rem;line-height:1.45;margin:0 0 0.75rem';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (slot && slot.acceptsGreetings) {
|
||||||
|
const hi = document.createElement('button');
|
||||||
|
hi.id = 'bio-say-hi';
|
||||||
|
hi.className = 'btn btn-primary btn-sm';
|
||||||
|
hi.textContent = 'Say hi';
|
||||||
|
row.prepend(hi);
|
||||||
|
hi.onclick = () => openGreetingCompose(slot.bioPostId, titleEl.textContent || name);
|
||||||
|
} else {
|
||||||
|
const na = document.createElement('button');
|
||||||
|
na.className = 'btn btn-ghost btn-sm';
|
||||||
|
na.disabled = true;
|
||||||
|
na.textContent = 'Not accepting greetings';
|
||||||
|
row.appendChild(na);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
bodyEl.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
bodyEl.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.8 (A3): greeting compose — swaps the bio modal body for a sealed
|
||||||
|
// one-shot compose aimed at the bio post's Greeting open slot.
|
||||||
|
function openGreetingCompose(bioPostIdHex, name) {
|
||||||
|
const titleEl = document.getElementById('popover-title');
|
||||||
|
const bodyEl = document.getElementById('popover-body');
|
||||||
|
if (!titleEl || !bodyEl) return;
|
||||||
|
titleEl.textContent = `Say hi to ${name}`;
|
||||||
|
bodyEl.innerHTML = `
|
||||||
|
<textarea id="greeting-text" rows="4" maxlength="600" placeholder="Introduce yourself..." style="width:100%;padding:0.4rem;background:#1a1a2e;color:#e0e0e0;border:1px solid #333;border-radius:4px;resize:vertical;font-family:inherit;font-size:0.9rem"></textarea>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:0.5rem;margin-top:0.5rem">
|
||||||
|
<span class="hint">Sealed — only ${escapeHtml(name)} can read this</span>
|
||||||
|
<button id="greeting-send-btn" class="btn btn-primary btn-sm">Send</button>
|
||||||
|
</div>`;
|
||||||
|
const input = document.getElementById('greeting-text');
|
||||||
|
setTimeout(() => input.focus(), 100);
|
||||||
|
const sendBtn = document.getElementById('greeting-send-btn');
|
||||||
|
const send = async () => {
|
||||||
|
const content = input.value.trim();
|
||||||
|
if (!content) return;
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
await invoke('send_greeting', { bioPostIdHex, content });
|
||||||
|
toast('Greeting sent');
|
||||||
|
closePopover();
|
||||||
|
} catch (e) {
|
||||||
|
toast('Error: ' + e);
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sendBtn.addEventListener('click', send);
|
||||||
|
input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.ctrlKey) send(); });
|
||||||
|
}
|
||||||
|
|
||||||
function openAuthorFeed(nodeId, name) {
|
function openAuthorFeed(nodeId, name) {
|
||||||
authorFilterNodeId = nodeId;
|
authorFilterNodeId = nodeId;
|
||||||
authorFilterName = name || nodeId.slice(0, 12);
|
authorFilterName = name || nodeId.slice(0, 12);
|
||||||
|
|
@ -2003,7 +2194,13 @@ function renderTimer(label, lastMs, intervalSecs, now) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
// Quotes MUST be escaped too: escaped output is interpolated into
|
||||||
|
// double-quoted HTML attributes (data-name="..."), and registry
|
||||||
|
// entries / greeting sender names are attacker-controlled — a name
|
||||||
|
// like `x" onclick="..."` would otherwise break out of the attribute
|
||||||
|
// and inject an inline handler (stored XSS).
|
||||||
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimeAgo(timestampMs) {
|
function formatTimeAgo(timestampMs) {
|
||||||
|
|
@ -2827,11 +3024,41 @@ async function doSyncAll() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.8 (A3): default persona id (hex) — the persona the Profiles lightbox
|
||||||
|
// and first-run consent act on. Falls back to the first-run auto persona.
|
||||||
|
async function getDefaultPersonaId() {
|
||||||
|
try {
|
||||||
|
const ids = await invoke('list_posting_identities');
|
||||||
|
const def = ids.find(p => p.isDefault) || ids[0];
|
||||||
|
if (def) return def.nodeId;
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
const auto = await invoke('get_first_run_auto_persona_id');
|
||||||
|
if (auto) return auto;
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function doSetupName() {
|
async function doSetupName() {
|
||||||
// Name is optional — users who want to stay anonymous can proceed with a blank field.
|
// Name is optional — users who want to stay anonymous can proceed with a blank field.
|
||||||
const name = setupName.value.trim();
|
const name = setupName.value.trim();
|
||||||
setupBtn.disabled = true;
|
setupBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
|
// v0.8 (A3): active choice at first publish — if the pre-checked
|
||||||
|
// "Accept greetings" box was unticked, record the opt-out BEFORE
|
||||||
|
// the first bio publish so no greeting slot ever ships (the
|
||||||
|
// republish inside set_display_name reads this setting).
|
||||||
|
const greetCheck = document.getElementById('setup-greetings-check');
|
||||||
|
if (greetCheck && !greetCheck.checked) {
|
||||||
|
// The opt-out write is LOAD-BEARING (round 8: an explicit
|
||||||
|
// opt-out must never silently fail and ship the slot anyway).
|
||||||
|
// Any failure here aborts the publish; the overlay stays up.
|
||||||
|
const personaId = await getDefaultPersonaId();
|
||||||
|
if (!personaId) {
|
||||||
|
throw new Error('could not resolve your persona to record the greeting opt-out — please try again');
|
||||||
|
}
|
||||||
|
await invoke('set_setting', { key: 'greetings_open.' + personaId, value: '0' });
|
||||||
|
}
|
||||||
await invoke('set_display_name', { name });
|
await invoke('set_display_name', { name });
|
||||||
setupOverlay.classList.add('hidden');
|
setupOverlay.classList.add('hidden');
|
||||||
toast(name ? 'Welcome, ' + name + '!' : 'Welcome!');
|
toast(name ? 'Welcome, ' + name + '!' : 'Welcome!');
|
||||||
|
|
@ -3180,6 +3407,7 @@ document.querySelectorAll('.tab').forEach(tab => {
|
||||||
if (!conversationsList.children.length) conversationsList.innerHTML = renderLoading();
|
if (!conversationsList.children.length) conversationsList.innerHTML = renderLoading();
|
||||||
loadMessages(true); loadDmRecipientOptions();
|
loadMessages(true); loadDmRecipientOptions();
|
||||||
clearNotifications('msg-');
|
clearNotifications('msg-');
|
||||||
|
clearNotifications('greet-');
|
||||||
}
|
}
|
||||||
if (target === 'settings') {
|
if (target === 'settings') {
|
||||||
loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible();
|
loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible();
|
||||||
|
|
@ -3216,6 +3444,16 @@ $('#profile-lightbox-btn').addEventListener('click', () => {
|
||||||
<input type="checkbox" id="lb-public-visible" ${currentVisible ? 'checked' : ''} />
|
<input type="checkbox" id="lb-public-visible" ${currentVisible ? 'checked' : ''} />
|
||||||
Show my profile to non-circle peers
|
Show my profile to non-circle peers
|
||||||
</label>
|
</label>
|
||||||
|
<label class="checkbox-label" style="margin:0.5rem 0 0.25rem;display:block;font-size:0.8rem">
|
||||||
|
<input type="checkbox" id="lb-registry-listed" />
|
||||||
|
List me in the network registry (searchable by anyone)
|
||||||
|
</label>
|
||||||
|
<input id="lb-registry-keywords" type="text" placeholder="Keywords, comma-separated (up to 8)" maxlength="280" style="width:100%;margin-bottom:0.25rem" />
|
||||||
|
<div id="lb-registry-status" class="empty-hint" style="font-size:0.72rem;margin-bottom:0.5rem">Not listed</div>
|
||||||
|
<label class="checkbox-label" style="margin:0.5rem 0;display:block;font-size:0.8rem">
|
||||||
|
<input type="checkbox" id="lb-greetings-open" checked />
|
||||||
|
Accept greetings (sealed hellos from people outside your circles)
|
||||||
|
</label>
|
||||||
<div style="display:flex;gap:0.5rem;justify-content:center;margin-top:0.75rem">
|
<div style="display:flex;gap:0.5rem;justify-content:center;margin-top:0.75rem">
|
||||||
<button class="btn btn-primary btn-sm" id="lb-profile-save">Save</button>
|
<button class="btn btn-primary btn-sm" id="lb-profile-save">Save</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3228,14 +3466,72 @@ $('#profile-lightbox-btn').addEventListener('click', () => {
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
overlay.querySelector('#lb-profile-save').addEventListener('click', async () => {
|
// v0.8 (A3): prefill the consent panel (registry listing + greeting
|
||||||
|
// consent) for the default persona. Status line mirrors auto-renew.
|
||||||
|
// The Save button stays DISABLED until the prefill settles: a listed
|
||||||
|
// user clicking Save against the unfilled (unchecked) checkbox would
|
||||||
|
// otherwise be treated as never-listed — their unlist intent dropped
|
||||||
|
// and auto-renew silently continuing.
|
||||||
|
let _lbWasListed = false;
|
||||||
|
const _lbSaveBtn = overlay.querySelector('#lb-profile-save');
|
||||||
|
_lbSaveBtn.disabled = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const personaId = await getDefaultPersonaId();
|
||||||
|
if (!personaId || !document.body.contains(overlay)) return;
|
||||||
|
try {
|
||||||
|
const status = await invoke('get_registry_status', { postingIdHex: personaId });
|
||||||
|
_lbWasListed = !!status.listed;
|
||||||
|
const listedCheck = overlay.querySelector('#lb-registry-listed');
|
||||||
|
const kwInput = overlay.querySelector('#lb-registry-keywords');
|
||||||
|
const statusEl = overlay.querySelector('#lb-registry-status');
|
||||||
|
if (listedCheck) listedCheck.checked = _lbWasListed;
|
||||||
|
if (kwInput && status.keywords.length) kwInput.value = status.keywords.join(', ');
|
||||||
|
if (statusEl) statusEl.textContent = _lbWasListed
|
||||||
|
? 'Listed — auto-renews every 30 days while checked'
|
||||||
|
: 'Not listed';
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
const open = await invoke('get_greetings_open', { postingIdHex: personaId });
|
||||||
|
const greetCheck = overlay.querySelector('#lb-greetings-open');
|
||||||
|
if (greetCheck) greetCheck.checked = !!open;
|
||||||
|
} catch (_) {}
|
||||||
|
} finally {
|
||||||
|
_lbSaveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
_lbSaveBtn.addEventListener('click', async () => {
|
||||||
const name = overlay.querySelector('#lb-profile-name').value.trim();
|
const name = overlay.querySelector('#lb-profile-name').value.trim();
|
||||||
const bio = overlay.querySelector('#lb-profile-bio').value.trim();
|
const bio = overlay.querySelector('#lb-profile-bio').value.trim();
|
||||||
if (!name) { toast('Display name is required'); return; }
|
if (!name) { toast('Display name is required'); return; }
|
||||||
try {
|
try {
|
||||||
|
const personaId = await getDefaultPersonaId();
|
||||||
|
// v0.8 (A3): write greeting consent BEFORE set_profile so the
|
||||||
|
// single bio republish below carries the right slot state
|
||||||
|
// (avoids a second republish via set_greetings_open). The
|
||||||
|
// write is LOAD-BEARING (round 8): if it fails, abort the
|
||||||
|
// publish rather than republishing with the wrong slot state.
|
||||||
|
const greetOpen = overlay.querySelector('#lb-greetings-open').checked;
|
||||||
|
if (!personaId) {
|
||||||
|
throw new Error('could not resolve your persona — profile not saved, please try again');
|
||||||
|
}
|
||||||
|
await invoke('set_setting', { key: 'greetings_open.' + personaId, value: greetOpen ? '1' : '0' });
|
||||||
await invoke('set_profile', { name, bio });
|
await invoke('set_profile', { name, bio });
|
||||||
const visible = overlay.querySelector('#lb-public-visible').checked;
|
const visible = overlay.querySelector('#lb-public-visible').checked;
|
||||||
await invoke('set_public_visible', { visible });
|
await invoke('set_public_visible', { visible });
|
||||||
|
// v0.8 (A3): registry listing. Checked = (re-)register — the
|
||||||
|
// core renews automatically every ~25 days while listed.
|
||||||
|
// Unchecking sends the self-certifying signed delete.
|
||||||
|
if (personaId) {
|
||||||
|
const listed = overlay.querySelector('#lb-registry-listed').checked;
|
||||||
|
const keywords = overlay.querySelector('#lb-registry-keywords').value
|
||||||
|
.split(',').map(k => k.trim()).filter(k => k).slice(0, 8);
|
||||||
|
if (listed) {
|
||||||
|
await invoke('register_persona', { postingIdHex: personaId, name, keywords });
|
||||||
|
} else if (_lbWasListed) {
|
||||||
|
await invoke('unregister_persona', { postingIdHex: personaId });
|
||||||
|
}
|
||||||
|
}
|
||||||
// Sync back to settings fields
|
// Sync back to settings fields
|
||||||
profileNameInput.value = name;
|
profileNameInput.value = name;
|
||||||
profileBioInput.value = bio;
|
profileBioInput.value = bio;
|
||||||
|
|
@ -3297,6 +3593,58 @@ $('#discover-toggle').addEventListener('click', () => {
|
||||||
if (!body.classList.contains('hidden')) loadDiscoverPeople();
|
if (!body.classList.contains('hidden')) loadDiscoverPeople();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v0.8 (A3): registry search — pulls the registry comment chain from
|
||||||
|
// peers, then queries locally. Results show name/keywords only; the bio
|
||||||
|
// is fetched on-demand when a result is clicked (openBioModal).
|
||||||
|
async function runRegistrySearch() {
|
||||||
|
const input = $('#registry-search-input');
|
||||||
|
const results = $('#registry-results');
|
||||||
|
const btn = $('#registry-search-btn');
|
||||||
|
const query = input.value.trim();
|
||||||
|
if (!query) { results.innerHTML = ''; return; }
|
||||||
|
btn.disabled = true;
|
||||||
|
results.innerHTML = renderLoading();
|
||||||
|
try {
|
||||||
|
const entries = await invoke('search_registry', { query });
|
||||||
|
if (!entries || entries.length === 0) {
|
||||||
|
results.innerHTML = renderEmptyState(
|
||||||
|
'No registry matches',
|
||||||
|
'Entries expire after 30 days — try different keywords.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
results.innerHTML = `<h4 class="subsection-title" style="margin:0 0 0.4rem">Registry results</h4>` + entries.map(e => {
|
||||||
|
const icon = generateIdenticon(e.authorId, 18);
|
||||||
|
const label = escapeHtml(e.displayName);
|
||||||
|
const kwLine = e.keywords && e.keywords.length
|
||||||
|
? `<div class="peer-card-meta">${e.keywords.map(k => escapeHtml(k)).join(' · ')}</div>`
|
||||||
|
: '';
|
||||||
|
const ageLine = e.updatedAtMs
|
||||||
|
? `<div class="peer-card-lastseen"><span class="last-seen">Listed ${formatTimeAgo(e.updatedAtMs)}</span></div>`
|
||||||
|
: '';
|
||||||
|
return `<div class="peer-card" data-node-id="${e.authorId}">
|
||||||
|
<div class="peer-card-row">${icon} <a class="peer-name-link bio-link" data-node-id="${e.authorId}" data-name="${label}">${label}</a></div>
|
||||||
|
${kwLine}
|
||||||
|
${ageLine}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
results.querySelectorAll('.bio-link').forEach(el => {
|
||||||
|
el.addEventListener('click', (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
openBioModal(el.dataset.nodeId, el.dataset.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
results.innerHTML = `<p class="status-err">Error: ${e}</p>`;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('#registry-search-btn').addEventListener('click', runRegistrySearch);
|
||||||
|
$('#registry-search-input').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') runRegistrySearch();
|
||||||
|
});
|
||||||
|
|
||||||
// "See new activity" button in Following: applies staged data and
|
// "See new activity" button in Following: applies staged data and
|
||||||
// re-renders so the user picks when to rearrange the list.
|
// re-renders so the user picks when to rearrange the list.
|
||||||
{
|
{
|
||||||
|
|
@ -3977,15 +4325,46 @@ function renderPersonasList() {
|
||||||
const deleteBtn = p.isDefault
|
const deleteBtn = p.isDefault
|
||||||
? ''
|
? ''
|
||||||
: `<button class="btn btn-ghost btn-sm delete-persona-btn" data-id="${p.nodeId}" style="font-size:0.65rem;color:#e74c3c">Delete</button>`;
|
: `<button class="btn btn-ghost btn-sm delete-persona-btn" data-id="${p.nodeId}" style="font-size:0.65rem;color:#e74c3c">Delete</button>`;
|
||||||
|
// Round 8: greeting consent is per-persona REVOCABLE. This toggle
|
||||||
|
// is the revocation path for non-default personas (the Profiles
|
||||||
|
// lightbox only manages the default persona). Label filled async.
|
||||||
|
const greetBtn = `<button class="btn btn-ghost btn-sm persona-greetings-btn" data-id="${p.nodeId}" style="font-size:0.65rem" disabled>Greetings: ...</button>`;
|
||||||
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:0.3rem 0;border-bottom:1px solid #222;gap:0.5rem">
|
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:0.3rem 0;border-bottom:1px solid #222;gap:0.5rem">
|
||||||
<div style="min-width:0;flex:1">
|
<div style="min-width:0;flex:1">
|
||||||
<div style="font-weight:600">${escapeHtml(label)}${defaultTag}</div>
|
<div style="font-weight:600">${escapeHtml(label)}${defaultTag}</div>
|
||||||
<div style="font-size:0.6rem;color:#666;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${p.nodeId.substring(0, 16)}...</div>
|
<div style="font-size:0.6rem;color:#666;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${p.nodeId.substring(0, 16)}...</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:0.3rem;flex-wrap:wrap;justify-content:flex-end">${setDefaultBtn}${deleteBtn}</div>
|
<div style="display:flex;gap:0.3rem;flex-wrap:wrap;justify-content:flex-end">${greetBtn}${setDefaultBtn}${deleteBtn}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
list.querySelectorAll('.persona-greetings-btn').forEach(btn => {
|
||||||
|
// Fill the current consent state, then enable the toggle.
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const open = await invoke('get_greetings_open', { postingIdHex: btn.dataset.id });
|
||||||
|
btn.dataset.open = open ? '1' : '0';
|
||||||
|
btn.textContent = open ? 'Greetings: on' : 'Greetings: off';
|
||||||
|
btn.disabled = false;
|
||||||
|
} catch (_) {
|
||||||
|
btn.textContent = 'Greetings: ?';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const next = btn.dataset.open !== '1';
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await invoke('set_greetings_open', { postingIdHex: btn.dataset.id, open: next });
|
||||||
|
btn.dataset.open = next ? '1' : '0';
|
||||||
|
btn.textContent = next ? 'Greetings: on' : 'Greetings: off';
|
||||||
|
toast(next
|
||||||
|
? 'Greetings enabled — bio republished with a greeting slot'
|
||||||
|
: 'Greetings disabled — slot revoked on published bios');
|
||||||
|
} catch (e) { toast('Error: ' + e); }
|
||||||
|
finally { btn.disabled = false; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
list.querySelectorAll('.set-default-persona-btn').forEach(btn => {
|
list.querySelectorAll('.set-default-persona-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|
@ -4040,6 +4419,10 @@ $('#create-persona-btn').addEventListener('click', () => {
|
||||||
<h3 style="color:#7fdbca;margin:0 0 0.75rem">New Persona</h3>
|
<h3 style="color:#7fdbca;margin:0 0 0.75rem">New Persona</h3>
|
||||||
<p style="font-size:0.7rem;color:#888;margin-bottom:0.75rem">Peers will see this persona as a distinct author. No one can tell which personas belong to the same device.</p>
|
<p style="font-size:0.7rem;color:#888;margin-bottom:0.75rem">Peers will see this persona as a distinct author. No one can tell which personas belong to the same device.</p>
|
||||||
<input id="new-persona-name" type="text" placeholder="Display name (e.g. Work, Garden Club)" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
|
<input id="new-persona-name" type="text" placeholder="Display name (e.g. Work, Garden Club)" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
|
||||||
|
<label class="checkbox-label" style="display:block;text-align:left;font-size:0.75rem;margin-bottom:0.75rem">
|
||||||
|
<input type="checkbox" id="new-persona-greetings" checked />
|
||||||
|
Accept greetings (sealed hellos from people outside your circles)
|
||||||
|
</label>
|
||||||
<div style="display:flex;gap:0.5rem;justify-content:center">
|
<div style="display:flex;gap:0.5rem;justify-content:center">
|
||||||
<button class="btn btn-primary btn-sm" id="new-persona-create">Create</button>
|
<button class="btn btn-primary btn-sm" id="new-persona-create">Create</button>
|
||||||
<button class="btn btn-ghost btn-sm" id="new-persona-cancel">Cancel</button>
|
<button class="btn btn-ghost btn-sm" id="new-persona-cancel">Cancel</button>
|
||||||
|
|
@ -4052,7 +4435,12 @@ $('#create-persona-btn').addEventListener('click', () => {
|
||||||
const name = nameInput.value.trim();
|
const name = nameInput.value.trim();
|
||||||
if (!name) { toast('Enter a name'); return; }
|
if (!name) { toast('Enter a name'); return; }
|
||||||
try {
|
try {
|
||||||
await invoke('create_posting_identity', { displayName: name });
|
// Round 8: greeting consent is an ACTIVE pre-checked choice at
|
||||||
|
// first publish. The choice rides the create call itself so the
|
||||||
|
// consent setting is written BEFORE the initial bio publish —
|
||||||
|
// an opted-out persona never ships a greeting slot at all.
|
||||||
|
const greetingsOpen = overlay.querySelector('#new-persona-greetings').checked;
|
||||||
|
await invoke('create_posting_identity', { displayName: name, greetingsOpen });
|
||||||
toast(`Persona created: ${name}`);
|
toast(`Persona created: ${name}`);
|
||||||
overlay.remove();
|
overlay.remove();
|
||||||
loadPersonas();
|
loadPersonas();
|
||||||
|
|
@ -4346,7 +4734,7 @@ async function init() {
|
||||||
// Update tab badges from welcome screen
|
// Update tab badges from welcome screen
|
||||||
updateTabBadge('feed', b.newFeed || 0);
|
updateTabBadge('feed', b.newFeed || 0);
|
||||||
updateTabBadge('myposts', b.newEngagement || 0);
|
updateTabBadge('myposts', b.newEngagement || 0);
|
||||||
updateTabBadge('messages', b.unreadMessages || 0);
|
updateTabBadge('messages', (b.unreadMessages || 0) + (b.greetings || 0));
|
||||||
// Ticker + notifications only after user leaves welcome screen
|
// Ticker + notifications only after user leaves welcome screen
|
||||||
// (welcome page already shows these counts directly)
|
// (welcome page already shows these counts directly)
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
@ -4500,6 +4888,7 @@ async function init() {
|
||||||
const badges = await invoke('get_badge_counts', { lastFeedViewMs: _lastFeedViewMs });
|
const badges = await invoke('get_badge_counts', { lastFeedViewMs: _lastFeedViewMs });
|
||||||
if (currentTab !== 'feed') updateTabBadge('feed', badges.newFeed);
|
if (currentTab !== 'feed') updateTabBadge('feed', badges.newFeed);
|
||||||
if (currentTab !== 'myposts') updateTabBadge('myposts', badges.newEngagement);
|
if (currentTab !== 'myposts') updateTabBadge('myposts', badges.newEngagement);
|
||||||
|
if (currentTab !== 'messages') updateTabBadge('messages', (badges.unreadMessages || 0) + (badges.greetings || 0));
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@
|
||||||
<h2>Welcome to ItsGoin</h2>
|
<h2>Welcome to ItsGoin</h2>
|
||||||
<p>Pick a display name if you want one — or leave blank to stay anonymous.</p>
|
<p>Pick a display name if you want one — or leave blank to stay anonymous.</p>
|
||||||
<input id="setup-name" type="text" placeholder="Display name (optional)" maxlength="50" autofocus />
|
<input id="setup-name" type="text" placeholder="Display name (optional)" maxlength="50" autofocus />
|
||||||
|
<!-- v0.8 (A3): active choice at first profile publish — pre-checked,
|
||||||
|
visible, opt-out BEFORE anything ships (never silently defaulted). -->
|
||||||
|
<label class="checkbox-label" style="display:block;text-align:left;font-size:0.8rem;margin:0.5rem 0 0.75rem;cursor:pointer">
|
||||||
|
<input type="checkbox" id="setup-greetings-check" checked />
|
||||||
|
Accept greetings — people who find you can send a sealed hello. Only you can read them. Turn off any time in Profiles.
|
||||||
|
</label>
|
||||||
<button id="setup-btn" class="btn btn-primary">Continue</button>
|
<button id="setup-btn" class="btn btn-primary">Continue</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -139,8 +145,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<button id="discover-toggle" class="btn btn-ghost btn-sm section-toggle">Discover People</button>
|
<button id="discover-toggle" class="btn btn-ghost btn-sm section-toggle">Hide Discover</button>
|
||||||
<div id="discover-body" class="hidden">
|
<div id="discover-body">
|
||||||
|
<!-- v0.8 (A3): registry search — on-demand chain pull, local query -->
|
||||||
|
<div class="input-row" style="margin-bottom:0.5rem">
|
||||||
|
<input id="registry-search-input" placeholder="Search the registry: name or keywords" />
|
||||||
|
<button id="registry-search-btn" class="btn btn-primary">Search</button>
|
||||||
|
</div>
|
||||||
|
<div id="registry-results"></div>
|
||||||
<p class="empty-hint" style="margin-bottom:0.5rem">Named profiles on the network you haven't followed or ignored.</p>
|
<p class="empty-hint" style="margin-bottom:0.5rem">Named profiles on the network you haven't followed or ignored.</p>
|
||||||
<div id="discover-list"></div>
|
<div id="discover-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -181,6 +193,15 @@
|
||||||
<h3>Message Requests</h3>
|
<h3>Message Requests</h3>
|
||||||
<div id="message-requests-list"></div>
|
<div id="message-requests-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- v0.8 (A3): greetings inbox — [Reply] and [Dismiss] ONLY.
|
||||||
|
Vouching is People-tab relationship management, never a
|
||||||
|
Messages action (round 9). -->
|
||||||
|
<div class="section-card" id="greetings-section">
|
||||||
|
<h3>Greetings</h3>
|
||||||
|
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.75rem">Sealed hellos from people outside your circles. Replies go back sealed — only the sender can read them.</p>
|
||||||
|
<div id="greetings-list"></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Settings tab -->
|
<!-- Settings tab -->
|
||||||
|
|
|
||||||
135
scripts/a3_integration_test.sh
Executable file
135
scripts/a3_integration_test.sh
Executable file
|
|
@ -0,0 +1,135 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# v0.8 A3 integration test — registry + greetings across 3 local nodes.
|
||||||
|
#
|
||||||
|
# Spec: A3-SPEC §6 multi-node scenario. Run from the repo root after
|
||||||
|
# `cargo build -p itsgoin-cli`. Uses FIFOs to drive the interactive REPL.
|
||||||
|
#
|
||||||
|
# 1. All nodes self-materialize the same registry post ID; node1 also
|
||||||
|
# runs the --publish-registry genesis one-shot (debug gate override).
|
||||||
|
# 2. node2 registers "Alice rust p2p" → node3 `search rust` finds her.
|
||||||
|
# node2 `unregister` → node3 re-search finds nothing.
|
||||||
|
# 3. node2's bio has a greeting slot; node3 greets it; node2 sees the
|
||||||
|
# unsealed text + real sender persona; node2 replies; node3 sees the
|
||||||
|
# reply. node1 (holder) stores only ciphertext.
|
||||||
|
# 5. Newest-wins: node2 re-registers; node3 sees exactly one entry.
|
||||||
|
# 6. (Optional, ITSGOIN_TEST_TTL_SECS) comment expiry sweep.
|
||||||
|
#
|
||||||
|
# Exit code 0 = all checks passed. Logs: /tmp/itsgoin-cli{1,2,3}.log
|
||||||
|
set -u
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
BIN=target/debug/itsgoin
|
||||||
|
[ -x "$BIN" ] || { echo "build first: cargo build -p itsgoin-cli"; exit 2; }
|
||||||
|
|
||||||
|
PASS=0; FAIL=0
|
||||||
|
check() { # check <desc> <cmd...>
|
||||||
|
local desc="$1"; shift
|
||||||
|
if "$@" >/dev/null 2>&1; then PASS=$((PASS+1)); echo "PASS: $desc";
|
||||||
|
else FAIL=$((FAIL+1)); echo "FAIL: $desc"; fi
|
||||||
|
}
|
||||||
|
strip_ansi() { sed 's/\x1b\[[0-9;]*m//g' "$1" | grep -v "WARN.*netlink\|WARN.*buffer_tool"; }
|
||||||
|
sq() { sqlite3 "/tmp/itsgoin-test$1/itsgoin.db" "$2"; }
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
{ exec 13>&- 14>&- 15>&-; } 2>/dev/null || true
|
||||||
|
kill $(pgrep -f 'itsgoin.*itsgoin-test') 2>/dev/null
|
||||||
|
rm -f /tmp/itsgoin-cmd{1,2,3}
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "== setup =="
|
||||||
|
kill $(pgrep -f 'itsgoin.*itsgoin-test') 2>/dev/null; sleep 1
|
||||||
|
for i in 1 2 3; do
|
||||||
|
rm -rf /tmp/itsgoin-test$i /tmp/itsgoin-cmd$i /tmp/itsgoin-cli$i.log
|
||||||
|
mkdir -p /tmp/itsgoin-test$i
|
||||||
|
mkfifo /tmp/itsgoin-cmd$i
|
||||||
|
done
|
||||||
|
|
||||||
|
export ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS=1
|
||||||
|
|
||||||
|
# Genesis one-shot on node1's data dir (debug-gate override).
|
||||||
|
"$BIN" /tmp/itsgoin-test1 --bind 127.0.0.1:18411 --publish-registry > /tmp/itsgoin-genesis.log 2>&1
|
||||||
|
check "genesis prints registry post id matching shipped constant" \
|
||||||
|
bash -c 'g=$(grep registry_post_id /tmp/itsgoin-genesis.log | awk "{print \$2}");
|
||||||
|
c=$(grep shipped_constant /tmp/itsgoin-genesis.log | awk "{print \$2}");
|
||||||
|
[ -n "$g" ] && [ "$g" = "$c" ]'
|
||||||
|
|
||||||
|
# Start the three nodes with FIFO-driven stdin.
|
||||||
|
for i in 1 2 3; do
|
||||||
|
( exec "$BIN" /tmp/itsgoin-test$i --bind 127.0.0.1:1841$i \
|
||||||
|
< /tmp/itsgoin-cmd$i > /tmp/itsgoin-cli$i.log 2>&1 ) &
|
||||||
|
done
|
||||||
|
# Keep FIFO write ends open for the whole run.
|
||||||
|
exec 13>/tmp/itsgoin-cmd1 14>/tmp/itsgoin-cmd2 15>/tmp/itsgoin-cmd3
|
||||||
|
sleep 6
|
||||||
|
|
||||||
|
n1_id=$(strip_ansi /tmp/itsgoin-cli1.log | grep -m1 "Node ID:" | awk '{print $3}')
|
||||||
|
n2_id=$(strip_ansi /tmp/itsgoin-cli2.log | grep -m1 "Node ID:" | awk '{print $3}')
|
||||||
|
echo "node1=$n1_id node2=$n2_id"
|
||||||
|
check "all nodes self-materialized the registry post" \
|
||||||
|
bash -c 'for i in 1 2 3; do sqlite3 /tmp/itsgoin-test$i/itsgoin.db \
|
||||||
|
"SELECT count(*) FROM posts WHERE hex(id) = upper(\"'"$(grep shipped_constant /tmp/itsgoin-genesis.log | awk '{print $2}')"'\")" \
|
||||||
|
| grep -q 1 || exit 1; done'
|
||||||
|
|
||||||
|
echo "== mesh: 2,3 -> 1; 3 -> 2 =="
|
||||||
|
echo "connect $n1_id@127.0.0.1:18411" >&14
|
||||||
|
echo "connect $n1_id@127.0.0.1:18411" >&15
|
||||||
|
sleep 4
|
||||||
|
echo "connect $n2_id@127.0.0.1:18412" >&15
|
||||||
|
sleep 4
|
||||||
|
|
||||||
|
echo "== step 2: register / search / unregister =="
|
||||||
|
echo "name Alice" >&14
|
||||||
|
sleep 3
|
||||||
|
echo "register Alice rust p2p" >&14
|
||||||
|
sleep 3
|
||||||
|
echo "search rust" >&15
|
||||||
|
sleep 8
|
||||||
|
check "node3 search finds Alice" grep -q "Alice" /tmp/itsgoin-cli3.log
|
||||||
|
|
||||||
|
echo "unregister" >&14
|
||||||
|
sleep 3
|
||||||
|
echo "search rust" >&15
|
||||||
|
sleep 8
|
||||||
|
check "node3 re-search finds nothing after signed delete" \
|
||||||
|
bash -c 'tail -20 /tmp/itsgoin-cli3.log | grep -q "no registry matches"'
|
||||||
|
|
||||||
|
echo "== step 3: greeting roundtrip =="
|
||||||
|
# node2's bio post id (latest Profile post) from its DB.
|
||||||
|
bio2=$(sq 2 'SELECT lower(hex(id)) FROM posts WHERE visibility_intent = '"'"'"Profile"'"'"' ORDER BY timestamp_ms DESC LIMIT 1')
|
||||||
|
check "node2 has a bio post with a greeting slot" \
|
||||||
|
bash -c '[ -n "'"$bio2"'" ] && sqlite3 /tmp/itsgoin-test2/itsgoin.db \
|
||||||
|
"SELECT fof_gating_json FROM posts WHERE lower(hex(id))=\"'"$bio2"'\"" | grep -q Greeting'
|
||||||
|
|
||||||
|
echo "name Bob" >&15
|
||||||
|
sleep 3
|
||||||
|
echo "greet $bio2 hello alice from bob" >&15
|
||||||
|
sleep 6
|
||||||
|
echo "greetings" >&14
|
||||||
|
sleep 4
|
||||||
|
check "node2 unseals the greeting text" grep -q "hello alice from bob" /tmp/itsgoin-cli2.log
|
||||||
|
|
||||||
|
echo "reply 0 hello back bob" >&14
|
||||||
|
sleep 6
|
||||||
|
echo "greetings" >&15
|
||||||
|
sleep 4
|
||||||
|
check "node3 unseals the reply via its stored fresh reply key" \
|
||||||
|
grep -q "hello back bob" /tmp/itsgoin-cli3.log
|
||||||
|
|
||||||
|
# Holder-side opacity: node1 never sees plaintext greeting bodies.
|
||||||
|
check "node1 stores only ciphertext (no greeting plaintext in db)" \
|
||||||
|
bash -c '! sqlite3 /tmp/itsgoin-test1/itsgoin.db \
|
||||||
|
"SELECT content FROM comments" | grep -q "hello alice"'
|
||||||
|
|
||||||
|
echo "== step 5: newest-wins =="
|
||||||
|
echo "register Alice rust p2p" >&14
|
||||||
|
sleep 2
|
||||||
|
echo "register AliceV2 rust mesh" >&14
|
||||||
|
sleep 4
|
||||||
|
echo "search rust" >&15
|
||||||
|
sleep 8
|
||||||
|
check "node3 sees exactly one (newest) entry for node2's persona" \
|
||||||
|
bash -c 'tail -6 /tmp/itsgoin-cli3.log | grep -c "rust" | grep -q "^1$" && tail -6 /tmp/itsgoin-cli3.log | grep -q AliceV2'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== results: $PASS passed, $FAIL failed =="
|
||||||
|
[ "$FAIL" -eq 0 ]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue