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

@ -2939,7 +2939,7 @@ document.querySelectorAll('.tab').forEach(tab => {
loadMessages(true); loadDmRecipientOptions();
clearNotifications('msg-');
}
if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); }
if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); loadUpdateSettings(); }
});
});
});
@ -3442,6 +3442,78 @@ let personasCache = [];
// 'all' (show everything) or a posting-id hex (filter to that persona).
let feedPersonaFilter = 'all';
// --- Release announcement / upgrade banner ---
async function loadUpgradeBanner() {
const banner = document.getElementById('upgrade-banner');
const titleEl = document.getElementById('upgrade-banner-title');
const bodyEl = document.getElementById('upgrade-banner-body');
const btn = document.getElementById('upgrade-banner-btn');
if (!banner || !titleEl || !bodyEl || !btn) return;
let channel = 'stable';
try { channel = await invoke('get_update_channel'); } catch (_) {}
let ann = null;
try { ann = await invoke('check_release_announcement', { channel }); } catch (_) { return; }
if (!ann) { banner.classList.add('hidden'); return; }
titleEl.textContent = ann.title || `v${ann.version} available`;
bodyEl.textContent = ann.body || `Upgrade to v${ann.version} (${ann.channel}).`;
btn.onclick = async () => {
try { await invoke('open_url_external', { url: ann.downloadUrl }); }
catch (e) { window.open(ann.downloadUrl, '_blank'); }
};
banner.classList.remove('hidden');
}
async function loadUpdateSettings() {
const statusEl = document.getElementById('update-status');
let channel = 'stable';
try { channel = await invoke('get_update_channel'); } catch (_) {}
const radio = document.querySelector(`input[name="update-channel"][value="${channel}"]`);
if (radio) radio.checked = true;
document.querySelectorAll('input[name="update-channel"]').forEach(el => {
el.addEventListener('change', async () => {
try {
await invoke('set_update_channel', { channel: el.value });
loadUpgradeBanner().catch(() => {});
refreshUpdateStatus(el.value);
} catch (e) { if (statusEl) statusEl.textContent = 'Error: ' + e; }
});
});
const checkBtn = document.getElementById('check-updates-btn');
if (checkBtn) {
checkBtn.onclick = async () => {
const ch = document.querySelector('input[name="update-channel"]:checked')?.value || 'stable';
await loadUpgradeBanner().catch(() => {});
refreshUpdateStatus(ch);
};
}
refreshUpdateStatus(channel);
}
async function refreshUpdateStatus(channel) {
const statusEl = document.getElementById('update-status');
if (!statusEl) return;
try {
const ann = await invoke('check_release_announcement', { channel });
if (ann) {
statusEl.innerHTML = `<strong style="color:#7fdbca">v${ann.version} available</strong> &mdash; <a href="#" id="status-upgrade-link">Download</a>`;
const link = document.getElementById('status-upgrade-link');
if (link) link.onclick = async (e) => {
e.preventDefault();
try { await invoke('open_url_external', { url: ann.downloadUrl }); }
catch (_) { window.open(ann.downloadUrl, '_blank'); }
};
} else {
statusEl.textContent = `You're up to date on the ${channel} channel.`;
}
} catch (_) {
statusEl.textContent = '';
}
}
async function loadPersonas() {
try {
personasCache = await invoke('list_posting_identities') || [];
@ -3930,6 +4002,9 @@ async function init() {
// Load personas up front so the compose-box picker is ready before
// the user opens the Feed tab.
loadPersonas().catch(() => {});
// Check for a newer release on the user's selected channel and,
// if found, surface the upgrade banner on the welcome screen.
loadUpgradeBanner().catch(() => {});
const info = await loadNodeInfo();
// Show first-run chooser only if no profile AND only one identity (auto-created)
let isFirstRun = false;