Compare commits

..

6 commits

Author SHA1 Message Date
Scott Reimers
fa02ace4cc feat: v0.8 Iteration C UI — mesh/temp slot display, N4 reach badge
Diagnostics follow the new topology: network summary shows mesh count against
the cap plus temp referral count (Preferred/Local/Wide tiles are gone), the
connections list labels slots mesh vs temp referral, and peers can show an N4
reach badge. Verified against the Rust DTOs (mesh_count/mesh_cap/temp_count/
n4_distinct, MeshSlot Display -> "mesh"/"temp") and the style.css classes.

Cargo.lock picks up the 0.8.0-alpha version bump.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
2026-07-30 13:05:20 -04:00
Scott Reimers
6e22e60c7b chore: bump version to 0.8.0-alpha + v0.8.0-alpha release notes
Version bumped in all five places (core/cli/tauri-app Cargo.toml,
tauri.conf.json, Android versionName + versionCode 8000).

download.html carries the v0.8.0-alpha release block: registry + greeting
comments as the headline, the manifest privacy fix and persona-split bug
family, comment ingest verification, and the protocol slimming. States
plainly that v0.8 cannot talk to v0.7.x and the anchor moves with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
2026-07-30 13:04:29 -04:00
Scott Reimers
e756fabb11 feat: v0.8 Iteration C — narrow mesh, N1-N4 uniques index, anchor convection
Topology rework. The mesh's job is width; depth now comes from knowledge.

Slots:
- Local/Wide collapse into ONE mesh pool: 20 desktop / 15 mobile
- Temp referral slots +4..+10 ABOVE the cap (10 desktop / 4 mobile), carry no
  knowledge exchange, graduate into a freed mesh slot or expire (5 min TTL),
  never evict an established mesh peer
- Fixes the Wide-never-fills bug: outbound paths hardcoded Local while the
  growth gate counted Local only, so growth stopped at 71 and 20 slots were
  inbound-only. Also closes register_connection paths that enforced no cap.

Knowledge — two-pool uniques announce (replaces N1/N2/N3 share lists):
- Pool 1 (forwardable): our N0-N2 uniques, deduped. Receiver stores shifted one
  bounce deeper as N1-N3, reporter-tagged.
- Pool 2 (terminal): our N3, deduped against pool 1. Receiver stores as N4,
  USED for search/resolution but NEVER re-announced. No N5 anywhere.
- Uniques merge mesh peers + social directs + CDN file authors + holder peers,
  so receivers cannot tell how we know an ID
- Anchor entries carry addresses; every other entry is a bare ID. The pools
  double as the anchor directory.
- Dedup at both store and announce; per-bounce caps; 2 MB payload ceiling
- Device-tiered depth: mobile holds 3 bounces and drops the terminal pool

Anchor convection (register loop retired — 0xC0 AnchorRegister deleted):
- Rolling in-memory caller window (no registration DB): a caller gets the 2
  most recent viable prior callers, its address goes to the next 2, then
  rotates out; still-connected callers preferred; RelayIntroduce coordinated
  when both ends are live
- Request classes: entry (<2 connections) always served; top-up refused
  cheaply under load, and the refusal feeds the adaptive weights
- Stochastic per-disconnect action {nothing | anchor intro | mesh intro} with
  weights from local anchor density + refusal feedback. No thresholds, no
  jitter timer. Recovery (mesh < 2) stays immediate and non-stochastic.
- Anchors mined from the uniques pools incl. retained pools of disconnected
  peers; known_anchors demoted to a bootstrap cache

Security fixes from review: remote address poisoning (hearsay anchors could be
dialled — known_anchors/is_anchor now written only from completed handshakes),
N4 leaking back into announcements via the anchor mirror (infinite propagation),
missing payload size caps, unbounded candidate scoring, and a bulk SQLite write
held under the ConnectionManager mutex.

deploy.sh: version regex now captures pre-release suffixes (0.8.0-alpha would
have truncated to 0.8.0 and broken every artifact path), announce channel
derives from the suffix, and the anchor runs --publish-registry at genesis.

228 core tests; a3 integration 9/9; new c_topology_test.sh 33/33 (both
independently re-run). EDM corpse, session-relay gating, Iteration A/B intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
2026-07-30 13:03:12 -04:00
Scott Reimers
36e3871c4b refactor: v0.8 Iteration B — cruft purge + ALPN bump to itsgoin/4
Zero-users ruling removes all wire-compat obligations. Net -1,443 LOC of Rust.

Protocol break:
- ALPN_V2 b"itsgoin/3" -> ALPN b"itsgoin/4" (renamed: the versioned name was
  what rotted). Old nodes are now refused at the QUIC handshake instead of
  half-interoperating with the v0.8 manifest/digest formats. Deploy gate from
  Iteration A is discharged.

Dead wire surface removed (MessageType 46 -> 40):
- DeleteRecord 0x51 (deletes ride ControlOp::DeletePost + InitialExchange)
- VisibilityUpdate 0x52 + its two dead senders (ControlOp::UpdateVisibility)
- SocialDisconnectNotice 0x71 (zero senders; checkin timeouts carry the signal)
- MeshPrefer 0xB3 + request_prefer + handler (preferred peers are gone)
- Legacy dual pull-matching half (have_post_ids) — behavior-identical
- PullSyncResponse.visibility_updates + counters (control posts cover it)
- serde(default) stripped from wire-only payloads (kept where
  skip_serializing_if makes it load-bearing; persisted-row defaults untouched)

Preferred-peer subsystem eliminated (design ruling: CDN file_holders replaced
the N+10 direct push/pull it served): slot tier, preferred_peers table (dropped),
preferred_tree semantics, rebalance Priority 0, find_relays_for preferred tiers,
7-day prune + 30-day watcher, dead FromStr impl. Slots are now Local/Wide only
(91 desktop / 12 mobile) pending Iteration C's single ~20-slot pool.

Other dead code: hostlist encoder + base64url helper, always-empty
downstream_addrs parameter chain, start_upnp_renewal_cycle no-op + 4 callers,
RELAY_TARGET_RATE_LIMIT, GetSecretSeed, stale comments swept.

Preserved deliberately: EDM scanner corpse (awaiting raw-UDP refactor),
PortScanHeartbeat, Iteration A startup migrations, session-relay opt-in gating.

Docs: design.html + tech.html synced (message counts, ALPN, purge status,
Rework asides flipped past-tense).

190 core tests pass; CLI + desktop build; A3 integration 9/9 (independently
re-run post-purge).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
2026-07-30 09:26:16 -04:00
Scott Reimers
17dbf076cb 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
2026-07-30 08:28:26 -04:00
Scott Reimers
66c9851061 docs: design.html v0.8 target rewrite — narrow mesh, uniques index, convection, registry
Full rewrite as the v0.8 target-design doc (7 rounds of design rulings):
- 20-peer mesh + temp referral slots; preferred peers eliminated
- N1-N4 uniques knowledge (two-pool announce: forwardable N0-N2 + terminal N3)
- Anchor convection (stochastic per-disconnect, adaptive weights, entry/top-up classes)
- Pool-carried anchor addresses; known_anchors demoted to bootstrap cache
- Discovery & First Contact: registrations-here post + HPKE greeting comments
- Comment TTL, 2-5 post concealment, traffic-uniformity adversary model
- Three-badge status system (Implemented/Rework/Planned), code-verified
- Roadmap: ID-class bug fixes first, registry tester-critical at #2, ALPN -> itsgoin/4
- Zero-users ruling: clean protocol break, no wire compat with <=v0.7.x

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
2026-07-29 23:29:34 -04:00
33 changed files with 12068 additions and 5437 deletions

6
Cargo.lock generated
View file

@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "itsgoin-cli"
version = "0.7.3"
version = "0.8.0-alpha"
dependencies = [
"anyhow",
"hex",
@ -2744,7 +2744,7 @@ dependencies = [
[[package]]
name = "itsgoin-core"
version = "0.7.3"
version = "0.8.0-alpha"
dependencies = [
"anyhow",
"base64 0.22.1",
@ -2769,7 +2769,7 @@ dependencies = [
[[package]]
name = "itsgoin-desktop"
version = "0.7.3"
version = "0.8.0-alpha"
dependencies = [
"anyhow",
"base64 0.22.1",

View file

@ -1,6 +1,6 @@
[package]
name = "itsgoin-cli"
version = "0.7.3"
version = "0.8.0-alpha"
edition = "2021"
[[bin]]

View file

@ -27,6 +27,7 @@ async fn main() -> anyhow::Result<()> {
let mut web_port: Option<u16> = None;
let mut print_identity = false;
let mut announce = false;
let mut publish_registry = false;
let mut ann_category: Option<String> = None;
let mut ann_title: 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; }
"--announce" => { announce = true; i += 1; }
"--publish-registry" => { publish_registry = true; i += 1; }
"--ann-category" => { ann_category = 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; }
@ -152,6 +154,29 @@ async fn main() -> anyhow::Result<()> {
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);
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 sockets: Vec<_> = addr.ip_addrs().collect();
// Show our display name if set
let my_name = node.get_display_name(&node.node_id).await.unwrap_or(None);
// Show our display name if set (profiles are keyed by POSTING identity,
// 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)");
println!();
@ -204,8 +230,21 @@ async fn main() -> anyhow::Result<()> {
println!(" redundancy Show replica counts for your posts");
println!(" worm <node_id> Worm lookup (find peer beyond 3-hop map)");
println!(" connections Show mesh connections");
println!(" slots Show mesh / temp-referral slot usage");
println!(" uniques [node_id_hex] Uniques index counts, or where an ID is known");
println!(" convection Adaptive convection weights + pool-mined anchors");
println!(" convect <anchor_id> Ask an anchor for peers (convection)");
println!(" uniques-pull Exchange uniques indexes with mesh peers");
println!(" social-routes Show social routing cache");
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!(" export-key Export identity key (KEEP SECRET)");
println!(" id Show this node's ID");
@ -214,14 +253,22 @@ async fn main() -> anyhow::Result<()> {
// Start background tasks (v2: mesh connections)
let _accept_handle = node.start_accept_loop();
let _pull_handle = node.start_pull_cycle(300); // 5 min pull cycle
let _sync_handle = node.start_sync_cycle(); // 60s: uniques-index exchange + content sync
let _diff_handle = node.start_diff_cycle(120); // 2 min routing diff
let _rebalance_handle = node.start_rebalance_cycle(600); // 10 min rebalance
let _growth_handle = node.start_growth_loop(); // reactive mesh growth
// Reactive mesh growth. `ITSGOIN_TEST_NO_GROWTH` suppresses it so an
// integration test can hold an exact topology (on loopback the growth loop
// otherwise collapses any chain into a full mesh within seconds, and a
// full mesh has no N2 at all — every peer is a direct). Test gate only.
let _growth_handle = if std::env::var("ITSGOIN_TEST_NO_GROWTH").is_ok() {
eprintln!("[test gate] growth loop disabled (ITSGOIN_TEST_NO_GROWTH)");
None
} else {
Some(node.start_growth_loop())
};
let _recovery_handle = node.start_recovery_loop(); // reactive anchor reconnect on mesh loss
let _checkin_handle = node.start_social_checkin_cycle(3600); // 1 hour social checkin
let _anchor_handle = node.start_anchor_register_cycle(600); // 10 min anchor register
let _upnp_handle = node.start_upnp_renewal_cycle(); // UPnP lease renewal (if mapped)
let _convection_handle = node.start_convection_loop(); // stochastic anchor convection + anchor self-probe
let _upnp_tcp_handle = node.start_upnp_tcp_renewal_cycle(); // UPnP TCP lease renewal
let _http_handle = node.start_http_server(); // HTTP post delivery (if publicly reachable)
let _bootstrap_check = node.start_bootstrap_connectivity_check(); // 24h isolation check
@ -254,6 +301,10 @@ async fn main() -> anyhow::Result<()> {
let stdin = io::stdin();
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!("> ");
io::stdout().flush()?;
@ -342,9 +393,13 @@ async fn main() -> anyhow::Result<()> {
if follows.is_empty() {
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 {
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 {
println!(" {} ({}){}", name, &hex::encode(nid)[..12], label);
} else {
@ -697,7 +752,7 @@ async fn main() -> anyhow::Result<()> {
"create-persona" => {
let name = arg.unwrap_or("").to_string();
match node.create_posting_identity(name).await {
match node.create_posting_identity(name, None).await {
Ok(id) => {
println!("Created posting identity: {}", hex::encode(id.node_id));
}
@ -817,7 +872,7 @@ async fn main() -> anyhow::Result<()> {
println!("(no mesh connections)");
} else {
println!("Mesh connections ({}):", conns.len());
for (nid, slot_kind, connected_at) in conns {
for (nid, slot, connected_at) in conns {
let name = node.get_display_name(&nid).await.unwrap_or(None);
let id_short = &hex::encode(nid)[..12];
let label = name.map(|n| format!("{} ({})", n, id_short))
@ -829,7 +884,95 @@ async fn main() -> anyhow::Result<()> {
.as_millis() as u64;
(now.saturating_sub(connected_at)) / 1000
};
println!(" {} [{:?}] connected {}s ago", label, slot_kind, duration_secs);
println!(" {} [{}] connected {}s ago", label, slot, duration_secs);
}
}
}
// v0.8 Iteration C observability: slot pool + uniques index.
"slots" => {
let conns = node.list_connections().await;
let mesh = conns.iter().filter(|(_, s, _)| s.is_mesh()).count();
let temp = conns.len() - mesh;
println!("mesh {}/{} temp-referral {}/{}",
mesh, node.device_profile().mesh_slots(),
temp, node.device_profile().temp_referral_slots());
for (nid, slot, _) in conns {
println!(" {} {}", &hex::encode(nid)[..12], slot);
}
}
"uniques" => {
let storage = node.storage.get().await;
if parts.len() > 1 {
match itsgoin_core::parse_node_id_hex(parts[1]) {
Ok(target) => {
let mut any = false;
for bounce in 2u8..=4 {
for reporter in storage.find_reporters_at(&target, bounce).unwrap_or_default() {
println!(" N{} via {}", bounce, &hex::encode(reporter)[..12]);
any = true;
}
}
if !any {
println!("(not in the uniques index)");
}
}
Err(e) => println!("Bad node id: {}", e),
}
} else {
println!("N2 {} N3 {} N4 {} (anchor density {:.2})",
storage.count_distinct_n2().unwrap_or(0),
storage.count_distinct_n3().unwrap_or(0),
storage.count_distinct_n4().unwrap_or(0),
storage.anchor_density().unwrap_or(0.0));
}
}
// The v0.8 "pull": exchange uniques indexes with every mesh peer.
"uniques-pull" => {
let n = node.uniques_pull().await;
println!("uniques-pull: exchanged with {} mesh peers", n);
}
// On-demand convection against a named anchor.
"convect" => {
if parts.len() < 2 {
println!("Usage: convect <anchor_node_id_hex>");
} else {
match itsgoin_core::parse_node_id_hex(parts[1]) {
Ok(anchor) => match node.convection_request(anchor).await {
Ok((_, true, ms)) => println!("convection: refused in {}ms", ms),
Ok((n, false, ms)) => println!("convection: {} new peers in {}ms", n, ms),
Err(e) => println!("convection failed: {}", e),
},
Err(e) => println!("Bad node id: {}", e),
}
}
}
// Convection diagnostics: the adaptive action weights, the
// pool-mined anchor directory, and a manual entry-class request.
"convection" => {
let (density, bias, mesh) = node.network.conn_handle().convection_state().await;
let cap = node.device_profile().mesh_slots();
let fill = if cap == 0 { 1.0 } else { mesh as f64 / cap as f64 };
let w = itsgoin_core::connection::convection_weights(density, bias, fill);
let total = w.nothing + w.anchor + w.mesh;
println!("anchor_density {:.3} anchor_bias {:.2} mesh {}/{}", density, bias, mesh, cap);
println!("weights: nothing {:.3} anchor {:.3} mesh {:.3} (P: {:.0}% / {:.0}% / {:.0}%)",
w.nothing, w.anchor, w.mesh,
100.0 * w.nothing / total, 100.0 * w.anchor / total, 100.0 * w.mesh / total);
let anchors = {
let storage = node.storage.get().await;
storage.list_pool_anchors(16).unwrap_or_default()
};
if anchors.is_empty() {
println!("pool anchors: (none)");
} else {
println!("pool anchors ({}):", anchors.len());
for (nid, addrs) in anchors {
println!(" {} {}", &hex::encode(nid)[..12], addrs.join(","));
}
}
}
@ -861,6 +1004,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" => {
println!("Shutting down...");
break;
@ -887,7 +1190,9 @@ async fn print_post(
) {
let author_hex = hex::encode(post.author);
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_label = match (author_name, is_me) {

View file

@ -1,6 +1,6 @@
[package]
name = "itsgoin-core"
version = "0.7.3"
version = "0.8.0-alpha"
edition = "2021"
[dependencies]

File diff suppressed because it is too large Load diff

View file

@ -923,15 +923,15 @@ pub fn rotate_group_key(
// --- CDN Manifest Signing ---
/// 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] {
let mut hasher = blake3::Hasher::new();
hasher.update(&manifest.post_id);
hasher.update(&manifest.author);
hasher.update(&manifest.created_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();
hasher.update(prev_json.as_bytes());
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 ---
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";
/// 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).
/// 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))
}
/// 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(
author: &NodeId,
post_id: &PostId,
content: &str,
timestamp_ms: u64,
ref_post_id: Option<&PostId>,
expires_at_ms: u64,
) -> blake3::Hash {
let mut hasher = blake3::Hasher::new_derive_key(COMMENT_SIGN_CONTEXT);
hasher.update(author);
hasher.update(post_id);
hasher.update(content.as_bytes());
hasher.update(&timestamp_ms.to_le_bytes());
// Domain-separated append: `None` yields the same digest as the v0.6.1
// scheme, so plain comments keep verifying; `Some(ref)` adds the ref id.
// Domain-separated appends (same pattern as the v0.6.2 `ref:` field).
if let Some(rid) = ref_post_id {
hasher.update(b"ref:");
hasher.update(rid);
}
hasher.update(b"expires:");
hasher.update(&expires_at_ms.to_le_bytes());
hasher.finalize()
}
@ -1096,13 +1114,14 @@ pub fn sign_comment(
content: &str,
timestamp_ms: u64,
ref_post_id: Option<&PostId>,
expires_at_ms: u64,
) -> Vec<u8> {
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()
}
/// Verify a comment's ed25519 signature.
/// Verify a comment's ed25519 signature (digest v2, expiry included).
pub fn verify_comment_signature(
author: &NodeId,
post_id: &PostId,
@ -1110,6 +1129,7 @@ pub fn verify_comment_signature(
timestamp_ms: u64,
signature: &[u8],
ref_post_id: Option<&PostId>,
expires_at_ms: u64,
) -> bool {
let Ok(verifying_key) = VerifyingKey::from_bytes(author) else {
return false;
@ -1117,10 +1137,177 @@ pub fn verify_comment_signature(
let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else {
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()
}
/// 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(&timestamp_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).
pub fn sign_reaction(
seed: &[u8; 32],
@ -1359,20 +1546,133 @@ mod tests {
let ref_post = [2u8; 32];
let content = "preview";
let ts = 1000u64;
let exp = 5000u64;
// 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.
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).
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.
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).
let sig_plain = sign_comment(&seed, &nid, &post_id, content, ts, None);
assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_plain, None));
// Plain-comment signature still works.
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, 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]
@ -1383,7 +1683,6 @@ mod tests {
let mut manifest = AuthorManifest {
post_id: [42u8; 32],
author: node_id,
author_addresses: vec!["10.0.0.1:4433".to_string()],
created_at: 1000,
updated_at: 2000,
previous_posts: vec![ManifestEntry {
@ -1400,6 +1699,97 @@ mod tests {
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]
fn test_forged_manifest_rejected() {
use crate::types::AuthorManifest;
@ -1409,7 +1799,6 @@ mod tests {
let mut manifest = AuthorManifest {
post_id: [42u8; 32],
author: node_id,
author_addresses: vec![],
created_at: 1000,
updated_at: 2000,
previous_posts: vec![],

View file

@ -91,7 +91,14 @@ pub async fn export_data(
let (posts, blob_cids) = if scope == ExportScope::IdentityOnly {
(vec![], vec![])
} 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 {
@ -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(
storage: &StoragePool,
node_id: &NodeId,
author_ids: &[NodeId],
) -> anyhow::Result<(Vec<ExportedPost>, Vec<[u8; 32]>)> {
let s = storage.get().await;
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 {
// Only export our own posts
if post.author != *node_id {
if !author_ids.contains(&post.author) {
continue;
}

View file

@ -20,7 +20,11 @@ use rand::RngCore;
use crate::crypto::{seal_wrap_slot, SealedWrapSlot};
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
/// 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
/// 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(
storage: &Storage,
author_persona_id: &NodeId,
open_slot: Option<(OpenSlotKind, u16)>,
) -> Result<Option<FoFCommentGatingBuilt>> {
// Gather the author's keyring with provenance: (V_x, owner, epoch).
// 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.
enum EntryKind {
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,
}
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));
}
// 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.
let bucket = crate::profile::next_vouch_batch_bucket(entries.len());
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 wrap_slots: Vec<WrapSlot> = Vec::with_capacity(entries.len());
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() {
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 {
slot_index: idx as u32,
v_x_owner,
@ -126,15 +161,28 @@ pub fn build_fof_comment_gating(
pub_x,
});
}
EntryKind::Open => open_slot_index = Some(idx as u32),
EntryKind::Dummy => {}
}
pub_post_set.push(pub_x);
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 {
slot_binder_nonce,
pub_post_set,
wrap_slots,
revocation_list: Vec::new(),
open_slot: open_slot_decl,
};
Ok(Some(FoFCommentGatingBuilt {
@ -142,6 +190,7 @@ pub fn build_fof_comment_gating(
cek,
slot_binder_nonce,
real_slot_provenance,
open_slot_index,
}))
}
@ -164,6 +213,9 @@ pub struct FoFCommentGatingBuilt {
/// `own_post_slot_provenance` for later cascade revocations.
/// Each entry: (slot_index, v_x_owner, v_x_epoch, pub_x).
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.
@ -314,6 +366,37 @@ pub fn sweep_unreadable_on_new_v_x(
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.
fn try_unlock_with_v_x(
gating: &crate::types::FoFCommentGating,
@ -376,6 +459,7 @@ pub fn build_fof_comment(
body: &str,
parent_comment_id: Option<[u8; 32]>,
now_ms: u64,
expires_at_ms: u64,
) -> Result<crate::types::InlineComment> {
use ed25519_dalek::{Signer, SigningKey};
@ -411,6 +495,7 @@ pub fn build_fof_comment(
"",
now_ms,
None,
expires_at_ms,
);
Ok(crate::types::InlineComment {
@ -424,6 +509,7 @@ pub fn build_fof_comment(
pub_x_index: Some(unlock.slot_index),
group_sig: Some(group_sig),
encrypted_payload: Some(encrypted),
expires_at_ms,
})
}
@ -439,7 +525,14 @@ pub fn verify_fof_group_sig(
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
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(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;
if idx >= gating.pub_post_set.len() { return false; }
if group_sig.len() != 64 { return false; }
@ -479,9 +572,10 @@ pub fn decrypt_fof_comment_payload(
// re-propagated via neighbor-manifest diffs.
/// Maximum allowed wrap_slots / pub_post_set entries on an incoming
/// FoF post. The bucket rule caps at `real + rand(0..=128)` above 256;
/// at a 4096-vouchee max realistic graph that's ~4224. Round up for
/// headroom; anything larger is presumed attacker-shaped.
/// FoF post. The bucket rule (`next_vouch_batch_bucket` in profile.rs) is
/// deterministic: ≤8 → 8; ≤256 → next power of two; >256 → next multiple
/// of 128. A 4096-vouchee realistic max buckets to 4096; 8192 gives 2x
/// headroom, anything larger is presumed attacker-shaped.
const MAX_FOF_WRAP_SLOTS: usize = 8192;
/// Maximum allowed revocation_list entries in a t=0 published gating
@ -552,6 +646,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(())
}
@ -955,7 +1067,7 @@ mod tests {
rand::rng().fill_bytes(&mut v_x_bob);
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).
assert_eq!(built.gating.pub_post_set.len(), 8);
assert_eq!(built.gating.wrap_slots.len(), 8);
@ -1009,7 +1121,7 @@ mod tests {
let s = temp_storage();
let (alice_id, _) = make_persona(5);
// 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");
}
@ -1031,7 +1143,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, &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.
// We can't assert real count directly without exposing internals,
// but we can confirm exactly two distinct successful unwraps:
@ -1088,7 +1200,7 @@ mod tests {
alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
// 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];
// Bob's device: has his V_me (which is v_x_bob since he handed it out).
@ -1128,6 +1240,7 @@ mod tests {
"great post alice",
None,
4000,
4_000_000_000_000,
).unwrap();
assert!(comment.content.is_empty(), "FoF comment body is encrypted, not in content");
assert!(comment.pub_x_index.is_some());
@ -1183,7 +1296,7 @@ mod tests {
rand::rng().fill_bytes(&mut v_x_bob);
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];
// Persist the post so apply_fof_revocation_locally can resolve
@ -1228,7 +1341,7 @@ mod tests {
};
let comment = build_fof_comment(
&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();
s.store_comment(&comment).unwrap();
assert_eq!(s.get_comments(&post_id).unwrap().len(), 1, "Bob's comment stored");
@ -1277,7 +1390,7 @@ mod tests {
s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();
// 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 = crate::types::Post {
author: alice_id, content: "alice".into(), attachments: vec![],
@ -1379,7 +1492,7 @@ mod tests {
rand::rng().fill_bytes(&mut v_me_old);
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 = crate::types::Post {
author: alice_id, content: "x".into(), attachments: vec![],
@ -1469,6 +1582,7 @@ mod tests {
pub_post_set: (0..slot_count).map(|_| [0u8; 32]).collect(),
wrap_slots: (0..slot_count).map(|_| dummy_wrap_slot()).collect(),
revocation_list: vec![],
open_slot: None,
}
}
@ -1574,7 +1688,7 @@ mod tests {
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).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
let post_id = [0xEE; 32];
let post = crate::types::Post {
author: alice_id, content: String::new(), attachments: vec![],
@ -1706,7 +1820,7 @@ mod tests {
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_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 {
author: alice_id, content: String::new(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
@ -1750,7 +1864,7 @@ mod tests {
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();
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
// shouldn't unlock for him yet.
@ -1826,7 +1940,7 @@ mod tests {
rand::rng().fill_bytes(&mut v_x_bob);
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_ct = encrypt_fof_body(body_plaintext, &built.cek, &built.slot_binder_nonce).unwrap();
@ -1905,7 +2019,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, &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.
assert_eq!(built.real_slot_provenance.len(), 3);
assert_eq!(built.gating.pub_post_set.len(), 8);
@ -1962,4 +2076,105 @@ mod tests {
assert!(verify_fof_revocation(&mallory_id, &post_id, &revoked_pub_x, 1000, 0, &sig));
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());
}
}

View file

@ -1,15 +1,9 @@
//! Group-key distribution as an encrypted post.
//!
//! v0.6.2 replaces the v0.6.1 `GroupKeyDistribute` wire push (admin →
//! member, uni-stream) with a standard public post that carries the group
//! seed inside `PostVisibility::Encrypted`. Each member is a recipient; the
//! Group-key distribution as an encrypted post: the group seed travels
//! inside `PostVisibility::Encrypted`. Each member is a recipient; the
//! post's CEK is wrapped per member using the admin's posting key. Members
//! receive the post via normal CDN / pull paths, decrypt with their posting
//! secret, and recover the seed + metadata.
//!
//! Removing the direct push eliminates the wire-level signal that a given
//! network endpoint is coordinating group membership with another specific
//! endpoint.
//! secret, and recover the seed + metadata. (No direct wire push — that
//! would signal which endpoints coordinate group membership.)
//!
//! Note: Members are identified by their **posting** NodeIds (the
//! author/recipient namespace since the v0.6.1 identity split), not network
@ -20,7 +14,7 @@ use crate::content::compute_post_id;
use crate::crypto;
use crate::storage::Storage;
use crate::types::{
GroupKeyDistributionContent, GroupKeyRecord, GroupMemberKey, NodeId, Post, PostId,
GroupKeyDistributionContent, GroupKeyRecord, NodeId, Post, PostId,
PostVisibility, PostingIdentity, VisibilityIntent,
};
@ -297,4 +291,40 @@ mod tests {
assert!(!applied2);
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);
}
}

View file

@ -8,7 +8,6 @@ use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::Mutex;
use tracing::{debug, info};
use crate::blob::BlobStore;
@ -106,7 +105,6 @@ pub async fn run_http_server(
port: u16,
storage: Arc<StoragePool>,
blob_store: Arc<BlobStore>,
downstream_addrs: Arc<Mutex<HashMap<[u8; 32], Vec<SocketAddr>>>>,
) -> anyhow::Result<()> {
let addr: SocketAddr = ([0, 0, 0, 0], port).into();
// Use SO_REUSEADDR + SO_REUSEPORT so TCP punch sockets can share the port
@ -156,10 +154,9 @@ pub async fn run_http_server(
let storage = Arc::clone(&storage);
let blob_store = Arc::clone(&blob_store);
let budget = Arc::clone(&budget);
let downstream_addrs = Arc::clone(&downstream_addrs);
tokio::spawn(async move {
handle_connection(stream, ip, slot, &storage, &blob_store, &downstream_addrs).await;
handle_connection(stream, ip, slot, &storage, &blob_store).await;
let mut b = budget.lock().unwrap();
match slot {
SlotKind::Content => b.release_content(ip),
@ -182,7 +179,6 @@ async fn handle_connection(
slot: SlotKind,
storage: &Arc<StoragePool>,
blob_store: &Arc<BlobStore>,
downstream_addrs: &Arc<Mutex<HashMap<[u8; 32], Vec<SocketAddr>>>>,
) {
// Keep-alive loop: handle sequential requests on the same connection
loop {
@ -222,7 +218,7 @@ async fn handle_connection(
}
}
SlotKind::Redirect => {
if !try_redirect(&mut stream, &post_id, storage, downstream_addrs).await {
if !try_redirect(&mut stream, &post_id, storage).await {
return;
}
}
@ -368,7 +364,6 @@ async fn try_redirect(
stream: &mut TcpStream,
post_id: &[u8; 32],
storage: &Arc<StoragePool>,
_downstream_addrs: &Arc<Mutex<HashMap<[u8; 32], Vec<SocketAddr>>>>,
) -> bool {
// Get downstream peers for this post
let downstream_peers = {
@ -583,114 +578,6 @@ pub fn html_escape(s: &str) -> String {
out
}
// --- Share link generation ---
/// Encode a list of socket addresses as compact binary, then base64url.
/// Per IPv4: [0x04][4 bytes IP][2 bytes port] = 7 bytes
/// Per IPv6: [0x06][16 bytes IP][2 bytes port] = 19 bytes
pub fn encode_hostlist(hosts: &[SocketAddr]) -> String {
let mut buf = Vec::with_capacity(hosts.len() * 19);
for host in hosts.iter().take(5) {
match host {
SocketAddr::V4(v4) => {
buf.push(0x04);
buf.extend_from_slice(&v4.ip().octets());
buf.extend_from_slice(&v4.port().to_be_bytes());
}
SocketAddr::V6(v6) => {
buf.push(0x06);
buf.extend_from_slice(&v6.ip().octets());
buf.extend_from_slice(&v6.port().to_be_bytes());
}
}
}
base64url_encode(&buf)
}
/// Decode a base64url-encoded hostlist back to socket addresses.
pub fn decode_hostlist(encoded: &str) -> Vec<SocketAddr> {
let buf = match base64url_decode(encoded) {
Some(b) => b,
None => return Vec::new(),
};
let mut addrs = Vec::new();
let mut i = 0;
while i < buf.len() {
match buf[i] {
0x04 if i + 7 <= buf.len() => {
let ip = std::net::Ipv4Addr::new(buf[i + 1], buf[i + 2], buf[i + 3], buf[i + 4]);
let port = u16::from_be_bytes([buf[i + 5], buf[i + 6]]);
addrs.push(SocketAddr::new(ip.into(), port));
i += 7;
}
0x06 if i + 19 <= buf.len() => {
let mut octets = [0u8; 16];
octets.copy_from_slice(&buf[i + 1..i + 17]);
let ip = std::net::Ipv6Addr::from(octets);
let port = u16::from_be_bytes([buf[i + 17], buf[i + 18]]);
addrs.push(SocketAddr::new(ip.into(), port));
i += 19;
}
_ => break, // malformed
}
}
addrs
}
// --- Minimal base64url implementation (no external dependency) ---
const B64_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
fn base64url_encode(data: &[u8]) -> String {
let mut out = String::with_capacity((data.len() * 4 + 2) / 3);
let mut i = 0;
while i + 2 < data.len() {
let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8) | data[i + 2] as u32;
out.push(B64_CHARS[((n >> 18) & 0x3F) as usize] as char);
out.push(B64_CHARS[((n >> 12) & 0x3F) as usize] as char);
out.push(B64_CHARS[((n >> 6) & 0x3F) as usize] as char);
out.push(B64_CHARS[(n & 0x3F) as usize] as char);
i += 3;
}
let remaining = data.len() - i;
if remaining == 2 {
let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8);
out.push(B64_CHARS[((n >> 18) & 0x3F) as usize] as char);
out.push(B64_CHARS[((n >> 12) & 0x3F) as usize] as char);
out.push(B64_CHARS[((n >> 6) & 0x3F) as usize] as char);
} else if remaining == 1 {
let n = (data[i] as u32) << 16;
out.push(B64_CHARS[((n >> 18) & 0x3F) as usize] as char);
out.push(B64_CHARS[((n >> 12) & 0x3F) as usize] as char);
}
out // no padding
}
fn base64url_decode(s: &str) -> Option<Vec<u8>> {
let mut buf = Vec::with_capacity(s.len() * 3 / 4);
let mut accum: u32 = 0;
let mut bits: u32 = 0;
for c in s.bytes() {
let val = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'-' => 62,
b'_' => 63,
b'=' => continue, // skip padding
_ => return None,
};
accum = (accum << 6) | val as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
buf.push((accum >> bits) as u8);
accum &= (1 << bits) - 1;
}
}
Some(buf)
}
#[cfg(test)]
mod tests {
use super::*;
@ -713,26 +600,6 @@ mod tests {
assert_eq!(html_escape("a&b"), "a&amp;b");
}
#[test]
fn test_base64url_roundtrip() {
let data = b"hello world";
let encoded = base64url_encode(data);
let decoded = base64url_decode(&encoded).unwrap();
assert_eq!(decoded, data);
}
#[test]
fn test_hostlist_roundtrip() {
use std::net::{Ipv4Addr, Ipv6Addr};
let hosts = vec![
SocketAddr::new(Ipv4Addr::new(192, 168, 1, 1).into(), 4433),
SocketAddr::new(Ipv6Addr::LOCALHOST.into(), 8080),
];
let encoded = encode_hostlist(&hosts);
let decoded = decode_hostlist(&encoded);
assert_eq!(decoded, hosts);
}
#[test]
fn test_parse_request_line() {
let req = b"GET /p/abc123 HTTP/1.1\r\nHost: example.com\r\n\r\n";

View file

@ -17,6 +17,7 @@ pub mod network;
pub mod node;
pub mod profile;
pub mod protocol;
pub mod registry;
pub mod storage;
pub mod stun;
pub mod types;

View file

@ -10,16 +10,15 @@ use tracing::{debug, error, info, warn};
use crate::activity::{ActivityCategory, ActivityLevel, ActivityLog};
use crate::blob::BlobStore;
use crate::connection::{initial_exchange_accept, initial_exchange_connect, ConnHandle, ConnectionActor, ConnectionManager, ExchangeResult};
use crate::content::verify_post_id;
use crate::protocol::{
read_message_type, read_payload, write_typed_message, BlobRequestPayload, BlobResponsePayload,
MessageType, ProfileUpdatePayload,
PullSyncRequestPayload, PullSyncResponsePayload, RefuseRedirectPayload,
ALPN_V2,
RefuseRedirectPayload,
ALPN,
};
use crate::storage::StoragePool;
use crate::types::{
DeviceProfile, DeviceRole, NodeId, PeerSlotKind, Post, PostId,
DeviceProfile, DeviceRole, NodeId, MeshSlot, Post, PostId,
PostVisibility, PublicProfile, SessionReachMethod, WormResult,
};
@ -95,7 +94,6 @@ impl Network {
secret_key: iroh::SecretKey,
storage: Arc<StoragePool>,
bind_addr: Option<SocketAddr>,
secret_seed: [u8; 32],
blob_store: Arc<BlobStore>,
profile: DeviceProfile,
activity_log: Arc<std::sync::Mutex<ActivityLog>>,
@ -103,7 +101,7 @@ impl Network {
let mut builder = iroh::Endpoint::builder()
.secret_key(secret_key)
.relay_mode(iroh::RelayMode::Disabled)
.alpns(vec![ALPN_V2.to_vec()])
.alpns(vec![ALPN.to_vec()])
.clear_address_lookup(); // Remove default pkarr + DNS (no dns.iroh.link publishing)
// mDNS LAN discovery only: enables automatic peer discovery on local network
@ -236,7 +234,6 @@ impl Network {
Arc::clone(&storage),
our_node_id,
Arc::clone(&is_anchor),
secret_seed,
blob_store,
profile,
Arc::clone(&activity_log),
@ -736,13 +733,16 @@ impl Network {
recv: iroh::endpoint::RecvStream,
is_mesh: &AtomicBool,
) -> bool {
// Try to allocate a slot
let accepted = conn_handle.accept_connection(
// Try to allocate a slot. `None` = both the mesh pool and the temp
// referral band are full.
let slot = conn_handle.accept_connection(
conn.clone(), remote_node_id, Some(remote_sock),
).await;
info!(peer = hex::encode(remote_node_id), accepted, "try_mesh_upgrade: accept_connection result");
if !accepted {
info!(peer = hex::encode(remote_node_id), accepted = slot.is_some(), "try_mesh_upgrade: accept_connection result");
let slot = match slot {
Some(s) => s,
None => {
let redirect = conn_handle.pick_random_redirect_peer(&remote_node_id).await;
let payload = RefuseRedirectPayload {
reason: "slots full".to_string(),
@ -756,11 +756,17 @@ impl Network {
conn_handle.log_activity(ActivityLevel::Info, ActivityCategory::Connection, format!("Refused {} mesh (slots full), added session", &hex::encode(remote_node_id)[..8]), Some(remote_node_id));
return false;
}
};
{
let s = storage.get().await;
// bugs-fixed #2: address always persisted, temp slots included.
let _ = s.upsert_peer(&remote_node_id, &[remote_sock], None);
let _ = s.add_mesh_peer(&remote_node_id, PeerSlotKind::Local, 0);
// But mesh_peers ONLY for a real mesh slot — that omission is what
// keeps temp referral peers out of our uniques announce.
if slot.is_mesh() {
let _ = s.add_mesh_peer(&remote_node_id);
}
if s.has_social_route(&remote_node_id).unwrap_or(false) {
let _ = s.touch_social_route_connect(
&remote_node_id,
@ -776,9 +782,10 @@ impl Network {
let our_nat_type = conn_handle.nat_type().await;
let our_http_capable = conn_handle.is_http_capable();
let our_http_addr = conn_handle.http_addr();
match initial_exchange_accept(storage, &our_node_id, send, recv, remote_node_id, anchor_addr, Some(remote_sock), our_nat_type, our_http_capable, our_http_addr, conn_handle.device_role(), None, false).await {
let our_depth = conn_handle.max_bounce();
match initial_exchange_accept(storage, &our_node_id, send, recv, remote_node_id, anchor_addr, Some(remote_sock), our_nat_type, our_http_capable, our_http_addr, conn_handle.device_role(), None, false, our_depth, slot.is_mesh()).await {
Ok(()) => {
info!(peer = hex::encode(remote_node_id), "Initial exchange complete (upgraded to mesh)");
info!(peer = hex::encode(remote_node_id), slot = %slot, "Initial exchange complete (upgraded to mesh)");
conn_handle.log_activity(ActivityLevel::Info, ActivityCategory::Connection, format!("Upgraded {} to mesh", &hex::encode(remote_node_id)[..8]), Some(remote_node_id));
}
Err(e) => {
@ -821,12 +828,15 @@ impl Network {
let conn = ConnectionManager::connect_to_unlocked(&self.endpoint, addr).await?;
// Register the established connection
self.conn_handle.register_connection(peer_id, conn.clone(), addrs, PeerSlotKind::Local).await;
let slot = match self.conn_handle.register_connection(peer_id, conn.clone(), addrs).await {
Some(s) => s,
None => anyhow::bail!("no slot available (mesh + temp full)"),
};
let anchor_addr = self.conn_handle.build_anchor_advertised_addr().await;
let our_nat_type = self.conn_handle.nat_type().await;
// Initial exchange WITHOUT holding conn_mgr lock
match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, peer_id, anchor_addr, our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None).await? {
match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, peer_id, anchor_addr, our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None, self.conn_handle.max_bounce(), slot.is_mesh()).await? {
ExchangeResult::Accepted { duplicate_active } => {
if duplicate_active {
self.duplicate_detected.store(true, std::sync::atomic::Ordering::Relaxed);
@ -872,29 +882,38 @@ impl Network {
}
}
/// Pull from all connected peers.
pub async fn pull_from_all(&self) -> anyhow::Result<PullStats> {
/// TRANSITIONAL content sync from every connected peer, plus the
/// engagement fetch that Iteration A's registry and greetings ride on.
///
/// Routes through the actor path (`content_sync_unlocked`), NOT a second
/// local implementation: the old `Network::pull_from_peer` stored posts
/// with `store_post_with_visibility` instead of `control::receive_post`, so
/// it silently dropped `intent` (control posts — deletes, revocations, key
/// distributions — were never processed) and never called
/// `touch_file_holder`, which is why posts received on the startup pull
/// registered no CDN holder.
pub async fn content_sync_all(&self) -> anyhow::Result<PullStats> {
let peers = self.conn_handle.connected_peers().await;
let mut total_posts = 0;
let mut total_vis = 0;
let mut success = 0;
for peer_id in peers {
// Uses Network::pull_from_peer which doesn't hold conn_mgr lock during I/O
let result = self.pull_from_peer(&peer_id).await;
let result = self.conn_handle.content_sync_from_peer(&peer_id).await;
match result {
Ok(stats) => {
total_posts += stats.posts_received;
total_vis += stats.visibility_updates;
success += 1;
// Also fetch engagement data
// Engagement fetch is what carries Iteration A's registry
// entries and greeting comments (BlobHeaderRequest/Response
// + BlobHeaderDiff). It MUST stay driven on a timer — this
// is the pull side of that safety net.
let _ = self.conn_handle.fetch_engagement_from_peer(&peer_id).await;
if stats.posts_received > 0 {
info!(
peer = hex::encode(peer_id),
posts = stats.posts_received,
"Pulled posts"
"Content sync: received posts"
);
}
}
@ -902,7 +921,7 @@ impl Network {
debug!(
peer = hex::encode(peer_id),
error = %e,
"Pull failed"
"Content sync failed"
);
}
}
@ -911,96 +930,97 @@ impl Network {
Ok(PullStats {
peers_pulled: success,
posts_received: total_posts,
visibility_updates: total_vis,
})
}
/// Broadcast routing diff to all connected peers.
/// Uses ConnHandle to get diff data, then sends outside the lock.
pub async fn broadcast_diff(&self) -> anyhow::Result<usize> {
use crate::protocol::{NodeListUpdatePayload, write_typed_message, MessageType};
let snapshot = self.conn_handle.get_diff_data().await;
if snapshot.n1_added.is_empty() && snapshot.n1_removed.is_empty()
&& snapshot.n2_added.is_empty() && snapshot.n2_removed.is_empty()
{
return Ok(0);
/// Run the uniques-index exchange (0x40/0x41) against every MESH peer.
///
/// This is what a "pull" means in v0.8: an exchange of the IDs each side
/// has a live path to. Symmetric, so one round trip refreshes both indexes.
/// Temp referral slots are skipped — they carry no knowledge.
pub async fn uniques_pull_all(&self) -> usize {
let conns = self.conn_handle.get_connection_map().await;
let mut exchanged = 0;
for (peer_id, conn, slot, _) in conns {
if !slot.is_mesh() {
continue;
}
let fut = crate::connection::ConnectionManager::uniques_pull_unlocked(
&self.conn_mgr,
conn,
&peer_id,
);
match tokio::time::timeout(std::time::Duration::from_secs(20), fut).await {
Ok(Ok(n)) => {
exchanged += 1;
if n > 0 {
debug!(peer = hex::encode(peer_id), rows = n, "Uniques pull: index updated");
}
}
Ok(Err(e)) => debug!(peer = hex::encode(peer_id), error = %e, "Uniques pull failed"),
Err(_) => debug!(peer = hex::encode(peer_id), "Uniques pull timed out"),
}
}
exchanged
}
let payload = NodeListUpdatePayload {
seq: snapshot.diff_seq,
n1_added: snapshot.n1_added,
n1_removed: snapshot.n1_removed,
n2_added: snapshot.n2_added,
n2_removed: snapshot.n2_removed,
/// Broadcast our uniques announce to every MESH peer.
///
/// Temporary referral slots are excluded — they carry no knowledge
/// exchange. Peers that advertised `depth < 4` get the terminal pool
/// stripped before send (the biggest saving on a cellular link).
///
/// The announce is always a full snapshot; `snapshot.unchanged` is what
/// stands in for incremental diffing, so an idle mesh sends nothing.
pub async fn broadcast_uniques(&self) -> anyhow::Result<usize> {
use crate::protocol::{write_typed_message, MessageType, UniquesSlice};
let snapshot = self.conn_handle.get_announce_data().await;
let payload = match snapshot.payload {
Some(p) if !snapshot.unchanged => p,
_ => return Ok(0),
};
let mut sent = 0;
for (peer_id, conn) in &snapshot.connections {
for (peer_id, conn, peer_depth) in &snapshot.connections {
let per_peer = crate::protocol::UniquesAnnouncePayload {
seq: payload.seq,
full: payload.full,
fwd: payload.fwd.clone(),
term: if *peer_depth >= 4 { payload.term.clone() } else { UniquesSlice::default() },
depth: payload.depth,
};
let result = async {
let mut send = conn.open_uni().await?;
write_typed_message(&mut send, MessageType::NodeListUpdate, &payload).await?;
write_typed_message(&mut send, MessageType::UniquesAnnounce, &per_peer).await?;
send.finish()?;
anyhow::Ok(())
}.await;
if result.is_ok() {
sent += 1;
} else {
debug!(peer = hex::encode(peer_id), "Failed to send routing diff");
debug!(peer = hex::encode(peer_id), "Failed to send uniques announce");
}
}
Ok(sent)
}
/// Broadcast full N1/N2 state to all mesh peers (periodic catch-up for missed diffs).
/// Periodic catch-up: force the next announce even if the pools are
/// unchanged, so a peer that missed one resynchronises.
/// (The old `broadcast_full_state` is subsumed — every announce is already
/// a full snapshot.)
pub async fn broadcast_full_state(&self) -> anyhow::Result<usize> {
use crate::protocol::{NodeListUpdatePayload, write_typed_message, MessageType};
let snapshot = self.conn_handle.get_diff_data().await;
// Build full state: all current N1 and N2 as "added", nothing removed
let all_n1 = self.conn_handle.connected_peers().await;
let all_n2 = {
let storage = self.storage.get().await;
storage.build_n2_share().unwrap_or_default()
};
if all_n1.is_empty() && all_n2.is_empty() {
return Ok(0);
}
let payload = NodeListUpdatePayload {
seq: snapshot.diff_seq,
n1_added: all_n1,
n1_removed: vec![],
n2_added: all_n2,
n2_removed: vec![],
};
let mut sent = 0;
for (_peer_id, conn) in &snapshot.connections {
let result = async {
let mut send = conn.open_uni().await?;
write_typed_message(&mut send, MessageType::NodeListUpdate, &payload).await?;
send.finish()?;
anyhow::Ok(())
}.await;
if result.is_ok() {
sent += 1;
}
}
Ok(sent)
self.conn_handle.force_announce().await;
self.broadcast_uniques().await
}
/// Push a profile update to all audience members (ephemeral-capable).
pub async fn push_profile(&self, profile: &PublicProfile) -> usize {
// v0.6.1: profiles broadcast on the wire are keyed by the network
// NodeId. They carry ONLY routing metadata (anchors, recent_peers,
// preferred_peers) — no display name / bio / avatar. Attaching a
// human-readable name to the network id would correlate the QUIC
// endpoint to a specific person. Persona-level display data will
// travel via signed posts from v0.6.2 onward.
// Profiles broadcast on the wire are keyed by the network NodeId.
// They carry ONLY routing metadata (anchors, recent_peers) — no
// display name / bio / avatar. Attaching a human-readable name to
// the network id would correlate the QUIC endpoint to a specific
// person. Persona-level display data travels via signed posts.
let push_profile = profile.sanitized_for_network_broadcast();
let payload = ProfileUpdatePayload {
profiles: vec![push_profile],
@ -1050,32 +1070,6 @@ impl Network {
sent
}
/// Push a visibility update to all connected peers.
/// Gets connections snapshot, sends I/O outside the lock.
pub async fn push_visibility(&self, update: &crate::types::VisibilityUpdate) -> usize {
use crate::protocol::{VisibilityUpdatePayload, write_typed_message, MessageType};
let conns = self.conn_handle.get_connection_map().await;
let payload = VisibilityUpdatePayload {
updates: vec![update.clone()],
};
let mut sent = 0;
for (peer_id, conn, _, _) in &conns {
let result = async {
let mut send = conn.open_uni().await?;
write_typed_message(&mut send, MessageType::VisibilityUpdate, &payload).await?;
send.finish()?;
anyhow::Ok(())
}.await;
if result.is_ok() {
sent += 1;
} else {
debug!(peer = hex::encode(peer_id), "Failed to push visibility update");
}
}
sent
}
/// Push an updated manifest to all known holders of the file (flat set,
/// up to 5 most-recent). Replaces the legacy downstream-tree push.
pub async fn push_manifest_to_downstream(
@ -1223,7 +1217,7 @@ impl Network {
}
/// Get connection info for display.
pub async fn connection_info(&self) -> Vec<(NodeId, PeerSlotKind, u64)> {
pub async fn connection_info(&self) -> Vec<(NodeId, MeshSlot, u64)> {
self.conn_handle.connection_info().await
}
@ -1258,11 +1252,15 @@ impl Network {
}
match self.connect_to_peer(peer_id, addr.clone()).await {
Ok(()) => Ok(()),
Ok(()) => {
self.promote_proven_anchor(peer_id, &addr).await;
Ok(())
}
Err(e) if e.to_string().contains("mesh refused") => {
// Anchor refused mesh — reconnect as session for registration
let conn = ConnectionManager::connect_to_unlocked(&self.endpoint, addr).await?;
let conn = ConnectionManager::connect_to_unlocked(&self.endpoint, addr.clone()).await?;
self.conn_handle.add_session(peer_id, conn, crate::types::SessionReachMethod::Direct, None).await;
self.promote_proven_anchor(peer_id, &addr).await;
self.conn_handle.log_activity(
ActivityLevel::Info,
ActivityCategory::Anchor,
@ -1275,6 +1273,28 @@ impl Network {
}
}
/// An anchor we have COMPLETED a QUIC handshake to. iroh has proven the
/// node ID owns this path, so — and only so — the entry may enter the
/// authoritative address state: `peers.is_anchor` and the `known_anchors`
/// bootstrap cache.
///
/// This is the counterpart to removing the announce-path mirror. Hearsay
/// anchor claims live in `reachable` only (mineable via `list_pool_anchors`,
/// which is what "the pools ARE the anchor directory" means); proven ones
/// live here, which is also what makes `known_anchors.last_seen_ms`
/// ordering meaningful again — and keeps the DNS-refreshed bootstrap anchor
/// from being evicted by a flood of gossip-sourced entries.
async fn promote_proven_anchor(&self, peer_id: NodeId, addr: &iroh::EndpointAddr) {
let socks: Vec<std::net::SocketAddr> = addr.ip_addrs().copied().collect();
if socks.is_empty() {
return;
}
let storage = self.storage.get().await;
let _ = storage.upsert_peer(&peer_id, &socks, None);
let _ = storage.set_peer_anchor(&peer_id, true);
let _ = storage.upsert_known_anchor(&peer_id, &socks);
}
/// Register an already-established QUIC connection as a mesh peer.
/// Sends InitialExchange first — if the remote accepts (responds with
/// InitialExchange), registers as mesh and spawns the stream loop.
@ -1291,13 +1311,15 @@ impl Network {
let anchor_addr = self.conn_handle.build_anchor_advertised_addr().await;
let our_nat_type = self.conn_handle.nat_type().await;
match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, peer_id, anchor_addr, our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None).await {
// Decide the slot BEFORE the exchange: the slot class is what says
// whether this handshake carries a uniques announce.
// bugs-fixed #3: a connection is registered before anything can drop it.
let slot = match self.conn_handle.register_connection(peer_id, conn.clone(), vec![]).await {
Some(s) => s,
None => anyhow::bail!("no slot available (mesh + temp full)"),
};
match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, peer_id, anchor_addr, our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None, self.conn_handle.max_bounce(), slot.is_mesh()).await {
Ok(ExchangeResult::Accepted { .. }) => {
self.conn_handle.register_connection(peer_id, conn.clone(), vec![], PeerSlotKind::Local).await;
{
let s = self.storage.get().await;
let _ = s.add_mesh_peer(&peer_id, PeerSlotKind::Local, 0);
}
// Spawn the per-connection stream loop
let conn_data = self.conn_handle.get_connection_map().await;
@ -1312,10 +1334,16 @@ impl Network {
Ok(ExchangeResult::Refused { redirect }) => {
let redir_info = redirect.as_ref().map(|r| r.n.clone());
info!(peer = hex::encode(peer_id), redirect = ?redir_info, "Mesh refused after hole punch, keeping as session");
self.conn_handle.disconnect_peer(&peer_id).await;
self.conn_handle.add_session(peer_id, conn, crate::types::SessionReachMethod::HolePunch, None).await;
anyhow::bail!("mesh refused: slots full");
}
Err(e) => Err(e),
Err(e) => {
// Roll back the slot we reserved — otherwise a failed handshake
// holds a mesh slot until the zombie sweep.
self.conn_handle.disconnect_peer(&peer_id).await;
Err(e)
}
}
}
@ -1376,7 +1404,9 @@ impl Network {
None,
);
if remaining == 0 {
// Recovery threshold is mesh < 2 (design.html §lifecycle), not zero:
// a single remaining peer is a partition waiting to happen.
if remaining < 2 {
self.conn_handle.notify_recovery();
} else {
self.conn_handle.notify_growth();
@ -1396,7 +1426,7 @@ impl Network {
for peer_id in &newly_connected {
let conn = self.conn_handle.get_connection(peer_id).await;
if let Some(conn) = conn {
match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, *peer_id, anchor_addr.clone(), our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None).await {
match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, *peer_id, anchor_addr.clone(), our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None, self.conn_handle.max_bounce(), true).await {
Ok(ExchangeResult::Accepted { .. }) => {}
Ok(ExchangeResult::Refused { redirect }) => {
debug!(peer = hex::encode(peer_id), "Auto-connect refused, disconnecting");
@ -1476,10 +1506,10 @@ impl Network {
loop {
// Check slots + pick candidate via ConnHandle (no lock contention)
let available = self.conn_handle.available_local_slots().await;
let available = self.conn_handle.available_mesh_slots().await;
if available == 0 {
debug!("Growth loop: local slots full");
self.log_activity(ActivityLevel::Info, ActivityCategory::Growth, "Local slots full".into(), None);
debug!("Growth loop: mesh slots full");
self.log_activity(ActivityLevel::Info, ActivityCategory::Growth, "Mesh slots full".into(), None);
break;
}
let candidate = {
@ -1508,25 +1538,39 @@ impl Network {
self.log_activity(ActivityLevel::Info, ActivityCategory::Growth, format!("Trying candidate {} (score {:.1})", &hex::encode(candidate_id)[..8], score), Some(candidate_id));
// Resolve address via ConnHandle (no lock during I/O)
let asked_a_reporter;
let addr_str = {
let local_addr = self.conn_handle.resolve_peer_addr_local(&candidate_id).await;
if let Some(endpoint_addr) = local_addr {
asked_a_reporter = true;
endpoint_addr.ip_addrs().next().map(|a| a.to_string())
} else {
// Network resolution: get reporter connections, resolve outside lock
// Network resolution: get reporter connections, resolve
// outside lock. The horizon here must match the horizon
// the CANDIDATE SCORER uses (2..=4, same as
// `ConnectionManager::resolve_address`) — when it did
// not, the growth loop scored N4 candidates it then had
// no reporter to ask about, and burned a
// `consecutive_failures` slot plus an UNREACHABLE_EXPIRY
// suppression on every one of them.
let reporters_and_conns = {
let storage = self.storage.get().await;
let n2 = storage.find_in_n2(&candidate_id).unwrap_or_default();
let n3 = storage.find_in_n3(&candidate_id).unwrap_or_default();
let mut reporter_set: std::collections::HashSet<NodeId> =
std::collections::HashSet::new();
for bounce in 2..=4u8 {
for r in storage.find_reporters_at(&candidate_id, bounce).unwrap_or_default() {
reporter_set.insert(r);
}
}
drop(storage);
let conn_map = self.conn_handle.get_connection_map().await;
let reporter_set: std::collections::HashSet<NodeId> = n2.into_iter().chain(n3).collect();
conn_map.into_iter()
.filter(|(nid, _, _, _)| reporter_set.contains(nid))
.map(|(_, conn, _, _)| conn)
.collect::<Vec<_>>()
};
asked_a_reporter = !reporters_and_conns.is_empty();
let mut resolved = None;
for conn in reporters_and_conns {
let result: anyhow::Result<Option<String>> = async {
@ -1553,10 +1597,19 @@ impl Network {
None => {
debug!(
peer = hex::encode(candidate_id),
"Growth loop: no address, marking unreachable"
asked_a_reporter,
"Growth loop: no address"
);
self.log_activity(ActivityLevel::Warn, ActivityCategory::Growth, format!("No address for {}", &hex::encode(candidate_id)[..8]), Some(candidate_id));
// Only a candidate we could actually ask about, and
// that came back with nothing, has earned an
// UNREACHABLE_EXPIRY suppression. Failing for want of a
// connected reporter says nothing about the candidate —
// suppressing it there would hide it even after a
// shallower reporter shows up.
if asked_a_reporter {
self.conn_handle.mark_unreachable(&candidate_id);
}
consecutive_failures += 1;
if consecutive_failures >= 3 {
debug!("Growth loop: 3 consecutive failures, backing off");
@ -1592,7 +1645,7 @@ impl Network {
consecutive_failures = 0;
// Broadcast diff so peers learn about our new connection
let _ = self.broadcast_diff().await;
let _ = self.broadcast_uniques().await;
// Brief pause to let InitialExchange update N2/N3 before next pick
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
@ -1650,7 +1703,7 @@ impl Network {
if introduced {
consecutive_failures = 0;
let _ = self.broadcast_diff().await;
let _ = self.broadcast_uniques().await;
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
} else {
self.conn_handle.mark_unreachable(&candidate_id);
@ -1667,76 +1720,6 @@ impl Network {
}
}
/// Pull posts from a peer (persistent if available, ephemeral otherwise).
pub async fn pull_from_peer(&self, peer_id: &NodeId) -> anyhow::Result<PullStats> {
let conn = self.get_connection(peer_id).await?;
let (our_follows, follows_sync, our_personas) = {
let storage = self.storage.get().await;
(
storage.list_follows()?,
storage.get_follows_with_last_sync().unwrap_or_default(),
storage.list_posting_identities().unwrap_or_default(),
)
};
// Merged pull: include every posting identity we hold so DMs addressed
// to any of our personas match on recipient. Our network NodeId is
// never an author nor a wrapped_key recipient — including it would
// never match and would leak the network↔posting boundary.
let mut query_list = our_follows;
for pi in &our_personas {
if !query_list.contains(&pi.node_id) {
query_list.push(pi.node_id);
}
}
let (mut send, mut recv) = conn.open_bi().await?;
write_typed_message(
&mut send,
MessageType::PullSyncRequest,
&PullSyncRequestPayload {
follows: query_list,
have_post_ids: vec![], // v4: empty, using since_ms instead
since_ms: follows_sync,
},
)
.await?;
send.finish()?;
let msg_type = read_message_type(&mut recv).await?;
if msg_type != MessageType::PullSyncResponse {
anyhow::bail!("expected PullSyncResponse, got {:?}", msg_type);
}
let response: PullSyncResponsePayload = read_payload(&mut recv, 64 * 1024 * 1024).await?;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let storage = self.storage.get().await;
let mut posts_received = 0;
let mut vis_updates = 0;
for sp in &response.posts {
if !storage.is_deleted(&sp.id)? && verify_post_id(&sp.id, &sp.post) {
if storage.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility)? {
posts_received += 1;
}
// Protocol v4: update last_sync_ms for the author
let _ = storage.update_follow_last_sync(&sp.post.author, now_ms);
}
}
for vu in response.visibility_updates {
if let Some(post) = storage.get_post(&vu.post_id)? {
if post.author == vu.author {
if storage.update_post_visibility(&vu.post_id, &vu.visibility)? {
vis_updates += 1;
}
}
}
}
Ok(PullStats {
peers_pulled: 1,
posts_received,
visibility_updates: vis_updates,
})
}
/// Send a uni-stream message. Uses persistent connection if available, ephemeral otherwise.
pub async fn send_to_peer_uni<T: Serialize>(
&self,
@ -1864,7 +1847,7 @@ impl Network {
if let Some(addr) = addr {
match tokio::time::timeout(
std::time::Duration::from_secs(5),
self.endpoint.connect(addr, ALPN_V2),
self.endpoint.connect(addr, ALPN),
).await {
Ok(Ok(conn)) => {
self.conn_handle.mark_reachable(peer_id);
@ -1984,41 +1967,146 @@ impl Network {
Some(addr)
}
// ---- Anchor referral delegation ----
// ---- Anchor convection delegation (design.html §anchors) ----
/// Request peer referrals from an anchor peer (mesh or session).
/// Does NOT hold conn_mgr lock during network I/O.
pub async fn request_anchor_referrals(
/// Ask an anchor for peers. The request itself enrols us in that anchor's
/// rolling window — there is no registration message; 0xC0 is retired.
///
/// Does NOT hold the conn_mgr lock during network I/O. A refusal comes back
/// as a single small message, so the `refused` case costs one round trip,
/// not a timeout.
pub async fn request_convection(
&self,
anchor: &NodeId,
) -> anyhow::Result<Vec<crate::protocol::AnchorReferral>> {
class: crate::protocol::ConvectionClass,
) -> anyhow::Result<crate::protocol::ConvectionResponsePayload> {
let conn = self.conn_handle.get_any_connection(anchor).await
.ok_or_else(|| anyhow::anyhow!("anchor peer not connected (mesh or session)"))?;
// Self-reported addresses, best first: the stable advertised address
// (if we are an anchor ourselves), then the UPnP external mapping, then
// whatever the endpoint knows. The anchor prefers what it OBSERVES over
// all of these; these only help when it cannot observe us.
let endpoint = self.conn_handle.endpoint().await;
let our_addrs: Vec<String> = endpoint.addr().ip_addrs()
let mut our_addrs: Vec<String> = endpoint.addr().ip_addrs()
.filter(|s| is_publicly_routable(s))
.map(|s| s.to_string())
.collect();
if let Some(ext) = self.conn_handle.upnp_external_addr().await {
let ext_str = ext.to_string();
if !our_addrs.contains(&ext_str) {
our_addrs.insert(0, ext_str);
}
}
if let Some(anchor_addr) = self.conn_handle.build_anchor_advertised_addr().await {
if !our_addrs.contains(&anchor_addr) {
our_addrs.insert(0, anchor_addr);
}
}
let request = crate::protocol::AnchorReferralRequestPayload {
let request = crate::protocol::ConvectionRequestPayload {
requester: self.our_node_id,
requester_addresses: our_addrs,
class,
};
let (mut send, mut recv) = conn.open_bi().await?;
crate::protocol::write_typed_message(&mut send, crate::protocol::MessageType::AnchorReferralRequest, &request).await?;
crate::protocol::write_typed_message(&mut send, crate::protocol::MessageType::ConvectionRequest, &request).await?;
send.finish()?;
let msg_type = crate::protocol::read_message_type(&mut recv).await?;
if msg_type != crate::protocol::MessageType::AnchorReferralResponse {
anyhow::bail!("expected AnchorReferralResponse, got {:?}", msg_type);
if msg_type != crate::protocol::MessageType::ConvectionResponse {
anyhow::bail!("expected ConvectionResponse, got {:?}", msg_type);
}
let response: crate::protocol::AnchorReferralResponsePayload = crate::protocol::read_payload(&mut recv, 4096).await?;
let response: crate::protocol::ConvectionResponsePayload =
crate::protocol::read_payload(&mut recv, 4096).await?;
// Touch session to prevent idle reaping
self.conn_handle.touch_session_if_exists(anchor);
Ok(response.referrals)
// Adaptive feedback (round 5): a refusal shifts weight toward mesh
// introductions and other anchors; a served request drifts it back.
if response.refused {
debug!(anchor = hex::encode(anchor), "Convection: refused (top-up, no capacity)");
self.conn_handle.record_convection_refusal(anchor);
} else {
self.conn_handle.record_convection_success(anchor);
}
// The NAT filter probe used to ride the register message. It has to
// ride something, and this is now the only message we send an anchor.
if self.conn_handle.nat_filtering().await == crate::types::NatFiltering::Unknown {
if let Err(e) = self.request_nat_filter_probe(anchor).await {
debug!(error = %e, "NAT filter probe request failed");
}
}
Ok(response)
}
/// Act on one convection response: connect to each referral, landing it in
/// whatever slot class is free (mesh first, then the temp referral band
/// above the cap). Returns how many connections were established.
///
/// `introduced` referrals skip the extra introduction round trip — the
/// anchor already pushed a RelayIntroduce toward them naming us, so they
/// are punching outward while we dial.
pub async fn act_on_convection(
&self,
anchor: &NodeId,
response: &crate::protocol::ConvectionResponsePayload,
) -> usize {
let mut connected = 0;
for referral in &response.referrals {
if referral.node_id == self.our_node_id {
continue;
}
if self.conn_handle.is_connected(&referral.node_id).await {
continue;
}
let mut ok = false;
// bugs-fixed #8 (receive side): a referral address is a third
// party's claim. Dialling `127.0.0.1:22` or `192.168.1.1:443`
// because an anchor said so is attacker-directed internal probing
// from our own host, and every such connect() enters iroh's
// per-endpoint path store permanently (v0.7.3 EDM lesson).
let usable: Option<&String> = referral.addresses.iter().find(|a| {
a.parse::<std::net::SocketAddr>()
.map(|sa| crate::connection::convection_addr_ok(&sa))
.unwrap_or(false)
});
if let Some(addr_str) = usable {
let connect_str = format!("{}@{}", hex::encode(referral.node_id), addr_str);
if let Ok((rid, raddr)) = crate::parse_connect_string(&connect_str) {
let fut = self.connect_to_peer(rid, raddr);
match tokio::time::timeout(std::time::Duration::from_secs(15), fut).await {
Ok(Ok(())) => {
info!(peer = hex::encode(rid), "Convection: connected to referred peer");
ok = true;
}
Ok(Err(e)) => debug!(error = %e, peer = hex::encode(rid), "Convection: direct connect failed"),
Err(_) => debug!(peer = hex::encode(rid), "Convection: direct connect timed out"),
}
}
}
if !ok && !referral.introduced {
// No coordinated introduction was possible — ask for one.
match self.connect_via_introduction_as_mesh(referral.node_id, *anchor).await {
Ok(()) => {
info!(peer = hex::encode(referral.node_id), "Convection: connected via introduction");
ok = true;
}
Err(e) => debug!(error = %e, peer = hex::encode(referral.node_id), "Convection: introduction failed"),
}
}
if ok {
connected += 1;
}
}
if connected > 0 {
self.notify_growth().await;
}
connected
}
/// Request NAT filter probe from an anchor (determines address-restricted vs port-restricted).
@ -2072,55 +2160,6 @@ impl Network {
Ok(())
}
/// Register our address with an anchor peer (mesh or session).
/// Also runs NAT filter probe if filtering is still Unknown.
/// Does NOT hold conn_mgr lock during network I/O.
pub async fn send_anchor_register(&self, anchor: &NodeId) -> anyhow::Result<()> {
let conn = self.conn_handle.get_any_connection(anchor).await
.ok_or_else(|| anyhow::anyhow!("anchor peer not connected (mesh or session)"))?;
let endpoint = self.conn_handle.endpoint().await;
let mut our_addrs: Vec<String> = endpoint.addr().ip_addrs()
.map(|s| s.to_string())
.collect();
// Prepend UPnP external address (most useful for remote peers)
if let Some(ext) = self.conn_handle.upnp_external_addr().await {
let ext_str = ext.to_string();
if !our_addrs.contains(&ext_str) {
our_addrs.insert(0, ext_str);
}
}
// Prepend stable anchor advertised address
if let Some(anchor_addr) = self.conn_handle.build_anchor_advertised_addr().await {
if !our_addrs.contains(&anchor_addr) {
our_addrs.insert(0, anchor_addr);
}
}
let payload = crate::protocol::AnchorRegisterPayload {
node_id: self.our_node_id,
addresses: our_addrs,
};
let mut send = conn.open_uni().await?;
crate::protocol::write_typed_message(&mut send, crate::protocol::MessageType::AnchorRegister, &payload).await?;
send.finish()?;
// Touch session to prevent idle reaping
self.conn_handle.touch_session_if_exists(anchor);
debug!(anchor = hex::encode(anchor), "Registered with anchor");
// Also run NAT filter probe if filtering is still Unknown
let needs_probe = self.conn_handle.nat_filtering().await == crate::types::NatFiltering::Unknown;
if needs_probe {
if let Err(e) = self.request_nat_filter_probe(anchor).await {
debug!(error = %e, "NAT filter probe request failed");
}
}
Ok(())
}
/// Check if a peer is a known anchor.
pub async fn is_anchor_peer(&self, node_id: &NodeId) -> bool {
let storage = self.storage.get().await;
@ -2291,6 +2330,22 @@ impl Network {
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
}
}
@ -2298,7 +2353,6 @@ impl Network {
pub struct PullStats {
pub peers_pulled: usize,
pub posts_received: usize,
pub visibility_updates: usize,
}
/// Decide whether a post should be sent to a requesting peer.

File diff suppressed because it is too large Load diff

View file

@ -68,7 +68,6 @@ pub fn apply_profile_post_if_applicable(
updated_at: content.timestamp_ms,
anchors: vec![],
recent_peers: vec![],
preferred_peers: vec![],
public_visible: true,
avatar_cid: content.avatar_cid,
};
@ -175,6 +174,9 @@ pub fn scan_vouch_grants_for_all_personas(
/// batch distributing the persona's current `V_me` to vouched personas.
/// `bio_epoch` is a monotonic per-persona counter that lets receivers
/// 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(
author: &NodeId,
author_secret: &[u8; 32],
@ -183,6 +185,7 @@ pub fn build_profile_post(
avatar_cid: Option<[u8; 32]>,
vouch_grants: Option<crate::types::VouchGrantBatch>,
bio_epoch: u32,
fof_gating: Option<crate::types::FoFCommentGating>,
) -> Post {
let timestamp_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -203,7 +206,7 @@ pub fn build_profile_post(
content: serde_json::to_string(&content).unwrap_or_default(),
attachments: vec![],
timestamp_ms,
fof_gating: None,
fof_gating,
supersedes_post_id: None,
}
}
@ -345,7 +348,7 @@ mod tests {
let s = temp_storage();
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();
let stored = s.get_profile(&pub_id).unwrap().expect("profile stored");
@ -360,7 +363,7 @@ mod tests {
let (sec_b, _pub_b) = make_keypair(2);
// 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));
assert!(res.is_err());
assert!(s.get_profile(&pub_a).unwrap().is_none());
@ -372,7 +375,7 @@ mod tests {
let (sec, pub_id) = make_keypair(3);
// 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.
let mut content: ProfilePostContent = serde_json::from_str(&newer.content).unwrap();
content.timestamp_ms = 10_000;
@ -382,7 +385,7 @@ mod tests {
apply_profile_post_if_applicable(&s, &newer, Some(&VisibilityIntent::Profile)).unwrap();
// 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();
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);

View file

@ -1,12 +1,15 @@
use serde::{Deserialize, Serialize};
use crate::types::{
BlobHeaderDiffOp, CdnManifest, DeleteRecord, GroupEpoch, GroupId, GroupMemberKey, NodeId,
PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, VisibilityUpdate, WormId,
BlobHeaderDiffOp, CdnManifest, DeleteRecord, GroupEpoch, GroupId, NodeId,
PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, WormId,
};
/// Single ALPN for Discovery Protocol v3 (N1/N2/N3 architecture)
pub const ALPN_V2: &[u8] = b"itsgoin/3";
/// Wire-protocol ALPN. One protocol version per build, no negotiation —
/// mismatched versions are refused at the QUIC handshake. v0.8 = itsgoin/4:
/// clean break from <=v0.7.x (zero-users ruling, design.html Appendix D
/// item 3).
pub const ALPN: &[u8] = b"itsgoin/4";
/// A post bundled with its visibility metadata for sync
#[derive(Debug, Serialize, Deserialize)]
@ -15,9 +18,8 @@ pub struct SyncPost {
pub post: Post,
pub visibility: PostVisibility,
/// Optional originator's intent, so receivers can filter control posts
/// out of the feed and process their ControlOp payload. Absent on
/// pre-v0.6.2 senders; receivers treat as "unknown"/regular post.
#[serde(default)]
/// out of the feed and process their ControlOp payload. None = regular
/// post.
pub intent: Option<crate::types::VisibilityIntent>,
}
@ -25,23 +27,29 @@ pub struct SyncPost {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum MessageType {
NodeListUpdate = 0x01,
UniquesAnnounce = 0x01,
InitialExchange = 0x02,
AddressRequest = 0x03,
AddressResponse = 0x04,
RefuseRedirect = 0x05,
PullSyncRequest = 0x40,
PullSyncResponse = 0x41,
// 0x42 (PostNotification), 0x43 (PostPush), 0x44 (AudienceRequest),
// 0x45 (AudienceResponse) retired in v0.6.2: persona-signed direct pushes
// are gone. Public posts propagate via the CDN; encrypted posts via pull.
/// The uniques-index exchange (design.html §sync). A pull is no longer a
/// post transfer: it is "if you want these IDs, talk to me and I'll help
/// you find them". Symmetric — request carries our pools, response carries
/// theirs, one round trip updates both sides.
UniquesPullRequest = 0x40,
UniquesPullResponse = 0x41,
/// TRANSITIONAL (Iteration D folds this into the update-cadence scheduler +
/// CDN replication). Content still has to move somewhere while
/// PostFetchRequest (0xD4) serves Public posts only: DMs, group posts,
/// Friends and FoFClosed posts have no other carrier. Splitting it off
/// 0x40/0x41 keeps §sync honest — a *pull* is not a post transfer, because
/// post transfer is not a pull.
ContentSyncRequest = 0x46,
ContentSyncResponse = 0x47,
ProfileUpdate = 0x50,
DeleteRecord = 0x51,
VisibilityUpdate = 0x52,
WormQuery = 0x60,
WormResponse = 0x61,
SocialAddressUpdate = 0x70,
SocialDisconnectNotice = 0x71,
SocialCheckin = 0x72,
// 0x80-0x81 reserved
BlobRequest = 0x90,
@ -49,19 +57,15 @@ pub enum MessageType {
ManifestRefreshRequest = 0x92,
ManifestRefreshResponse = 0x93,
ManifestPush = 0x94,
// 0x95 (BlobDeleteNotice) retired in v0.6.2 — remote holders evict via LRU.
// 0xA0 (GroupKeyDistribute) retired in v0.6.2 — group seeds now travel
// as encrypted posts via the CDN. See `group_key_distribution` module.
GroupKeyRequest = 0xA1,
GroupKeyResponse = 0xA2,
RelayIntroduce = 0xB0,
RelayIntroduceResult = 0xB1,
SessionRelay = 0xB2,
MeshPrefer = 0xB3,
CircleProfileUpdate = 0xB4,
AnchorRegister = 0xC0,
AnchorReferralRequest = 0xC1,
AnchorReferralResponse = 0xC2,
// 0xC0 retired in v0.8 — anchors keep a rolling window of recent callers
// fed by the request itself; there is no registration message and no
// registration database.
ConvectionRequest = 0xC1,
ConvectionResponse = 0xC2,
AnchorProbeRequest = 0xC3,
AnchorProbeResult = 0xC4,
PortScanHeartbeat = 0xC5,
@ -83,36 +87,31 @@ pub enum MessageType {
impl MessageType {
pub fn from_byte(b: u8) -> Option<Self> {
match b {
0x01 => Some(Self::NodeListUpdate),
0x01 => Some(Self::UniquesAnnounce),
0x02 => Some(Self::InitialExchange),
0x03 => Some(Self::AddressRequest),
0x04 => Some(Self::AddressResponse),
0x05 => Some(Self::RefuseRedirect),
0x40 => Some(Self::PullSyncRequest),
0x41 => Some(Self::PullSyncResponse),
0x40 => Some(Self::UniquesPullRequest),
0x41 => Some(Self::UniquesPullResponse),
0x46 => Some(Self::ContentSyncRequest),
0x47 => Some(Self::ContentSyncResponse),
0x50 => Some(Self::ProfileUpdate),
0x51 => Some(Self::DeleteRecord),
0x52 => Some(Self::VisibilityUpdate),
0x60 => Some(Self::WormQuery),
0x61 => Some(Self::WormResponse),
0x70 => Some(Self::SocialAddressUpdate),
0x71 => Some(Self::SocialDisconnectNotice),
0x72 => Some(Self::SocialCheckin),
0x90 => Some(Self::BlobRequest),
0x91 => Some(Self::BlobResponse),
0x92 => Some(Self::ManifestRefreshRequest),
0x93 => Some(Self::ManifestRefreshResponse),
0x94 => Some(Self::ManifestPush),
0xA1 => Some(Self::GroupKeyRequest),
0xA2 => Some(Self::GroupKeyResponse),
0xB0 => Some(Self::RelayIntroduce),
0xB1 => Some(Self::RelayIntroduceResult),
0xB2 => Some(Self::SessionRelay),
0xB3 => Some(Self::MeshPrefer),
0xB4 => Some(Self::CircleProfileUpdate),
0xC0 => Some(Self::AnchorRegister),
0xC1 => Some(Self::AnchorReferralRequest),
0xC2 => Some(Self::AnchorReferralResponse),
0xC1 => Some(Self::ConvectionRequest),
0xC2 => Some(Self::ConvectionResponse),
0xC3 => Some(Self::AnchorProbeRequest),
0xC4 => Some(Self::AnchorProbeResult),
0xC5 => Some(Self::PortScanHeartbeat),
@ -140,13 +139,152 @@ impl MessageType {
// --- Payload structs ---
/// Initial exchange: N1/N2 node lists + profile + deletes + post_ids + peer addresses
/// A packed set of 32-byte IDs: base64 of the raw concatenated bytes.
///
/// `NodeId = [u8; 32]` serialised through serde_json becomes a JSON array of 32
/// decimal numbers (~100-130 bytes/ID — a 3-4x blowup). The uniques pools can
/// carry thousands of IDs, so they ride packed instead.
pub type PackedIds = String;
/// Encode a set of IDs into a packed base64 blob.
pub fn pack_ids(ids: &[NodeId]) -> PackedIds {
use base64::Engine;
let mut raw = Vec::with_capacity(ids.len() * 32);
for id in ids {
raw.extend_from_slice(id);
}
base64::engine::general_purpose::STANDARD.encode(raw)
}
/// Decode a packed base64 blob back into IDs. Trailing partial IDs are dropped.
pub fn unpack_ids(packed: &str) -> Vec<NodeId> {
use base64::Engine;
let raw = match base64::engine::general_purpose::STANDARD.decode(packed) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
raw.chunks_exact(32)
.map(|c| {
let mut id = [0u8; 32];
id.copy_from_slice(c);
id
})
.collect()
}
/// How many 32-byte IDs a packed base64 blob holds, WITHOUT decoding it.
///
/// Standard base64 emits 4 chars per 3 input bytes (padded), so decoded length
/// is `chars / 4 * 3` minus padding; integer division by 32 absorbs the padding
/// slack for any non-empty blob. Exact for every blob `pack_ids` produces.
pub fn packed_id_count(packed: &str) -> usize {
(packed.len() / 4) * 3 / 32
}
/// The ONLY entry type in a uniques pool that carries an address.
///
/// design.html §layers: anchor entries carry addresses so the pools double as
/// the anchor directory; every other entry is a bare ID (there is no point
/// sharing an address that needs an introduction anyway). Addresses MUST be
/// filtered through `is_publicly_routable()` before they go on the wire.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnchorEntry {
/// hex node id (anchors are always node-class)
pub i: String,
/// e.g. ["1.2.3.4:4433"]
pub a: Vec<String>,
}
/// One slice of a uniques pool. Node-class and author-class IDs live in
/// separate arrays so a receiver structurally cannot mistake a persona ID for
/// a connectable node ID.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UniquesSlice {
/// Connectable network IDs, bare.
#[serde(default)]
pub nodes: PackedIds,
/// Posting/persona/throwaway IDs, bare. CDN-resolvable only — never a
/// connect target.
#[serde(default)]
pub authors: PackedIds,
/// Address-bearing anchor entries (node-class by definition).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub anchors: Vec<AnchorEntry>,
}
impl UniquesSlice {
pub fn is_empty(&self) -> bool {
self.nodes.is_empty() && self.authors.is_empty() && self.anchors.is_empty()
}
/// Number of IDs carried (for logging / budget checks).
///
/// `pack_ids` concatenates ALL IDs and base64-encodes the blob ONCE, so the
/// encoded length is `4 * ceil(32n/3)` characters — about 42.67 per ID, not
/// 44 (44 is the encoding of exactly ONE 32-byte ID). Counting `len()/44`
/// undercounts by ~3% at scale and by 33% for a 3-ID blob. Since this backs
/// budget checks, it is computed from the DECODED byte length instead.
pub fn len(&self) -> usize {
packed_id_count(&self.nodes) + packed_id_count(&self.authors) + self.anchors.len()
}
}
/// 0x01 — the two-pool uniques announce (design.html §layers).
///
/// POOL 1 (`fwd`) is FORWARDABLE and indexed by OUR bounce:
/// fwd[0] = N0 — ourselves (and our own anchor address, if we are an anchor)
/// fwd[1] = N1 — our own uniques: mesh peers, social directs, CDN file
/// authors, CDN file-holder peers, merged so the receiver
/// cannot tell which source an entry came from
/// fwd[2] = N2 — what our N1 reported to us
/// The receiver stores `fwd[i]` at bounce `i + 1` (their N1..N3), tagged to us
/// as reporter, and MAY re-announce it shifted one deeper.
///
/// POOL 2 (`term`) is TERMINAL: our N3, deduplicated against every fwd slice.
/// The receiver stores it as their N4 and USES it for search / address
/// resolution, but NEVER re-announces it. The structural separation — a
/// distinct field, not a flag in a merged list — is what makes "never
/// re-announce" un-forgettable at the announce builder: the builder simply
/// never reads bounce-4 rows. There is no N5 anywhere.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UniquesAnnouncePayload {
pub seq: u64,
/// Always true in v0.8 — the announce is a full snapshot that replaces this
/// reporter's rows. (Incremental diffing over four layers with anchor
/// metadata was not worth the divergence risk; the sender skips the send
/// entirely when nothing changed, which recovers most of the saving.)
pub full: bool,
/// Pool 1, forwardable. Always length 3 (bounces 0, 1, 2).
pub fwd: Vec<UniquesSlice>,
/// Pool 2, terminal. Empty when the recipient advertised `depth < 4`.
#[serde(default)]
pub term: UniquesSlice,
/// Deepest bounce the SENDER retains (4 desktop, 3 mobile). Lets the peer
/// skip building/sending the terminal pool for us — the single biggest
/// bandwidth saving on a cellular link.
pub depth: u8,
}
impl UniquesAnnouncePayload {
pub fn empty(seq: u64, depth: u8) -> Self {
Self {
seq,
full: true,
fwd: vec![UniquesSlice::default(), UniquesSlice::default(), UniquesSlice::default()],
term: UniquesSlice::default(),
depth,
}
}
}
/// Initial exchange: uniques announce + profile + deletes + post_ids + peer addresses
#[derive(Debug, Serialize, Deserialize)]
pub struct InitialExchangePayload {
/// Our connections + social contacts NodeIds (no addresses)
pub n1_node_ids: Vec<NodeId>,
/// Our deduplicated N2 NodeIds (no addresses)
pub n2_node_ids: Vec<NodeId>,
/// Our uniques pools (full snapshot). `None` means "this connection carries
/// no knowledge" — i.e. the sender placed us in a temporary referral slot.
/// Explicit on the wire rather than a pair of empty vectors so the receiver
/// can tell "nothing to share" from "not sharing".
#[serde(default)]
pub uniques: Option<UniquesAnnouncePayload>,
/// Our profile
pub profile: Option<PublicProfile>,
/// Our delete records
@ -154,68 +292,57 @@ pub struct InitialExchangePayload {
/// Our post IDs (for replica tracking)
pub post_ids: Vec<PostId>,
/// Our N+10:Addresses (connected peers with addresses) for social routing
#[serde(default)]
pub peer_addresses: Vec<PeerWithAddress>,
/// If sender is an anchor, their stable advertised address (e.g. "174.127.120.52:4433")
#[serde(default)]
pub anchor_addr: Option<String>,
/// What the sender sees as the receiver's address (STUN-like observed addr)
#[serde(default)]
pub your_observed_addr: Option<String>,
/// Sender's NAT type ("public", "easy", "hard", "unknown")
#[serde(default)]
pub nat_type: Option<String>,
/// Sender's NAT mapping behavior ("eim", "edm", "unknown")
#[serde(default)]
pub nat_mapping: Option<String>,
/// Sender's NAT filtering behavior ("open", "port_restricted", "unknown")
#[serde(default)]
pub nat_filtering: Option<String>,
/// Whether the sender is running an HTTP server for direct browser access
#[serde(default)]
pub http_capable: bool,
/// External HTTP address if known (e.g. "1.2.3.4:4433")
#[serde(default)]
pub http_addr: Option<String>,
/// CDN replication device role: "intermittent", "available", "persistent"
#[serde(default)]
pub device_role: Option<String>,
/// CDN cache pressure: 0-255 availability score (255 = lots of capacity)
#[serde(default)]
pub cache_pressure: Option<u8>,
/// Set by anchor when it detects this NodeId is already connected from a different address
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duplicate_active: Option<bool>,
}
/// Incremental N1/N2 changes
/// 0x40/0x41 — the uniques-index exchange, both directions.
///
/// The requester sends its pools; the responder answers with its own. One
/// round trip refreshes both indexes, which is what makes an index *exchange*
/// rather than a fetch. Content does not ride this path.
#[derive(Debug, Serialize, Deserialize)]
pub struct NodeListUpdatePayload {
pub seq: u64,
pub n1_added: Vec<NodeId>,
pub n1_removed: Vec<NodeId>,
pub n2_added: Vec<NodeId>,
pub n2_removed: Vec<NodeId>,
pub struct UniquesPullPayload {
/// The sender's two pools. `None` = "I carry no knowledge on this link"
/// (temp referral slot), mirroring `InitialExchangePayload.uniques`.
#[serde(default)]
pub uniques: Option<UniquesAnnouncePayload>,
}
/// Pull-based post sync request
/// 0x46 — TRANSITIONAL content sync request. Identical in shape to the v0.7
/// pull request; only the opcode moved. See `MessageType::ContentSyncRequest`.
#[derive(Debug, Serialize, Deserialize)]
pub struct PullSyncRequestPayload {
pub struct ContentSyncRequestPayload {
/// Our follows (for the responder to filter)
pub follows: Vec<NodeId>,
/// Post IDs we already have (backward compat — empty for v4 senders)
#[serde(default)]
pub have_post_ids: Vec<PostId>,
/// Protocol v4: per-author timestamps (Vec of tuples for serde compat)
#[serde(default)]
/// Per-author last-sync timestamps (Vec of tuples for serde compat)
pub since_ms: Vec<(NodeId, u64)>,
}
/// Pull-based post sync response
/// 0x47 — TRANSITIONAL content sync response.
#[derive(Debug, Serialize, Deserialize)]
pub struct PullSyncResponsePayload {
pub struct ContentSyncResponsePayload {
pub posts: Vec<SyncPost>,
pub visibility_updates: Vec<VisibilityUpdate>,
}
/// Profile update (pushed via uni-stream)
@ -224,18 +351,6 @@ pub struct ProfileUpdatePayload {
pub profiles: Vec<PublicProfile>,
}
/// Delete record (pushed via uni-stream)
#[derive(Debug, Serialize, Deserialize)]
pub struct DeleteRecordPayload {
pub records: Vec<DeleteRecord>,
}
/// Visibility update (pushed via uni-stream)
#[derive(Debug, Serialize, Deserialize)]
pub struct VisibilityUpdatePayload {
pub updates: Vec<VisibilityUpdate>,
}
/// Address resolution request (bi-stream: ask reporter for a hop-2 peer's address)
#[derive(Debug, Serialize, Deserialize)]
pub struct AddressRequestPayload {
@ -248,10 +363,8 @@ pub struct AddressResponsePayload {
pub target: NodeId,
pub address: Option<String>,
/// Set when the target is known-disconnected (requester registered as watcher)
#[serde(default)]
pub disconnected_at: Option<u64>,
/// Target's N+10:Addresses if known
#[serde(default)]
pub peer_addresses: Vec<PeerWithAddress>,
}
@ -268,15 +381,12 @@ pub struct WormQueryPayload {
pub worm_id: WormId,
pub target: NodeId,
/// Additional IDs to search for (up to 10 recent_peers of target)
#[serde(default)]
pub needle_peers: Vec<NodeId>,
pub ttl: u8,
pub visited: Vec<NodeId>,
/// Optional: also search for a specific post by ID
#[serde(default)]
pub post_id: Option<PostId>,
/// Optional: also search for a specific blob by CID
#[serde(default)]
pub blob_id: Option<[u8; 32]>,
}
@ -286,19 +396,15 @@ pub struct WormResponsePayload {
pub worm_id: WormId,
pub found: bool,
/// Which needle was actually found (target or one of its recent_peers)
#[serde(default)]
pub found_id: Option<NodeId>,
pub addresses: Vec<String>,
pub reporter: Option<NodeId>,
pub hop: Option<u8>,
/// One random wide-peer referral: (node_id, address) for bloom round
#[serde(default)]
pub wide_referral: Option<(NodeId, String)>,
/// Node that holds the requested post (may differ from found_id)
#[serde(default)]
pub post_holder: Option<NodeId>,
/// Node that holds the requested blob
#[serde(default)]
pub blob_holder: Option<NodeId>,
}
@ -312,12 +418,6 @@ pub struct SocialAddressUpdatePayload {
pub peer_addresses: Vec<PeerWithAddress>,
}
/// Disconnect notice: "peer X disconnected"
#[derive(Debug, Serialize, Deserialize)]
pub struct SocialDisconnectNoticePayload {
pub node_id: NodeId,
}
/// Lightweight keepalive checkin (bidirectional)
#[derive(Debug, Serialize, Deserialize)]
pub struct SocialCheckinPayload {
@ -333,7 +433,6 @@ pub struct SocialCheckinPayload {
pub struct BlobRequestPayload {
pub cid: [u8; 32],
/// Requester's addresses so the host can record downstream
#[serde(default)]
pub requester_addresses: Vec<String>,
}
@ -343,16 +442,12 @@ pub struct BlobResponsePayload {
pub cid: [u8; 32],
pub found: bool,
/// Base64-encoded blob bytes (empty if not found)
#[serde(default)]
pub data_b64: String,
/// Author manifest + host info (if available)
#[serde(default)]
pub manifest: Option<CdnManifest>,
/// Whether host accepted requester as downstream
#[serde(default)]
pub cdn_registered: bool,
/// If not registered (host full), try these peers
#[serde(default)]
pub cdn_redirect_peers: Vec<PeerWithAddress>,
}
@ -390,23 +485,6 @@ pub struct ManifestPushEntry {
// encrypted posts (`VisibilityIntent::GroupKeyDistribute`). See
// `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 identifier for deduplication
@ -453,18 +531,6 @@ pub struct SessionRelayPayload {
pub target: NodeId,
}
/// Mesh preference negotiation (bi-stream: request + response)
#[derive(Debug, Serialize, Deserialize)]
pub struct MeshPreferPayload {
/// true = "I want us to be preferred peers" (request)
pub requesting: bool,
/// true = "I agree to be preferred peers" (response only)
pub accepted: bool,
/// Reason for rejection (response only, when accepted=false)
#[serde(default)]
pub reject_reason: Option<String>,
}
/// Circle profile update: encrypted profile variant for a circle (uni-stream push)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CircleProfileUpdatePayload {
@ -479,33 +545,63 @@ pub struct CircleProfileUpdatePayload {
pub updated_at: u64,
}
// --- Anchor referral payloads ---
// --- Anchor convection payloads (design.html §anchors) ---
/// Node registers its address with an anchor (uni-stream)
#[derive(Debug, Serialize, Deserialize)]
pub struct AnchorRegisterPayload {
pub node_id: NodeId,
pub addresses: Vec<String>,
/// The one request-class bit (round-5 ruling).
///
/// ENTRY = bootstrap or recovery, i.e. the caller has fewer than 2 mesh
/// connections. Always served — a node that cannot get in cannot come back.
/// TOP_UP = growth with a working mesh. Served capacity-permitting and refused
/// CHEAPLY (a single response message, no timeout burned). Top-ups are the
/// convection medium — they are what keeps referral windows fresh — so they
/// are not throttled aggressively; the refusal doubles as the adaptive load
/// signal the caller feeds back into its action weights.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConvectionClass {
Entry,
TopUp,
}
/// Node requests peer referrals from an anchor (bi-stream request)
impl ConvectionClass {
/// Entry class is decided purely by "can this node function right now".
pub fn for_mesh_count(mesh_count: usize) -> Self {
if mesh_count < 2 { Self::Entry } else { Self::TopUp }
}
pub fn is_entry(self) -> bool {
matches!(self, Self::Entry)
}
}
/// 0xC1 — "I'm joining / I need peers". The request itself is what enrols the
/// caller in the anchor's rolling window; there is no separate registration.
#[derive(Debug, Serialize, Deserialize)]
pub struct AnchorReferralRequestPayload {
pub struct ConvectionRequestPayload {
pub requester: NodeId,
pub requester_addresses: Vec<String>,
pub class: ConvectionClass,
}
/// Anchor responds with peer referrals (bi-stream response)
/// 0xC2 — up to 2 recent prior callers, or a cheap refusal.
#[derive(Debug, Serialize, Deserialize)]
pub struct AnchorReferralResponsePayload {
pub referrals: Vec<AnchorReferral>,
pub struct ConvectionResponsePayload {
pub referrals: Vec<ConvectionReferral>,
/// Cheap refusal of a TOP_UP request: one message, immediately, so the
/// caller never burns a timeout. Never set for ENTRY-class requests.
#[serde(default)]
pub refused: bool,
}
/// A single peer referral from an anchor
/// A single referral out of the anchor's rolling caller window.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnchorReferral {
pub struct ConvectionReferral {
pub node_id: NodeId,
pub addresses: Vec<String>,
/// The anchor had BOTH ends live and has pushed a RelayIntroduce toward
/// this peer naming the caller as requester — so the peer is already
/// punching outward. Dial straight through instead of paying for a second
/// introduction round trip.
#[serde(default)]
pub introduced: bool,
}
// --- Anchor probe payloads ---
@ -533,7 +629,9 @@ pub struct AnchorProbeResultPayload {
pub observed_addr: Option<String>,
}
/// Port scan heartbeat during scanning hole punch (informational)
/// Port scan heartbeat during scanning hole punch (informational).
/// Reserved wire surface for the EDM scanner (`edm_port_scan_disabled_v0_7_3`
/// in connection.rs) — no sender/handler until the raw-UDP scan refactor.
#[derive(Debug, Serialize, Deserialize)]
pub struct PortScanHeartbeatPayload {
pub peer: NodeId,
@ -584,7 +682,6 @@ pub struct BlobHeaderResponsePayload {
/// True if the sender has a newer header than requested
pub updated: bool,
/// JSON-serialized BlobHeader (if updated)
#[serde(default)]
pub header_json: Option<String>,
}
@ -725,36 +822,31 @@ mod tests {
#[test]
fn message_type_roundtrip() {
let types = [
MessageType::NodeListUpdate,
MessageType::UniquesAnnounce,
MessageType::InitialExchange,
MessageType::AddressRequest,
MessageType::AddressResponse,
MessageType::RefuseRedirect,
MessageType::PullSyncRequest,
MessageType::PullSyncResponse,
MessageType::UniquesPullRequest,
MessageType::UniquesPullResponse,
MessageType::ContentSyncRequest,
MessageType::ContentSyncResponse,
MessageType::ProfileUpdate,
MessageType::DeleteRecord,
MessageType::VisibilityUpdate,
MessageType::WormQuery,
MessageType::WormResponse,
MessageType::SocialAddressUpdate,
MessageType::SocialDisconnectNotice,
MessageType::SocialCheckin,
MessageType::BlobRequest,
MessageType::BlobResponse,
MessageType::ManifestRefreshRequest,
MessageType::ManifestRefreshResponse,
MessageType::ManifestPush,
MessageType::GroupKeyRequest,
MessageType::GroupKeyResponse,
MessageType::RelayIntroduce,
MessageType::RelayIntroduceResult,
MessageType::SessionRelay,
MessageType::MeshPrefer,
MessageType::CircleProfileUpdate,
MessageType::AnchorRegister,
MessageType::AnchorReferralRequest,
MessageType::AnchorReferralResponse,
MessageType::ConvectionRequest,
MessageType::ConvectionResponse,
MessageType::AnchorProbeRequest,
MessageType::AnchorProbeResult,
MessageType::PortScanHeartbeat,
@ -841,43 +933,6 @@ mod tests {
assert_eq!(decoded2.reject_reason.unwrap(), "target not reachable");
}
#[test]
fn mesh_prefer_payload_roundtrip() {
// Request
let request = MeshPreferPayload {
requesting: true,
accepted: false,
reject_reason: None,
};
let json = serde_json::to_string(&request).unwrap();
let decoded: MeshPreferPayload = serde_json::from_str(&json).unwrap();
assert!(decoded.requesting);
assert!(!decoded.accepted);
assert!(decoded.reject_reason.is_none());
// Accepted response
let accept = MeshPreferPayload {
requesting: false,
accepted: true,
reject_reason: None,
};
let json2 = serde_json::to_string(&accept).unwrap();
let decoded2: MeshPreferPayload = serde_json::from_str(&json2).unwrap();
assert!(!decoded2.requesting);
assert!(decoded2.accepted);
// Rejected response
let reject = MeshPreferPayload {
requesting: false,
accepted: false,
reject_reason: Some("slots full".to_string()),
};
let json3 = serde_json::to_string(&reject).unwrap();
let decoded3: MeshPreferPayload = serde_json::from_str(&json3).unwrap();
assert!(!decoded3.accepted);
assert_eq!(decoded3.reject_reason.unwrap(), "slots full");
}
#[test]
fn session_relay_payload_roundtrip() {
let payload = SessionRelayPayload {
@ -913,56 +968,105 @@ mod tests {
}
#[test]
fn anchor_register_payload_roundtrip() {
let payload = AnchorRegisterPayload {
node_id: [1u8; 32],
addresses: vec!["192.168.1.5:4433".to_string(), "10.0.0.1:4433".to_string()],
};
let json = serde_json::to_string(&payload).unwrap();
let decoded: AnchorRegisterPayload = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.node_id, [1u8; 32]);
assert_eq!(decoded.addresses.len(), 2);
assert_eq!(decoded.addresses[0], "192.168.1.5:4433");
}
#[test]
fn anchor_referral_request_payload_roundtrip() {
let payload = AnchorReferralRequestPayload {
fn convection_request_carries_the_class_bit() {
for class in [ConvectionClass::Entry, ConvectionClass::TopUp] {
let payload = ConvectionRequestPayload {
requester: [2u8; 32],
requester_addresses: vec!["10.0.0.2:4433".to_string()],
class,
};
let json = serde_json::to_string(&payload).unwrap();
let decoded: AnchorReferralRequestPayload = serde_json::from_str(&json).unwrap();
let decoded: ConvectionRequestPayload = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.requester, [2u8; 32]);
assert_eq!(decoded.requester_addresses, vec!["10.0.0.2:4433"]);
assert_eq!(decoded.class, class);
}
// The class is derived purely from "can this node function right now".
assert!(ConvectionClass::for_mesh_count(0).is_entry());
assert!(ConvectionClass::for_mesh_count(1).is_entry());
assert!(!ConvectionClass::for_mesh_count(2).is_entry());
assert!(!ConvectionClass::for_mesh_count(20).is_entry());
}
#[test]
fn anchor_referral_response_payload_roundtrip() {
let payload = AnchorReferralResponsePayload {
fn convection_response_roundtrip_including_cheap_refusal() {
let payload = ConvectionResponsePayload {
referrals: vec![
AnchorReferral {
ConvectionReferral {
node_id: [3u8; 32],
addresses: vec!["10.0.0.3:4433".to_string()],
introduced: false,
},
AnchorReferral {
ConvectionReferral {
node_id: [4u8; 32],
addresses: vec!["10.0.0.4:4433".to_string(), "192.168.1.4:4433".to_string()],
addresses: vec!["10.0.0.4:4433".to_string()],
introduced: true,
},
],
refused: false,
};
let json = serde_json::to_string(&payload).unwrap();
let decoded: AnchorReferralResponsePayload = serde_json::from_str(&json).unwrap();
let decoded: ConvectionResponsePayload = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.referrals.len(), 2);
assert_eq!(decoded.referrals[0].node_id, [3u8; 32]);
assert_eq!(decoded.referrals[0].addresses, vec!["10.0.0.3:4433"]);
assert_eq!(decoded.referrals[1].node_id, [4u8; 32]);
assert_eq!(decoded.referrals[1].addresses.len(), 2);
assert!(!decoded.referrals[0].introduced);
assert!(decoded.referrals[1].introduced);
assert!(!decoded.refused);
// Empty referrals
let empty = AnchorReferralResponsePayload { referrals: vec![] };
let json2 = serde_json::to_string(&empty).unwrap();
let decoded2: AnchorReferralResponsePayload = serde_json::from_str(&json2).unwrap();
// A refusal is one small message with no referrals — the caller reads
// it immediately instead of burning a timeout.
let refusal = ConvectionResponsePayload { referrals: vec![], refused: true };
let json2 = serde_json::to_string(&refusal).unwrap();
assert!(json2.len() < 64, "refusal must stay tiny: {}", json2);
let decoded2: ConvectionResponsePayload = serde_json::from_str(&json2).unwrap();
assert!(decoded2.refused);
assert!(decoded2.referrals.is_empty());
}
#[test]
fn uniques_pull_is_an_index_exchange_not_a_post_transfer() {
let payload = UniquesPullPayload {
uniques: Some(UniquesAnnouncePayload::empty(7, 4)),
};
let json = serde_json::to_string(&payload).unwrap();
let decoded: UniquesPullPayload = serde_json::from_str(&json).unwrap();
let u = decoded.uniques.unwrap();
assert_eq!(u.seq, 7);
assert_eq!(u.depth, 4);
assert_eq!(u.fwd.len(), 3);
// No post field exists on this payload at all — content rides 0x46/0x47.
assert!(!json.contains("posts"));
// A temp referral slot carries no knowledge in either direction.
let none = UniquesPullPayload { uniques: None };
let json2 = serde_json::to_string(&none).unwrap();
let decoded2: UniquesPullPayload = serde_json::from_str(&json2).unwrap();
assert!(decoded2.uniques.is_none());
}
/// `len()` backs budget checks, so it must be EXACT — not the old
/// `bytes / 44` approximation, which assumed one base64 encoding per ID
/// when `pack_ids` encodes the whole concatenated blob once.
#[test]
fn uniques_slice_len_counts_packed_ids_exactly() {
for n in [0usize, 1, 2, 3, 7, 100, 1000] {
let ids: Vec<crate::types::NodeId> = (0..n)
.map(|i| {
let mut id = [0u8; 32];
id[0] = (i % 251) as u8;
id[1] = (i / 251) as u8;
id
})
.collect();
let packed = pack_ids(&ids);
assert_eq!(packed_id_count(&packed), n, "packed_id_count for n={}", n);
assert_eq!(unpack_ids(&packed).len(), n);
let slice = UniquesSlice {
nodes: packed.clone(),
authors: packed.clone(),
anchors: vec![AnchorEntry { i: "aa".into(), a: vec!["1.2.3.4:1".into()] }],
};
assert_eq!(slice.len(), n * 2 + 1, "UniquesSlice::len for n={}", n);
}
}
}

474
crates/core/src/registry.rs Normal file
View 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(&REGISTRY_POST_ID, &post));
storage.store_post_with_intent(
&REGISTRY_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(&REGISTRY_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(),
&REGISTRY_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(&REGISTRY_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(&REGISTRY_POST_ID, &author, t0).unwrap());
s.store_comment(&reg_comment(author, t0, "Old", &["a"])).unwrap();
// Newer entry at t0+1000: accepted, older row hard-deleted.
assert!(s.upsert_registry_entry_newest_wins(&REGISTRY_POST_ID, &author, t0 + 1000).unwrap());
s.store_comment(&reg_comment(author, t0 + 1000, "New", &["b"])).unwrap();
let comments = s.get_comments(&REGISTRY_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(&REGISTRY_POST_ID, &author, t0 + 500).unwrap());
assert_eq!(s.get_comments(&REGISTRY_POST_ID).unwrap().len(), 1);
// Same-timestamp re-ingest: idempotent accept.
assert!(s.upsert_registry_entry_newest_wins(&REGISTRY_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(&reg_comment(alice, t0, "Alice", &["rust", "p2p"])).unwrap();
s.store_comment(&reg_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");
}
}

View 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}}}

File diff suppressed because it is too large Load diff

View file

@ -68,7 +68,22 @@ pub struct Attachment {
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);
/// 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)]
pub struct PublicProfile {
pub node_id: NodeId,
@ -84,9 +99,6 @@ pub struct PublicProfile {
/// Up to 10 currently-connected peer NodeIds (for 11-needle worm search)
#[serde(default)]
pub recent_peers: Vec<NodeId>,
/// Bilateral preferred peer NodeIds (stable relay hubs)
#[serde(default)]
pub preferred_peers: Vec<NodeId>,
/// Whether display_name/bio are visible to non-circle peers
#[serde(default = "default_true")]
pub public_visible: bool,
@ -98,7 +110,7 @@ pub struct PublicProfile {
impl PublicProfile {
/// Return a copy with persona-level display data (display_name, bio,
/// avatar_cid) stripped, leaving only the routing metadata (anchors,
/// recent_peers, preferred_peers). v0.6.1 broadcasts the profile under
/// recent_peers). v0.6.1 broadcasts the profile under
/// the network NodeId; attaching a human-readable name to that key would
/// correlate the network endpoint to a specific person. Persona display
/// data will travel via signed posts from v0.6.2 onward.
@ -450,14 +462,6 @@ pub struct DeleteRecord {
pub signature: Vec<u8>,
}
/// An update to a post's visibility (new wrapped keys after revocation)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisibilityUpdate {
pub post_id: PostId,
pub author: NodeId,
pub visibility: PostVisibility,
}
/// How to handle revoking a recipient's access to past encrypted posts
#[derive(Debug, Clone, Copy)]
pub enum RevocationMode {
@ -645,33 +649,67 @@ impl NatProfile {
}
/// Device profile — determines connection slot budget
///
/// v0.8 (Iteration C): the Local/Wide split is gone. There is ONE mesh pool
/// (`mesh_slots`) plus a strictly-additional band of temporary referral slots
/// (`temp_referral_slots`) that live ABOVE the mesh cap and carry no knowledge
/// exchange. See design.html §connections.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceProfile {
/// Desktop: 81 local + 20 wide = 101 mesh connections
/// Desktop: 20 mesh + up to 10 temp referral
Desktop,
/// Mobile: 10 local + 5 wide = 15 mesh connections
/// Mobile: 15 mesh + up to 4 temp referral
Mobile,
}
impl DeviceProfile {
pub fn preferred_slots(&self) -> usize {
match self {
DeviceProfile::Desktop => 10,
DeviceProfile::Mobile => 3,
/// The single mesh pool size (v0.8 narrow mesh, deep knowledge).
///
/// `ITSGOIN_TEST_MESH_SLOTS` overrides it so integration tests can reach
/// the temp-referral boundary with a handful of nodes instead of 21. Test
/// gate only — same pattern as `ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS`.
///
/// Read ONCE into a `OnceLock`: this is called from `ConnectionManager`
/// construction and from every path that wants a fresh cap, and an env
/// lookup per call is a syscall-shaped cost on a hot path (and lets the
/// mesh cap change under a running node, which nothing expects).
pub fn mesh_slots(&self) -> usize {
static OVERRIDE: std::sync::OnceLock<Option<usize>> = std::sync::OnceLock::new();
let over = *OVERRIDE.get_or_init(|| {
std::env::var("ITSGOIN_TEST_MESH_SLOTS")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|n| *n > 0)
});
if let Some(n) = over {
return n;
}
}
pub fn local_slots(&self) -> usize {
match self {
DeviceProfile::Desktop => 71,
DeviceProfile::Mobile => 7,
}
}
pub fn wide_slots(&self) -> usize {
match self {
DeviceProfile::Desktop => 20,
DeviceProfile::Mobile => 5,
DeviceProfile::Mobile => 15,
}
}
/// Temporary referral slots, allocated ABOVE `mesh_slots`. These exist only
/// to facilitate introductions: they carry no uniques exchange, are never
/// persisted to `mesh_peers`, and either graduate into a freed mesh slot or
/// expire. Ruling: +4..+10 above the cap.
pub fn temp_referral_slots(&self) -> usize {
match self {
DeviceProfile::Desktop => 10,
DeviceProfile::Mobile => 4,
}
}
/// Deepest bounce this device stores in the `reachable` index.
/// Desktop keeps the full 4-bounce horizon; mobile caps at 3 (it drops the
/// terminal pool on receipt and advertises `depth = 3` so senders skip
/// building it). Alternative considered and NOT implemented: Bloom-compress
/// N4 on mobile (see `apply_uniques_announce` doc comment).
pub fn max_bounce(&self) -> u8 {
match self {
DeviceProfile::Desktop => 4,
DeviceProfile::Mobile => 3,
}
}
@ -711,37 +749,101 @@ impl std::fmt::Display for SessionReachMethod {
}
}
/// Slot kind for mesh connections
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PeerSlotKind {
/// Bilateral preferred connections (Desktop: 10, Mobile: 3)
Preferred,
/// Diverse local connections (Desktop: 71, Mobile: 7)
Local,
/// Bloom-sourced random distant connections (20 slots)
Wide,
/// Which class of slot a live connection occupies.
///
/// v0.8: `Mesh` is the real pool (capped at `DeviceProfile::mesh_slots`) and is
/// the ONLY class that participates in the uniques announce. `TempReferral`
/// slots sit above the cap, exist purely to broker introductions, and are never
/// written to `mesh_peers` — which is what structurally keeps them out of our
/// own announcements. They graduate into a freed mesh slot or expire.
///
/// NOT on the wire — purely local accounting.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MeshSlot {
Mesh,
TempReferral { expires_at_ms: u64 },
}
impl std::fmt::Display for PeerSlotKind {
impl MeshSlot {
pub fn is_mesh(&self) -> bool {
matches!(self, MeshSlot::Mesh)
}
pub fn is_temp(&self) -> bool {
!self.is_mesh()
}
}
impl std::fmt::Display for MeshSlot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PeerSlotKind::Preferred => write!(f, "preferred"),
PeerSlotKind::Local => write!(f, "local"),
PeerSlotKind::Wide => write!(f, "wide"),
MeshSlot::Mesh => write!(f, "mesh"),
MeshSlot::TempReferral { .. } => write!(f, "temp"),
}
}
}
impl std::str::FromStr for PeerSlotKind {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"preferred" => Ok(PeerSlotKind::Preferred),
"local" | "social" => Ok(PeerSlotKind::Local),
"wide" => Ok(PeerSlotKind::Wide),
_ => Err(anyhow::anyhow!("unknown slot kind: {}", s)),
/// Class of an ID carried in a uniques pool.
///
/// A `Node` ID is a network identity we may connect to / hole-punch / relay to.
/// An `Author` ID is a posting identity (persona, throwaway, greeting ID). It
/// has NO address and NO device linkage (Iteration A stripped
/// `AuthorManifest.author_addresses`), so it must NEVER enter the address
/// resolution cascade or the growth-candidate scorer — it resolves only through
/// CDN holder search. The pools keep the two classes in separate arrays so a
/// receiver structurally cannot confuse them.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IdClass {
Node = 0,
Author = 1,
}
impl IdClass {
pub fn as_i64(self) -> i64 {
self as i64
}
pub fn from_i64(v: i64) -> Self {
if v == 1 { IdClass::Author } else { IdClass::Node }
}
}
/// One entry in the uniques index / announce pools.
///
/// `addresses` is non-empty ONLY when `is_anchor` — that is the §layers privacy
/// invariant, enforced at the store (`add_reach` blanks the column otherwise)
/// and at the wire builder (only `AnchorEntry` has an address field at all).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReachEntry {
pub id: NodeId,
pub id_class: IdClass,
pub is_anchor: bool,
pub addresses: Vec<String>,
}
impl ReachEntry {
/// A bare node-class entry (no address).
pub fn node(id: NodeId) -> Self {
Self { id, id_class: IdClass::Node, is_anchor: false, addresses: Vec::new() }
}
/// A bare author-class entry (no address, never a connect target).
pub fn author(id: NodeId) -> Self {
Self { id, id_class: IdClass::Author, is_anchor: false, addresses: Vec::new() }
}
/// An anchor entry — the only kind that carries an address.
pub fn anchor(id: NodeId, addresses: Vec<String>) -> Self {
Self { id, id_class: IdClass::Node, is_anchor: true, addresses }
}
}
/// The two announce pools, pre-wire.
///
/// `fwd` is always length 3 (our N0, N1, N2) and is FORWARDABLE — the receiver
/// stores `fwd[i]` at bounce `i + 1` and may re-announce it shifted.
/// `term` is our N3 and is TERMINAL — the receiver stores it at bounce 4, uses
/// it, and never re-announces it.
#[derive(Debug, Clone, Default)]
pub struct UniquesPools {
pub fwd: Vec<Vec<ReachEntry>>,
pub term: Vec<ReachEntry>,
}
// --- Social Routing Cache ---
@ -856,8 +958,6 @@ pub struct AuthorManifest {
/// The post this manifest is anchored to
pub post_id: PostId,
pub author: NodeId,
/// Author's N+10:A (ip:port strings)
pub author_addresses: Vec<String>,
/// Original post creation time (ms)
pub created_at: u64,
/// When manifest was last updated (ms)
@ -870,20 +970,16 @@ pub struct AuthorManifest {
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)]
pub struct CdnManifest {
pub author_manifest: AuthorManifest,
/// Serving host's NodeId
/// Serving host's NodeId (the QUIC-authenticated device serving the blob)
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
@ -897,8 +993,6 @@ pub struct SocialRouteEntry {
pub last_connected_ms: u64,
pub last_seen_ms: u64,
pub reach_method: ReachMethod,
/// 2-layer preferred peer tree (~100 nodes) for fast relay candidate search
pub preferred_tree: Vec<NodeId>,
}
// --- Engagement System ---
@ -978,6 +1072,38 @@ pub struct InlineComment {
/// Non-FoF observers see only ciphertext + sigs — body is opaque.
#[serde(default, skip_serializing_if = "Option::is_none")]
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 89): `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
@ -1076,6 +1202,34 @@ pub struct RevocationEntry {
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-comment-policy post. Carries the wrap slots + the matching
/// pub_post_set + the slot_binder_nonce. The `revocation_list` is
@ -1097,6 +1251,10 @@ pub struct FoFCommentGating {
/// arrive; the on-wire t=0 snapshot is empty.
#[serde(default)]
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
@ -1128,7 +1286,19 @@ pub enum BlobHeaderDiffOp {
RemoveReaction { reactor: NodeId, emoji: String, post_id: PostId },
AddComment(InlineComment),
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),
ThreadSplit { new_post_id: PostId },
/// Write an encrypted receipt slot (64 bytes encrypted data)

View file

@ -69,7 +69,7 @@
use std::net::SocketAddr;
use std::num::NonZeroU16;
use std::time::Duration;
use tracing::{debug, info, warn};
use tracing::{debug, info};
/// An active port mapping. While this value is held, the underlying
/// `portmapper::Client` keeps the lease alive in a background task.

View file

@ -3,7 +3,8 @@
//! renders HTML, serves blobs. No permanent storage of fetched content.
//!
//! Routes (behind Apache reverse proxy):
//! GET /p/<postid_hex>/<author_hex> → render post HTML (fetched on-demand)
//! GET /p/<postid_hex> → render post HTML (fetched on-demand; anchor
//! resolves holders itself)
//! GET /b/<blobid_hex> → serve blob (images/videos)
use std::net::SocketAddr;
@ -89,7 +90,7 @@ fn extract_header<'a>(buf: &'a [u8], name: &str) -> Option<&'a str> {
None
}
/// Handle GET /p/<postid_hex>/<author_hex>
/// Handle GET /p/<postid_hex>
///
/// Three-tier serving:
/// 1. Redirect to a CDN holder with a public/punchable HTTP server
@ -112,26 +113,11 @@ async fn serve_post(stream: &mut TcpStream, path: &str, node: &Arc<Node>, browse
_ => return,
};
// Parse optional author_id (after the slash)
let author_id: Option<NodeId> = if rest.len() > 65 {
let author_hex = &rest[65..];
if author_hex.len() == 64 && author_hex.chars().all(|c| c.is_ascii_hexdigit()) {
hex::decode(author_hex).ok().and_then(|b| b.try_into().ok())
} else {
None
}
} else {
None
};
// Single lock: gather holders, local post, AND author name if local
let (holders, local_post, local_author_name) = {
let store = node.storage.get().await;
let mut holders = Vec::new();
if let Some(author) = author_id {
holders.push(author);
}
if let Ok(file_holders) = store.get_file_holders(&post_id) {
for (peer, _addrs) in file_holders {
if !holders.contains(&peer) {
@ -177,8 +163,9 @@ async fn serve_post(stream: &mut TcpStream, path: &str, node: &Arc<Node>, browse
}
}
// Fetch via content search + PostFetch
let author = author_id.unwrap_or([0u8; 32]);
// Fetch via content search + PostFetch (no author hint — holders resolved
// from file_holders / worm search)
let author: NodeId = [0u8; 32];
info!("Web: proxying post {} via QUIC (no redirect candidate found)", post_hex);
let search_result = tokio::time::timeout(

View file

@ -1,6 +1,6 @@
[package]
name = "itsgoin-desktop"
version = "0.7.3"
version = "0.8.0-alpha"
edition = "2021"
[lib]

View file

@ -6,7 +6,7 @@ use tracing::info;
use itsgoin_core::identity::IdentityManager;
use itsgoin_core::node::Node;
use itsgoin_core::types::{NodeId, PeerSlotKind, Post, PostId, PostVisibility, VisibilityIntent};
use itsgoin_core::types::{NodeId, Post, PostId, PostVisibility, VisibilityIntent};
/// The active Node, swappable on identity switch.
type AppNode = Arc<tokio::sync::RwLock<Arc<Node>>>;
@ -145,6 +145,8 @@ struct BadgeCountsDto {
unread_messages: usize,
new_reacts: usize,
new_comments: usize,
/// v0.8 (A3): undismissed greetings in the inbox (Messages badge adds this).
greetings: usize,
}
#[derive(Serialize)]
@ -279,7 +281,11 @@ async fn post_to_dto(
// Engagement data
let reaction_counts = {
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()
.map(|(emoji, count, reacted_by_me)| ReactionCountDto { emoji, count, reacted_by_me })
.collect()
@ -555,9 +561,13 @@ async fn list_posting_identities(
async fn create_posting_identity(
state: State<'_, AppNode>,
display_name: String,
greetings_open: Option<bool>,
) -> Result<PostingIdentityDto, String> {
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 {
node_id: hex::encode(id.node_id),
display_name: id.display_name,
@ -863,10 +873,12 @@ async fn post_to_dto_batch(
// Batch queries — 3 queries total instead of 4 × N
let (reaction_map, comment_map, intent_map, posting_identities) = {
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 intents = storage.get_post_intents_batch(&post_ids).unwrap_or_default();
let identities = storage.list_posting_identities().unwrap_or_default();
(reactions, comments, intents, identities)
};
// Map posting-id -> display-name so we can tag author=persona posts.
@ -1351,7 +1363,7 @@ async fn list_peers(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
.into_iter()
.map(|(nid, _, _)| nid)
.collect();
let (social_ids, n2_ids, n3_ids) = {
let (social_ids, n2_ids, n3_ids, n4_ids) = {
let storage = node.storage.get().await;
let social: std::collections::HashSet<_> = storage
.list_social_routes()
@ -1360,7 +1372,7 @@ async fn list_peers(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
.map(|r| r.node_id)
.collect();
let n2: std::collections::HashSet<_> = storage
.build_n2_share()
.list_reach_ids_at(2)
.unwrap_or_default()
.into_iter()
.collect();
@ -1369,7 +1381,12 @@ async fn list_peers(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
.unwrap_or_default()
.into_iter()
.collect();
(social, n2, n3)
let n4: std::collections::HashSet<_> = storage
.list_distinct_n4()
.unwrap_or_default()
.into_iter()
.collect();
(social, n2, n3, n4)
};
let mut dtos = Vec::with_capacity(records.len());
@ -1394,6 +1411,8 @@ async fn list_peers(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
"n2"
} else if n3_ids.contains(&rec.node_id) {
"n3"
} else if n4_ids.contains(&rec.node_id) {
"n4"
} else {
"known"
};
@ -1729,6 +1748,264 @@ async fn set_session_relay_enabled(state: State<'_, AppNode>, enabled: bool) ->
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.
/// Desktop: spawns the platform opener (xdg-open / open / cmd start).
/// Only https:// URLs are accepted to avoid being a generic command exec.
@ -1796,12 +2073,12 @@ async fn list_connections(state: State<'_, AppNode>) -> Result<Vec<ConnectionDto
let node = get_node(&state).await;
let conns = node.list_connections().await;
let mut dtos = Vec::with_capacity(conns.len());
for (nid, slot_kind, connected_at) in conns {
for (nid, slot, connected_at) in conns {
let display_name = node.get_display_name(&nid).await.unwrap_or(None);
dtos.push(ConnectionDto {
node_id: hex::encode(nid),
display_name,
slot_kind: format!("{:?}", slot_kind),
slot_kind: slot.to_string(),
connected_at,
});
}
@ -2063,6 +2340,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]
async fn get_badge_counts(
state: State<'_, AppNode>,
@ -2071,11 +2370,17 @@ async fn get_badge_counts(
let node = get_node(&state).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
let feed_posts = storage.get_feed().map_err(|e| e.to_string())?;
let new_feed = feed_posts.iter()
.filter(|(id, p, _vis)| {
p.author != node.node_id
!own_ids.contains(&p.author)
&& p.timestamp_ms > last_feed_view_ms
&& !matches!(
storage.get_post_intent(id).ok().flatten(),
@ -2088,18 +2393,20 @@ async fn get_badge_counts(
let all_posts = storage.list_posts_reverse_chron().map_err(|e| e.to_string())?;
let mut new_engagement = 0usize;
for (id, post, _vis) in &all_posts {
if post.author != node.node_id { continue; }
if !own_ids.contains(&post.author) { continue; }
// Skip DMs
if matches!(
storage.get_post_intent(id).ok().flatten(),
Some(VisibilityIntent::Direct(_))
) { 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(|(_, count, _)| *count)
.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 {
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 {
@ -2114,14 +2421,14 @@ async fn get_badge_counts(
matches!(
storage.get_post_intent(id).ok().flatten(),
Some(VisibilityIntent::Direct(_))
) || (p.author != node.node_id && matches!(
) || (!own_ids.contains(&p.author) && matches!(
storage.get_post_with_visibility(id).ok().flatten(),
Some((_, PostVisibility::Encrypted { .. }))
))
});
let mut seen_partners = std::collections::HashSet::new();
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
continue;
} else {
@ -2139,16 +2446,22 @@ async fn get_badge_counts(
let mut new_reacts = 0usize;
let mut new_comments = 0usize;
for (id, post, _vis) in &all_posts {
if post.author != node.node_id { continue; }
let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id)
if !own_ids.contains(&post.author) { continue; }
let total_reacts: u64 = storage.get_reaction_counts(id, &own_ids)
.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));
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; }
}
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]
@ -2187,12 +2500,13 @@ async fn sync_from_peer(state: State<'_, AppNode>, node_id_hex: String) -> Resul
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct NetworkSummaryDto {
preferred_count: usize,
local_count: usize,
wide_count: usize,
mesh_count: usize,
temp_count: usize,
mesh_cap: usize,
total_connections: usize,
n2_distinct: usize,
n3_distinct: usize,
n4_distinct: usize,
has_public_v6: bool,
has_public_v4: bool,
has_upnp: bool,
@ -2202,29 +2516,24 @@ struct NetworkSummaryDto {
async fn get_network_summary(state: State<'_, AppNode>) -> Result<NetworkSummaryDto, String> {
let node = get_node(&state).await;
let conns = node.list_connections().await;
let mut preferred = 0usize;
let mut local = 0usize;
let mut wide = 0usize;
for (_nid, slot_kind, _at) in &conns {
match slot_kind {
PeerSlotKind::Preferred => preferred += 1,
PeerSlotKind::Local => local += 1,
PeerSlotKind::Wide => wide += 1,
}
}
let (n2, n3) = {
let mesh = conns.iter().filter(|(_, slot, _)| slot.is_mesh()).count();
let temp = conns.len() - mesh;
let (n2, n3, n4) = {
let storage = node.storage.get().await;
let n2 = storage.count_distinct_n2().unwrap_or(0);
let n3 = storage.count_distinct_n3().unwrap_or(0);
(n2, n3)
(
storage.count_distinct_n2().unwrap_or(0),
storage.count_distinct_n3().unwrap_or(0),
storage.count_distinct_n4().unwrap_or(0),
)
};
Ok(NetworkSummaryDto {
preferred_count: preferred,
local_count: local,
wide_count: wide,
mesh_count: mesh,
temp_count: temp,
mesh_cap: node.device_profile().mesh_slots(),
total_connections: conns.len(),
n2_distinct: n2,
n3_distinct: n3,
n4_distinct: n4,
has_public_v6: node.network.has_public_v6(),
has_public_v4: node.network.is_anchor(),
has_upnp: node.network.has_upnp(),
@ -2446,15 +2755,14 @@ struct ActivityLogDto {
events: Vec<ActivityEventDto>,
rebalance_last_ms: u64,
rebalance_interval_secs: u64,
anchor_register_last_ms: u64,
anchor_register_interval_secs: u64,
convection_last_ms: u64,
}
#[tauri::command]
async fn get_activity_log(state: State<'_, AppNode>) -> Result<ActivityLogDto, String> {
let node = get_node(&state).await;
let events = node.get_activity_log(200);
let (rebalance_last, anchor_last) = node.timer_state();
let (rebalance_last, convection_last) = node.timer_state();
let dto_events: Vec<ActivityEventDto> = events.into_iter().map(|e| {
ActivityEventDto {
timestamp_ms: e.timestamp_ms,
@ -2468,8 +2776,7 @@ async fn get_activity_log(state: State<'_, AppNode>) -> Result<ActivityLogDto, S
events: dto_events,
rebalance_last_ms: rebalance_last,
rebalance_interval_secs: 600,
anchor_register_last_ms: anchor_last,
anchor_register_interval_secs: 600,
convection_last_ms: convection_last,
})
}
@ -2485,31 +2792,47 @@ async fn trigger_rebalance(state: State<'_, AppNode>) -> Result<String, String>
async fn request_referrals(state: State<'_, AppNode>) -> Result<String, String> {
let node = get_node(&state).await;
let node_id = node.node_id;
// Try known_anchors table first (populated by anchor register cycle),
// fall back to anchor peers from the peers table (is_anchor = true)
let mesh = node.network.conn_handle().mesh_count().await;
// Manual trigger uses the same class rule as the automatic path: below 2
// mesh peers this is an ENTRY request and is always served.
let class = itsgoin_core::protocol::ConvectionClass::for_mesh_count(mesh);
// Pool-mined anchors first (the uniques pools ARE the anchor directory),
// then the known_anchors bootstrap cache, then anchor-flagged peers.
let anchors: Vec<(NodeId, Vec<std::net::SocketAddr>)> = {
let storage = node.storage.get().await;
let known = storage.list_known_anchors().unwrap_or_default();
if !known.is_empty() {
known
} else {
storage.list_anchor_peers().unwrap_or_default()
.into_iter()
.map(|r| (r.node_id, r.addresses))
.collect()
let mut out: Vec<(NodeId, Vec<std::net::SocketAddr>)> = Vec::new();
let mut seen: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
for (nid, addrs) in storage.list_pool_anchors(16).unwrap_or_default() {
let socks: Vec<std::net::SocketAddr> = addrs.iter().filter_map(|a| a.parse().ok()).collect();
if !socks.is_empty() && seen.insert(nid) {
out.push((nid, socks));
}
}
for (nid, addrs) in storage.list_known_anchors().unwrap_or_default() {
if seen.insert(nid) {
out.push((nid, addrs));
}
}
for r in storage.list_anchor_peers().unwrap_or_default() {
if seen.insert(r.node_id) {
out.push((r.node_id, r.addresses));
}
}
out
};
if anchors.is_empty() {
return Ok("No known anchors".to_string());
}
let mut total = 0usize;
let mut reachable = 0usize;
let mut connected = 0usize;
let mut asked = 0usize;
let mut refused = 0usize;
for (anchor_nid, anchor_addrs) in &anchors {
if *anchor_nid == node_id {
continue;
}
// Connect to anchor if not already connected
if !node.network.is_peer_connected(anchor_nid).await {
if !node.network.is_peer_connected_or_session(anchor_nid).await {
let endpoint_id = match itsgoin_core::EndpointId::from_bytes(anchor_nid) {
Ok(eid) => eid,
Err(_) => continue,
@ -2518,40 +2841,26 @@ async fn request_referrals(state: State<'_, AppNode>) -> Result<String, String>
for sa in anchor_addrs {
addr = addr.with_ip_addr(*sa);
}
if let Err(_) = node.network.connect_to_peer(*anchor_nid, addr).await {
if node.network.connect_to_anchor(*anchor_nid, addr).await.is_err() {
continue;
}
}
match node.network.request_anchor_referrals(anchor_nid).await {
Ok(referrals) => {
reachable += 1;
for referral in &referrals {
if referral.node_id == node_id {
continue;
}
if let Some(addr_str) = referral.addresses.first() {
let connect_str = format!(
"{}@{}",
hex::encode(referral.node_id),
addr_str,
);
if let Ok((rid, raddr)) = itsgoin_core::parse_connect_string(&connect_str) {
match node.network.connect_to_peer(rid, raddr).await {
Ok(()) => {}
Err(_) => {
// Direct connect failed (NAT) — try hole punch via anchor
let _ = node.network.connect_via_introduction(rid, *anchor_nid).await;
}
}
total += 1;
}
}
match node.network.request_convection(anchor_nid, class).await {
Ok(response) => {
asked += 1;
if response.refused {
refused += 1;
} else {
connected += node.network.act_on_convection(anchor_nid, &response).await;
}
}
Err(_) => {}
}
}
Ok(format!("Got {} referrals from {} anchors", total, reachable))
Ok(format!(
"Convection: {} new peers from {} anchors ({} refused)",
connected, asked, refused
))
}
#[tauri::command]
@ -2895,14 +3204,13 @@ async fn switch_identity(
// Start background tasks on the new node
new_node.start_accept_loop();
new_node.start_pull_cycle(300);
new_node.start_sync_cycle();
new_node.start_diff_cycle(120);
new_node.start_rebalance_cycle(600);
new_node.start_growth_loop();
new_node.start_recovery_loop();
new_node.start_social_checkin_cycle(3600);
new_node.start_anchor_register_cycle(600);
new_node.start_upnp_renewal_cycle();
new_node.start_convection_loop();
new_node.start_upnp_tcp_renewal_cycle();
new_node.start_http_server();
new_node.start_bootstrap_connectivity_check();
@ -2994,6 +3302,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(
&node.data_dir,
&node.storage,
@ -3018,14 +3328,13 @@ async fn clear_duplicate_flag(state: State<'_, AppNode>) -> Result<(), String> {
node.network.duplicate_detected.store(false, std::sync::atomic::Ordering::Relaxed);
// Start the sync tasks that were skipped during bootstrap
node.start_accept_loop();
node.start_pull_cycle(300);
node.start_sync_cycle();
node.start_diff_cycle(120);
node.start_rebalance_cycle(600);
node.start_growth_loop();
node.start_recovery_loop();
node.start_social_checkin_cycle(3600);
node.start_anchor_register_cycle(600);
node.start_upnp_renewal_cycle();
node.start_convection_loop();
node.start_upnp_tcp_renewal_cycle();
node.start_http_server();
node.start_bootstrap_connectivity_check();
@ -3083,11 +3392,13 @@ async fn import_public_posts(
zip_path: String,
) -> Result<String, String> {
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(
std::path::Path::new(&zip_path),
&node.storage,
&node.blob_store,
&node.node_id,
&node.default_posting_id,
).await.map_err(|e| e.to_string())?;
Ok(result.message)
}
@ -3141,12 +3452,14 @@ async fn import_merge_with_key(
key_hex: String,
) -> Result<String, String> {
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(
std::path::Path::new(&zip_path),
&key_hex,
&node.storage,
&node.blob_store,
&node.node_id,
&node.default_posting_id,
&node.secret_seed(),
).await.map_err(|e| e.to_string())?;
Ok(result.message)
@ -3263,14 +3576,13 @@ pub fn run() {
// Start all background networking tasks
boot_node.start_accept_loop();
boot_node.start_pull_cycle(300);
boot_node.start_sync_cycle();
boot_node.start_diff_cycle(120);
boot_node.start_rebalance_cycle(600);
boot_node.start_growth_loop();
boot_node.start_recovery_loop();
boot_node.start_social_checkin_cycle(3600);
boot_node.start_anchor_register_cycle(600);
boot_node.start_upnp_renewal_cycle();
boot_node.start_convection_loop();
boot_node.start_upnp_tcp_renewal_cycle();
boot_node.start_http_server();
boot_node.start_bootstrap_connectivity_check();
@ -3414,6 +3726,17 @@ pub fn run() {
exit_app,
get_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!())
.expect("error while building tauri application")

View file

@ -1,6 +1,6 @@
{
"productName": "itsgoin",
"version": "0.7.3",
"version": "0.8.0-alpha",
"identifier": "com.itsgoin.app",
"build": {
"frontendDist": "../../frontend",

View file

@ -18,8 +18,19 @@ SSH_OPTS="-o StrictHostKeyChecking=no"
KEYSTORE="itsgoin.keystore"
KS_ALIAS="itsgoin"
VERSION=$(grep '"version"' crates/tauri-app/tauri.conf.json | head -1 | sed 's/.*"\([0-9.]*\)".*/\1/')
echo "=== Deploying v${VERSION} ==="
# Captures the FULL version string including any pre-release suffix
# (e.g. 0.8.0-alpha). The old digits-and-dots-only pattern silently
# truncated at the dash, so every ${VERSION} filename below missed the
# artifacts Tauri had actually produced.
VERSION=$(grep '"version"' crates/tauri-app/tauri.conf.json | head -1 | sed 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
# Pre-release versions announce on their own channel so stable clients
# are not told an alpha is "the" current release.
case "$VERSION" in
*-*) ANN_CHANNEL="${VERSION#*-}" ;;
*) ANN_CHANNEL="stable" ;;
esac
echo "=== Deploying v${VERSION} (announce channel: ${ANN_CHANNEL}) ==="
# Builds run SERIALLY — parallel cargo invocations write to the same
# target/ directory, which causes intermittent failures (linuxdeploy
@ -76,10 +87,11 @@ sshpass -p "$SSH_PASS" ssh $SSH_OPTS "$SSH_HOST" "
kill \$(cat ~/itsgoin-anchor.pid 2>/dev/null) 2>/dev/null
sleep 1
mv ~/bin/itsgoin.new ~/bin/itsgoin && chmod +x ~/bin/itsgoin
~/bin/itsgoin ~/itsgoin-anchor-data --publish-registry 2>&1 | tail -3
~/bin/itsgoin ~/itsgoin-anchor-data \
--announce \
--ann-category release \
--ann-channel stable \
--ann-channel ${ANN_CHANNEL} \
--ann-version ${VERSION} \
--ann-url https://itsgoin.com/download.html \
--ann-title 'ItsGoin v${VERSION} available' \

View file

@ -1090,7 +1090,117 @@ function setupMyPostsMediaObserver() {
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) {
// 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 {
const [posts, follows] = await Promise.all([
invoke('get_all_posts'),
@ -1336,7 +1446,9 @@ async function loadMessages(force) {
const hasUnread = thread.posts.some(p => !p.isMe && p.timestampMs > lastReadMs);
if (hasUnread) unreadCount++;
}
updateTabBadge('messages', unreadCount);
// v0.8 (A3): the Messages badge = unread DMs + undismissed greetings.
_lastDmUnread = unreadCount;
updateTabBadge('messages', unreadCount + _greetingsCount);
} catch (e) {
conversationsList.innerHTML = `<div class="section-card"><p class="status-err">Error: ${e}</p></div>`;
}
@ -1438,6 +1550,7 @@ async function loadPeers() {
else if (p.reach === 'n1') reachBadge = '<span class="reach-badge reach-n1">N1</span>';
else if (p.reach === 'n2') reachBadge = '<span class="reach-badge reach-n2">N2</span>';
else if (p.reach === 'n3') reachBadge = '<span class="reach-badge reach-n3">N3</span>';
else if (p.reach === 'n4') reachBadge = '<span class="reach-badge reach-n4">N4</span>';
let actions = '';
if (p.nodeId === myNodeId) {
@ -1669,7 +1782,7 @@ async function openBioModal(nodeId, preloadedName) {
overlay.classList.remove('hidden');
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 follows = await invoke('list_follows').catch(() => []);
const ignored = await invoke('list_ignored_peers').catch(() => []);
@ -1677,7 +1790,7 @@ async function openBioModal(nodeId, preloadedName) {
const following = follows.some(f => f.nodeId === nodeId);
const isIgnored = ignored.some(i => i.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 icon = generateIdenticon(nodeId, 48);
@ -1686,12 +1799,12 @@ async function openBioModal(nodeId, preloadedName) {
<div style="display:flex;gap:0.75rem;align-items:flex-start;margin-bottom:0.75rem">
${icon}
<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>
</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>'}
<div style="display:flex;gap:0.4rem;flex-wrap:wrap">
${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 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>
${(following && isVouched)
? `<button id="bio-unfriend" class="btn btn-ghost btn-sm">Unfriend</button>`
@ -1785,11 +1898,90 @@ async function openBioModal(nodeId, preloadedName) {
} catch (e) { toast('Error: ' + e); }
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) {
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 &mdash; 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) {
authorFilterNodeId = nodeId;
authorFilterName = name || nodeId.slice(0, 12);
@ -1887,11 +2079,11 @@ async function loadNetworkSummary() {
const s = await invoke('get_network_summary');
networkSummaryEl.innerHTML = `<div class="diag-grid">
<div class="diag-item"><span class="diag-value">${s.totalConnections}</span><span class="diag-label">Connections</span></div>
<div class="diag-item"><span class="diag-value">${s.preferredCount}</span><span class="diag-label">Preferred</span></div>
<div class="diag-item"><span class="diag-value">${s.localCount}</span><span class="diag-label">Mesh</span></div>
<div class="diag-item"><span class="diag-value">${s.wideCount}</span><span class="diag-label">Non-mesh N1</span></div>
<div class="diag-item"><span class="diag-value">${s.meshCount}/${s.meshCap}</span><span class="diag-label">Mesh</span></div>
<div class="diag-item"><span class="diag-value">${s.tempCount}</span><span class="diag-label">Temp referral</span></div>
<div class="diag-item"><span class="diag-value">${s.n2Distinct}</span><span class="diag-label">N2 Reach</span></div>
<div class="diag-item"><span class="diag-value">${s.n3Distinct}</span><span class="diag-label">N3 Reach</span></div>
<div class="diag-item"><span class="diag-value">${s.n4Distinct}</span><span class="diag-label">N4 Reach</span></div>
</div>`;
} catch (e) {
networkSummaryEl.innerHTML = `<p class="empty-hint">Could not load network summary</p>`;
@ -1908,10 +2100,9 @@ async function loadConnections() {
connectionsList.innerHTML = conns.map(c => {
const label = escapeHtml(peerLabel(c.nodeId, c.displayName));
const icon = generateIdenticon(c.nodeId, 18);
const slotClass = c.slotKind === 'Preferred' ? 'slot-preferred'
: c.slotKind === 'Wide' ? 'slot-wide' : 'slot-local';
const slotLabel = c.slotKind === 'Local' ? 'Mesh'
: c.slotKind === 'Wide' ? 'Non-mesh N1' : c.slotKind;
const slotClass = c.slotKind === 'temp' ? 'slot-temp' : 'slot-mesh';
const slotLabel = c.slotKind === 'mesh' ? 'Mesh'
: c.slotKind === 'temp' ? 'Temp referral' : c.slotKind;
const duration = c.connectedAt ? relativeTime(c.connectedAt) : '';
return `<div class="peer-card">
<div class="peer-card-row">${icon} ${label} <span class="slot-badge ${slotClass}">${slotLabel}</span></div>
@ -2003,7 +2194,13 @@ function renderTimer(label, lastMs, intervalSecs, now) {
}
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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() {
// Name is optional — users who want to stay anonymous can proceed with a blank field.
const name = setupName.value.trim();
setupBtn.disabled = true;
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 });
setupOverlay.classList.add('hidden');
toast(name ? 'Welcome, ' + name + '!' : 'Welcome!');
@ -3180,6 +3407,7 @@ document.querySelectorAll('.tab').forEach(tab => {
if (!conversationsList.children.length) conversationsList.innerHTML = renderLoading();
loadMessages(true); loadDmRecipientOptions();
clearNotifications('msg-');
clearNotifications('greet-');
}
if (target === 'settings') {
loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible();
@ -3216,6 +3444,16 @@ $('#profile-lightbox-btn').addEventListener('click', () => {
<input type="checkbox" id="lb-public-visible" ${currentVisible ? 'checked' : ''} />
Show my profile to non-circle peers
</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">
<button class="btn btn-primary btn-sm" id="lb-profile-save">Save</button>
</div>
@ -3228,14 +3466,72 @@ $('#profile-lightbox-btn').addEventListener('click', () => {
</div>
</div>`;
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 bio = overlay.querySelector('#lb-profile-bio').value.trim();
if (!name) { toast('Display name is required'); return; }
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 });
const visible = overlay.querySelector('#lb-public-visible').checked;
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
profileNameInput.value = name;
profileBioInput.value = bio;
@ -3297,6 +3593,58 @@ $('#discover-toggle').addEventListener('click', () => {
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(' &middot; ')}</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
// re-renders so the user picks when to rearrange the list.
{
@ -3332,7 +3680,7 @@ function openDiagnostics() {
</div>
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;flex-wrap:wrap;justify-content:center">
<button id="rebalance-btn" class="btn btn-ghost btn-sm">Rebalance Now</button>
<button id="request-referrals-btn" class="btn btn-ghost btn-sm">Request Referrals</button>
<button id="request-referrals-btn" class="btn btn-ghost btn-sm">Find Peers (Convection)</button>
</div>
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;flex-wrap:wrap;justify-content:center">
<button id="show-ourinfo-btn" class="btn btn-ghost btn-sm">Our Info</button>
@ -3468,11 +3816,11 @@ function openDiagnostics() {
try {
const r = await invoke('request_referrals');
btn.textContent = r;
setTimeout(() => { btn.textContent = 'Request Referrals'; btn.disabled = false; }, 3000);
setTimeout(() => { btn.textContent = 'Find Peers (Convection)'; btn.disabled = false; }, 3000);
loadAllDiagnostics();
} catch (e) {
btn.textContent = 'Failed';
setTimeout(() => { btn.textContent = 'Request Referrals'; btn.disabled = false; }, 3000);
setTimeout(() => { btn.textContent = 'Find Peers (Convection)'; btn.disabled = false; }, 3000);
}
});
loadAllDiagnostics();
@ -3977,15 +4325,46 @@ function renderPersonasList() {
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>`;
// 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">
<div style="min-width:0;flex:1">
<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>
<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>`;
}).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 => {
btn.addEventListener('click', async () => {
btn.disabled = true;
@ -4040,6 +4419,10 @@ $('#create-persona-btn').addEventListener('click', () => {
<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>
<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">
<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>
@ -4052,7 +4435,12 @@ $('#create-persona-btn').addEventListener('click', () => {
const name = nameInput.value.trim();
if (!name) { toast('Enter a name'); return; }
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}`);
overlay.remove();
loadPersonas();
@ -4346,7 +4734,7 @@ async function init() {
// Update tab badges from welcome screen
updateTabBadge('feed', b.newFeed || 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
// (welcome page already shows these counts directly)
}).catch(() => {});
@ -4500,6 +4888,7 @@ async function init() {
const badges = await invoke('get_badge_counts', { lastFeedViewMs: _lastFeedViewMs });
if (currentTab !== 'feed') updateTabBadge('feed', badges.newFeed);
if (currentTab !== 'myposts') updateTabBadge('myposts', badges.newEngagement);
if (currentTab !== 'messages') updateTabBadge('messages', (badges.unreadMessages || 0) + (badges.greetings || 0));
} catch (_) {}
}, 30000);

View file

@ -13,6 +13,12 @@
<h2>Welcome to ItsGoin</h2>
<p>Pick a display name if you want one &mdash; or leave blank to stay anonymous.</p>
<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 &mdash; 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>
</div>
</div>
@ -139,8 +145,14 @@
</div>
<div class="section-card">
<button id="discover-toggle" class="btn btn-ghost btn-sm section-toggle">Discover People</button>
<div id="discover-body" class="hidden">
<button id="discover-toggle" class="btn btn-ghost btn-sm section-toggle">Hide Discover</button>
<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>
<div id="discover-list"></div>
</div>
@ -181,6 +193,15 @@
<h3>Message Requests</h3>
<div id="message-requests-list"></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 &mdash; only the sender can read them.</p>
<div id="greetings-list"></div>
</div>
</section>
<!-- Settings tab -->

View file

@ -359,9 +359,8 @@ header h1 { font-size: clamp(1.4rem, 2.5vw, 2rem); color: #7fdbca; margin: 0; }
/* Slot kind badges */
.slot-badge { font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 3px; font-family: system-ui, sans-serif; }
.slot-preferred { background: #1a3a2e; color: #7fdbca; }
.slot-local { background: #1e2040; color: #aab; }
.slot-wide { background: #2a2a1e; color: #e2b93d; }
.slot-mesh { background: #1e2040; color: #aab; }
.slot-temp { background: #2a2a1e; color: #e2b93d; }
/* Reach level badges */
.reach-badge { font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 3px; font-family: system-ui, sans-serif; }
@ -369,6 +368,7 @@ header h1 { font-size: clamp(1.4rem, 2.5vw, 2rem); color: #7fdbca; margin: 0; }
.reach-n1 { background: #1a2e3a; color: #7fc4db; }
.reach-n2 { background: #1e2040; color: #aab; }
.reach-n3 { background: #1e2040; color: #667; }
.reach-n4 { background: #1a1a2e; color: #556; }
/* Diagnostics summary grid */
.diag-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.4rem; margin-bottom: 0.75rem; }

142
scripts/a3_integration_test.sh Executable file
View file

@ -0,0 +1,142 @@
#!/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"; }
# Same noise filter for the tail-window checks below. The netlink/mDNS watcher
# logs in unpredictable bursts, and a burst landing between a REPL command and
# its assertion pushes the answer out of a raw `tail -N` window — a false
# failure with nothing wrong in the node.
quiet_tail() { grep -av "netlink\|buffer_tool\|iroh_quinn\|swarm_discovery" "$1" | tail -"$2"; }
export -f quiet_tail
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 'quiet_tail /tmp/itsgoin-cli3.log 20 | 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 'quiet_tail /tmp/itsgoin-cli3.log 6 | grep -c "rust" | grep -q "^1$" &&
quiet_tail /tmp/itsgoin-cli3.log 6 | grep -q AliceV2'
echo
echo "== results: $PASS passed, $FAIL failed =="
[ "$FAIL" -eq 0 ]

278
scripts/c_topology_test.sh Executable file
View file

@ -0,0 +1,278 @@
#!/usr/bin/env bash
# v0.8 Iteration C integration test — TOPOLOGY.
#
# Run from the repo root after `cargo build -p itsgoin-cli`, AFTER
# scripts/a3_integration_test.sh (C does not replace A). Drives interactive
# REPLs over FIFOs, asserts by grepping logs and by sqlite3 against node DBs.
#
# SCENARIO 1 Two-pool uniques exchange over a chain: pool 1 shifts one bounce
# deeper per hop, pool 2 lands at N4, and N4 is USED but NEVER
# re-announced (no N5 anywhere). Plus the §layers privacy
# invariant: only anchor entries carry an address.
# SCENARIO 2 Anchor convection: the rolling window hands each caller the 2
# most recent prior callers, produces a REAL connection, rotates
# entries out after 2 hand-outs, coordinates a RelayIntroduce when
# both ends are live, and refuses a top-up CHEAPLY.
# SCENARIO 3 Temp referral slots: allocated ABOVE the mesh cap, carrying no
# knowledge, never evicting an established mesh peer, graduating
# into a freed mesh slot.
# STANDING 0xC0 AnchorRegister is gone; no automatic session relay anywhere.
#
# Exit code 0 = all checks passed. Logs: /tmp/itsgoin-ctop{1..7}.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-ctop$1/itsgoin.db" "$2"; }
# Is `id` (hex) present in node N's uniques index at `bounce`?
at_bounce() { # at_bounce <node> <id_hex> <bounce>
[ "$(sq "$1" "SELECT count(*) FROM reachable WHERE lower(hex(reachable_id))='$2' AND bounce=$3")" != "0" ]
}
anywhere() { # anywhere <node> <id_hex>
[ "$(sq "$1" "SELECT count(*) FROM reachable WHERE lower(hex(reachable_id))='$2'")" != "0" ]
}
# `check` runs its command via `bash -c`, which does not inherit shell functions.
export -f strip_ansi sq at_bounce anywhere
NODES="1 2 3 4 5 6 7"
FDS=""
cleanup() {
for fd in 21 22 23 24 25 26 27; do eval "exec $fd>&-" 2>/dev/null || true; done
kill $(pgrep -f 'itsgoin.*itsgoin-ctop') 2>/dev/null
rm -f /tmp/itsgoin-ctopcmd*
}
trap cleanup EXIT
# Test gates:
# - loopback referrals: the convection address filter is publicly-routable-only
# (bugs-fixed #8), which would make every 127.0.0.1 referral unusable.
# - mesh cap 2: reaches the temp-referral boundary with 4 nodes, not 21.
# - no growth loop: on loopback the growth loop collapses any chain into a
# full mesh in seconds, and a full mesh has no N2 at all (every peer is a
# direct). Topology here is script-controlled on purpose.
export ITSGOIN_TEST_ALLOW_LOOPBACK_REFERRALS=1
export ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS=1
export ITSGOIN_TEST_NO_GROWTH=1
export RUST_LOG="${RUST_LOG:-itsgoin_core=debug,info,iroh=warn,swarm_discovery=warn}"
echo "== setup =="
kill $(pgrep -f 'itsgoin.*itsgoin-ctop') 2>/dev/null; sleep 1
for i in $NODES; do
rm -rf /tmp/itsgoin-ctop$i /tmp/itsgoin-ctopcmd$i /tmp/itsgoin-ctop$i.log
mkdir -p /tmp/itsgoin-ctop$i
mkfifo /tmp/itsgoin-ctopcmd$i
done
start_node() { # start_node <n> <port> [extra env assignments...]
local i="$1" port="$2"; shift 2
( exec env "$@" "$BIN" /tmp/itsgoin-ctop$i --bind 127.0.0.1:$port \
< /tmp/itsgoin-ctopcmd$i > /tmp/itsgoin-ctop$i.log 2>&1 ) &
}
# Nodes 1-6: chain + convection. Node 7 joins late (fresh index).
for i in 1 2 3 4 5 6 7; do start_node $i $((18430 + i)) ITSGOIN_TEST_NO_GROWTH=1; done
exec 21>/tmp/itsgoin-ctopcmd1 22>/tmp/itsgoin-ctopcmd2 23>/tmp/itsgoin-ctopcmd3 \
24>/tmp/itsgoin-ctopcmd4 25>/tmp/itsgoin-ctopcmd5 26>/tmp/itsgoin-ctopcmd6 \
27>/tmp/itsgoin-ctopcmd7
sleep 7
for i in $NODES; do
eval "n${i}=\$(strip_ansi /tmp/itsgoin-ctop$i.log | grep -m1 'Node ID:' | awk '{print \$3}')"
done
echo "n1=$n1 n2=$n2 n3=$n3 n4=$n4 n5=$n5"
[ -n "$n5" ] || { echo "FAIL: nodes did not start"; exit 1; }
##############################################################################
echo
echo "== scenario 1: two-pool uniques exchange, N4 terminal =="
##############################################################################
# Chain 1 - 2 - 3 - 4 - 5. Built inward so each hop's initial exchange already
# carries the deeper knowledge where possible.
echo "connect $n1@127.0.0.1:18431" >&22; sleep 3
echo "connect $n2@127.0.0.1:18432" >&23; sleep 3
echo "connect $n3@127.0.0.1:18433" >&24; sleep 3
echo "connect $n4@127.0.0.1:18434" >&25; sleep 4
# Drive propagation explicitly: each uniques-pull round moves knowledge one
# bounce along the chain. This IS the v0.8 pull — an index exchange, no posts.
for round in 1 2 3 4 5; do
for fd in 21 22 23 24 25; do echo "uniques-pull" >&$fd; done
sleep 3
done
check "uniques pull is an index exchange (peers exchanged, no post transfer)" \
bash -c 'grep -q "uniques-pull: exchanged with [1-9]" /tmp/itsgoin-ctop3.log'
# Pool 1 shifts one bounce deeper per hop, tagged to the reporter.
check "node1 holds node3 at N2 (pool 1 shifted one bounce)" at_bounce 1 "$n3" 2
check "node1 holds node4 at N3" at_bounce 1 "$n4" 3
check "node1 holds node5 at N4 (terminal pool)" at_bounce 1 "$n5" 4
check "node5 holds node1 at N4 from the other end of the chain" at_bounce 5 "$n1" 4
# N4 is USED — address resolution and search read it.
check "node1 can look up its N4 entry (used, not merely stored)" \
bash -c 'grep -q "N4 via" <(echo "uniques '"$n5"'" >&21; sleep 3; tail -40 /tmp/itsgoin-ctop1.log)'
# §layers privacy invariant: ONLY anchor entries carry an address.
check "no non-anchor index row carries an address (on any node)" \
bash -c 'for i in 1 2 3 4 5; do
c=$(sqlite3 /tmp/itsgoin-ctop$i/itsgoin.db \
"SELECT count(*) FROM reachable WHERE is_anchor=0 AND addresses NOT IN ('"''"',\"[]\")");
[ "$c" = "0" ] || exit 1; done'
# Per-reporter dedup: one row per (reporter, id) — an ID can never sit at two
# depths from the same reporter.
check "one index row per (reporter, id) — no duplicate depths" \
bash -c 'for i in 1 2 3 4 5; do
d=$(sqlite3 /tmp/itsgoin-ctop$i/itsgoin.db \
"SELECT count(*) FROM (SELECT reporter_node_id, reachable_id FROM reachable
GROUP BY reporter_node_id, reachable_id HAVING count(*) > 1)");
[ "$d" = "0" ] || exit 1; done'
# Nothing is stored past bounce 4. There is no N5 table and no N5 row.
check "stored horizon never exceeds 4 bounces" \
bash -c 'for i in 1 2 3 4 5; do
m=$(sqlite3 /tmp/itsgoin-ctop$i/itsgoin.db "SELECT ifnull(max(bounce),0) FROM reachable");
[ "$m" -le 4 ] || exit 1; done'
# THE KEY ASSERTION: a fresh node joining node1 must NOT learn node5. node1
# knows node5 only at N4, and the terminal pool is never re-announced.
echo "connect $n1@127.0.0.1:18431" >&27; sleep 4
for round in 1 2 3; do echo "uniques-pull" >&27; echo "uniques-pull" >&21; sleep 3; done
check "N4 is never re-announced: node7 never learns node5" \
bash -c '! /usr/bin/env bash -c "[ \"\$(sqlite3 /tmp/itsgoin-ctop7/itsgoin.db \
\"SELECT count(*) FROM reachable WHERE lower(hex(reachable_id))=\\\"'"$n5"'\\\"\")\" != \"0\" ]"'
check "node7 DID learn node2 (the exchange itself works)" anywhere 7 "$n2"
##############################################################################
echo
echo "== scenario 2: anchor convection =="
##############################################################################
# Node 6 plays the anchor: directly dialable, and it is the one every caller
# asks. Callers are node1 and node5 — the two ENDS of the chain, so they have
# no path to each other and a referral that lands is unambiguous.
echo "connect $n6@127.0.0.1:18436" >&21; sleep 3
echo "convect $n6" >&21; sleep 5 # node1 admitted; window was empty
echo "connect $n6@127.0.0.1:18436" >&25; sleep 3
echo "convect $n6" >&25; sleep 10 # node5 is referred to node1
check "anchor serves referrals out of the rolling window" \
bash -c 'grep -q "Convection: serving referrals" /tmp/itsgoin-ctop6.log'
check "the anchor admitted callers to the window without any registration msg" \
bash -c 'grep -q "Convection: admitted caller to rolling window" /tmp/itsgoin-ctop6.log'
check "the anchor referred the PRIOR caller to the next one" \
bash -c 'grep "Convection: referring peer" /tmp/itsgoin-ctop6.log | grep -q "'"$n1"'"'
check "a convection referral produced a real peer connection" \
bash -c 'grep -q "Convection: connected to referred peer\|Convection: connected via introduction" \
/tmp/itsgoin-ctop5.log'
check "node5 is now actually mesh-connected to node1 (chain ends joined)" \
bash -c 'echo "connections" >&25; sleep 3; strip_ansi /tmp/itsgoin-ctop5.log | tail -20 | grep -q "'"${n1:0:12}"'"'
# Both ends live on the anchor => coordinate an introduction, not a cold address.
check "anchor coordinates RelayIntroduce when both ends are live" \
bash -c 'grep -q "Convection: coordinated introduction" /tmp/itsgoin-ctop6.log'
# Rotation: an entry is handed to exactly 2 callers, then leaves the window.
echo "convect $n6" >&24; sleep 6
echo "convect $n6" >&23; sleep 6
echo "convect $n6" >&27; sleep 6
check "each window entry is handed out at most twice, then rotates out" \
bash -c 'n=$(grep "Convection: referring peer" /tmp/itsgoin-ctop6.log | grep -c "referred=\"\?'"$n1"'"); \
[ "$n" -le 2 ]'
check "later callers are served the more RECENT callers" \
bash -c 'grep "Convection: referring peer" /tmp/itsgoin-ctop6.log | tail -3 | grep -q "'"$n5"'\|'"$n4"'"'
# CHEAP REFUSAL: node3 is an interior chain node (2 mesh peers) so its request
# is TOP-UP class. It asks node5, whose own convection window is empty — there
# is nothing fresh to circulate, so the refusal must be one message, not a
# burned timeout.
echo "connect $n5@127.0.0.1:18435" >&23; sleep 4
echo "convect $n5" >&23; sleep 6
check "top-up against an empty window is refused" \
bash -c 'grep -q "convection: refused" /tmp/itsgoin-ctop3.log'
check "the refusal is CHEAP (single message, well under a timeout)" \
bash -c 'ms=$(grep -o "convection: refused in [0-9]*ms" /tmp/itsgoin-ctop3.log | tail -1 |
grep -o "[0-9]*" | head -1); [ -n "$ms" ] && [ "$ms" -lt 3000 ]'
check "entry class is served even where top-up was refused" \
bash -c 'grep "Convection: serving referrals" /tmp/itsgoin-ctop6.log | grep -q entry'
check "the refusing anchor lands in the caller's penalty box (refusal feedback)" \
bash -c 'echo "convection" >&23; sleep 3; strip_ansi /tmp/itsgoin-ctop3.log | tail -20 |
grep -oE "anchor_bias [0-9.]+" | tail -1 | awk "{ exit !(\$2 < 1.0) }"'
##############################################################################
echo
echo "== scenario 3: temp referral slots (mesh cap 2) =="
##############################################################################
# Restart nodes 1-4 with a mesh cap of 2 so the temp-referral band is reachable.
for fd in 21 22 23 24 25 26 27; do echo "quit" >&$fd 2>/dev/null; done
sleep 3
kill $(pgrep -f 'itsgoin.*itsgoin-ctop') 2>/dev/null; sleep 2
for fd in 21 22 23 24 25 26 27; do eval "exec $fd>&-" 2>/dev/null || true; done
for i in 1 2 3 4; do
rm -rf /tmp/itsgoin-ctop$i /tmp/itsgoin-ctopcmd$i /tmp/itsgoin-ctop$i.log
mkdir -p /tmp/itsgoin-ctop$i; mkfifo /tmp/itsgoin-ctopcmd$i
start_node $i $((18440 + i)) ITSGOIN_TEST_MESH_SLOTS=2 \
ITSGOIN_TEST_ALLOW_LOOPBACK_REFERRALS=1 ITSGOIN_TEST_NO_GROWTH=1
done
exec 21>/tmp/itsgoin-ctopcmd1 22>/tmp/itsgoin-ctopcmd2 23>/tmp/itsgoin-ctopcmd3 \
24>/tmp/itsgoin-ctopcmd4
sleep 7
for i in 1 2 3 4; do
eval "m${i}=\$(strip_ansi /tmp/itsgoin-ctop$i.log | grep -m1 'Node ID:' | awk '{print \$3}')"
done
# Fill node1's 2 mesh slots, then send a third peer at it.
echo "connect $m1@127.0.0.1:18441" >&22; sleep 4
echo "connect $m1@127.0.0.1:18441" >&23; sleep 4
echo "slots" >&21; sleep 3
check "mesh pool fills to the cap" \
bash -c 'strip_ansi /tmp/itsgoin-ctop1.log | grep -q "mesh 2/2"'
echo "connect $m1@127.0.0.1:18441" >&24; sleep 5
echo "slots" >&21; sleep 3
check "the 4th peer lands in a temp referral slot ABOVE the cap" \
bash -c 'strip_ansi /tmp/itsgoin-ctop1.log | tail -30 | grep -q "mesh 2/2 temp-referral 1/"'
check "an established mesh peer was never evicted to make room" \
bash -c 'strip_ansi /tmp/itsgoin-ctop1.log | tail -30 | grep -q "mesh 2/2"'
check "the temp slot carries NO knowledge (never a reporter in our index)" \
bash -c '[ "$(sqlite3 /tmp/itsgoin-ctop1/itsgoin.db \
"SELECT count(*) FROM reachable WHERE lower(hex(reporter_node_id))=\"'"$m4"'\"")" = "0" ]'
check "and a temp peer is never written to mesh_peers" \
bash -c '[ "$(sqlite3 /tmp/itsgoin-ctop1/itsgoin.db \
"SELECT count(*) FROM mesh_peers WHERE lower(hex(node_id))=\"'"$m4"'\"")" = "0" ]'
# Free a mesh slot: the temp peer graduates into it. The wait covers the mesh
# keepalive interval (30s) — an abruptly-gone QUIC peer is noticed when the
# next keepalive write fails, not instantly.
echo "quit" >&22; sleep 45
echo "slots" >&21; sleep 5
check "the temp referral slot GRADUATES into the freed mesh slot" \
bash -c 'strip_ansi /tmp/itsgoin-ctop1.log | tail -20 | grep -q "temp-referral 0/"'
check "the graduated peer now exchanges knowledge (written to mesh_peers)" \
bash -c '[ "$(sqlite3 /tmp/itsgoin-ctop1/itsgoin.db \
"SELECT count(*) FROM mesh_peers WHERE lower(hex(node_id))=\"'"$m4"'\"")" != "0" ]'
##############################################################################
echo
echo "== standing assertions =="
##############################################################################
check "0xC0 AnchorRegister is gone from the tree" \
bash -c '! grep -rn "AnchorRegister" crates/ --include=*.rs'
check "no automatic session relay anywhere in the convection path" \
bash -c '! grep -riE "session relay" /tmp/itsgoin-ctop*.log'
check "no registration cycle writes known_anchors (bootstrap cache only)" \
bash -c '! grep -rn "start_anchor_register_cycle" crates/ --include=*.rs'
echo
echo "== results: $PASS passed, $FAIL failed =="
[ "$FAIL" -eq 0 ]

File diff suppressed because it is too large Load diff

View file

@ -26,6 +26,52 @@
<h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 0.25rem;">Download ItsGoin</h1>
<p>Available for Android, Linux, and Windows. Free and open source.</p>
<h2 style="margin-top: 2rem;">v0.8.0-alpha &mdash; July 30, 2026</h2>
<p style="color: var(--text-muted); font-size: 0.85rem;"><strong>Clean protocol break.</strong> ALPN moves to <code>itsgoin/4</code> &mdash; v0.8 nodes and v0.7.x nodes refuse each other at the QUIC handshake rather than half-interoperating. Alpha: testers only. See <a href="design.html">design.html</a> for the full v0.8 architecture.</p>
<div class="downloads">
<a href="itsgoin-0.8.0-alpha.apk" class="download-btn btn-android">
Android APK
<span class="sub">v0.8.0-alpha</span>
</a>
<a href="itsgoin_0.8.0-alpha_amd64.AppImage" class="download-btn btn-linux">
Linux AppImage
<span class="sub">v0.8.0-alpha</span>
</a>
<a href="itsgoin-cli-0.8.0-alpha-linux-amd64" class="download-btn btn-linux">
Linux CLI / Anchor
<span class="sub">v0.8.0-alpha</span>
</a>
<a href="itsgoin-0.8.0-alpha-windows-x64-setup.exe" class="download-btn btn-windows">
Windows Installer
<span class="sub">v0.8.0-alpha</span>
</a>
</div>
<h3 style="margin-top: 1.25rem; font-size: 1rem;">Finding people &mdash; the headline feature</h3>
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 0.5rem;">
<li><strong>Registry &mdash; opt-in discoverability.</strong> A well-known "registrations here" post whose comment chain <em>is</em> the directory. Registering publishes a signed comment carrying your display name, keywords, and public author ID &mdash; self-certifying, so any holder can verify it against the key it names, with no authority involved. Entries carry a fixed 30-day expiry and auto-renew while you stay listed, so the chain self-cleans and is never more than a month stale. Unlisting sends a signed delete every holder honors. Your author ID stays location-anonymous: it maps to content through ordinary CDN holder search, never to an address.</li>
<li><strong>Greeting comments &mdash; talking to strangers.</strong> Messaging with no prior key relationship. A greeting is an opaque comment on a bio post, signed with a published open-slot key so it passes the CDN's comment-verification gate, with its body sealed to the recipient's posting key. Inside: who you really are and a return path. Outside: ciphertext authored by a throwaway ID. Replies carry a fresh key and the next rendezvous, so a conversation hops temporary identities and no two messages share an outer identity. Since encrypted comment blobs are already commonplace on public posts, greetings blend into ordinary traffic &mdash; if everyone's anonymous, anonymity doesn't stand out.</li>
<li><strong>Vouching is deliberately separate.</strong> No vouch control exists on any messaging surface. Talking to someone never grants them anything; vouching is a considered relationship act that lives in the People tab.</li>
<li><strong>Comment expiry.</strong> Ordinary comments now carry a randomized 30&ndash;365 day lifetime fixed at creation and covered by the signature, so every holder expires them at the same moment. Throwaway conversation identities eventually vanish from the network entirely.</li>
<li><strong>No proof-of-work.</strong> Considered and rejected: its cost lands inverted &mdash; a phone registrant pays seconds of battery while a botnet rig pays effectively nothing. Flood limits are holder-side size and count caps; the durable answer is vouch-gated registration once the graph supports it.</li>
</ul>
<h3 style="margin-top: 1.25rem; font-size: 1rem;">Privacy &amp; correctness fixes</h3>
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 0.5rem;">
<li><strong>Manifests no longer carry device addresses.</strong> <code>AuthorManifest</code> shipped your device's network addresses under a posting-identity author &mdash; a direct link between a persona and the machine hosting it. The field is gone from the struct <em>and</em> the signature digest, with startup migrations that re-sign your own stored manifests and purge unverifiable foreign copies. This is what makes a registered author ID safe to publish.</li>
<li><strong>Persona-split bug family fixed.</strong> The v0.6.1 network-key/posting-key split left roughly fourteen places comparing a network NodeId where posting identities now live. Consequences included: <em>visibility revocation silently failed on every post</em>, group-key distribution to later-added circle members never worked, encrypted attachments wouldn't decrypt, the replication cycle never found under-replicated content, and your own blobs missed their eviction protection tier. Every "is this mine?" check now tests membership across all of your posting personas.</li>
<li><strong>Comment ingest is verified everywhere.</strong> Two pull paths stored incoming comments with no signature verification at all. All three ingest sites now share one acceptance gate. Also fixed during review: forged tombstones could delete comments through the ingest path, a delete path trusted the sender rather than a signature, a forged policy message could poison a post's comment intake remotely, and the frontend's HTML escaper didn't escape quotes (an attribute-breakout XSS reachable through registry names, which also affected seven pre-existing sites).</li>
</ul>
<h3 style="margin-top: 1.25rem; font-size: 1rem;">Protocol slimming</h3>
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 0.5rem;">
<li><strong>Message types 46 &rarr; 40, about 1,400 lines of Rust removed.</strong> Deleted: <code>DeleteRecord</code> (0x51) and <code>VisibilityUpdate</code> (0x52), both superseded by signed control posts; <code>SocialDisconnectNotice</code> (0x71) and <code>MeshPrefer</code> (0xB3), which nothing had sent in generations; <code>GroupKeyRequest</code>/<code>Response</code> (0xA1/0xA2), an orphaned pair that could never have granted a key after the persona split; and the legacy half of the dual pull-matching path.</li>
<li><strong>Preferred peers eliminated.</strong> The preferred tier existed to serve direct N+10 push/pull that the CDN's holder model replaced. Gone: the slot tier, the <code>preferred_peers</code> table, preferred-tree routing semantics, the rebalance priority pass, the relay candidate tiers, and the 7-day and 30-day watchers.</li>
</ul>
<p style="color: var(--text-muted); font-size: 0.85rem;"><strong>Upgrade note:</strong> v0.8.0-alpha cannot talk to v0.7.x at all &mdash; that is intentional, and the anchor moves with this release. Existing data directories migrate in place on first run (manifest re-signing, legacy profile cleanup, registry materialization).</p>
<h2 style="margin-top: 2rem;">v0.7.3 &mdash; May 15, 2026</h2>
<p style="color: var(--text-muted); font-size: 0.85rem;">Bandwidth + bootstrap hardening on top of v0.7.2. Wire-compatible with v0.7.0/v0.7.1/v0.7.2.</p>

View file

@ -124,8 +124,8 @@ wrapped_key[i] = X25519_DH(author_ed25519, recipient_ed25519[i]) XOR CEK</code><
<!-- Sync -->
<section>
<h2>Sync protocol (v3)</h2>
<p>The protocol uses a single ALPN (<code>itsgoin/3</code>) with 37+ message types multiplexed over QUIC bi-streams and uni-streams.</p>
<h2>Sync protocol (v4)</h2>
<p>The protocol uses a single ALPN (<code>itsgoin/4</code>) with 40 message types multiplexed over QUIC bi-streams and uni-streams.</p>
<div class="card">
<h3>Pull-based sync</h3>