Network-wide announcements signed by the bootstrap anchor posting id

New primitive: `VisibilityIntent::Announcement`, a public post whose
author MUST be the hardcoded bootstrap anchor posting identity
(`DEFAULT_ANCHOR_POSTING_ID`) and whose content carries an ed25519
signature by that key. Forged announcements (any other author, or
bad signature) are rejected by `control::receive_post` before storage
— they never enter the DB and never propagate via neighbor-manifest
diffs. Only the real anchor can publish announcements, and it does so
sparingly as part of the release deploy flow.

Uses release announcements to drive an in-app upgrade banner:
- Anchor publishes a signed `{category:release, version, channel,
  download_url, ...}` post during every deploy.
- Clients receive it via the normal CDN; `apply_announcement_if_applicable`
  stores the latest-per-category/channel in the settings kv, keyed
  e.g. `announcement:release:stable`.
- Welcome screen checks storage on startup; if the stored release
  version > CARGO_PKG_VERSION on the user's selected channel, a banner
  appears with a Download button that opens the system browser.
- Settings gets "Updates" section with Stable / Beta radio + Check-now
  button + current status line.

Core:
- `DEFAULT_ANCHOR_POSTING_ID: NodeId` constant (32 bytes, the anchor's
  current posting id — `17af141956ae...`).
- New `VisibilityIntent::Announcement` variant; feed filters in all 6
  `get_feed*` / `list_posts*` query sites updated to also exclude the
  new intent AND the pre-existing `GroupKeyDistribute` intent.
- `types::AnnouncementContent` + `ReleaseAnnouncement` structs.
- `crypto::{sign,verify}_announcement` — length-prefixed field digest
  with a "has release" 1-byte flag.
- New `announcement` module with `verify_announcement_post`,
  `apply_announcement_if_applicable`, `latest_release`,
  `build_announcement_post`, and a `StoredAnnouncement` envelope saved
  to settings so the UI can render without a full post scan.
- `Node::publish_announcement` refuses to run unless the default posting
  id equals the anchor constant — accidental use on client installs
  fails loud.

Wire / receive:
- `control::receive_post` verifies announcement signatures upfront
  alongside Control and Profile. Same pattern; same guarantees.

CLI one-shots (no daemon):
- `itsgoin <data_dir> --print-identity` — prints network_id +
  default_posting_id, exits.
- `itsgoin <data_dir> --announce --ann-category release
  --ann-channel stable --ann-version X --ann-title ... --ann-body ...
  --ann-url https://itsgoin.com/download.html` — builds + stores +
  propagates the signed post, exits.

deploy.sh:
- Now runs the announce one-shot inside the anchor-restart window
  (after binary swap, before start). The DB is free during that gap,
  so the one-shot can write without conflicting with the running
  daemon. The restarted daemon loads all storage on boot and serves
  the new announcement to pulling peers.

Tauri IPC:
- `check_release_announcement(channel)` → Option<ReleaseAnnouncementDto>
  — returns None when up-to-date.
- `get_update_channel` / `set_update_channel(channel)` — persists in
  settings kv key `ui_update_channel`; defaults to stable.
- `open_url_external(url)` — desktop-only (xdg-open / open / cmd start);
  refuses non-http(s) URLs. Android needs the opener plugin — TODO.

Frontend:
- Upgrade banner on the welcome screen, populated by
  `loadUpgradeBanner()`. Hidden when no newer release is known.
- Settings → Updates section with Stable/Beta radio + Check-now button
  + current status line.

Tests: announcement signature roundtrip; non-anchor author rejection;
non-announcement intent is a no-op. 124 / 124 core tests pass.
This commit is contained in:
Scott Reimers 2026-04-23 01:50:12 -04:00
parent 67d9367eec
commit 481e1c8435
13 changed files with 728 additions and 15 deletions

View file

@ -25,6 +25,14 @@ async fn main() -> anyhow::Result<()> {
let mut daemon = false;
let mut mobile = false;
let mut web_port: Option<u16> = None;
let mut print_identity = false;
let mut announce = false;
let mut ann_category: Option<String> = None;
let mut ann_title: Option<String> = None;
let mut ann_body: Option<String> = None;
let mut ann_channel: Option<String> = None;
let mut ann_version: Option<String> = None;
let mut ann_url: Option<String> = None;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
@ -66,6 +74,14 @@ async fn main() -> anyhow::Result<()> {
}));
i += 2;
}
"--print-identity" => { print_identity = true; i += 1; }
"--announce" => { announce = 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; }
"--ann-channel" => { ann_channel = args.get(i + 1).cloned(); i += 2; }
"--ann-version" => { ann_version = args.get(i + 1).cloned(); i += 2; }
"--ann-url" => { ann_url = args.get(i + 1).cloned(); i += 2; }
_ => {
if data_dir == "./itsgoin-data" {
data_dir = args[i].clone();
@ -88,6 +104,54 @@ async fn main() -> anyhow::Result<()> {
}
let profile = if mobile { DeviceProfile::Mobile } else { DeviceProfile::Desktop };
// One-shot: --print-identity opens the node briefly, prints the
// network + default-posting ids in full hex, and exits. Used by
// deploy/inspection tooling.
if print_identity {
let node = Node::open_with_bind(&data_dir, bind_addr, profile).await?;
println!("network_id: {}", hex::encode(node.node_id));
println!("default_posting_id: {}", hex::encode(node.default_posting_id));
return Ok(());
}
// One-shot: --announce builds, stores, and propagates a signed
// announcement post authored by this node's default posting identity.
// Only works when this node is the bootstrap anchor (see
// `Node::publish_announcement`). Used by the release deploy flow.
if announce {
let category = ann_category.clone().unwrap_or_else(|| "release".to_string());
let title = ann_title.clone().unwrap_or_else(|| {
match (&ann_version, &ann_channel) {
(Some(v), Some(c)) => format!("ItsGoin v{} ({}) available", v, c),
(Some(v), None) => format!("ItsGoin v{} available", v),
_ => "ItsGoin announcement".to_string(),
}
});
let body = ann_body.clone().unwrap_or_default();
let release = match (ann_channel.as_deref(), ann_version.as_deref()) {
(Some(ch), Some(ver)) => Some(itsgoin_core::types::ReleaseAnnouncement {
channel: ch.to_string(),
version: ver.to_string(),
download_url: ann_url.clone().unwrap_or_else(|| "https://itsgoin.com/download.html".to_string()),
}),
_ => None,
};
let node = Node::open_with_bind(&data_dir, bind_addr, profile).await?;
match node.publish_announcement(category.clone(), title.clone(), body, release).await {
Ok(post_id) => {
println!("announcement_post_id: {}", hex::encode(post_id));
println!("category: {}", category);
println!("title: {}", title);
}
Err(e) => {
eprintln!("Failed to publish announcement: {}", 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?);

View file

@ -0,0 +1,220 @@
//! Network-wide announcements, authored only by the bootstrap anchor's
//! posting identity (see `crate::DEFAULT_ANCHOR_POSTING_ID`).
//!
//! Shape: a standard public post with `VisibilityIntent::Announcement`
//! whose content is a JSON `AnnouncementContent` carrying a signed
//! payload. Propagation uses the normal CDN path; there is no direct
//! push. Because the signature check is against a single hardcoded key,
//! any peer can try to publish an announcement but only the real anchor's
//! posts verify — forgeries are rejected before storage in
//! `control::receive_post`, and thus never re-propagate via neighbor
//! manifest diffs.
//!
//! Use cases: release notices (triggers welcome-screen upgrade banner),
//! protocol notices, security-relevant admin broadcasts. Intended to be
//! used sparingly.
use crate::crypto;
use crate::storage::Storage;
use crate::types::{
AnnouncementContent, NodeId, Post, PostId, PostVisibility, ReleaseAnnouncement,
VisibilityIntent,
};
/// Verify that a candidate post is a valid announcement: its author must
/// be `DEFAULT_ANCHOR_POSTING_ID` and its signature must verify. Returns
/// the decoded `AnnouncementContent` on success.
pub fn verify_announcement_post(post: &Post) -> anyhow::Result<AnnouncementContent> {
if post.author != crate::DEFAULT_ANCHOR_POSTING_ID {
anyhow::bail!("announcement author is not the bootstrap anchor");
}
let content: AnnouncementContent = serde_json::from_str(&post.content).map_err(|e| {
anyhow::anyhow!("announcement content is not a valid AnnouncementContent: {}", e)
})?;
if !crypto::verify_announcement(
&post.author,
&content.category,
&content.title,
&content.body,
content.timestamp_ms,
&content.release,
&content.signature,
) {
anyhow::bail!("invalid announcement signature");
}
Ok(content)
}
/// If the post is an announcement, verify + apply by upserting into the
/// `settings`-backed latest-announcement slot(s). Only applied if newer
/// than the stored row for the same category/channel (last-writer-wins
/// by timestamp).
pub fn apply_announcement_if_applicable(
s: &Storage,
post: &Post,
intent: Option<&VisibilityIntent>,
) -> anyhow::Result<()> {
if !matches!(intent, Some(VisibilityIntent::Announcement)) {
return Ok(());
}
let content = verify_announcement_post(post)?;
// Serialize the full content + a post_id pointer so the UI can render
// the banner without re-fetching the post. Storage is settings-kv
// keyed by "announcement:<category>:<channel>".
let post_id = crate::content::compute_post_id(post);
let key = announcement_key(&content.category, content.release.as_ref().map(|r| r.channel.as_str()));
if let Some(existing_ts) = s.get_setting(&format!("{}:ts", key))? {
if let Ok(prev) = existing_ts.parse::<u64>() {
if prev >= content.timestamp_ms {
return Ok(());
}
}
}
let envelope = StoredAnnouncement {
post_id,
content: content.clone(),
};
let json = serde_json::to_string(&envelope)?;
s.set_setting(&key, &json)?;
s.set_setting(&format!("{}:ts", key), &content.timestamp_ms.to_string())?;
Ok(())
}
fn announcement_key(category: &str, channel: Option<&str>) -> String {
match channel {
Some(ch) => format!("announcement:{}:{}", category, ch),
None => format!("announcement:{}", category),
}
}
/// Envelope stored in settings so the UI can fetch the latest announcement
/// without a full post scan. Includes the post_id so the UI can jump to
/// the full post if needed.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct StoredAnnouncement {
pub post_id: PostId,
pub content: AnnouncementContent,
}
/// Read the latest release announcement for a channel, if any.
pub fn latest_release(s: &Storage, channel: &str) -> anyhow::Result<Option<StoredAnnouncement>> {
let key = announcement_key("release", Some(channel));
match s.get_setting(&key)? {
Some(json) => Ok(serde_json::from_str(&json).ok()),
None => Ok(None),
}
}
/// Build a signed announcement post. Must be called with the anchor's
/// posting keypair (its seed matching `DEFAULT_ANCHOR_POSTING_ID`).
pub fn build_announcement_post(
author: &NodeId,
author_secret: &[u8; 32],
category: &str,
title: &str,
body: &str,
release: Option<ReleaseAnnouncement>,
) -> Post {
let timestamp_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let signature = crypto::sign_announcement(
author_secret,
category,
title,
body,
timestamp_ms,
&release,
);
let content = AnnouncementContent {
category: category.to_string(),
title: title.to_string(),
body: body.to_string(),
timestamp_ms,
release,
signature,
};
Post {
author: *author,
content: serde_json::to_string(&content).unwrap_or_default(),
attachments: vec![],
timestamp_ms,
}
}
/// Post visibility is always Public — the signature binds the content
/// and only the anchor key can produce a verifying post.
pub fn announcement_visibility() -> PostVisibility {
PostVisibility::Public
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::Storage;
use ed25519_dalek::SigningKey;
fn temp_storage() -> Storage {
Storage::open(":memory:").unwrap()
}
fn make_keypair(seed_byte: u8) -> ([u8; 32], NodeId) {
let seed = [seed_byte; 32];
let signing_key = SigningKey::from_bytes(&seed);
let public = signing_key.verifying_key();
(seed, *public.as_bytes())
}
/// Override DEFAULT_ANCHOR_POSTING_ID for tests by using whatever key
/// we generate. We can't change the const, so instead verify_announcement_post
/// is called with a post whose author matches the real const — we test
/// the signature + serialization logic in isolation via a custom flow.
#[test]
fn announcement_signature_roundtrip() {
let (sec, pubk) = make_keypair(0xaa);
let release = Some(ReleaseAnnouncement {
channel: "stable".to_string(),
version: "0.6.3".to_string(),
download_url: "https://itsgoin.com/download.html".to_string(),
});
let ts = 1234u64;
let sig = crypto::sign_announcement(&sec, "release", "v0.6.3 available", "notes", ts, &release);
assert!(crypto::verify_announcement(&pubk, "release", "v0.6.3 available", "notes", ts, &release, &sig));
// Flipping any field should break verification.
assert!(!crypto::verify_announcement(&pubk, "release", "v0.6.4 available", "notes", ts, &release, &sig));
let other_release = Some(ReleaseAnnouncement {
channel: "beta".to_string(),
..release.as_ref().unwrap().clone()
});
assert!(!crypto::verify_announcement(&pubk, "release", "v0.6.3 available", "notes", ts, &other_release, &sig));
}
#[test]
fn non_anchor_author_is_rejected() {
// Craft a post whose author is NOT the hardcoded anchor key.
let (sec, pubk) = make_keypair(0xbb);
let release = Some(ReleaseAnnouncement {
channel: "stable".to_string(),
version: "0.6.3".to_string(),
download_url: "x".to_string(),
});
let post = build_announcement_post(&pubk, &sec, "release", "Hi", "", release);
let res = verify_announcement_post(&post);
assert!(res.is_err(), "post authored by non-anchor key must be rejected");
}
#[test]
fn apply_noops_on_non_announcement_intent() {
let s = temp_storage();
let (sec, pubk) = make_keypair(0xcc);
let post = build_announcement_post(&pubk, &sec, "release", "t", "b", None);
// Not an announcement intent — apply should no-op and return Ok.
apply_announcement_if_applicable(&s, &post, Some(&VisibilityIntent::Public)).unwrap();
assert!(s.get_setting("announcement:release").unwrap().is_none());
}
}

View file

@ -111,6 +111,12 @@ pub fn receive_post(
Some(VisibilityIntent::Profile) => {
crate::profile::verify_profile_post(post)?;
}
Some(VisibilityIntent::Announcement) => {
// Only the hardcoded anchor posting identity may author
// announcements. verify_announcement_post checks both the
// author match and the signature.
crate::announcement::verify_announcement_post(post)?;
}
_ => {}
}
@ -122,6 +128,7 @@ pub fn receive_post(
if stored {
apply_control_post_if_applicable(s, post, intent)?;
crate::profile::apply_profile_post_if_applicable(s, post, intent)?;
crate::announcement::apply_announcement_if_applicable(s, post, intent)?;
}
Ok(stored)
}

View file

@ -411,6 +411,75 @@ pub fn verify_profile(
vk.verify_strict(&profile_post_bytes(display_name, bio, avatar_cid, timestamp_ms), &sig).is_ok()
}
/// Canonical bytes for an announcement signature. Length-prefixed strings
/// prevent extension/reordering attacks; the release subfields are all
/// bundled after a 1-byte "has release" flag.
fn announcement_bytes(
category: &str,
title: &str,
body: &str,
timestamp_ms: u64,
release: &Option<crate::types::ReleaseAnnouncement>,
) -> Vec<u8> {
let cat = category.as_bytes();
let tit = title.as_bytes();
let bd = body.as_bytes();
let mut buf = Vec::with_capacity(128 + cat.len() + tit.len() + bd.len());
buf.extend_from_slice(b"annc:");
buf.extend_from_slice(&(cat.len() as u64).to_le_bytes());
buf.extend_from_slice(cat);
buf.extend_from_slice(&(tit.len() as u64).to_le_bytes());
buf.extend_from_slice(tit);
buf.extend_from_slice(&(bd.len() as u64).to_le_bytes());
buf.extend_from_slice(bd);
buf.extend_from_slice(&timestamp_ms.to_le_bytes());
match release {
Some(r) => {
buf.push(1u8);
let c = r.channel.as_bytes();
let v = r.version.as_bytes();
let u = r.download_url.as_bytes();
buf.extend_from_slice(&(c.len() as u64).to_le_bytes());
buf.extend_from_slice(c);
buf.extend_from_slice(&(v.len() as u64).to_le_bytes());
buf.extend_from_slice(v);
buf.extend_from_slice(&(u.len() as u64).to_le_bytes());
buf.extend_from_slice(u);
}
None => buf.push(0u8),
}
buf
}
pub fn sign_announcement(
seed: &[u8; 32],
category: &str,
title: &str,
body: &str,
timestamp_ms: u64,
release: &Option<crate::types::ReleaseAnnouncement>,
) -> Vec<u8> {
let signing_key = SigningKey::from_bytes(seed);
let sig = signing_key.sign(&announcement_bytes(category, title, body, timestamp_ms, release));
sig.to_bytes().to_vec()
}
pub fn verify_announcement(
author: &NodeId,
category: &str,
title: &str,
body: &str,
timestamp_ms: u64,
release: &Option<crate::types::ReleaseAnnouncement>,
signature: &[u8],
) -> bool {
if signature.len() != 64 { return false; }
let sig_bytes: [u8; 64] = match signature.try_into() { Ok(b) => b, Err(_) => return false };
let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes);
let Ok(vk) = VerifyingKey::from_bytes(author) else { return false };
vk.verify_strict(&announcement_bytes(category, title, body, timestamp_ms, release), &sig).is_ok()
}
/// Verify an ed25519 delete signature: the author's public key signed the post_id.
pub fn verify_delete_signature(author: &NodeId, post_id: &PostId, signature: &[u8]) -> bool {
if signature.len() != 64 {

View file

@ -9,6 +9,7 @@ pub mod http;
pub mod export;
pub mod identity;
pub mod import;
pub mod announcement;
pub mod network;
pub mod node;
pub mod profile;
@ -24,6 +25,17 @@ pub use iroh::{EndpointAddr, EndpointId};
use types::NodeId;
/// Posting-identity public key of the bootstrap anchor. Only announcements
/// authored by this key are accepted by `control::receive_post` for the
/// `VisibilityIntent::Announcement` intent. Hardcoded so clients cannot be
/// tricked into accepting a forged network-wide announcement.
pub const DEFAULT_ANCHOR_POSTING_ID: NodeId = [
0x17, 0xaf, 0x14, 0x19, 0x56, 0xae, 0x0b, 0x50,
0xdc, 0x1c, 0xb9, 0x24, 0x8c, 0xad, 0xf5, 0xfc,
0xa3, 0x71, 0xea, 0x2d, 0x85, 0x31, 0xac, 0x9a,
0xdd, 0x3c, 0x03, 0xca, 0xff, 0xc6, 0x14, 0x41,
];
/// Parse a connect string "nodeid_hex@ip:port" or "nodeid_hex@host:port" or bare "nodeid_hex"
/// into (NodeId, EndpointAddr). Supports DNS hostnames via `ToSocketAddrs`.
/// Shared utility used by CLI, Tauri, and bootstrap.

View file

@ -1072,13 +1072,14 @@ impl Node {
VisibilityIntent::Friends => storage.list_public_follows(),
VisibilityIntent::Circle(name) => storage.get_circle_members(name),
VisibilityIntent::Direct(ids) => Ok(ids.clone()),
// Control / Profile posts are always Public on the wire;
// GroupKeyDistribute posts build their own recipient list in
// `group_key_distribution::build_distribution_post`. None of
// the three use this resolver.
// Control / Profile / Announcement posts are always Public on
// the wire; GroupKeyDistribute posts build their own recipient
// list in `group_key_distribution::build_distribution_post`.
// None of these use the standard resolver.
VisibilityIntent::Control
| VisibilityIntent::Profile
| VisibilityIntent::GroupKeyDistribute => Ok(vec![]),
| VisibilityIntent::GroupKeyDistribute
| VisibilityIntent::Announcement => Ok(vec![]),
}
}
@ -1886,6 +1887,73 @@ impl Node {
// ---- end Groups ----
// ---- Announcements ----
/// Publish a signed network-wide announcement. Only succeeds when run
/// on the bootstrap anchor — the default posting identity must be
/// `DEFAULT_ANCHOR_POSTING_ID`. Called from `itsgoin announce` during
/// release deploys.
pub async fn publish_announcement(
&self,
category: String,
title: String,
body: String,
release: Option<crate::types::ReleaseAnnouncement>,
) -> anyhow::Result<PostId> {
if self.default_posting_id != crate::DEFAULT_ANCHOR_POSTING_ID {
anyhow::bail!(
"refusing to publish announcement: default posting identity is not the bootstrap anchor"
);
}
let post = crate::announcement::build_announcement_post(
&self.default_posting_id,
&self.default_posting_secret,
&category,
&title,
&body,
release,
);
let post_id = crate::content::compute_post_id(&post);
let timestamp_ms = post.timestamp_ms;
{
let storage = self.storage.get().await;
storage.store_post_with_intent(
&post_id,
&post,
&PostVisibility::Public,
&VisibilityIntent::Announcement,
)?;
crate::announcement::apply_announcement_if_applicable(
&*storage,
&post,
Some(&VisibilityIntent::Announcement),
)?;
}
self.update_neighbor_manifests_as(
&self.default_posting_id,
&self.default_posting_secret,
&post_id,
timestamp_ms,
).await;
info!(
post_id = hex::encode(post_id),
category = %category,
"Published network-wide announcement"
);
Ok(post_id)
}
/// Return the latest stored release announcement for the given channel
/// (\"stable\" or \"beta\"), or `None` if none is known yet.
pub async fn latest_release_announcement(
&self,
channel: &str,
) -> anyhow::Result<Option<crate::announcement::StoredAnnouncement>> {
let storage = self.storage.get().await;
crate::announcement::latest_release(&*storage, channel)
}
/// Scan any newly-received `VisibilityIntent::GroupKeyDistribute` posts
/// and apply ones we can decrypt with one of our posting identities.
/// Intended to run after a sync pass so group seeds propagate to members

View file

@ -866,7 +866,7 @@ impl Storage {
pub fn list_posts_reverse_chron(&self) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
let mut stmt = self.conn.prepare(
"SELECT id, author, content, attachments, timestamp_ms, visibility FROM posts
WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"'))
WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"' AND visibility_intent != '\"Announcement\"' AND visibility_intent != '\"GroupKeyDistribute\"'))
ORDER BY timestamp_ms DESC",
)?;
let rows = stmt.query_map([], |row| {
@ -903,7 +903,7 @@ impl Storage {
"SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility
FROM posts p
INNER JOIN follows f ON p.author = f.node_id
WHERE (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"'))
WHERE (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"' AND p.visibility_intent != '\"Announcement\"' AND p.visibility_intent != '\"GroupKeyDistribute\"'))
ORDER BY p.timestamp_ms DESC",
)?;
let rows = stmt.query_map([], |row| {
@ -940,12 +940,12 @@ impl Storage {
"SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility
FROM posts p INNER JOIN follows f ON p.author = f.node_id
WHERE p.timestamp_ms < ?1
AND (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"'))
AND (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"' AND p.visibility_intent != '\"Announcement\"' AND p.visibility_intent != '\"GroupKeyDistribute\"'))
ORDER BY p.timestamp_ms DESC LIMIT ?2"
} else {
"SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility
FROM posts p INNER JOIN follows f ON p.author = f.node_id
WHERE (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"'))
WHERE (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"' AND p.visibility_intent != '\"Announcement\"' AND p.visibility_intent != '\"GroupKeyDistribute\"'))
ORDER BY p.timestamp_ms DESC LIMIT ?2"
};
let mut stmt = self.conn.prepare(sql)?;
@ -963,12 +963,12 @@ impl Storage {
"SELECT id, author, content, attachments, timestamp_ms, visibility
FROM posts
WHERE timestamp_ms < ?1
AND (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"'))
AND (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"' AND visibility_intent != '\"Announcement\"' AND visibility_intent != '\"GroupKeyDistribute\"'))
ORDER BY timestamp_ms DESC LIMIT ?2"
} else {
"SELECT id, author, content, attachments, timestamp_ms, visibility
FROM posts
WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"'))
WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"' AND visibility_intent != '\"Announcement\"' AND visibility_intent != '\"GroupKeyDistribute\"'))
ORDER BY timestamp_ms DESC LIMIT ?2"
};
let mut stmt = self.conn.prepare(sql)?;

View file

@ -252,6 +252,11 @@ pub enum VisibilityIntent {
/// standard encrypted post that propagates via the CDN. Members
/// decrypt with their posting secret to recover the seed.
GroupKeyDistribute,
/// Network-wide announcement (release notice, protocol notice, etc.),
/// authored only by the hardcoded anchor posting identity and verified
/// against it on receive. Propagates via the CDN like any other post.
/// Filtered out of feeds; surfaced only in the Settings / welcome UI.
Announcement,
}
/// Content payload of a `VisibilityIntent::Profile` post — persona display
@ -271,6 +276,48 @@ pub struct ProfilePostContent {
pub signature: Vec<u8>,
}
/// Content payload of a `VisibilityIntent::Announcement` post.
///
/// A network-wide announcement signed by the bootstrap anchor's posting
/// identity (see `DEFAULT_ANCHOR_POSTING_ID`). Carries either a release
/// notice (`category == "release"` with `release` populated) or a generic
/// notice (`category == "notice"`). Propagates via the CDN; no direct push.
///
/// Signature is ed25519 over length-prefixed bytes of all fields except
/// `signature` itself — see `crypto::sign_announcement`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnnouncementContent {
/// Machine-readable category: "release" | "notice".
pub category: String,
/// Short human-readable title shown in UI.
pub title: String,
/// Longer body / changelog notes.
#[serde(default)]
pub body: String,
/// When this announcement was authored (ms). Enforces monotonicity —
/// older announcements for the same category never overwrite newer
/// stored state.
pub timestamp_ms: u64,
/// Release-specific fields, only set when `category == "release"`.
#[serde(default)]
pub release: Option<ReleaseAnnouncement>,
/// 64-byte ed25519 signature by `DEFAULT_ANCHOR_POSTING_ID`.
pub signature: Vec<u8>,
}
/// Release-specific subfields of an announcement. Kept separate so a
/// `category == "notice"` post doesn't need these filled in.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleaseAnnouncement {
/// "stable" | "beta".
pub channel: String,
/// Semver version string, e.g. "0.6.2".
pub version: String,
/// URL clients open in the system browser for the download (typically
/// `https://itsgoin.com/download.html`).
pub download_url: String,
}
/// Content payload of a `VisibilityIntent::GroupKeyDistribute` post.
/// Wrapped inside a standard `PostVisibility::Encrypted` envelope — members
/// decrypt via `crypto::decrypt_post` with their posting secret, then parse

File diff suppressed because one or more lines are too long

View file

@ -241,6 +241,7 @@ async fn post_to_dto(
VisibilityIntent::Control => "control".to_string(),
VisibilityIntent::Profile => "profile".to_string(),
VisibilityIntent::GroupKeyDistribute => "group_key_distribute".to_string(),
VisibilityIntent::Announcement => "announcement".to_string(),
},
_ => "unknown".to_string(),
}
@ -1339,6 +1340,117 @@ struct KnownAnchorDto {
addresses: Vec<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ReleaseAnnouncementDto {
channel: String,
version: String,
title: String,
body: String,
download_url: String,
timestamp_ms: u64,
is_newer_than_current: bool,
}
/// Check for a newer release on the user's selected update channel.
/// Returns None if no announcement is stored for that channel or the
/// stored announcement isn't actually newer than the running build.
#[tauri::command]
async fn check_release_announcement(
state: State<'_, AppNode>,
channel: String,
) -> Result<Option<ReleaseAnnouncementDto>, String> {
let node = get_node(&state).await;
let stored = node.latest_release_announcement(&channel).await.map_err(|e| e.to_string())?;
let Some(stored) = stored else { return Ok(None); };
let Some(release) = stored.content.release.as_ref() else { return Ok(None); };
let current = env!("CARGO_PKG_VERSION");
let is_newer = version_is_newer(&release.version, current);
if !is_newer { return Ok(None); }
Ok(Some(ReleaseAnnouncementDto {
channel: release.channel.clone(),
version: release.version.clone(),
title: stored.content.title.clone(),
body: stored.content.body.clone(),
download_url: release.download_url.clone(),
timestamp_ms: stored.content.timestamp_ms,
is_newer_than_current: true,
}))
}
/// Simple dotted-semver compare. Returns true if `a` > `b`.
/// Handles "0.6.2" vs "0.6.3" and suffixed labels like "0.6.3-beta"
/// (beta suffix treated as pre-release, so "0.6.3-beta" < "0.6.3").
fn version_is_newer(a: &str, b: &str) -> bool {
fn parse(v: &str) -> (Vec<u32>, bool) {
let (core, pre) = match v.split_once('-') {
Some((c, _)) => (c, true),
None => (v, false),
};
let nums: Vec<u32> = core.split('.').filter_map(|p| p.parse().ok()).collect();
(nums, pre)
}
let (na, pa) = parse(a);
let (nb, pb) = parse(b);
let len = na.len().max(nb.len());
for i in 0..len {
let ai = *na.get(i).unwrap_or(&0);
let bi = *nb.get(i).unwrap_or(&0);
if ai != bi { return ai > bi; }
}
// Cores equal — a stable (no pre) outranks a pre-release.
!pa && pb
}
/// Read the user's preferred update channel from local settings.
/// Defaults to "stable" if never set.
#[tauri::command]
async fn get_update_channel(state: State<'_, AppNode>) -> Result<String, String> {
let node = get_node(&state).await;
let storage = node.storage.get().await;
Ok(storage
.get_setting("ui_update_channel")
.map_err(|e| e.to_string())?
.unwrap_or_else(|| "stable".to_string()))
}
/// Persist the user's preferred update channel.
#[tauri::command]
async fn set_update_channel(state: State<'_, AppNode>, channel: String) -> Result<(), String> {
if channel != "stable" && channel != "beta" {
return Err(format!("invalid channel: {}", channel));
}
let node = get_node(&state).await;
let storage = node.storage.get().await;
storage.set_setting("ui_update_channel", &channel).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.
/// TODO: Android requires an Intent.ACTION_VIEW — add tauri-plugin-opener
/// when we need the banner to work on mobile.
#[tauri::command]
fn open_url_external(url: String) -> Result<(), String> {
if !url.starts_with("https://") && !url.starts_with("http://") {
return Err("only http(s) urls are allowed".to_string());
}
#[cfg(target_os = "linux")]
let res = std::process::Command::new("xdg-open").arg(&url).spawn();
#[cfg(target_os = "macos")]
let res = std::process::Command::new("open").arg(&url).spawn();
#[cfg(target_os = "windows")]
let res = std::process::Command::new("cmd").args(["/c", "start", "", &url]).spawn();
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
let res: std::io::Result<std::process::Child> = Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"open_url_external not supported on this platform",
));
res.map(|_| ()).map_err(|e| e.to_string())
}
#[tauri::command]
async fn list_known_anchors(state: State<'_, AppNode>) -> Result<Vec<KnownAnchorDto>, String> {
let node = get_node(&state).await;
@ -2907,6 +3019,10 @@ pub fn run() {
set_anchors,
list_anchor_peers,
list_known_anchors,
check_release_announcement,
get_update_channel,
set_update_channel,
open_url_external,
list_connections,
worm_lookup,
list_social_routes,