Two pre-release fixes found during audit.
1) GroupKeyDistribute admin forgery (critical)
`group_key_distribution::try_apply_distribution_post` trusted the
`admin` field inside the decrypted payload without verifying it
matched the post's author. Exploit: any peer who learns a victim's
posting NodeId (public — appears as a recipient on any DM/group
post) and observes a target group_id in the wild could craft an
encrypted distribution post claiming to be from the legitimate
admin. The victim's storage uses INSERT OR REPLACE on group_keys,
so a successful forgery would overwrite the victim's legitimate
group key record and stored seed, breaking future rotations / key
distributions from the real admin.
Fix: reject the distribution post when `content.admin != post.author`.
Added test `forged_admin_is_rejected` that seeds a legitimate
record, attempts a forgery, and asserts the legitimate record is
untouched.
2) Cap concurrent port-scan hole punches at 1 (bandwidth)
`hole_punch_with_scanning` fires ~100 QUIC ClientHellos/sec for up
to SCAN_MAX_DURATION_SECS (300s), ~1 Mbps per active scanner. With
no cap, the growth loop / anchor referrals / replication paths
could spawn several scanners at once and drive sustained multi-Mbps
upload — particularly pathological on obfuscated VPNs where every
probe stalls at a proxy timeout, explaining the reported 10 Mbps
sustained upload after anchor connect.
Fix: module-level `tokio::sync::Semaphore(1)` guarding entry to the
scanning loop. Second-and-beyond callers fall back to the cheaper
`hole_punch_parallel` (standard punching, no 100/sec port walk)
instead of spawning another scanner. Permit is held for the scanner
lifetime and released on return. Added unit test
`scanner_semaphore_caps_concurrent_scans_at_one`.
Both changes leave the successful-call path untouched (single scanner
still runs; legitimate key distributions still apply). 120 / 120 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).
Introduces **groups** — a new many-way primitive anchored at a public root
post — reusing the existing circle encryption machinery. Circles stay
one-way (admin posts only, as before). Groups are distinguished from
circles by a single field: a non-null `canonical_root_post_id` on the
group-key record.
Type / schema changes:
- `GroupKeyRecord.canonical_root_post_id: Option<PostId>` (serde default).
When `Some`, the record represents a group rooted at that public post;
when `None`, it's a traditional circle.
- `group_keys` table gets a `canonical_root_post_id BLOB` column + an
index `idx_group_keys_root` on it. Migration added for upgraded DBs;
the CREATE TABLE statement carries the column so fresh DBs match.
Wire:
- `GroupKeyDistributePayload` gains an optional `canonical_root_post_id`
field. v0.6.1 peers will deserialize it as absent and continue treating
the record as a circle. All three sender sites (new-circle distribute,
add-member distribute, epoch-rotation distribute) pass the field through.
Storage:
- `create_group_key` / `get_group_key` / `get_group_key_by_circle` write
and read the new column. Added a shared `row_to_group_key` helper so the
two lookup functions don't drift.
- New `get_group_by_canonical_root(root_post_id)` — the inverse lookup
used by posting / retrieval flows.
Node API (new):
- `create_group_from_post(root_post_id, initial_members)` — creates a
backing circle named `group:<6-byte-hex-of-root>`, initializes the
group key with `canonical_root_post_id` set, and invites each initial
member (reusing `add_to_circle`'s wrap+distribute path so members get
the seed on the wire). Returns `(GroupId, circle_name)`.
- `post_to_group(root_post_id, content, attachments)` — any member with
the group seed can call this. Looks up the group by root, routes
through `create_post_with_visibility(Circle(name))` (which already
chooses `GroupEncrypted` when the seed is present), then stores a
`ThreadMeta` row linking the new post back to the canonical root so
retrieval can reconstruct the group.
- `list_group_posts_by_root(root_post_id)` — returns all contributions
via the ThreadMeta parent index. Callers decrypt normally; members see
full content, non-members see encrypted blobs.
Shared plumbing:
- `create_group_key_for_circle` now delegates to a shared
`create_group_key_inner(circle_name, canonical_root)` helper, keeping
one place where the record is constructed and the seed is persisted.
Notes:
- No crypto change: groups use the same `GroupEncrypted` primitive
circles already used. The "admin-only post" restriction on circles was
a UX choice, not a cryptographic limit — groups expose the many-way
path directly by letting any member with the seed call `post_to_group`.
- ThreadMeta is the clustering primitive. It already existed for split
comment threads; groups reuse it so the query pattern
("posts whose parent is root X") stays in one place.
- Frontend UI for groups is deferred — the backend surface is complete
and exercise-able via Tauri/CLI.
Tests: new storage test asserts canonical_root lookup round-trips and
that circles (no root) are invisible to the root lookup. 117 / 117 core
tests pass.
A comment can now reference a separate Post that carries the full body
(long text, attachments, rich formatting). The inline comment's `content`
becomes a short preview string; the referenced post propagates through the
normal CDN and readers fetch it lazily when rendering the expanded view.
Type change:
- `InlineComment` gains `ref_post_id: Option<PostId>` (#[serde(default)]).
When None, `content` is the full comment text (v0.6.1 shape — unchanged on
the wire). When Some, `content` is the preview.
Signature binding:
- `crypto::sign_comment` / `verify_comment_signature` now take
`ref_post_id: Option<&PostId>`. The signed digest appends
`b"ref:" || ref_post_id` only when a ref is present, so plain comments
produce the same digest as the v0.6.1 scheme and remain verifiable
without a migration. When a ref is present the signature binds it, so a
peer can't strip or swap the reference without re-signing.
Storage:
- `comments` table gets a `ref_post_id BLOB` column (nullable). Added to
both the CREATE TABLE statement and a conditional ALTER TABLE migration
so upgraded DBs pick it up automatically.
- `store_comment`, `get_comments`, `get_comments_with_tombstones` read and
write the column.
Node API:
- `comment_on_post` stays as the plain-comment entry point (calls the
inner helper with `ref_post_id = None`).
- New `comment_on_post_with_ref(post_id, preview, ref_post_id)` for rich
comments. Both share a single inner helper that signs, stores, and
propagates via BlobHeaderDiff.
connection.rs BlobHeaderDiff handler passes `comment.ref_post_id.as_ref()`
to the signature verify so forged or rewritten refs are rejected.
Tests: new crypto test asserting the signature binds ref_post_id (strip /
swap / drop all fail); new storage test asserting ref_post_id roundtrips
through live + tombstone reads. 116 / 116 core tests pass.
Client-side UX (pulling the ref post on expand, composing rich comments)
is frontend work that will land with the next UI iteration.
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.
v0.6.2 wire fork: every persona-identifying direct push is gone. Public posts
propagate only through the CDN (pull + header-diff neighbor propagation).
Encrypted posts propagate only through pull with merged author-or-recipient
match. There is no remaining sender→recipient traffic correlation signal on
the wire for content.
Protocol (network-breaking):
- Retire MessageType 0x42 (PostNotification), 0x43 (PostPush),
0x44 (AudienceRequest), 0x45 (AudienceResponse). Their payload structs are
deleted along with the handlers and senders.
- SocialDisconnectNotice (0x71) / SocialAddressUpdate (0x70) sender
functions targeting audience are deleted; the existing handlers stay
(both already dead code on the send side).
Core removals:
- `push_to_audience`, `notify_post`, `push_delete`,
`push_disconnect_to_audience`, `push_address_update_to_audience`,
`send_audience_request`, `send_audience_response`, `send_to_audience` —
all gone from network.rs.
- `handle_post_notification` removed from connection.rs.
- `request_audience`, `approve_audience`, `deny_audience`,
`remove_audience`, `list_audience_members`, `list_audience` removed from
Node.
- `audience_pushed` step removed from post creation.
- `AudienceDirection`, `AudienceStatus`, `AudienceRecord`,
`AudienceApprovalMode` removed from types.
- Storage: `store_audience`, `list_audience`, `list_audience_members`,
`remove_audience`, `row_to_audience_record`, `audience_crud` test, the
`audience` CREATE TABLE, and the audience-dependent social route rebuild
branch all removed. Upgraded DBs retain the orphan `audience` table;
nothing touches it.
Follow-on cleanups:
- `SocialRelation::Audience` + `::Mutual` collapsed into just `Follow`.
The Display/FromStr impl accepts legacy "audience"/"mutual" strings from
pre-v0.6.2 DBs and maps them to Follow.
- Blob-eviction priority function drops the audience factor; relationship
is now own-author vs followed vs other. Tests updated accordingly.
- `CommentPermission::AudienceOnly` → `FollowersOnly`. Check uses the
author's public follows (`list_public_follows`) rather than a separate
audience table. `ModerationMode::AudienceOnly` similarly renamed.
- Follow/unfollow routines simplified: no audience downgrade logic;
unfollow removes the social route entirely.
UI:
- CLI: `audience*` commands removed.
- Tauri: `AudienceDto`, `list_audience`, `list_audience_outbound`,
`request_audience`, `approve_audience`, `remove_audience` commands
removed from invoke_handler. Frontend: audience panel and audience/mutual
badges removed; compose permission dropdown shows "Followers" instead of
"Audience"; `loadAudience` is a no-op stub that hides any leftover DOM.
Tests: 111 / 111 core tests pass.
Breaking change: v0.6.2 nodes won't interoperate with v0.6.1 for delete
propagation, visibility updates, direct post push, post notifications, or
audience requests. Upgrade both ends.
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.
Phase 3 merged-pull was shipped when network_id == posting_id (pre-
v0.6.1), so adding our_node_id to the pull query's follows list was
enough to trigger recipient-match on DMs. v0.6.1 broke that:
- Fresh installs generate independent network + posting keys; DMs are
encrypted to posting id, but the pull query carried network id.
Recipient-match never fired. Non-follower DMs never reached.
- Upgraders rotated the network key; DMs addressed to the old key
(now the default posting id) never matched either.
Fix: pull-request builders now include every entry from
list_posting_identities() in query_list. Network id is omitted — it
is never an author and never a wrapped_key.recipient, so adding it
would only leak the boundary without ever matching.
Four call sites fixed: Network::pull_from_peer, and
ConnectionManager::{pull_from_peer, pull_from_peer_unlocked, and the
post-notification-triggered pull path}.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AndroidFs trait must be in scope for show_open_file_dialog / read
method resolution — AndroidFsExt alone isn't enough. Matches the
existing share_file pattern that does use both traits.
Caught on the v0.6.1 Android build; previous cargo check only
validated the desktop target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reset All Data:
- Sentinel now written at the app-level data_dir instead of the
active identity's subdir. On Android the subdir path was never
checked at startup, so reset silently did nothing.
- On detection, wipe EVERYTHING under the app data_dir: identity.key,
itsgoin.db + WAL + SHM, blobs, all identity subdirs. Next launch
is truly fresh — new network key, new posting key, no prior data.
First-run name:
- Display name is optional. Blank submits as anonymous.
- First-run modal + profile overlay placeholder updated to say
"Display name (optional)".
Android file picker:
- pick_file on Android now uses tauri-plugin-android-fs'
show_open_file_dialog (Storage Access Framework OPEN_DOCUMENT).
Read the picked URI's bytes, stage them in the app's private cache
as a timestamped file, return the staged path so existing
import_* code can read it as a regular filesystem path.
- Zip filter passes application/zip + application/octet-stream (some
file providers report the latter for .zip).
Android auto-backup off:
- AndroidManifest: allowBackup="false", fullBackupContent="false",
dataExtractionRules pointing at new data_extraction_rules.xml
- New data_extraction_rules.xml excludes all domains from both
cloud-backup and device-transfer. Prior default (allowBackup=true)
silently replicated identity.key to Google Drive for any user with
cloud backup on — which effectively published the root secret to
a third party without asking. Users who want off-device backup use
Settings -> Export (explicit zip they control).
Import as personas:
- New import_as_personas function in core/import.rs + new
import_as_personas_cmd Tauri IPC.
- Reads identity.key from the bundle and adds it to posting_identities
as a persona. Also reads posting_identities.json (v0.6+ bundles)
and adds each entry. Dedupes by node_id.
- Posts stay AS-AUTHORED — original post_id, original author,
original signatures, original wrapped_key recipients. No
re-encryption. Content encrypted to any of the imported keys
becomes decryptable because we now hold the secrets.
- Blobs, follows, profiles copied across.
- If current device has <=1 posting identity (the fresh-install one)
and the bundle brings more, auto-switch the default to the first
imported persona. Covers first-run-then-import flow cleanly.
Import wizard UI:
- New default option: "Restore as personas" — posts keep original
authors; source's keys become personas you can post as.
- Old "Merge with decryption key" retained as "Consolidate under
current default persona (requires source key)" for the case where
a user intentionally abandons a persona.
- "Public posts only" and "Add as separate identity" retained.
deploy.sh made executable (chmod +x tracked).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fresh installs now generate two independent ed25519 keys — one as the
network (QUIC) identity in identity.key, and a SEPARATE one as the
default posting identity in posting_identities. They share nothing.
v0.6.0 upgraders: if the default posting key equals the network key
(the state Phase 4's migration left us in), rotate identity.key to a
fresh random value. The old key stays in posting_identities as the
default persona — peers keep seeing the same author on our posts; only
the QUIC NodeId changes. A one-shot reconnect-churn on upgrade, then
back to normal.
Storage:
- Drop seed_posting_identity_from_network (v0.6.0-specific helper)
- Add count_posting_identities()
Node::open_with_bind:
- Load identity.key (network secret — network-only from now on)
- Ensure posting_identities has at least one entry; if empty, generate
an INDEPENDENT random posting key as the default
- Detect default-posting-key == network-key collision and rotate
identity.key, logging the migration
- default_posting_id / default_posting_secret resolved from storage
Decrypt:
- decrypt_posts now takes &[PostingIdentity] and tries each held
persona as a recipient candidate. Past DMs to any persona on this
device (including ones added via Import as personas) decrypt
correctly. Callers pre-load list_posting_identities() alongside
group_seeds.
- decrypt_just_created looks up the author's specific posting identity
rather than assuming the default.
Profile broadcasts (wire-level privacy):
- Profile stays keyed by network NodeId — the field is load-bearing
for N1/N2/N3 social routing (anchors/recent_peers/preferred_peers
feed build_preferred_tree_for and peer-anchor reachability lookup).
- But push_profile and InitialExchange now STRIP display_name, bio,
and avatar_cid before sending, via new
PublicProfile::sanitized_for_network_broadcast(). A name attached to
the network id would correlate the QUIC endpoint to a human. Until
v0.6.2 introduces persona-signed profile posts, peers display
authors as hex.
Auto-follow only the default posting id (network id is never an
author, following it would be dead weight).
All 111 core tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mostly a framing refresh — the underlying architecture description
matches what v0.6.0 actually does. Per-subsection status badges so a
reader can see at a glance what's shipped, what's partial, and
what's deferred:
- §28.1 two-layer identity: Shipped
- §28.2 persona types: Shipped (minus ephemeral — folded into §28.4)
- §28.3 multi-device: Partial (export/import works; no QR linking UX)
- §28.4 ephemeral rotating DM IDs: Deferred + connection-model
rationale written out
- §28.5 CDN holder sets: Shipped, with drop-migration note
- §28.6 DM privacy: Shipped (2 of 3 mechanisms; comment-as-intro
machinery exists but no dedicated UX yet)
- §28.7 user-facing: split into Shipped and Not-yet items
- §28.8 key safety: Shipped (unchanged content)
- §28.9 phase-by-phase rollout: each phase tagged Shipped v0.6.0
except Phase 6 (Deferred)
Final paragraph replaces the old "beta and stable are separate
networks" language with the hard-fork framing and a link to the
upgrade path on the download page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v0.5 export format matured in v0.5.1; exports from 0.5.0 or earlier
won't import cleanly into v0.6. Add a secondary warning pointing those
users to a two-hop upgrade via v0.5.3, with direct links to the archived
0.5.3 artifacts that remain on the server.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the single-sentence fork notice with an ordered list covering
export from v0.5, download, and import-on-first-launch — the same
instructions we're telling individual users.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phases 1-5 of the Identity Architecture rollout are on master; Phase 6
(rotating DM IDs) is deferred. This bumps the protocol-breaking
release: v0.5 and v0.6 cannot interoperate.
Packaging:
- Cargo + tauri.conf: 0.5.3 -> 0.6.0
- deploy.sh: drop -beta filename suffix
- download.html: single v0.6.0 section with fork notice at the top;
changelog entry summarising the five phases and the schema migration;
Phase 6's rotating-DM-ID status noted as deferred
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Settings > Personas:
- List all held posting identities with display_name + truncated nodeId
- Default badge; Set-default / Delete buttons per non-default persona
- "New Persona" modal prompts for a display name and creates via IPC
Compose box:
- A #persona-select dropdown appears when 2+ personas exist
- doPost attaches postingIdHex to create_post / create_post_with_files
when a non-default persona is selected
Tauri:
- create_post and create_post_with_files take an optional
posting_id_hex; when present they route through create_post_as,
otherwise through the default create_post_with_visibility
- PostDto gains asPersona: name of the authoring posting identity if
the author matches any of our held personas
- is_me now recognises ALL our posting identities, not just the
network key (both post_to_dto and post_to_dto_batch)
Feed:
- Per-post "(you) as <PersonaName>" label on own posts authored by a
non-default persona
- Persona filter pill row above the feed (hidden for single-persona
users); pills toggle between All and each persona; matches when
post.author or post.recipients contains the selected posting id
- Applied after loadFeed initial render and after appendFeedPage so
filter survives infinite-scroll
App.js:
- personasCache + loadPersonas() loaded on startup so compose picker
is populated before the Feed tab mounts
- loadPersonas() also called when Settings tab opens
Backend was unchanged; only the UI and IPC surface expanded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Users can now hold multiple posting identities on one device and
publish content under any of them. Each persona has its own ed25519
key; peers see them as distinct authors with no link back to the
device's network identity.
Node methods:
- list_posting_identities() -> Vec<PostingIdentity>
- create_posting_identity(display_name) — generates a fresh ed25519
key, persists, auto-follows self
- delete_posting_identity(node_id) — refuses to delete the default
- set_default_posting_identity(node_id) — validates identity exists;
Node's cached default_posting_id/secret picks up on next restart
- create_post_as(posting_id, content, intent, attachments) — routes
through a shared create_post_inner that takes posting_id +
posting_secret as parameters
Post creation pipeline:
- create_post_with_visibility now delegates to create_post_inner
using default_posting_id/secret
- create_post_inner threads posting_id / posting_secret through
every content-signing, encryption, manifest, blob-header, and
CDN-manifest step — the persona is fully honored end to end
- update_neighbor_manifests now takes a posting_id param too, so
posts from persona X only update neighbor manifests for X's own
prior posts
Tauri IPC:
- list_posting_identities / create_posting_identity /
delete_posting_identity / set_default_posting_identity
- create_post_as with posting_id_hex + the same visibility params
as create_post
CLI:
- personas / create-persona <name> / delete-persona <id>
- post-as <posting_id> <text>
Smoke-tested two-persona scenario:
- A creates "Work" persona; posts from default and Work
- B follows both; pulls from A; gets all three posts
- Authors are AB84BA... (Work) and 7CD949... (default) — distinct
on the wire
Frontend UX (Settings > Personas, compose picker, filter pills,
merged feed labels) is scoped as a separate commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Decouple the signing identity from the network identity. This phase
ships the plumbing only — every device still has exactly one posting
identity, copied from the network key on first 0.6.3 launch so all
existing signed content keeps verifying. Phase 5 builds the
multi-persona UX on top.
Types:
- New PostingIdentity struct: { node_id, secret_seed, display_name,
created_at }
Storage:
- New posting_identities(node_id, secret_seed, display_name,
created_at) table
- Methods: upsert / get / list / delete posting identities;
get/set default posting id (stored in settings)
- seed_posting_identity_from_network: idempotent migration inserts
the network key as the single posting identity and sets it default
on first 0.6.3 launch
Node:
- default_posting_id + default_posting_secret fields populated on
startup via the migration
- All content signing / encryption / key wrapping now uses
default_posting_secret; the old Node.secret_seed field is gone
(iroh holds the network secret internally)
- author field on all locally-created content is now
default_posting_id (equal to node_id for upgraders until Phase 5
introduces separate personas)
- Auto-follow-self covers both network_id and default_posting_id
(same in 0.6.3, may diverge in 0.6.4+)
Export/import:
- Bundle now includes posting_identities.json in
IdentityOnly / PostsWithIdentity / Everything scopes
- restore_posting_identities(zip, storage) reads and upserts on
import
Smoke-tested:
- Fresh 0.6.3 install: posting_identities seeded from network key;
default set; new post's author = default_posting_id = network_id
- Two-node pull sync: B pulls A's post, signature verifies across
the wire
- 111 core tests pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A non-follower can now receive DMs addressed to them via a normal pull
cycle, with no distinguishable "searching for DMs" traffic pattern —
the pull query is a uniform list of NodeIds that the server matches
against both authors and wrapped-key recipients.
Schema (migrations on first 0.6.2 launch):
- New post_recipients(post_id, recipient) index table with index on
recipient column
- Seed migration scans existing encrypted posts, extracts recipients
and group members from visibility JSON, populates the index
Write path:
- store_post_with_visibility / store_post_with_intent populate
post_recipients on successful insert
- update_post_visibility rebuilds the index for the updated post
- apply_delete cascade-removes post_recipients entries
Server pull handler (should_send_post):
- Renamed semantic: requester_follows → query_list. Contains every
NodeId the client wants posts for (follows + own NodeId).
- Encrypted/GroupEncrypted posts match if ANY recipient / group
member is in query_list (previously only if == requester).
- Wire protocol unchanged — the same PullSyncRequestPayload.follows
field now carries both follow targets and own NodeId indistinguishably.
Client pull paths (all three call sites in network.rs + connection.rs):
- Always append own NodeId to the query list before sending pull sync.
Storage helper:
- get_post_ids_for_recipients(nids) — bulk IN-match using the
idx_post_recipients_recipient index, for future SQL-side filtering.
Tests:
- should_send_post's recipient tests updated to pass query_list
containing requester (matches new contract).
- Added encrypted_post_matches_via_query_list_even_for_third_party_recipient
proving the server matches on any recipient in the list, not just
the requester itself.
All 111 core tests pass. Smoke-tested end-to-end: A posts encrypted
DM to B; B connects + syncs; B decrypts and reads DM; both sides'
post_recipients correctly populated on store.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The file_holders table is now the only tracker of per-file peer
relationships. post_upstream, post_downstream, blob_upstream, and
blob_downstream are dropped at first launch after the seed migration
copies any existing entries.
Schema:
- DROP TABLE IF EXISTS on all four legacy tables after seeding
- Seed migration guards with sqlite_master table_exists check so fresh
installs don't crash trying to read non-existent sources
- Remove CREATE TABLE statements for the four tables from init
- Remove Protocol v4 Phase 6 post_upstream priority migration (dead)
- Remove blob_upstream preferred_tree column migration (dead)
Rust:
- Remove add/get/remove post_upstream, post_downstream,
blob_upstream, blob_downstream methods
- Remove get_blob_upstream_preferred_tree / update variant
- Rewrite get_eviction_candidates's downstream_count subquery to
count file_holders entries
- Rewrite apply_delete's cascade cleanup to clear file_holders
instead of post_upstream/post_downstream
- cleanup_cdn_for_blob now clears file_holders for the CID
Callers:
- All dual-write sites in connection.rs and node.rs now do
touch_file_holder only (legacy writes removed)
- get_stale_manifests replaced with get_stale_manifest_cids; caller
in node.rs picks a refresh source from file_holders
Tests:
- Remove blob_upstream_crud, blob_downstream_crud_and_limit,
blob_upstream_preferred_tree, remove_blob_upstream,
post_downstream_crud
- Add file_holders_lru_cap and file_holders_direction_promotion tests
All 110 core tests passing. Workspace compiles clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch ALL propagation-decision reads to the flat holder set.
push_manifest_to_downstream now targets file_holders instead of
blob_downstream. ManifestPush receive-side relay likewise — known
holders fan out to up to 5 most-recent peers instead of a directional
tree.
Blob delete notices: single flat fan-out to file_holders; the legacy
upstream_node tree-healing field is emitted as None (wire-stable via
serde default) and ignored on receive — the post-0.6 flat model
doesn't need sender-role distinction. send_blob_delete_notices keeps
its Option<&Upstream> parameter as an unused placeholder for signature
stability with the call sites in this commit.
Other reads migrated:
- blob fetch cascade: step 2 now tries "known holders" (up to 5)
instead of a single upstream
- manifest refresh: downstream_count reported from file_holder_count
- web/http post holder enumeration
- Worm search post/blob holder fallback (both connection.rs paths)
- DeleteRecord fan-out rewires to file_holders
- Under-replication replication check: < 2 holders
Storage additions:
- get_file_holder_count(file_id)
- remove_file_holder(file_id, peer_id)
Legacy upstream/downstream writes are still happening from Phase 2b;
those + the tables themselves go in 2e.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
propagate_engagement_diff now targets the post's flat holder set (up to
5 most-recent) instead of the post_downstream directional tree. The
holder set naturally subsumes the old upstream+downstream partition, so
the separate "also send to upstreams" loops at each engagement call
site are removed (reactions, comments, comment edit/delete, receipt
slots, comment slots).
handle_blob_header_diff on receive:
- records the sending peer as a file holder (an engagement exchange is
proof the peer holds the post)
- re-propagates to the holder set minus the sender
Writes to post_upstream / post_downstream still occur from Phase 2b
(dual-write); those and the legacy tables will be removed in 2e.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Populate the flat holder set alongside every existing post_upstream /
post_downstream / blob_upstream / blob_downstream write so that read
paths can be switched over in the next commit without losing continuity.
Events wired:
- Pull sync receive (3 paths in connection.rs)
- PostPush receive (public posts only after Phase 1)
- PostFetch via notification (discovery pull)
- PostDownstreamRegister
- Replication accept (downstream) + replication-driven pull (upstream)
- Attachment upstream recorded after replication blob fetch
- ManifestPush receive (remote is a CID holder)
- ManifestPush send (downstream peer becomes CID holder)
- Blob fetch fallback (upstream lateral sources)
Direction is tracked as Received vs Sent. Not load-bearing for routing;
retained for future use. LRU cap of 5 enforced on every touch.
Legacy upstream/downstream writes remain in place; they'll go away
together with the table drops at the end of this phase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New flat per-file holder set replaces the directional upstream/downstream
trees. Keyed by 32-byte content-addressed file_id (works for both PostId
and blob CID). LRU-capped at 5 holders per file on touch.
- HolderDirection enum (Sent/Received/Both) — tracked for potential
reuse, not load-bearing for propagation
- touch_file_holder / get_file_holders / delete_file_holders
- seed_file_holders_from_legacy: one-time idempotent seed from
post_upstream, post_downstream, blob_upstream, blob_downstream so
users upgrading from 0.6.0 don't start with empty holder sets
Table and methods land here; call-site refactor and legacy-table drop
follow in subsequent commits within this phase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Encrypted posts now propagate only via the CDN (ManifestPush + neighbor
header updates), eliminating the sender→recipient traffic signal on the
wire. Encrypted DMs are indistinguishable from any other encrypted post.
- Remove push_post_to_recipients entirely from network.rs
- Remove call sites in create_post and re-encrypt-on-revoke
- PostPush handler now ignores non-public visibility (kept for public
audience push path)
Known gap: non-follower DMs won't reach until Phase 3 (merged pull +
recipient-match). Followers receive via the existing CDN path — new
posts trigger neighbor-manifest updates, ManifestPush fans out to
downstream holders, recipients pull missing post IDs from followed
authors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Explicit stance: 0.6.x beta does not interoperate with 0.5 stable.
Removes dual-write migration machinery, mixed-version wire handlers,
and cross-track testing. Users cross tracks via export/import bundle.
Preserves: local upgrade path for user's own data; serde-default wire
field additions; identity bundle format compat.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Promote 0.5.3 to stable on download page
- "Coming in Beta" section describes 0.6.x privacy features
- Add design.html §28: Identity Architecture (Planned) — network/posting
ID split, multi-persona, ephemeral DM IDs, file-holder CDN, CDN-only
DM privacy
- IMPLEMENTATION_PLAN_0.6.md: phased rollout across 0.6.0 through 0.6.5,
each backward compatible, each a standalone release
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Store the external address reported by peers via your_observed_addr in
initial exchange. Display it in Our Info panel with NAT classification.
Replaces reliance on iroh's pkarr/STUN for external address discovery
while keeping clear_address_lookup() (no dns.iroh.link publishing).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Shifted angle from "better social network" to "return to what it was."
Updated features (video/audio/files, Windows), removed aspirational claims,
reframed limitations as intentional design choices.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When anchor reports duplicate identity, sync pauses and a red banner
shows with a "Continue Anyway" button. Clicking it clears the flag and
starts all sync tasks. Handles false positives from network changes
(WiFi/cellular/VPN switches) without requiring complex staleness checks.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Feed pagination:
- Cursor-based pagination: get_feed_page/get_all_posts_page (20 posts/page)
- Batched engagement queries (3 bulk SQL queries instead of 4 per post)
- IntersectionObserver for infinite scroll (sentinel at midpoint)
- Viewport-based media loading (blobs only load when post enters view)
- Pre-fetch next page immediately after current page renders
Duplicate identity detection:
- Anchor detects when a NodeId is already mesh-connected during initial
exchange and sets duplicate_active flag in response
- Client skips sync tasks when duplicate detected
- Frontend shows red warning banner
Privacy:
- Fixed pkarr leak: clear_address_lookup() removes default dns.iroh.link
publishing. Only mDNS (local network) discovery enabled.
Android:
- SAF integration via tauri-plugin-android-fs: exports open native "Save As"
dialog so users can save to Downloads/Drive/etc.
- Download/export paths use app data dir on Android (writable)
- File picker gated behind desktop cfg (blocking_pick not on Android)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Node::open_with_bind no longer runs bootstrap (anchor connect, NAT
probe, referrals). New run_bootstrap() method called from background
task after UI is live.
- Background tasks (pull cycle, diff cycle, etc.) start after bootstrap
completes, not during block_on.
- Feed no longer pre-loaded during welcome screen readiness check.
Ready button enables immediately after get_node_info succeeds.
- Feed loads on tab switch, not during startup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- bundleMediaFramework: true — bundles full GStreamer plugin set in AppImage.
Fixes WebKit hang on video/audio (missing appsink/autoaudiosink plugins).
MSDK/VA plugins removed post-build to avoid Ubuntu DMA assertion crash.
- Import creates complete posts: BlobHeader, VisibilityIntent, pinned blobs,
self-follow. Imported posts now indistinguishable from locally created ones.
- First-run chooser: Start Fresh or Import Identity on fresh install only.
Profile setup shown for existing identities without display name.
- File pickers: native Browse buttons on export (folder) and import (ZIP)
via tauri-plugin-dialog.
- Export path: relative paths resolved against home dir.
- Lightbox close: only on overlay/image click, not inner form content.
- Growth loop skips self as N2 candidate.
- Node shutdown on identity switch (prevents zombie background tasks).
- media-src CSP includes asset protocol.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- First-run: show Start Fresh / Import chooser only when single auto-created
identity with no profile (not on every boot without a display name).
- Identity switch: shut down old node's endpoint before starting new one.
Fixes lockup after multiple switches (zombie background tasks).
- File picker: native Browse buttons on export (folder) and import (ZIP file)
via tauri-plugin-dialog.
- Export path: resolve relative paths against home dir (was using process cwd).
- Lightbox close: only close on overlay/image click, not inner form content.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The global click handler was removing any .image-lightbox on any click
inside it, which broke export/import/identity wizards (clicking radio
buttons or text inputs closed the wizard). Now only closes on overlay
background click or image click, preserving form interaction.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- handle_pull_request: extracted to static method, conn_mgr lock no longer
held during network I/O (read request + write response)
- handle_address_request: inlined at call site with brief lock to gather
data, response written after lock release
- handle_session_relay: capacity check + target lookup under brief lock,
rejection write moved outside lock scope
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Network Diagnostics: "Our Info" button shows addresses with NAT status,
device role, UPnP, HTTP capability. Addresses stacked for mobile.
- Hole punch race: re-check for existing connection before and after relay
introduction to avoid wasting minutes on redundant punch attempts.
- Relay introduction now carries requester/target NAT mapping+filtering
so hole punch strategy uses fresh profiles instead of stale stored ones.
Critical for phones that switch between WiFi/cellular/VPN.
- STUN fix: filter DNS results to IPv4 (was resolving to IPv6 first on
dual-stack, causing silent send failure and "NAT unknown").
- Welcome screen: Ready button with loading bar for instant feed access.
- LAN addresses show just "LAN" (no misleading punchability label).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Progress bar animates during backend readiness check. Once local feed is
loaded from SQLite, button enables with teal highlight. Click switches to
feed tab with cached content — no network wait needed for returning users.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Merge-with-key: decrypt exported posts using original identity seed, re-create
under current identity with prior_author in BlobHeader for provenance tracking.
Download page restructured with stable (v0.4.4) + beta (v0.5.0-beta) sections.
Version bumped across all crates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
Security: identity.key written with 0600 permissions (Unix). Docker bridge
IPs (172.17-31.x) filtered from is_shareable_addr to prevent topology
disclosure in relay introductions.
Lock contention: ManifestPush relay and DeleteRecord CDN notify now gather
connections under lock, then send outside lock.
UI: syncBtn null guard prevents crash on hidden element.
Documentation: design.html version badge updated to v0.4.4. Self Last
Encounter threshold corrected from 3h to 4h. Multi-Device Identity section
rewritten for multi-identity-per-device (complete) + multi-device (planned)
+ post merge (planned). MEMORY.md updated to v0.4.4+ status.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New Tauri commands: list_identities, create_identity, switch_identity,
delete_identity, import_identity_key, get_active_identity.
Settings UI: Identities section with list (active indicator, switch/delete
buttons), Create New Identity lightbox, Import Identity Key lightbox.
Export/Import buttons as placeholders for Phase 2/3.
Identity switch does hot-swap: tears down old Node, starts new one with
all background tasks, swaps AppNode RwLock. Frontend reloads after switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>