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

@ -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