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.
Removes the last persona-signed direct push on the wire. Group/circle
seeds no longer travel via the 0xA0 `GroupKeyDistribute` uni-stream from
admin to member. Instead the admin publishes an encrypted post containing
the seed + metadata; each member is a recipient; the post propagates via
the normal CDN. Members decrypt with their posting secret to recover the
seed.
Eliminates the wire-level coordination signal between an admin endpoint
and each member endpoint when a group is created, a member is added, or
a key is rotated.
Core pieces:
- New `VisibilityIntent::GroupKeyDistribute` variant.
- New `types::GroupKeyDistributionContent` — JSON payload inside the
encrypted post: group_id, circle_name, epoch, group_public_key, admin,
canonical_root_post_id, group_seed.
- New `group_key_distribution` module:
- `build_distribution_post(admin, admin_secret, record, group_seed, members)`
returns `(PostId, Post, PostVisibility::Encrypted)` — wraps the CEK
per member using standard `crypto::encrypt_post`.
- `try_apply_distribution_post(s, post, visibility, our_personas)`
iterates every posting identity's secret trying to decrypt; on
success stores `group_key` + `group_seed` and returns true.
- `process_pending(s, our_personas)` scans stored
GroupKeyDistribute-intent posts and applies any we can decrypt.
Node API:
- `add_to_circle`: builds a distribution post wrapping the current seed
to just the new member, stores with intent=GroupKeyDistribute, and
propagates via `update_neighbor_manifests_as` (no direct push).
- `create_group_key_inner`: at group creation, after wrapping keys for
every non-self member, builds one distribution post addressed to all
of them and propagates through the CDN.
- `rotate_group_key`: same pattern at epoch rotation.
- New `Node::process_group_key_distributions` — scans and applies.
`sync_all` now calls it automatically so seeds take effect right after
a pull cycle.
Removals (wire-breaking; v0.6.2 already forked):
- MessageType 0xA0 (`GroupKeyDistribute`), its payload struct, the
handler in connection.rs, and `Network::push_group_key` all deleted.
ConnectionManager's `secret_seed` (network secret) is no longer used for
group-key unwrapping — that shifted to posting secrets in the apply
pass, matching the v0.6.1+ identity split where group keys are wrapped
to posting NodeIds.
Tests: new `member_decrypts_and_applies` covers a recipient decrypting +
storing the seed and a non-recipient failing to apply. Workspace
compiles clean; 118 / 118 core tests pass on a stable run (pre-existing
flaky `relay_cooldown` test with a 1ms timing window is unrelated).
Display metadata (display_name, bio, avatar_cid) is no longer broadcast via
the ProfileUpdate direct push when the user edits their name. It travels as
a signed public post with VisibilityIntent::Profile, authored by the posting
identity, and propagates through the normal neighbor-manifest CDN path.
Core pieces:
- `types::ProfilePostContent` — JSON payload serialized into the post's
content field. Ed25519 signature by the posting secret over length-prefixed
display_name + bio + 32-byte avatar_cid (or zeros) + timestamp.
- `crypto::{sign,verify}_profile` with strict length prefixing to prevent
extension attacks.
- New `profile` module: `build_profile_post`, `verify_profile_post`,
`apply_profile_post_if_applicable`. Last-writer-wins by timestamp.
- `control::receive_post` now verifies Profile-intent posts upfront (same
as Control) so bogus signatures never enter storage, and applies them
after store so the `profiles` row updates atomically with the insert.
Node API:
- `Node::set_profile` rewritten: builds a signed Profile post, stores under
intent=Profile, applies it locally (upserts the profiles row keyed by the
posting identity), then propagates via `update_neighbor_manifests_as`.
Stops calling `network.push_profile` — display changes no longer trigger
a direct wire push.
- `Node::my_profile` / `has_profile` read by `default_posting_id` instead of
`node_id`, matching where the row is written now.
ProfileUpdate (0x50) and push_profile stay for now — they still carry
routing-only data (anchors, recent_peers, preferred_peers) via
`sanitized_for_network_broadcast` and are used by `set_anchors` /
`set_public_visible`. Removing the routing fields would be a broader
cleanup; scoped out of this phase.
Tests: roundtrip verify+store, wrong-author rejection (not stored), and
older-timestamp ignored. 114 / 114 core tests pass.
Replaces two persona-signed direct pushes with CDN-propagated control posts:
a single `VisibilityIntent::Control` post type whose content is a signed
`ControlOp` the receiver verifies and applies. Deletes and visibility updates
now flow through the same neighbor-manifest CDN path as regular content — no
direct recipient push needed for persona-signed ops.
Core pieces:
- `VisibilityIntent::Control` + `VisibilityIntent::Profile` variants.
- `ControlOp::DeletePost` / `ControlOp::UpdateVisibility` (JSON, ed25519-signed
by the target post's author over op-specific byte strings).
- `crypto::{sign,verify}_control_{delete,visibility}` signing primitives.
- `control::build_delete_control_post` + `build_visibility_control_post`
for authors to construct control posts.
- `control::receive_post` — unified incoming-post path used by all 6 receive
sites. Verifies control signatures BEFORE storing, so bogus controls never
enter storage and can't be re-propagated via neighbor-manifest diffs.
- `control::apply_control_post_if_applicable` — executes the op under the
same storage guard as the insert.
Feed filter:
- Feeds (`get_feed`, `get_feed_page`, `list_posts_page`,
`list_posts_reverse_chron`) now exclude `Control` and `Profile` posts so
they propagate + tombstone without surfacing.
- Sync/export path (`list_posts_with_visibility`) keeps its own unfiltered
query so control posts still propagate via CDN.
Wire protocol:
- `SyncPost` carries `intent: Option<VisibilityIntent>` so control posts
arrive with their intent preserved.
- `BlobDeleteNotice` (0x95) removed — orphan blobs on remote holders evict
naturally via LRU rather than via a persona-signed push. Code path,
payload, sender, tests, and `delete_blob_with_cdn_notify` all gone.
Tests: control delete roundtrip (apply + tombstone) and wrong-author
rejection (not stored, not applied). 112/112 core tests pass.
Export (export.rs): ZIP archive with auto-chunking at 4GB. Four scopes:
identity only, posts only, posts+identity, everything (posts+key+follows+
profiles+settings). Includes blobs. Manifest JSON tracks metadata.
Import (import.rs): Read ZIP summary without importing (preview).
Import public posts into current identity with new PostIds + original
timestamps. Import as new identity (creates identity subdir from key).
Uses spawn_blocking for ZIP I/O to avoid Send issues with ZipArchive.
Tauri IPC: export_data, import_summary, import_public_posts,
import_as_new_identity commands. IdentityManager.base_dir() getter.
Frontend: Export wizard lightbox with scope radio buttons + output dir.
Import wizard with ZIP path, preview summary, action selection.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New identity.rs: IdentityManager manages per-identity data directories
under identities/{node_id_hex}/. Supports create, list, switch, delete,
import from key. Legacy flat layout auto-migrates on first launch.
Tauri AppState refactored from Arc<Node> to AppNode (Arc<RwLock<Arc<Node>>>)
+ AppIdentity (Arc<Mutex<IdentityManager>>). All 76 IPC commands updated
to get_node(&state).await pattern. Enables hot-swap identity switching.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>