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?);