Compare commits

...
Sign in to create a new pull request.

15 commits

Author SHA1 Message Date
Scott Reimers
fa02ace4cc feat: v0.8 Iteration C UI — mesh/temp slot display, N4 reach badge
Diagnostics follow the new topology: network summary shows mesh count against
the cap plus temp referral count (Preferred/Local/Wide tiles are gone), the
connections list labels slots mesh vs temp referral, and peers can show an N4
reach badge. Verified against the Rust DTOs (mesh_count/mesh_cap/temp_count/
n4_distinct, MeshSlot Display -> "mesh"/"temp") and the style.css classes.

Cargo.lock picks up the 0.8.0-alpha version bump.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
2026-07-30 13:05:20 -04:00
Scott Reimers
6e22e60c7b chore: bump version to 0.8.0-alpha + v0.8.0-alpha release notes
Version bumped in all five places (core/cli/tauri-app Cargo.toml,
tauri.conf.json, Android versionName + versionCode 8000).

download.html carries the v0.8.0-alpha release block: registry + greeting
comments as the headline, the manifest privacy fix and persona-split bug
family, comment ingest verification, and the protocol slimming. States
plainly that v0.8 cannot talk to v0.7.x and the anchor moves with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
2026-07-30 13:04:29 -04:00
Scott Reimers
e756fabb11 feat: v0.8 Iteration C — narrow mesh, N1-N4 uniques index, anchor convection
Topology rework. The mesh's job is width; depth now comes from knowledge.

Slots:
- Local/Wide collapse into ONE mesh pool: 20 desktop / 15 mobile
- Temp referral slots +4..+10 ABOVE the cap (10 desktop / 4 mobile), carry no
  knowledge exchange, graduate into a freed mesh slot or expire (5 min TTL),
  never evict an established mesh peer
- Fixes the Wide-never-fills bug: outbound paths hardcoded Local while the
  growth gate counted Local only, so growth stopped at 71 and 20 slots were
  inbound-only. Also closes register_connection paths that enforced no cap.

Knowledge — two-pool uniques announce (replaces N1/N2/N3 share lists):
- Pool 1 (forwardable): our N0-N2 uniques, deduped. Receiver stores shifted one
  bounce deeper as N1-N3, reporter-tagged.
- Pool 2 (terminal): our N3, deduped against pool 1. Receiver stores as N4,
  USED for search/resolution but NEVER re-announced. No N5 anywhere.
- Uniques merge mesh peers + social directs + CDN file authors + holder peers,
  so receivers cannot tell how we know an ID
- Anchor entries carry addresses; every other entry is a bare ID. The pools
  double as the anchor directory.
- Dedup at both store and announce; per-bounce caps; 2 MB payload ceiling
- Device-tiered depth: mobile holds 3 bounces and drops the terminal pool

Anchor convection (register loop retired — 0xC0 AnchorRegister deleted):
- Rolling in-memory caller window (no registration DB): a caller gets the 2
  most recent viable prior callers, its address goes to the next 2, then
  rotates out; still-connected callers preferred; RelayIntroduce coordinated
  when both ends are live
- Request classes: entry (<2 connections) always served; top-up refused
  cheaply under load, and the refusal feeds the adaptive weights
- Stochastic per-disconnect action {nothing | anchor intro | mesh intro} with
  weights from local anchor density + refusal feedback. No thresholds, no
  jitter timer. Recovery (mesh < 2) stays immediate and non-stochastic.
- Anchors mined from the uniques pools incl. retained pools of disconnected
  peers; known_anchors demoted to a bootstrap cache

Security fixes from review: remote address poisoning (hearsay anchors could be
dialled — known_anchors/is_anchor now written only from completed handshakes),
N4 leaking back into announcements via the anchor mirror (infinite propagation),
missing payload size caps, unbounded candidate scoring, and a bulk SQLite write
held under the ConnectionManager mutex.

deploy.sh: version regex now captures pre-release suffixes (0.8.0-alpha would
have truncated to 0.8.0 and broken every artifact path), announce channel
derives from the suffix, and the anchor runs --publish-registry at genesis.

228 core tests; a3 integration 9/9; new c_topology_test.sh 33/33 (both
independently re-run). EDM corpse, session-relay gating, Iteration A/B intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
2026-07-30 13:03:12 -04:00
Scott Reimers
36e3871c4b refactor: v0.8 Iteration B — cruft purge + ALPN bump to itsgoin/4
Zero-users ruling removes all wire-compat obligations. Net -1,443 LOC of Rust.

Protocol break:
- ALPN_V2 b"itsgoin/3" -> ALPN b"itsgoin/4" (renamed: the versioned name was
  what rotted). Old nodes are now refused at the QUIC handshake instead of
  half-interoperating with the v0.8 manifest/digest formats. Deploy gate from
  Iteration A is discharged.

Dead wire surface removed (MessageType 46 -> 40):
- DeleteRecord 0x51 (deletes ride ControlOp::DeletePost + InitialExchange)
- VisibilityUpdate 0x52 + its two dead senders (ControlOp::UpdateVisibility)
- SocialDisconnectNotice 0x71 (zero senders; checkin timeouts carry the signal)
- MeshPrefer 0xB3 + request_prefer + handler (preferred peers are gone)
- Legacy dual pull-matching half (have_post_ids) — behavior-identical
- PullSyncResponse.visibility_updates + counters (control posts cover it)
- serde(default) stripped from wire-only payloads (kept where
  skip_serializing_if makes it load-bearing; persisted-row defaults untouched)

Preferred-peer subsystem eliminated (design ruling: CDN file_holders replaced
the N+10 direct push/pull it served): slot tier, preferred_peers table (dropped),
preferred_tree semantics, rebalance Priority 0, find_relays_for preferred tiers,
7-day prune + 30-day watcher, dead FromStr impl. Slots are now Local/Wide only
(91 desktop / 12 mobile) pending Iteration C's single ~20-slot pool.

Other dead code: hostlist encoder + base64url helper, always-empty
downstream_addrs parameter chain, start_upnp_renewal_cycle no-op + 4 callers,
RELAY_TARGET_RATE_LIMIT, GetSecretSeed, stale comments swept.

Preserved deliberately: EDM scanner corpse (awaiting raw-UDP refactor),
PortScanHeartbeat, Iteration A startup migrations, session-relay opt-in gating.

Docs: design.html + tech.html synced (message counts, ALPN, purge status,
Rework asides flipped past-tense).

190 core tests pass; CLI + desktop build; A3 integration 9/9 (independently
re-run post-purge).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
2026-07-30 09:26:16 -04:00
Scott Reimers
17dbf076cb feat: v0.8 Iteration A — ID-class fixes, manifest privacy, registry + greetings
A1 — ID-class bug family (v0.6.1 persona split left network-NodeId comparisons
where posting IDs live). ~14 sites fixed with multi-persona semantics ("author is
one of MY posting identities", not == default):
- revoke_post_access / revoke_circle_access (revocation was broken for every post)
- group key create/add-member/rotate (add-member distribution now works)
- circle profile set/delete/get, encrypted-attachment CEK unwrap
- receipt/comment slot authorship, replication cycle, blob eviction own-tier
- orphaned GroupKeyRequest/Response (0xA1/0xA2) deleted (no send side existed)

A2 — Manifest privacy: AuthorManifest.author_addresses removed from struct AND
signature digest; CdnManifest tree-era fields dropped. Startup migrations re-sign
own manifests, purge unverifiable foreign copies, strip persona fields from legacy
network-id profile rows. Posting identities are now location-anonymous.

A3 — Discovery & first contact:
- Open-slot comments: derivable slot V_x (blake3 from author + slot_binder_nonce)
  so strangers pass the CDN comment-verification gate; OpenSlotDecl in FoF gating
- Greetings: HPKE-style sealed GreetingBody (return_path + fresh reply_pubkey),
  throwaway outer ID, size-bucketed; messaging-first (Reply/Dismiss, no vouch)
- Registry: frozen canonical registry post + REGISTRY_POST_ID, signed registration
  comments (self-certifying deletes), newest-wins per persona, --publish-registry
- Comment TTL: expires_at_ms inside signed digest v2 (context bump), rand 30-365d
  ordinary / fixed 30d registrations, expiry sweep on the eviction cycle
- Single accept_incoming_comment() gate wired into all three ingest sites, closing
  a pre-existing hole where both pull paths stored comments with no verification
- UI: consent panel (visible/listed/greetings, active choice at first publish),
  registry search in Discover, sealed "Say hi" compose, greetings inbox

Security fixes from review: forged-tombstone injection, delete-by-sender-trust,
remote SetPolicy poisoning, stored XSS (escapeHtml quotes, 7 pre-existing sites),
greeting off-switch now revokes prior bios' slots.

No PoW (rejected: inverted cost). Session-relay gating untouched.
201 core tests pass; CLI + desktop build; 3-node integration green.

DEPLOY GATE: wire-incompatible with v0.7.3 — ALPN bump to itsgoin/4 lands in
Iteration B. Do not build/deploy/anchor-swap before that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
2026-07-30 08:28:26 -04:00
Scott Reimers
66c9851061 docs: design.html v0.8 target rewrite — narrow mesh, uniques index, convection, registry
Full rewrite as the v0.8 target-design doc (7 rounds of design rulings):
- 20-peer mesh + temp referral slots; preferred peers eliminated
- N1-N4 uniques knowledge (two-pool announce: forwardable N0-N2 + terminal N3)
- Anchor convection (stochastic per-disconnect, adaptive weights, entry/top-up classes)
- Pool-carried anchor addresses; known_anchors demoted to bootstrap cache
- Discovery & First Contact: registrations-here post + HPKE greeting comments
- Comment TTL, 2-5 post concealment, traffic-uniformity adversary model
- Three-badge status system (Implemented/Rework/Planned), code-verified
- Roadmap: ID-class bug fixes first, registry tester-critical at #2, ALPN -> itsgoin/4
- Zero-users ruling: clean protocol break, no wire compat with <=v0.7.x

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
2026-07-29 23:29:34 -04:00
Scott Reimers
6ef11fa61c fix: v0.7.3 — disable EDM scanner, bootstrap batching, stale-anchor prune
Bandwidth + bootstrap hardening on top of v0.7.2. Wire-compatible with
v0.7.0/v0.7.1/v0.7.2; no protocol changes.

EDM port scanner DISABLED
- hole_punch_with_scanning() now does only single quick punch + parallel
  punch over 30s window. The EDM port-scanner branch is gone from the
  live path because per-probe endpoint.connect() amplifies catastrophically:
  iroh accumulates every connect() target into a per-endpoint paths set
  and probes them all under QUIC NAT-traversal in the background. A
  100-probes/sec / 5-min scan inserted ~30k paths; iroh probed all of
  them. Observed at 22MB/s outbound from one client — DoS-grade.
- Scanner body preserved as edm_port_scan_disabled_v0_7_3() with all
  supporting helpers (PortWalkIter, scanner_semaphore, role-based
  scanner/puncher split, found_tx/found_rx channel pattern,
  deadline + tokio::select! orchestration) marked #[allow(dead_code)].
  Refactor target: replace per-probe endpoint.connect() with raw
  socket.send_to() so probes don't enter iroh's path store.

Bootstrap probing batched
- New probe_anchors_batched() helper: 3 anchors in flight at a time,
  2s stagger between batch dispatches, 10s per-anchor timeout, no abort
  on success. First success unblocks the bootstrap flow; remaining
  probes continue in background and fill peer connections naturally.
- Phase 2 (bootstrap fallback) still only fires when every discovered
  anchor failed — preserves load-distribution intent. Replaces the
  sequential 50s+ timeout cascade users observed with old data dirs.

Stale-anchor self-pruning
- New storage.get_known_anchor_last_seen() and storage.delete_known_anchor().
- maybe_prune_stale_anchor(): when a probe fails AND last_seen_ms > 3 days,
  delete the entry from known_anchors immediately. Recoverable anchors
  (failed once, succeeded recently) are preserved. Self-healing for old
  data dirs whose discovered anchors point to keypairs that rotated
  months ago.

Android close button kills NodeService
- New NodeService.stopFromNative() Kotlin static method called via JNI
  from android_wifi::stop_node_service(). exit_app invokes it on Android
  before app.exit(0). Previously the button ended the Activity but the
  foreground service kept networking running.

Cosmetic
- Power-icon SVG (inline) replaces &#x23FB; so Android webviews lacking
  U+23FB don't render a missing-image tofu box.

Docs
- design.html section 11 rewritten for portmapper (UPnP+NAT-PMP+PCP,
  v0.7.2) including per-platform contract and bidirectional anchor
  watcher.
- design.html section 10 marks session relay as opt-in (v0.7.2) and EDM
  scanner as disabled-pending-refactor (v0.7.3).
- download.html carries v0.7.3 release notes.
- MEMORY.md updated; older v0.7.0/v0.7.1 status sections condensed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:33:45 -06:00
Scott Reimers
4706e81603 feat: v0.7.2 — portmapper (UPnP+PCP+NAT-PMP), session-relay opt-in, URL Phase 1
Network/reachability improvements + a relay-privacy fix. Wire-compatible
with v0.7.0/v0.7.1; no protocol changes.

- Replace hand-rolled UPnP (igd-next) with the portmapper crate. All three
  protocols (UPnP-IGD / NAT-PMP / PCP) run in parallel, auto-renew
  internally. PCP adds IPv6 firewall pinholes and works on iOS without
  the multicast entitlement. Pulled in transitively via iroh already, no
  net dep growth.
- Android: UPnP/PCP/NAT-PMP attempted on WiFi/Ethernet with a
  WifiManager.MulticastLock acquired for the lifetime of the mapping.
  Cellular skipped early (no UPnP/PCP gateway, avoid 3s discovery waste).
- TCP port-mapping gate removed for mobile — phones with permissive NAT
  can now serve HTTP for direct browser fetches.
- Anchor reachability watcher (bidirectional): clears is_anchor after
  >5min of no port mapping; restores it when the mapping comes back.
  Network roams self-heal without restart. Mobile never auto-anchors.
- Session relay opt-in restored. relay.session_relay_enabled setting
  defaults OFF (anchors included — servers shouldn't silently burn
  bandwidth either). Gates both serving (can_accept_relay_pipe) and
  using (auto-fallback in node.rs). UI toggle in Settings. Relay-style
  signaling (RelayIntroduce / worm_lookup / N1-N3 shares) unaffected.
- URL Phase 1: share links now contain only the post ID
  (itsgoin.net/p/<post>). Anchor handler already supported post-ID-only
  URLs (author was optional); just dropped the author hex from the
  generator. Older URLs with author hex continue to work.
- Quick app close button in header (with confirm) — useful for stopping
  network activity between sessions on mobile.
- JNI null-pointer guards on ndk_context handles in android_wifi.rs.

MEMORY rule sharpened to distinguish session relay (byte pipe, opt-in)
from relay-style signaling/discovery (always on).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:03:39 -06:00
Scott Reimers
069257c2d8 chore: bump version to 0.7.1 + v0.7.1 release notes
Bumps Cargo.toml + tauri.conf.json + android tauri.properties
(versionCode 7000 → 7001). v0.7.1 = UI polish + bug-fix pass on
v0.7.0, wire-compatible.

download.html: new v0.7.1 section at top with detailed bullets for:
- Friend button (follow+vouch)
- Default visibility = Extended Friends (FoF)
- Network Identity → Device Address rename
- Settings persona/device clarity
- Profile-display-name bug fix
- Redundancy panel author-query fix
- My Posts horizontal-scroll regression fix

v0.7.0 entry retained below with full original release notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:56:52 -06:00
Scott Reimers
100ea55a15 ux+fix: rename Network Identity → Device Address (UI) + redundancy authors
#6: UI rename — Network Identity → Device Address.
Just labels/wording, no backend changes. Tauri command names
(list_identities, create_identity, etc.) and DTO fields stay as-is;
the rename is purely user-facing. Settings labels, lightbox titles,
toasts, danger-zone text, and welcome-screen "Import an Identity"
button all updated. Distinguishes more clearly from Personas (which
ARE the posting identity peers see).

#7: Bug fix — redundancy showed 0 for new posts.
Root cause: get_redundancy_summary queried
  SELECT p.id FROM posts WHERE p.author = ?1
with ?1 = self.node_id (the device's network NodeId). After the
v0.6.0 network/posting-ID split, posts are authored by posting
identities (personas), not the network NodeId — so the query
returned 0 of my posts and the redundancy panel showed all zeros.

Fix: storage::get_redundancy_summary now takes &[NodeId] and uses
WHERE author IN (?, ?, ...) over every persona on the device.
Node::get_redundancy_summary gathers them via
list_posting_identities() before delegating.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:49:54 -06:00
Scott Reimers
83fd30753f ux: default visibility = Extended Friends (FoF) + clean visibility picker
#8 from the v0.7.0 device-test feedback round, confirmed by Scott.

Promotes FoF from a comment-policy option to a top-level visibility
choice, with FoF as the new default.

- visibility-select: new option order with `fof_closed` selected by
  default:
    - Extended Friends (FoF)  ← default
    - Friends
    - Public
    - Circle
- comment-perm-select: removed the old `fof_closed` option (its job
  is now done by the visibility picker). Kept `friends_of_friends`
  for the Mode 2 combo (public body + FoF-gated comments).
- updateVisibilityUI now hides comment-perm-select when visibility is
  `fof_closed` — the audience choice already implies the comment
  policy, no extra picker needed. Shown again on public/friends/circle.
- Compose dispatch logic re-rooted on visibility instead of comment
  policy:
    - fof_closed → create_post_fof_closed (Mode 1)
    - public + comment-perm=friends_of_friends → create_post_with_fof_comments (Mode 2)
    - everything else → existing create_post path
- After-post reset goes to `fof_closed` (not `public`) to preserve the
  privacy-by-default posture.

Tracker memory updated: #8 marked complete; #10 clarified (per-group
persona selection, scoped alongside the deferred Group UI work in #9).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:39:44 -06:00
Scott Reimers
346d23d4d8 ux: Friend-button default + profile-rename plumbing + export/import clarity
Three of the v0.7.0 device-testing feedback items, deferred for later
rebuild/redeploy (Scott opted to batch UI fixes).

(#1) Friend button on bio modal:
- Primary action when neither following nor vouched: [Friend] (=
  follow + vouch in one click) plus secondary [Follow only].
- When following without a vouch: [Add Vouch] primary, [Unfollow]
  secondary.
- When both follow + vouch (= friends): [Unfriend] (= revoke vouch
  + unfollow, with the rotation-cost confirm wording).
- The standalone [Vouch] / [Revoke Vouch] flows stay reachable from
  the existing Vouches list in Settings.

(#2) Profile shows "unnamed" — bug fix:
- set_profile updated the profiles table + emitted a profile post,
  but never updated posting_identities.display_name. list_posting_
  identities returns from the latter, so the Personas list kept
  showing "(unnamed)" forever after the first-run auto-persona was
  named.
- Now set_profile also upserts the posting_identities row with the
  new display_name (secret_seed + created_at preserved).

(#5) Export/import + persona-vs-device clarity:
- Settings reorder: new "Your data on this device" explainer card up
  top (Personas = who you are to peers; Identities = device's
  network address, rarely useful to touch).
- "Move to another device" section renamed + given a plain-English
  description; primary [Export personas] / [Import from another
  device] buttons.
- "Identities (advanced)" demoted below; warning text added.
- Export wizard heading: "Export your personas"; radio labels use
  persona/keys language consistently.
- Import wizard heading: "Import from another device"; explainer
  notes that the default action restores personas.

Tracking memory created at memory/project_v071_followups.md for
deferred items (#4 PQ vouch delivery, #6 rename, #7 redundancy,
#8/#9/#10 awaiting clarification).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:32:30 -06:00
Scott Reimers
f714a17385 fix(ui): wrap #visibility-row and cap select width to prevent My Posts horizontal overflow
Symptom (mobile): on the My Posts tab the fixed header + bottom tabs
disappeared until a swipe gesture pulled them in — classic mobile-
browser-chrome auto-hide that fires when the body has unwanted
horizontal scroll.

Root cause: the new FoF compose option "Body+Comments: FoF only
(Mode 1)" pushed the unwrapped #visibility-row (5 dropdowns side by
side, no flex-wrap) past viewport width on phones. Body picked up
overflow-x → mobile browser detected horizontal scroll → fixed
positioning behavior gets weird in that mode on Chrome Android and
WebView.

Two-line fix:
- flex-wrap: wrap on #visibility-row so the row breaks to multiple
  lines on narrow screens.
- max-width: 100% on the row and on the selects inside it; the SELECT
  widget renders at most container-width even if its longest option
  is wider, so the long FoF option no longer balloons the widget.

Verified by inspection: no other view has a similar wide-dropdown row
that hugs the right edge; this is My Posts-specific because compose
lives there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:42:54 -06:00
Scott Reimers
ec393c7f85 fix(deploy): serialize CLI/APK/AppImage builds to avoid cargo target contention
The v0.7.0 release surfaced a real bug: parallel cargo invocations
all writing to target/ caused linuxdeploy to fail mid-AppImage bundle
("Error failed to bundle project failed to run linuxdeploy"). Cargo's
own target-dir locking doesn't fully serialize three concurrent
cargo-front-ends across CLI + Android-cross-compile + tauri-bundle.

Solo cargo tauri build succeeded immediately after deploy.sh aborted,
confirming the parallel invocation was the root cause.

Switching to serial builds. The extra wall time is small because
cargo's incremental cache deduplicates compilation of the shared
itsgoin-core crate across the three builds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:12:51 -06:00
Scott Reimers
ad9282f24a docs: flip FoF section 20a badges to v0.7.0 + sessions.md release entry
design.html section 20a (Friend-of-Friend Visibility):
- Section header badge: planned → v0.7.0 complete
- FoFClosed PostVisibility table row: planned → v0.7.0
- All 5 layer rows in the implementation table: planned → v0.7.0
- Custom-subset row retained as v2 (genuinely deferred per spec)

sessions.md: full session entry for the Layer 1-5 implementation arc.
34 commits, ~24 new fof:: integration tests, key design decisions
preserved (slot_binder_nonce circularity fix, per-post pub_x/priv_x,
multi-epoch receiver-chain V_me storage, retroactive cascade delete,
key-burn semantics).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:57:25 -06:00
36 changed files with 13414 additions and 5904 deletions

10
Cargo.lock generated
View file

@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "itsgoin-cli"
version = "0.7.0"
version = "0.8.0-alpha"
dependencies = [
"anyhow",
"hex",
@ -2744,7 +2744,7 @@ dependencies = [
[[package]]
name = "itsgoin-core"
version = "0.7.0"
version = "0.8.0-alpha"
dependencies = [
"anyhow",
"base64 0.22.1",
@ -2753,8 +2753,10 @@ dependencies = [
"curve25519-dalek",
"ed25519-dalek",
"hex",
"igd-next",
"iroh",
"jni",
"ndk-context",
"portmapper",
"rand 0.9.2",
"rusqlite",
"serde",
@ -2767,7 +2769,7 @@ dependencies = [
[[package]]
name = "itsgoin-desktop"
version = "0.7.0"
version = "0.8.0-alpha"
dependencies = [
"anyhow",
"base64 0.22.1",

View file

@ -1,6 +1,6 @@
[package]
name = "itsgoin-cli"
version = "0.7.0"
version = "0.8.0-alpha"
edition = "2021"
[[bin]]

View file

@ -27,6 +27,7 @@ async fn main() -> anyhow::Result<()> {
let mut web_port: Option<u16> = None;
let mut print_identity = false;
let mut announce = false;
let mut publish_registry = false;
let mut ann_category: Option<String> = None;
let mut ann_title: Option<String> = None;
let mut ann_body: Option<String> = None;
@ -76,6 +77,7 @@ async fn main() -> anyhow::Result<()> {
}
"--print-identity" => { print_identity = true; i += 1; }
"--announce" => { announce = true; i += 1; }
"--publish-registry" => { publish_registry = 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; }
@ -152,6 +154,29 @@ async fn main() -> anyhow::Result<()> {
return Ok(());
}
// One-shot: --publish-registry — genesis publish of the network
// registry post. Refuses unless this node's default posting identity
// is the bootstrap anchor's (debug builds may override for tests via
// ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS=1). Prints the registry post ID
// for verification against the shipped constant.
if publish_registry {
let node = Node::open_with_bind(&data_dir, bind_addr, profile).await?;
match node.publish_registry_genesis().await {
Ok(post_id) => {
println!("registry_post_id: {}", hex::encode(post_id));
println!(
"shipped_constant: {}",
hex::encode(itsgoin_core::registry::REGISTRY_POST_ID)
);
}
Err(e) => {
eprintln!("Failed to publish registry genesis: {}", 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?);
@ -162,8 +187,9 @@ async fn main() -> anyhow::Result<()> {
let addr = node.endpoint_addr();
let sockets: Vec<_> = addr.ip_addrs().collect();
// Show our display name if set
let my_name = node.get_display_name(&node.node_id).await.unwrap_or(None);
// Show our display name if set (profiles are keyed by POSTING identity,
// not the network NodeId).
let my_name = node.get_display_name(&node.default_posting_id).await.unwrap_or(None);
let name_display = my_name.as_deref().unwrap_or("(not set)");
println!();
@ -204,8 +230,21 @@ async fn main() -> anyhow::Result<()> {
println!(" redundancy Show replica counts for your posts");
println!(" worm <node_id> Worm lookup (find peer beyond 3-hop map)");
println!(" connections Show mesh connections");
println!(" slots Show mesh / temp-referral slot usage");
println!(" uniques [node_id_hex] Uniques index counts, or where an ID is known");
println!(" convection Adaptive convection weights + pool-mined anchors");
println!(" convect <anchor_id> Ask an anchor for peers (convection)");
println!(" uniques-pull Exchange uniques indexes with mesh peers");
println!(" social-routes Show social routing cache");
println!(" name <display_name> Set your display name");
println!(" register <name> [keywords...] Register default persona in the network registry (30d, re-run to renew)");
println!(" unregister Remove your registry entry (signed delete)");
println!(" search <keywords> Search the registry (pulls chain, queries locally)");
println!(" greet <bio_post_id_hex> <text> Send a sealed greeting to a bio post's author");
println!(" greetings List unsealed greetings on your bio (with reply/dismiss index)");
println!(" reply <greeting_index> <text> Sealed reply to a greeting's return path (fresh reply key)");
println!(" dismiss <greeting_index> Dismiss a greeting (local only)");
println!(" greetings-open <on|off> Toggle the greeting slot on your bio (republishes bio)");
println!(" stats Show node stats");
println!(" export-key Export identity key (KEEP SECRET)");
println!(" id Show this node's ID");
@ -214,14 +253,22 @@ async fn main() -> anyhow::Result<()> {
// Start background tasks (v2: mesh connections)
let _accept_handle = node.start_accept_loop();
let _pull_handle = node.start_pull_cycle(300); // 5 min pull cycle
let _sync_handle = node.start_sync_cycle(); // 60s: uniques-index exchange + content sync
let _diff_handle = node.start_diff_cycle(120); // 2 min routing diff
let _rebalance_handle = node.start_rebalance_cycle(600); // 10 min rebalance
let _growth_handle = node.start_growth_loop(); // reactive mesh growth
// Reactive mesh growth. `ITSGOIN_TEST_NO_GROWTH` suppresses it so an
// integration test can hold an exact topology (on loopback the growth loop
// otherwise collapses any chain into a full mesh within seconds, and a
// full mesh has no N2 at all — every peer is a direct). Test gate only.
let _growth_handle = if std::env::var("ITSGOIN_TEST_NO_GROWTH").is_ok() {
eprintln!("[test gate] growth loop disabled (ITSGOIN_TEST_NO_GROWTH)");
None
} else {
Some(node.start_growth_loop())
};
let _recovery_handle = node.start_recovery_loop(); // reactive anchor reconnect on mesh loss
let _checkin_handle = node.start_social_checkin_cycle(3600); // 1 hour social checkin
let _anchor_handle = node.start_anchor_register_cycle(600); // 10 min anchor register
let _upnp_handle = node.start_upnp_renewal_cycle(); // UPnP lease renewal (if mapped)
let _convection_handle = node.start_convection_loop(); // stochastic anchor convection + anchor self-probe
let _upnp_tcp_handle = node.start_upnp_tcp_renewal_cycle(); // UPnP TCP lease renewal
let _http_handle = node.start_http_server(); // HTTP post delivery (if publicly reachable)
let _bootstrap_check = node.start_bootstrap_connectivity_check(); // 24h isolation check
@ -254,6 +301,10 @@ async fn main() -> anyhow::Result<()> {
let stdin = io::stdin();
let reader = stdin.lock();
// A3: cached greeting list so `reply <n>` / `dismiss <n>` indices
// refer to the last `greetings` output.
let mut last_greetings: Vec<itsgoin_core::node::GreetingRecord> = Vec::new();
print!("> ");
io::stdout().flush()?;
@ -342,9 +393,13 @@ async fn main() -> anyhow::Result<()> {
if follows.is_empty() {
println!("(not following anyone)");
}
let own_ids: Vec<itsgoin_core::types::NodeId> = node
.list_posting_identities().await.unwrap_or_default()
.into_iter().map(|p| p.node_id).collect();
for nid in follows {
let name = node.get_display_name(&nid).await.unwrap_or(None);
let label = if nid == node.node_id { " (you)" } else { "" };
// Follows are posting ids — "you" = any of our personas.
let label = if own_ids.contains(&nid) { " (you)" } else { "" };
if let Some(name) = name {
println!(" {} ({}){}", name, &hex::encode(nid)[..12], label);
} else {
@ -697,7 +752,7 @@ async fn main() -> anyhow::Result<()> {
"create-persona" => {
let name = arg.unwrap_or("").to_string();
match node.create_posting_identity(name).await {
match node.create_posting_identity(name, None).await {
Ok(id) => {
println!("Created posting identity: {}", hex::encode(id.node_id));
}
@ -817,7 +872,7 @@ async fn main() -> anyhow::Result<()> {
println!("(no mesh connections)");
} else {
println!("Mesh connections ({}):", conns.len());
for (nid, slot_kind, connected_at) in conns {
for (nid, slot, connected_at) in conns {
let name = node.get_display_name(&nid).await.unwrap_or(None);
let id_short = &hex::encode(nid)[..12];
let label = name.map(|n| format!("{} ({})", n, id_short))
@ -829,7 +884,95 @@ async fn main() -> anyhow::Result<()> {
.as_millis() as u64;
(now.saturating_sub(connected_at)) / 1000
};
println!(" {} [{:?}] connected {}s ago", label, slot_kind, duration_secs);
println!(" {} [{}] connected {}s ago", label, slot, duration_secs);
}
}
}
// v0.8 Iteration C observability: slot pool + uniques index.
"slots" => {
let conns = node.list_connections().await;
let mesh = conns.iter().filter(|(_, s, _)| s.is_mesh()).count();
let temp = conns.len() - mesh;
println!("mesh {}/{} temp-referral {}/{}",
mesh, node.device_profile().mesh_slots(),
temp, node.device_profile().temp_referral_slots());
for (nid, slot, _) in conns {
println!(" {} {}", &hex::encode(nid)[..12], slot);
}
}
"uniques" => {
let storage = node.storage.get().await;
if parts.len() > 1 {
match itsgoin_core::parse_node_id_hex(parts[1]) {
Ok(target) => {
let mut any = false;
for bounce in 2u8..=4 {
for reporter in storage.find_reporters_at(&target, bounce).unwrap_or_default() {
println!(" N{} via {}", bounce, &hex::encode(reporter)[..12]);
any = true;
}
}
if !any {
println!("(not in the uniques index)");
}
}
Err(e) => println!("Bad node id: {}", e),
}
} else {
println!("N2 {} N3 {} N4 {} (anchor density {:.2})",
storage.count_distinct_n2().unwrap_or(0),
storage.count_distinct_n3().unwrap_or(0),
storage.count_distinct_n4().unwrap_or(0),
storage.anchor_density().unwrap_or(0.0));
}
}
// The v0.8 "pull": exchange uniques indexes with every mesh peer.
"uniques-pull" => {
let n = node.uniques_pull().await;
println!("uniques-pull: exchanged with {} mesh peers", n);
}
// On-demand convection against a named anchor.
"convect" => {
if parts.len() < 2 {
println!("Usage: convect <anchor_node_id_hex>");
} else {
match itsgoin_core::parse_node_id_hex(parts[1]) {
Ok(anchor) => match node.convection_request(anchor).await {
Ok((_, true, ms)) => println!("convection: refused in {}ms", ms),
Ok((n, false, ms)) => println!("convection: {} new peers in {}ms", n, ms),
Err(e) => println!("convection failed: {}", e),
},
Err(e) => println!("Bad node id: {}", e),
}
}
}
// Convection diagnostics: the adaptive action weights, the
// pool-mined anchor directory, and a manual entry-class request.
"convection" => {
let (density, bias, mesh) = node.network.conn_handle().convection_state().await;
let cap = node.device_profile().mesh_slots();
let fill = if cap == 0 { 1.0 } else { mesh as f64 / cap as f64 };
let w = itsgoin_core::connection::convection_weights(density, bias, fill);
let total = w.nothing + w.anchor + w.mesh;
println!("anchor_density {:.3} anchor_bias {:.2} mesh {}/{}", density, bias, mesh, cap);
println!("weights: nothing {:.3} anchor {:.3} mesh {:.3} (P: {:.0}% / {:.0}% / {:.0}%)",
w.nothing, w.anchor, w.mesh,
100.0 * w.nothing / total, 100.0 * w.anchor / total, 100.0 * w.mesh / total);
let anchors = {
let storage = node.storage.get().await;
storage.list_pool_anchors(16).unwrap_or_default()
};
if anchors.is_empty() {
println!("pool anchors: (none)");
} else {
println!("pool anchors ({}):", anchors.len());
for (nid, addrs) in anchors {
println!(" {} {}", &hex::encode(nid)[..12], addrs.join(","));
}
}
}
@ -861,6 +1004,166 @@ async fn main() -> anyhow::Result<()> {
}
}
"register" => {
if let Some(rest) = arg {
let mut words = rest.split_whitespace();
let name = words.next().unwrap_or("").to_string();
let keywords: Vec<String> = words.map(|w| w.to_string()).collect();
if name.is_empty() {
println!("Usage: register <name> [keywords...]");
} else {
let pid = node.default_posting_id;
match node.register_persona(&pid, &name, &keywords).await {
Ok(()) => println!(
"Registered '{}' ({} keywords). Listed for 30 days; auto-renews while listed.",
name, keywords.len()
),
Err(e) => println!("Error: {}", e),
}
}
} else {
println!("Usage: register <name> [keywords...]");
}
}
"unregister" => {
let pid = node.default_posting_id;
match node.unregister_persona(&pid).await {
Ok(()) => println!("Registry entry removed (signed delete propagated)."),
Err(e) => println!("Error: {}", e),
}
}
"search" => {
let query = arg.unwrap_or("");
match node.search_registry(query).await {
Ok(hits) => {
if hits.is_empty() {
println!("(no registry matches)");
}
for h in hits {
println!(
" {} {} [{}]",
h.name,
hex::encode(h.author),
h.keywords.join(", "),
);
}
}
Err(e) => println!("Error: {}", e),
}
}
"greet" => {
if let Some(rest) = arg {
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
if parts.len() < 2 {
println!("Usage: greet <bio_post_id_hex> <text>");
} else {
match itsgoin_core::parse_node_id_hex(parts[0]) {
Ok(bio_post_id) => {
match node.send_greeting(bio_post_id, parts[1].to_string()).await {
Ok(()) => println!("Greeting sent (sealed — only the bio author can read it)."),
Err(e) => println!("Error: {}", e),
}
}
Err(e) => println!("Invalid post ID: {}", e),
}
}
} else {
println!("Usage: greet <bio_post_id_hex> <text>");
}
}
"greetings" => {
match node.list_greetings().await {
Ok(greetings) => {
if greetings.is_empty() {
println!("(no greetings)");
}
for (i, g) in greetings.iter().enumerate() {
let name = if g.sender_name.is_empty() {
hex::encode(g.sender_persona)[..12].to_string()
} else {
g.sender_name.clone()
};
println!(
" [{}] {} ({}): {}",
i, name, &hex::encode(g.sender_persona)[..12], g.text
);
}
last_greetings = greetings;
}
Err(e) => println!("Error: {}", e),
}
}
"reply" => {
if let Some(rest) = arg {
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
match (parts.first().and_then(|s| s.parse::<usize>().ok()), parts.get(1)) {
(Some(idx), Some(text)) => {
match last_greetings.get(idx) {
Some(g) => {
match node.reply_to_greeting(
g.comment_author, g.post_id, g.timestamp_ms,
text.to_string(),
).await {
Ok(()) => println!("Reply sent (sealed to the greeting's fresh reply key)."),
Err(e) => println!("Error: {}", e),
}
}
None => println!("No greeting at index {} — run `greetings` first.", idx),
}
}
_ => println!("Usage: reply <greeting_index> <text>"),
}
} else {
println!("Usage: reply <greeting_index> <text>");
}
}
"dismiss" => {
match arg.and_then(|a| a.trim().parse::<usize>().ok()) {
Some(idx) => match last_greetings.get(idx) {
Some(g) => {
match node.dismiss_greeting(g.comment_author, g.post_id, g.timestamp_ms).await {
Ok(()) => println!("Greeting dismissed (local only)."),
Err(e) => println!("Error: {}", e),
}
}
None => println!("No greeting at index {} — run `greetings` first.", idx),
},
None => println!("Usage: dismiss <greeting_index>"),
}
}
"greetings-open" => {
match arg.map(|a| a.trim()) {
Some("on") | Some("off") => {
let open = arg.map(|a| a.trim()) == Some("on");
let pid = node.default_posting_id;
match node.set_greetings_open(&pid, open).await {
Ok(()) => println!(
"Greetings {} (bio republished).",
if open { "OPEN — strangers can send sealed greetings" } else { "CLOSED" }
),
Err(e) => println!("Error: {}", e),
}
}
_ => {
let pid = node.default_posting_id;
match node.get_greetings_open(&pid).await {
Ok(open) => println!(
"Greetings are {}. Usage: greetings-open <on|off>",
if open { "open" } else { "closed" }
),
Err(e) => println!("Error: {}", e),
}
}
}
}
"quit" | "exit" | "q" => {
println!("Shutting down...");
break;
@ -887,7 +1190,9 @@ async fn print_post(
) {
let author_hex = hex::encode(post.author);
let author_short = &author_hex[..12];
let is_me = &post.author == &node.node_id;
// Posts are authored by posting identities — "me" = any of our personas.
let is_me = node.list_posting_identities().await.unwrap_or_default()
.iter().any(|p| p.node_id == post.author);
let author_name = node.get_display_name(&post.author).await.unwrap_or(None);
let author_label = match (author_name, is_me) {

View file

@ -1,6 +1,6 @@
[package]
name = "itsgoin-core"
version = "0.7.0"
version = "0.8.0-alpha"
edition = "2021"
[dependencies]
@ -19,7 +19,11 @@ ed25519-dalek = { version = "=3.0.0-pre.1", features = ["rand_core", "zeroize"]
chacha20poly1305 = "0.10"
base64 = "0.22"
zip = { version = "2", default-features = false, features = ["deflate"] }
igd-next = { version = "0.16", features = ["tokio"] }
portmapper = "0.14"
[target.'cfg(target_os = "android")'.dependencies]
jni = "0.21"
ndk-context = "0.1"
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,256 @@
//! Android-only helpers for WiFi detection and MulticastLock acquisition.
//!
//! SSDP discovery (UPnP) requires receiving multicast UDP on 239.255.255.250:1900.
//! Android filters incoming multicast unless a WifiManager.MulticastLock is held.
//! These helpers acquire the lock for the duration of an SSDP attempt.
//!
//! Cellular networks (CGNAT) almost never expose UPnP/PCP gateways, so we gate
//! UPnP attempts on "is on WiFi" to avoid wasting a 3s discovery timeout on
//! every cellular startup.
#![cfg(target_os = "android")]
use jni::objects::{JObject, JString, JValue};
use jni::JavaVM;
use tracing::{debug, warn};
/// Returns true if the active network is WiFi (or Ethernet).
/// Returns false on cellular, no-network, or any error.
pub fn is_on_wifi() -> bool {
match check_wifi_inner() {
Ok(v) => v,
Err(e) => {
debug!("Android WiFi check failed: {}", e);
false
}
}
}
fn check_wifi_inner() -> Result<bool, String> {
let ctx = ndk_context::android_context();
if ctx.vm().is_null() {
return Err("ndk_context: null JavaVM (not initialized?)".into());
}
if ctx.context().is_null() {
return Err("ndk_context: null activity context".into());
}
let vm = unsafe { JavaVM::from_raw(ctx.vm() as *mut _) }
.map_err(|e| format!("JavaVM init: {:?}", e))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| format!("attach_current_thread: {:?}", e))?;
let activity = unsafe { JObject::from_raw(ctx.context() as *mut _) };
// service = activity.getSystemService(Context.CONNECTIVITY_SERVICE)
let svc_name = env
.new_string("connectivity")
.map_err(|e| format!("new_string: {:?}", e))?;
let svc = env
.call_method(
&activity,
"getSystemService",
"(Ljava/lang/String;)Ljava/lang/Object;",
&[JValue::Object(&svc_name)],
)
.map_err(|e| format!("getSystemService: {:?}", e))?
.l()
.map_err(|e| format!("getSystemService cast: {:?}", e))?;
// network = service.getActiveNetwork()
let network = env
.call_method(&svc, "getActiveNetwork", "()Landroid/net/Network;", &[])
.map_err(|e| format!("getActiveNetwork: {:?}", e))?
.l()
.map_err(|e| format!("getActiveNetwork cast: {:?}", e))?;
if network.is_null() {
return Ok(false);
}
// caps = service.getNetworkCapabilities(network)
let caps = env
.call_method(
&svc,
"getNetworkCapabilities",
"(Landroid/net/Network;)Landroid/net/NetworkCapabilities;",
&[JValue::Object(&network)],
)
.map_err(|e| format!("getNetworkCapabilities: {:?}", e))?
.l()
.map_err(|e| format!("getNetworkCapabilities cast: {:?}", e))?;
if caps.is_null() {
return Ok(false);
}
// NetworkCapabilities.TRANSPORT_WIFI = 1, TRANSPORT_ETHERNET = 3
let has_wifi = env
.call_method(&caps, "hasTransport", "(I)Z", &[JValue::Int(1)])
.map_err(|e| format!("hasTransport(WIFI): {:?}", e))?
.z()
.map_err(|e| format!("hasTransport(WIFI) cast: {:?}", e))?;
let has_eth = env
.call_method(&caps, "hasTransport", "(I)Z", &[JValue::Int(3)])
.map_err(|e| format!("hasTransport(ETH): {:?}", e))?
.z()
.map_err(|e| format!("hasTransport(ETH) cast: {:?}", e))?;
Ok(has_wifi || has_eth)
}
/// RAII guard that holds an Android WifiManager.MulticastLock for its lifetime.
/// Release happens on Drop.
pub struct MulticastLockGuard {
vm: JavaVM,
lock: jni::objects::GlobalRef,
}
impl MulticastLockGuard {
pub fn acquire(tag: &str) -> Option<Self> {
match Self::acquire_inner(tag) {
Ok(g) => Some(g),
Err(e) => {
debug!("MulticastLock acquire failed: {}", e);
None
}
}
}
fn acquire_inner(tag: &str) -> Result<Self, String> {
let ctx = ndk_context::android_context();
if ctx.vm().is_null() {
return Err("ndk_context: null JavaVM (not initialized?)".into());
}
if ctx.context().is_null() {
return Err("ndk_context: null activity context".into());
}
let vm = unsafe { JavaVM::from_raw(ctx.vm() as *mut _) }
.map_err(|e| format!("JavaVM init: {:?}", e))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| format!("attach_current_thread: {:?}", e))?;
let activity = unsafe { JObject::from_raw(ctx.context() as *mut _) };
// wifi = activity.getApplicationContext().getSystemService(Context.WIFI_SERVICE)
// Application context is important: WifiManager from Activity context can leak the activity.
let app_ctx = env
.call_method(
&activity,
"getApplicationContext",
"()Landroid/content/Context;",
&[],
)
.map_err(|e| format!("getApplicationContext: {:?}", e))?
.l()
.map_err(|e| format!("getApplicationContext cast: {:?}", e))?;
let svc_name: JString = env
.new_string("wifi")
.map_err(|e| format!("new_string: {:?}", e))?;
let wifi = env
.call_method(
&app_ctx,
"getSystemService",
"(Ljava/lang/String;)Ljava/lang/Object;",
&[JValue::Object(&svc_name)],
)
.map_err(|e| format!("getSystemService(wifi): {:?}", e))?
.l()
.map_err(|e| format!("getSystemService(wifi) cast: {:?}", e))?;
if wifi.is_null() {
return Err("WifiManager is null".into());
}
// lock = wifi.createMulticastLock(tag)
let tag_str = env
.new_string(tag)
.map_err(|e| format!("new_string(tag): {:?}", e))?;
let lock = env
.call_method(
&wifi,
"createMulticastLock",
"(Ljava/lang/String;)Landroid/net/wifi/WifiManager$MulticastLock;",
&[JValue::Object(&tag_str)],
)
.map_err(|e| format!("createMulticastLock: {:?}", e))?
.l()
.map_err(|e| format!("createMulticastLock cast: {:?}", e))?;
// lock.setReferenceCounted(false)
let _ = env.call_method(
&lock,
"setReferenceCounted",
"(Z)V",
&[JValue::Bool(0)],
);
// lock.acquire()
env.call_method(&lock, "acquire", "()V", &[])
.map_err(|e| format!("acquire: {:?}", e))?;
let global = env
.new_global_ref(&lock)
.map_err(|e| format!("new_global_ref: {:?}", e))?;
drop(env);
Ok(MulticastLockGuard { vm, lock: global })
}
}
impl Drop for MulticastLockGuard {
fn drop(&mut self) {
let env = match self.vm.attach_current_thread() {
Ok(e) => e,
Err(e) => {
warn!("MulticastLock release: attach failed: {:?}", e);
return;
}
};
let mut env = env;
if let Err(e) = env.call_method(&self.lock, "release", "()V", &[]) {
warn!("MulticastLock release failed: {:?}", e);
}
}
}
/// Stop the Android `NodeService` foreground service. Called from the
/// in-app close button so the network process actually exits rather
/// than continuing to run as a foreground service after the Activity
/// closes (foreground services are kept alive across Activity exit by
/// design).
///
/// Errors are logged but not propagated — best-effort cleanup before
/// `AppHandle::exit(0)` finishes the Activity.
pub fn stop_node_service() {
if let Err(e) = stop_node_service_inner() {
warn!("stop_node_service failed (will exit anyway): {}", e);
}
}
fn stop_node_service_inner() -> Result<(), String> {
let ctx = ndk_context::android_context();
if ctx.vm().is_null() {
return Err("ndk_context: null JavaVM".into());
}
if ctx.context().is_null() {
return Err("ndk_context: null activity context".into());
}
let vm = unsafe { JavaVM::from_raw(ctx.vm() as *mut _) }
.map_err(|e| format!("JavaVM init: {:?}", e))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| format!("attach_current_thread: {:?}", e))?;
let activity = unsafe { JObject::from_raw(ctx.context() as *mut _) };
// NodeService.stopFromNative(activity)
env.call_static_method(
"com/itsgoin/app/NodeService",
"stopFromNative",
"(Landroid/content/Context;)V",
&[JValue::Object(&activity)],
)
.map_err(|e| format!("stopFromNative: {:?}", e))?;
Ok(())
}

File diff suppressed because it is too large Load diff

View file

@ -923,15 +923,15 @@ pub fn rotate_group_key(
// --- CDN Manifest Signing ---
/// Compute the canonical digest for an AuthorManifest (for signing/verification).
/// Digest = BLAKE3(post_id ‖ author ‖ created_at_le ‖ updated_at_le ‖ author_addresses_json ‖ previous_posts_json ‖ following_posts_json)
/// Digest = BLAKE3(post_id ‖ author ‖ created_at_le ‖ updated_at_le ‖ previous_posts_json ‖ following_posts_json)
/// v0.8: author_addresses removed from AuthorManifest (and from this digest) —
/// posting-identity manifests must not carry device addresses.
fn manifest_digest(manifest: &crate::types::AuthorManifest) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
hasher.update(&manifest.post_id);
hasher.update(&manifest.author);
hasher.update(&manifest.created_at.to_le_bytes());
hasher.update(&manifest.updated_at.to_le_bytes());
let addrs_json = serde_json::to_string(&manifest.author_addresses).unwrap_or_default();
hasher.update(addrs_json.as_bytes());
let prev_json = serde_json::to_string(&manifest.previous_posts).unwrap_or_default();
hasher.update(prev_json.as_bytes());
let next_json = serde_json::to_string(&manifest.following_posts).unwrap_or_default();
@ -1010,8 +1010,20 @@ pub fn random_slot_noise(size: usize) -> Vec<u8> {
// --- Engagement crypto ---
const REACTION_WRAP_CONTEXT: &str = "itsgoin/private-reaction/v1";
const COMMENT_SIGN_CONTEXT: &str = "itsgoin/comment-sig/v1";
/// v0.8 (A3): digest v2 — expires_at_ms enters the signed digest so a
/// comment's TTL can never be silently extended by holders. v0.8 has
/// ZERO wire-compat obligations (zero-users ruling), so v1 is gone.
const COMMENT_SIGN_CONTEXT: &str = "itsgoin/comment-sig/v2";
const REACTION_SIGN_CONTEXT: &str = "itsgoin/reaction-sig/v1";
/// v0.8 (A3): self-certifying comment-delete signature context.
const COMMENT_DELETE_CONTEXT: &str = "itsgoin/comment-delete/v1";
/// v0.8 (A3): derivable open-slot V_x context (design §27). Anyone can
/// compute V_open from the post alone: author pubkey + slot_binder_nonce.
const OPEN_SLOT_VX_CONTEXT: &str = "itsgoin/open-slot-vx/v1";
/// v0.8 (A3): greeting-body seal contexts. The bio post id is baked into
/// the derivation (same domain-separation style as vouch grants).
const GREETING_KEY_CONTEXT: &str = "itsgoin/greeting/v1/key";
const GREETING_NONCE_CONTEXT: &str = "itsgoin/greeting/v1/nonce";
/// Encrypt a private reaction payload (only the post author can decrypt).
/// Uses X25519 DH between reactor and author, then ChaCha20-Poly1305.
@ -1067,25 +1079,31 @@ pub fn decrypt_private_reaction(
String::from_utf8(plaintext).map_err(|e| anyhow::anyhow!("invalid utf8: {}", e))
}
/// Sign a comment: ed25519 over BLAKE3(author || post_id || content || timestamp_ms).
/// Sign a comment: ed25519 over BLAKE3(author || post_id || content ||
/// timestamp_ms [|| ref:ref_post_id] || expires:expires_at_ms).
///
/// Digest v2 (A3): `expires_at_ms` is part of the comment's identity —
/// holders cannot extend a comment's life without invalidating the sig.
fn comment_digest(
author: &NodeId,
post_id: &PostId,
content: &str,
timestamp_ms: u64,
ref_post_id: Option<&PostId>,
expires_at_ms: u64,
) -> blake3::Hash {
let mut hasher = blake3::Hasher::new_derive_key(COMMENT_SIGN_CONTEXT);
hasher.update(author);
hasher.update(post_id);
hasher.update(content.as_bytes());
hasher.update(&timestamp_ms.to_le_bytes());
// Domain-separated append: `None` yields the same digest as the v0.6.1
// scheme, so plain comments keep verifying; `Some(ref)` adds the ref id.
// Domain-separated appends (same pattern as the v0.6.2 `ref:` field).
if let Some(rid) = ref_post_id {
hasher.update(b"ref:");
hasher.update(rid);
}
hasher.update(b"expires:");
hasher.update(&expires_at_ms.to_le_bytes());
hasher.finalize()
}
@ -1096,13 +1114,14 @@ pub fn sign_comment(
content: &str,
timestamp_ms: u64,
ref_post_id: Option<&PostId>,
expires_at_ms: u64,
) -> Vec<u8> {
let signing_key = SigningKey::from_bytes(seed);
let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id);
let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id, expires_at_ms);
signing_key.sign(digest.as_bytes()).to_bytes().to_vec()
}
/// Verify a comment's ed25519 signature.
/// Verify a comment's ed25519 signature (digest v2, expiry included).
pub fn verify_comment_signature(
author: &NodeId,
post_id: &PostId,
@ -1110,6 +1129,7 @@ pub fn verify_comment_signature(
timestamp_ms: u64,
signature: &[u8],
ref_post_id: Option<&PostId>,
expires_at_ms: u64,
) -> bool {
let Ok(verifying_key) = VerifyingKey::from_bytes(author) else {
return false;
@ -1117,10 +1137,177 @@ pub fn verify_comment_signature(
let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else {
return false;
};
let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id);
let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id, expires_at_ms);
verifying_key.verify(digest.as_bytes(), &sig).is_ok()
}
/// Convenience: verify an `InlineComment`'s identity signature.
pub fn verify_inline_comment_signature(comment: &crate::types::InlineComment) -> bool {
verify_comment_signature(
&comment.author,
&comment.post_id,
&comment.content,
comment.timestamp_ms,
&comment.signature,
comment.ref_post_id.as_ref(),
comment.expires_at_ms,
)
}
// --- v0.8 (A3): self-certifying comment deletes ---
fn comment_delete_digest(author: &NodeId, post_id: &PostId, timestamp_ms: u64) -> blake3::Hash {
let mut hasher = blake3::Hasher::new_derive_key(COMMENT_DELETE_CONTEXT);
hasher.update(author);
hasher.update(post_id);
hasher.update(&timestamp_ms.to_le_bytes());
hasher.finalize()
}
/// Sign a comment delete: ed25519 by the comment author's posting key
/// over BLAKE3(context="itsgoin/comment-delete/v1", author || post_id ||
/// timestamp_ms). Verifiable from the delete op alone — works on holders
/// that never met the persona (registry unregister path).
pub fn sign_comment_delete(
seed: &[u8; 32],
author: &NodeId,
post_id: &PostId,
timestamp_ms: u64,
) -> Vec<u8> {
let signing_key = SigningKey::from_bytes(seed);
let digest = comment_delete_digest(author, post_id, timestamp_ms);
signing_key.sign(digest.as_bytes()).to_bytes().to_vec()
}
/// Verify a self-certifying comment-delete signature.
pub fn verify_comment_delete(
author: &NodeId,
post_id: &PostId,
timestamp_ms: u64,
signature: &[u8],
) -> bool {
let Ok(verifying_key) = VerifyingKey::from_bytes(author) else { return false; };
let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else { return false; };
let digest = comment_delete_digest(author, post_id, timestamp_ms);
verifying_key.verify(digest.as_bytes(), &sig).is_ok()
}
// --- v0.8 (A3): derivable open-slot V_x (design §27) ---
/// Derive the open slot's V_x from the post alone:
/// `V_open = blake3::derive_key("itsgoin/open-slot-vx/v1",
/// author_posting_pubkey || slot_binder_nonce)`.
/// Computable by any stranger (author is on the Post, nonce is in the
/// gating); distinct per publish. The CEK recovered through an open slot
/// is PUBLIC — the outer CEK layer is camouflage only; confidentiality
/// (greetings) comes from the inner HPKE-style seal.
pub fn derive_open_slot_vx(
author_posting_pubkey: &NodeId,
slot_binder_nonce: &[u8; 32],
) -> [u8; 32] {
let mut input = [0u8; 64];
input[..32].copy_from_slice(author_posting_pubkey);
input[32..].copy_from_slice(slot_binder_nonce);
blake3::derive_key(OPEN_SLOT_VX_CONTEXT, &input)
}
// --- v0.8 (A3): sealed greeting bodies (bucketed) ---
fn derive_greeting_key_nonce(
shared_secret: &[u8; 32],
bio_post_id: &PostId,
) -> ([u8; 32], [u8; 12]) {
let key_ctx = format!("{}/{}", GREETING_KEY_CONTEXT, hex_lower(bio_post_id));
let nonce_ctx = format!("{}/{}", GREETING_NONCE_CONTEXT, hex_lower(bio_post_id));
let key = blake3::derive_key(&key_ctx, shared_secret);
let nonce_full = blake3::derive_key(&nonce_ctx, shared_secret);
let mut nonce = [0u8; 12];
nonce.copy_from_slice(&nonce_full[..12]);
(key, nonce)
}
/// Generate a fresh x25519 keypair `(priv_scalar, pub)` for greeting
/// reply keys / ephemeral seal keys. Same ed25519→x25519 derivation path
/// the rest of the codebase uses.
pub fn generate_x25519_keypair() -> ([u8; 32], [u8; 32]) {
generate_vouch_batch_ephemeral()
}
/// Seal a greeting body to `recipient_x25519_pub`, padded to exactly
/// `body_bucket` plaintext bytes. Output layout:
/// `eph_x25519_pub(32) || ChaCha20-Poly1305(len_u32_le || body || pad)`.
/// Total ciphertext length is `32 + 4 + body_bucket + 16` for every
/// greeting in the same bucket — all greetings on a bio are
/// size-identical ciphertexts.
///
/// The recipient key is the bio author's posting key converted via
/// `ed25519_pubkey_to_x25519_public` for original greetings, or the raw
/// per-greeting `reply_pubkey` for replies. `bio_post_id` is the post the
/// comment is placed ON (binds the seal to that post).
pub fn seal_greeting_body(
recipient_x25519_pub: &[u8; 32],
bio_post_id: &PostId,
plaintext: &[u8],
body_bucket: usize,
) -> Result<Vec<u8>> {
if plaintext.len() > body_bucket {
bail!(
"greeting body too large: {} > bucket {}",
plaintext.len(),
body_bucket
);
}
let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral();
let shared = x25519_dh(&eph_priv, recipient_x25519_pub);
let (key, nonce) = derive_greeting_key_nonce(&shared, bio_post_id);
// len prefix + body + random pad to the bucket.
let mut padded = Vec::with_capacity(4 + body_bucket);
padded.extend_from_slice(&(plaintext.len() as u32).to_le_bytes());
padded.extend_from_slice(plaintext);
let mut pad = vec![0u8; body_bucket - plaintext.len()];
rand::rng().fill_bytes(&mut pad);
padded.extend_from_slice(&pad);
let cipher = ChaCha20Poly1305::new_from_slice(&key)
.map_err(|e| anyhow::anyhow!("greeting cipher init: {}", e))?;
let ciphertext = cipher
.encrypt(Nonce::from_slice(&nonce), padded.as_slice())
.map_err(|e| anyhow::anyhow!("greeting seal: {}", e))?;
let mut out = Vec::with_capacity(32 + ciphertext.len());
out.extend_from_slice(&eph_pub);
out.extend_from_slice(&ciphertext);
Ok(out)
}
/// Try to open a sealed greeting body with the recipient's x25519
/// private scalar. Returns `None` on any shape/AEAD failure (not sealed
/// to this key, or tampered).
pub fn open_greeting_body(
recipient_x25519_priv: &[u8; 32],
bio_post_id: &PostId,
sealed: &[u8],
) -> Option<Vec<u8>> {
if sealed.len() < 32 + 4 + 16 {
return None;
}
let mut eph_pub = [0u8; 32];
eph_pub.copy_from_slice(&sealed[..32]);
let shared = x25519_dh(recipient_x25519_priv, &eph_pub);
let (key, nonce) = derive_greeting_key_nonce(&shared, bio_post_id);
let cipher = ChaCha20Poly1305::new_from_slice(&key).ok()?;
let padded = cipher.decrypt(Nonce::from_slice(&nonce), &sealed[32..]).ok()?;
if padded.len() < 4 {
return None;
}
let real_len = u32::from_le_bytes(padded[..4].try_into().ok()?) as usize;
if 4 + real_len > padded.len() {
return None;
}
Some(padded[4..4 + real_len].to_vec())
}
/// Sign a reaction: ed25519 over BLAKE3(reactor || post_id || emoji || timestamp_ms).
pub fn sign_reaction(
seed: &[u8; 32],
@ -1359,20 +1546,133 @@ mod tests {
let ref_post = [2u8; 32];
let content = "preview";
let ts = 1000u64;
let exp = 5000u64;
// Signature including ref_post_id.
let sig_with_ref = sign_comment(&seed, &nid, &post_id, content, ts, Some(&ref_post));
let sig_with_ref = sign_comment(&seed, &nid, &post_id, content, ts, Some(&ref_post), exp);
// Verifies only when the ref is supplied.
assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&ref_post)));
assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&ref_post), exp));
// Same signature must NOT verify when the ref is dropped (binding).
assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, None));
assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, None, exp));
// Nor when the ref is swapped.
let other_ref = [3u8; 32];
assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&other_ref)));
assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&other_ref), exp));
// Plain-comment signature still works (backward compat with v0.6.1).
let sig_plain = sign_comment(&seed, &nid, &post_id, content, ts, None);
assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_plain, None));
// Plain-comment signature still works.
let sig_plain = sign_comment(&seed, &nid, &post_id, content, ts, None, exp);
assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_plain, None, exp));
}
/// A3: digest v2 covers expires_at_ms — mutating the expiry flips
/// verification (no silent TTL extension possible).
#[test]
fn comment_signature_binds_expiry() {
let (seed, nid) = make_keypair(8);
let post_id = [1u8; 32];
let ts = 1000u64;
let exp = 90_000_000u64;
let sig = sign_comment(&seed, &nid, &post_id, "hi", ts, None, exp);
assert!(verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, exp));
// Extended expiry must fail.
assert!(!verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, exp + 1));
// Stripped expiry (0) must fail.
assert!(!verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, 0));
}
/// A3: self-certifying comment-delete roundtrip + wrong-key reject.
#[test]
fn comment_delete_sign_verify() {
let (seed, nid) = make_keypair(9);
let (_mseed, mallory) = make_keypair(10);
let post_id = [4u8; 32];
let ts = 123_456u64;
let sig = sign_comment_delete(&seed, &nid, &post_id, ts);
assert_eq!(sig.len(), 64);
assert!(verify_comment_delete(&nid, &post_id, ts, &sig));
// Wrong author key.
assert!(!verify_comment_delete(&mallory, &post_id, ts, &sig));
// Wrong tuple.
assert!(!verify_comment_delete(&nid, &post_id, ts + 1, &sig));
assert!(!verify_comment_delete(&nid, &[5u8; 32], ts, &sig));
// Garbage signature.
assert!(!verify_comment_delete(&nid, &post_id, ts, &[0u8; 64]));
assert!(!verify_comment_delete(&nid, &post_id, ts, &[]));
}
/// A3: derive_open_slot_vx determinism + distinctness across nonces
/// and authors.
#[test]
fn open_slot_vx_deterministic_and_distinct() {
let (_s1, a1) = make_keypair(21);
let (_s2, a2) = make_keypair(22);
let n1 = [0xAA; 32];
let n2 = [0xBB; 32];
let v = derive_open_slot_vx(&a1, &n1);
assert_eq!(v, derive_open_slot_vx(&a1, &n1), "deterministic");
assert_ne!(v, derive_open_slot_vx(&a1, &n2), "distinct per nonce");
assert_ne!(v, derive_open_slot_vx(&a2, &n1), "distinct per author");
}
/// A3: seal/open_greeting_body roundtrip, exact-bucket ciphertext
/// length equality across different plaintext lengths, tamper reject,
/// wrong-key reject.
#[test]
fn greeting_body_roundtrip_and_bucketing() {
let (bob_priv, bob_pub) = make_persona_x25519(30);
let (carol_priv, _carol_pub) = make_persona_x25519(31);
let bio_post_id: PostId = [0x77; 32];
const BUCKET: usize = 1024;
let body = br#"{"v":1,"sender_persona":"aa","sender_name":"Al","text":"hi","return_path":"bb","reply_pubkey":"cc"}"#;
let sealed = seal_greeting_body(&bob_pub, &bio_post_id, body, BUCKET).unwrap();
assert_eq!(sealed.len(), 32 + 4 + BUCKET + 16, "fixed-size ciphertext");
// Different plaintext length → same ciphertext length.
let sealed2 = seal_greeting_body(&bob_pub, &bio_post_id, b"x", BUCKET).unwrap();
assert_eq!(sealed.len(), sealed2.len(), "bucket hides length");
// Recipient opens.
let opened = open_greeting_body(&bob_priv, &bio_post_id, &sealed).unwrap();
assert_eq!(opened, body.to_vec());
// Non-recipient cannot.
assert!(open_greeting_body(&carol_priv, &bio_post_id, &sealed).is_none());
// Wrong bio post id cannot.
assert!(open_greeting_body(&bob_priv, &[0x88; 32], &sealed).is_none());
// Tampered ciphertext fails AEAD.
let mut tampered = sealed.clone();
let last = tampered.len() - 1;
tampered[last] ^= 0x01;
assert!(open_greeting_body(&bob_priv, &bio_post_id, &tampered).is_none());
// Oversized body refused.
assert!(seal_greeting_body(&bob_pub, &bio_post_id, &vec![0u8; BUCKET + 1], BUCKET).is_err());
}
/// A3: a reply sealed to a fresh `reply_pubkey` opens with the stored
/// reply private key and NOT with the recipient's long-term key.
#[test]
fn greeting_reply_sealed_to_fresh_key_only() {
// Long-term persona key of the greeting's original sender.
let (longterm_priv, _longterm_pub) = make_persona_x25519(40);
// Fresh per-greeting reply keypair minted by that sender.
let (reply_priv, reply_pub) = generate_x25519_keypair();
let return_path_post: PostId = [0x99; 32];
let reply_body = b"hello back";
let sealed = seal_greeting_body(&reply_pub, &return_path_post, reply_body, 1024).unwrap();
// Stored fresh reply key opens it.
let opened = open_greeting_body(&reply_priv, &return_path_post, &sealed).unwrap();
assert_eq!(opened, reply_body.to_vec());
// Long-term key does NOT.
assert!(open_greeting_body(&longterm_priv, &return_path_post, &sealed).is_none());
}
#[test]
@ -1383,7 +1683,6 @@ mod tests {
let mut manifest = AuthorManifest {
post_id: [42u8; 32],
author: node_id,
author_addresses: vec!["10.0.0.1:4433".to_string()],
created_at: 1000,
updated_at: 2000,
previous_posts: vec![ManifestEntry {
@ -1400,6 +1699,97 @@ mod tests {
assert!(verify_manifest_signature(&manifest));
}
/// v0.8 (A2): manifests must never carry device addresses — neither the
/// author-signed part nor the CdnManifest wire wrapper.
#[test]
fn test_manifest_carries_no_addresses() {
use crate::types::{AuthorManifest, CdnManifest, ManifestEntry};
let (seed, node_id) = make_keypair(1);
let (_hseed, host_id) = make_keypair(2);
let mut manifest = AuthorManifest {
post_id: [42u8; 32],
author: node_id,
created_at: 1000,
updated_at: 2000,
previous_posts: vec![ManifestEntry {
post_id: [1u8; 32],
timestamp_ms: 900,
has_attachments: true,
}],
following_posts: vec![],
signature: vec![],
};
manifest.signature = sign_manifest(&seed, &manifest);
assert!(verify_manifest_signature(&manifest));
let author_json = serde_json::to_string(&manifest).unwrap();
assert!(
!author_json.contains("addresses"),
"AuthorManifest JSON must not contain any address field: {author_json}"
);
let cdn = CdnManifest {
author_manifest: manifest,
host: host_id,
};
let cdn_json = serde_json::to_string(&cdn).unwrap();
assert!(
!cdn_json.contains("addresses"),
"CdnManifest JSON must not contain any address field: {cdn_json}"
);
assert!(!cdn_json.contains("downstream_count"));
assert!(!cdn_json.contains("\"source\""));
}
/// A1: crypto pairings must use the MATCHED persona's (id, seed) tuple.
/// The historical bug paired the default posting secret with the NETWORK
/// NodeId — a pair that can never unwrap anything.
#[test]
fn test_cek_unwrap_requires_matching_persona_pair() {
let (author_seed, author_id) = make_keypair(1);
let (persona_seed, persona_id) = make_keypair(2); // recipient persona
let (network_seed, network_id) = make_keypair(9); // device network key
let (_encrypted, wrapped_keys) =
encrypt_post("hi", &author_seed, &author_id, &[persona_id]).unwrap();
// Mismatched pair (persona seed + network id): recipient lookup fails.
let r = unwrap_cek_for_recipient(&persona_seed, &network_id, &author_id, &wrapped_keys).unwrap();
assert!(r.is_none(), "network id must not appear in recipients");
// Network pair entirely: also fails.
let r = unwrap_cek_for_recipient(&network_seed, &network_id, &author_id, &wrapped_keys).unwrap();
assert!(r.is_none());
// Matched persona pair: succeeds.
let r = unwrap_cek_for_recipient(&persona_seed, &persona_id, &author_id, &wrapped_keys).unwrap();
assert!(r.is_some(), "matched persona (id, seed) pair must unwrap the CEK");
}
/// A1 (revocation): rewrap_visibility works with the AUTHOR persona's
/// (seed, id) pair — and cannot work with the network pair the old code
/// passed.
#[test]
fn test_rewrap_visibility_uses_author_persona_pair() {
let (author_seed, author_id) = make_keypair(3); // non-default persona
let (_b_seed, bob_id) = make_keypair(4);
let (_c_seed, carol_id) = make_keypair(5);
let (network_seed, network_id) = make_keypair(9);
let (_encrypted, wrapped_keys) =
encrypt_post("secret", &author_seed, &author_id, &[bob_id, carol_id]).unwrap();
// Old bug shape: (default/other secret, network id) — must fail.
assert!(rewrap_visibility(&network_seed, &network_id, &wrapped_keys, &[author_id, bob_id]).is_err());
// Correct: the author persona's own pair — succeeds and re-wraps
// for the reduced recipient set.
let new_wrapped = rewrap_visibility(&author_seed, &author_id, &wrapped_keys, &[author_id, bob_id]).unwrap();
assert!(new_wrapped.iter().any(|wk| wk.recipient == bob_id));
assert!(!new_wrapped.iter().any(|wk| wk.recipient == carol_id), "revoked recipient must be gone");
}
#[test]
fn test_forged_manifest_rejected() {
use crate::types::AuthorManifest;
@ -1409,7 +1799,6 @@ mod tests {
let mut manifest = AuthorManifest {
post_id: [42u8; 32],
author: node_id,
author_addresses: vec![],
created_at: 1000,
updated_at: 2000,
previous_posts: vec![],

View file

@ -91,7 +91,14 @@ pub async fn export_data(
let (posts, blob_cids) = if scope == ExportScope::IdentityOnly {
(vec![], vec![])
} else {
gather_posts(storage, node_id).await?
// Own posts are authored by POSTING identities (personas) — the
// network NodeId never authors posts. Export across all personas.
let author_ids: Vec<NodeId> = {
let s = storage.get().await;
s.list_posting_identities()?
.into_iter().map(|p| p.node_id).collect()
};
gather_posts(storage, &author_ids).await?
};
let (follows, profiles, settings) = if scope == ExportScope::Everything {
@ -245,10 +252,10 @@ pub async fn export_data(
})
}
/// Gather own posts and their blob CIDs.
/// Gather own posts (authored by any of our posting identities) and their blob CIDs.
async fn gather_posts(
storage: &StoragePool,
node_id: &NodeId,
author_ids: &[NodeId],
) -> anyhow::Result<(Vec<ExportedPost>, Vec<[u8; 32]>)> {
let s = storage.get().await;
let posts_with_vis = s.list_posts_with_visibility()?;
@ -257,7 +264,7 @@ async fn gather_posts(
for (id, post, vis) in &posts_with_vis {
// Only export our own posts
if post.author != *node_id {
if !author_ids.contains(&post.author) {
continue;
}

View file

@ -20,7 +20,11 @@ use rand::RngCore;
use crate::crypto::{seal_wrap_slot, SealedWrapSlot};
use crate::storage::Storage;
use crate::types::{FoFCommentGating, NodeId, WrapSlot};
use crate::types::{FoFCommentGating, NodeId, OpenSlotDecl, OpenSlotKind, WrapSlot};
/// v0.8 (A3): hard cap on a declared open-slot body bucket. Anything
/// larger on receive is presumed attacker-shaped.
pub const MAX_OPEN_SLOT_BODY_BUCKET: u16 = 4096;
/// Build the `FoFCommentGating` block for a post about to be published
/// under `CommentPermission::FriendsOfFriends`. The author's keyring
@ -33,9 +37,16 @@ use crate::types::{FoFCommentGating, NodeId, WrapSlot};
///
/// Side effect: this function is pure; no storage writes. The caller
/// owns persisting the resulting Post.
/// `open_slot`: when `Some((kind, body_bucket))`, one EXTRA real slot is
/// sealed under the derivable `V_open =
/// derive_open_slot_vx(author, slot_binder_nonce)` and declared in
/// `FoFCommentGating.open_slot` (A3 — greeting/registry open slots).
/// The open slot is a REAL wrap slot sealed by the normal path; what
/// makes it open is only that its V_x is derivable by anyone.
pub fn build_fof_comment_gating(
storage: &Storage,
author_persona_id: &NodeId,
open_slot: Option<(OpenSlotKind, u16)>,
) -> Result<Option<FoFCommentGatingBuilt>> {
// Gather the author's keyring with provenance: (V_x, owner, epoch).
// The author's own V_me appears with owner=author_persona_id.
@ -70,6 +81,9 @@ pub fn build_fof_comment_gating(
// (slot_index, owner, epoch, pub_x) afterward for provenance.
enum EntryKind {
Real { v_x_owner: NodeId, v_x_epoch: u32 },
/// A3: the derivable open slot (greeting/registry). Not part of
/// V_x provenance — its key is public by construction.
Open,
Dummy,
}
let mut entries: Vec<(EntryKind, [u8; 32], WrapSlot)> = Vec::with_capacity(tagged_keys.len());
@ -88,6 +102,25 @@ pub fn build_fof_comment_gating(
entries.push((EntryKind::Real { v_x_owner: *owner, v_x_epoch: *epoch }, pub_x, slot));
}
// A3: seal the open slot (if requested) under the derivable V_open.
if open_slot.is_some() {
let v_open = crate::crypto::derive_open_slot_vx(author_persona_id, &slot_binder_nonce);
let mut seed = [0u8; 32];
rand::rng().fill_bytes(&mut seed);
let signing_key = SigningKey::from_bytes(&seed);
let pub_x = *signing_key.verifying_key().as_bytes();
let sealed: SealedWrapSlot = seal_wrap_slot(&v_open, &slot_binder_nonce, &cek, &seed)?;
entries.push((
EntryKind::Open,
pub_x,
WrapSlot {
prefilter_tag: sealed.prefilter_tag,
read_ciphertext: sealed.read_ciphertext,
sign_ciphertext: sealed.sign_ciphertext,
},
));
}
// Pad to bucket with dummies.
let bucket = crate::profile::next_vouch_batch_bucket(entries.len());
let mut rng = rand::rng();
@ -117,8 +150,10 @@ pub fn build_fof_comment_gating(
let mut pub_post_set: Vec<[u8; 32]> = Vec::with_capacity(entries.len());
let mut wrap_slots: Vec<WrapSlot> = Vec::with_capacity(entries.len());
let mut real_slot_provenance: Vec<RealSlotProvenance> = Vec::new();
let mut open_slot_index: Option<u32> = None;
for (idx, (kind, pub_x, slot)) in entries.into_iter().enumerate() {
if let EntryKind::Real { v_x_owner, v_x_epoch } = kind {
match kind {
EntryKind::Real { v_x_owner, v_x_epoch } => {
real_slot_provenance.push(RealSlotProvenance {
slot_index: idx as u32,
v_x_owner,
@ -126,15 +161,28 @@ pub fn build_fof_comment_gating(
pub_x,
});
}
EntryKind::Open => open_slot_index = Some(idx as u32),
EntryKind::Dummy => {}
}
pub_post_set.push(pub_x);
wrap_slots.push(slot);
}
let open_slot_decl = match (open_slot, open_slot_index) {
(Some((kind, body_bucket)), Some(slot_index)) => Some(OpenSlotDecl {
slot_index,
kind,
body_bucket,
}),
_ => None,
};
let gating = FoFCommentGating {
slot_binder_nonce,
pub_post_set,
wrap_slots,
revocation_list: Vec::new(),
open_slot: open_slot_decl,
};
Ok(Some(FoFCommentGatingBuilt {
@ -142,6 +190,7 @@ pub fn build_fof_comment_gating(
cek,
slot_binder_nonce,
real_slot_provenance,
open_slot_index,
}))
}
@ -164,6 +213,9 @@ pub struct FoFCommentGatingBuilt {
/// `own_post_slot_provenance` for later cascade revocations.
/// Each entry: (slot_index, v_x_owner, v_x_epoch, pub_x).
pub real_slot_provenance: Vec<RealSlotProvenance>,
/// A3: index of the derivable open slot, when one was requested.
/// Mirrors `gating.open_slot.slot_index`.
pub open_slot_index: Option<u32>,
}
/// One entry per real (non-dummy) slot in a published FoF post.
@ -314,6 +366,37 @@ pub fn sweep_unreadable_on_new_v_x(
Ok(unlocked)
}
/// v0.8 (A3): the stranger-side derive+open helper. Derives the post's
/// open-slot `V_open` from the post alone and opens the DECLARED slot,
/// returning a `PostUnlock` usable with [`build_fof_comment`].
/// `persona_id` is filled with the caller-provided commenter id
/// (typically a throwaway identity minted per greeting).
///
/// Returns `None` when the post has no gating, no open-slot declaration,
/// or the declared slot doesn't open under the derived key (malformed or
/// key-burned open slot = the author's global off-switch).
pub fn derive_open_slot_unlock(
post: &crate::types::Post,
commenter_persona_id: &NodeId,
) -> Option<PostUnlock> {
let gating = post.fof_gating.as_ref()?;
let decl = gating.open_slot.as_ref()?;
let slot = gating.wrap_slots.get(decl.slot_index as usize)?;
let v_open = crate::crypto::derive_open_slot_vx(&post.author, &gating.slot_binder_nonce);
let opened = crate::crypto::open_wrap_slot(
&v_open,
&gating.slot_binder_nonce,
&slot.read_ciphertext,
&slot.sign_ciphertext,
)?;
Some(PostUnlock {
persona_id: *commenter_persona_id,
slot_index: decl.slot_index,
cek: opened.cek,
priv_x_seed: opened.priv_x_seed,
})
}
/// Inner helper: prefilter + AEAD-open against a single V_x.
fn try_unlock_with_v_x(
gating: &crate::types::FoFCommentGating,
@ -376,6 +459,7 @@ pub fn build_fof_comment(
body: &str,
parent_comment_id: Option<[u8; 32]>,
now_ms: u64,
expires_at_ms: u64,
) -> Result<crate::types::InlineComment> {
use ed25519_dalek::{Signer, SigningKey};
@ -411,6 +495,7 @@ pub fn build_fof_comment(
"",
now_ms,
None,
expires_at_ms,
);
Ok(crate::types::InlineComment {
@ -424,6 +509,7 @@ pub fn build_fof_comment(
pub_x_index: Some(unlock.slot_index),
group_sig: Some(group_sig),
encrypted_payload: Some(encrypted),
expires_at_ms,
})
}
@ -439,7 +525,14 @@ pub fn verify_fof_group_sig(
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
let Some(pub_x_index) = comment.pub_x_index else { return false; };
let Some(group_sig) = comment.group_sig.as_ref() else { return false; };
let Some(encrypted_payload) = comment.encrypted_payload.as_ref() else { return false; };
// A3: plaintext open-slot comments (registry entries) carry their
// body in `content` with no encrypted_payload — the group_sig then
// covers the content bytes instead. FoF/greeting comments always
// carry Some(encrypted_payload) as before.
let encrypted_payload: &[u8] = match comment.encrypted_payload.as_deref() {
Some(p) => p,
None => comment.content.as_bytes(),
};
let idx = pub_x_index as usize;
if idx >= gating.pub_post_set.len() { return false; }
if group_sig.len() != 64 { return false; }
@ -479,9 +572,10 @@ pub fn decrypt_fof_comment_payload(
// re-propagated via neighbor-manifest diffs.
/// Maximum allowed wrap_slots / pub_post_set entries on an incoming
/// FoF post. The bucket rule caps at `real + rand(0..=128)` above 256;
/// at a 4096-vouchee max realistic graph that's ~4224. Round up for
/// headroom; anything larger is presumed attacker-shaped.
/// FoF post. The bucket rule (`next_vouch_batch_bucket` in profile.rs) is
/// deterministic: ≤8 → 8; ≤256 → next power of two; >256 → next multiple
/// of 128. A 4096-vouchee realistic max buckets to 4096; 8192 gives 2x
/// headroom, anything larger is presumed attacker-shaped.
const MAX_FOF_WRAP_SLOTS: usize = 8192;
/// Maximum allowed revocation_list entries in a t=0 published gating
@ -552,6 +646,24 @@ pub fn validate_fof_gating_on_receive(post: &crate::types::Post) -> Result<()> {
);
}
// A3: open-slot declaration sanity.
if let Some(decl) = gating.open_slot.as_ref() {
if decl.slot_index as usize >= gating.wrap_slots.len() {
anyhow::bail!(
"open_slot.slot_index {} out of bounds ({} slots)",
decl.slot_index,
gating.wrap_slots.len(),
);
}
if decl.body_bucket == 0 || decl.body_bucket > MAX_OPEN_SLOT_BODY_BUCKET {
anyhow::bail!(
"open_slot.body_bucket {} out of range (1..={})",
decl.body_bucket,
MAX_OPEN_SLOT_BODY_BUCKET,
);
}
}
Ok(())
}
@ -955,7 +1067,7 @@ mod tests {
rand::rng().fill_bytes(&mut v_x_bob);
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("gating built");
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("gating built");
// Real count = 2 (own + Bob). Bucket = 8 (minimum floor).
assert_eq!(built.gating.pub_post_set.len(), 8);
assert_eq!(built.gating.wrap_slots.len(), 8);
@ -1009,7 +1121,7 @@ mod tests {
let s = temp_storage();
let (alice_id, _) = make_persona(5);
// No V_me inserted.
let built = build_fof_comment_gating(&s, &alice_id).unwrap();
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap();
assert!(built.is_none(), "no V_me → no gating block");
}
@ -1031,7 +1143,7 @@ mod tests {
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &same_key, 2000, None).unwrap();
s.insert_received_vouch_key(&alice_id, &carol_id, 1, &same_key, 3000, None).unwrap();
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
// Unique-key count = 2 (V_me_alice + same_key). Bucket = 8.
// We can't assert real count directly without exposing internals,
// but we can confirm exactly two distinct successful unwraps:
@ -1088,7 +1200,7 @@ mod tests {
alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
// Alice publishes the gating block.
let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built");
let built = build_fof_comment_gating(&alice_storage, &alice_id, None).unwrap().expect("built");
let parent_post_id = [0xCC; 32];
// Bob's device: has his V_me (which is v_x_bob since he handed it out).
@ -1128,6 +1240,7 @@ mod tests {
"great post alice",
None,
4000,
4_000_000_000_000,
).unwrap();
assert!(comment.content.is_empty(), "FoF comment body is encrypted, not in content");
assert!(comment.pub_x_index.is_some());
@ -1183,7 +1296,7 @@ mod tests {
rand::rng().fill_bytes(&mut v_x_bob);
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
let post_id = [0xDE; 32];
// Persist the post so apply_fof_revocation_locally can resolve
@ -1228,7 +1341,7 @@ mod tests {
};
let comment = build_fof_comment(
&post_id, &bob_unlock, &built.slot_binder_nonce,
&bob_id, &bob_seed, "hello", None, 4000,
&bob_id, &bob_seed, "hello", None, 4000, 4_000_000_000_000,
).unwrap();
s.store_comment(&comment).unwrap();
assert_eq!(s.get_comments(&post_id).unwrap().len(), 1, "Bob's comment stored");
@ -1277,7 +1390,7 @@ mod tests {
s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();
// Initial gating: Alice only.
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
let post_id = [0xBC; 32];
let post = crate::types::Post {
author: alice_id, content: "alice".into(), attachments: vec![],
@ -1379,7 +1492,7 @@ mod tests {
rand::rng().fill_bytes(&mut v_me_old);
s.insert_own_vouch_key(&alice_id, 1, &v_me_old, 1000).unwrap();
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
let post_id = [0xAB; 32];
let post = crate::types::Post {
author: alice_id, content: "x".into(), attachments: vec![],
@ -1469,6 +1582,7 @@ mod tests {
pub_post_set: (0..slot_count).map(|_| [0u8; 32]).collect(),
wrap_slots: (0..slot_count).map(|_| dummy_wrap_slot()).collect(),
revocation_list: vec![],
open_slot: None,
}
}
@ -1574,7 +1688,7 @@ mod tests {
rand::rng().fill_bytes(&mut v_me_alice);
s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
let post_id = [0xEE; 32];
let post = crate::types::Post {
author: alice_id, content: String::new(), attachments: vec![],
@ -1706,7 +1820,7 @@ mod tests {
rand::rng().fill_bytes(&mut v_me_alice);
alice_storage.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 500).unwrap();
alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 600, None).unwrap();
let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built");
let built = build_fof_comment_gating(&alice_storage, &alice_id, None).unwrap().expect("built");
let post = crate::types::Post {
author: alice_id, content: String::new(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
@ -1750,7 +1864,7 @@ mod tests {
rand::rng().fill_bytes(&mut v_x_carol);
alice_storage.insert_received_vouch_key(&alice_id, &carol_id, 1, &v_x_carol, 200, None).unwrap();
let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built");
let built = build_fof_comment_gating(&alice_storage, &alice_id, None).unwrap().expect("built");
// Bob's storage: holds his own V_me only (no Carol-V_x). The post
// shouldn't unlock for him yet.
@ -1826,7 +1940,7 @@ mod tests {
rand::rng().fill_bytes(&mut v_x_bob);
s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap();
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
let body_plaintext = "secret to the FoF set only";
let body_ct = encrypt_fof_body(body_plaintext, &built.cek, &built.slot_binder_nonce).unwrap();
@ -1905,7 +2019,7 @@ mod tests {
s.insert_received_vouch_key(&alice_id, &bob_id, 3, &v_x_bob, 2000, None).unwrap();
s.insert_received_vouch_key(&alice_id, &carol_id, 5, &v_x_carol, 3000, None).unwrap();
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built");
// 3 unique V_x's → 3 real slots, padded to bucket 8.
assert_eq!(built.real_slot_provenance.len(), 3);
assert_eq!(built.gating.pub_post_set.len(), 8);
@ -1962,4 +2076,105 @@ mod tests {
assert!(verify_fof_revocation(&mallory_id, &post_id, &revoked_pub_x, 1000, 0, &sig));
let _ = alice_seed;
}
// --- v0.8 (A3): open-slot tests ---
/// Gating built with an open slot: declaration present, validates on
/// receive; a stranger (no vouch keys at all) derives V_open from the
/// post alone, recovers CEK + signing seed, and a comment built with
/// the derived key passes the CDN group_sig check unmodified.
#[test]
fn open_slot_stranger_derive_and_comment() {
use crate::types::{OpenSlotKind, PostingIdentity};
use ed25519_dalek::SigningKey;
let s = temp_storage();
let (alice_id, alice_seed) = make_persona(140);
s.upsert_posting_identity(&PostingIdentity {
node_id: alice_id, secret_seed: alice_seed,
display_name: "Alice".into(), created_at: 1000,
}).unwrap();
let mut v_me_alice = [0u8; 32];
rand::rng().fill_bytes(&mut v_me_alice);
s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap();
let built = build_fof_comment_gating(&s, &alice_id, Some((OpenSlotKind::Greeting, 1024)))
.unwrap().expect("built");
let decl = built.gating.open_slot.as_ref().expect("open slot declared");
assert_eq!(decl.kind, OpenSlotKind::Greeting);
assert_eq!(decl.body_bucket, 1024);
assert_eq!(Some(decl.slot_index), built.open_slot_index);
assert!((decl.slot_index as usize) < built.gating.wrap_slots.len());
let post = crate::types::Post {
author: alice_id, content: String::new(), attachments: vec![],
timestamp_ms: 3000, fof_gating: Some(built.gating.clone()),
supersedes_post_id: None,
};
// Wire-shape validation passes.
validate_fof_gating_on_receive(&post).unwrap();
// A stranger mints a throwaway identity, derives the unlock.
let (stranger_id, stranger_seed) = make_persona(141);
let unlock = derive_open_slot_unlock(&post, &stranger_id)
.expect("stranger derives the open slot unlock");
assert_eq!(unlock.cek, built.cek, "stranger recovers the (public) CEK");
assert_eq!(unlock.slot_index, decl.slot_index);
// The derived signing seed matches the published pub_x.
let derived_pub = SigningKey::from_bytes(&unlock.priv_x_seed).verifying_key().to_bytes();
assert_eq!(built.gating.pub_post_set[decl.slot_index as usize], derived_pub);
// Stranger authors a comment through the normal FoF path — must
// pass the CDN group_sig gate unmodified.
let comment = build_fof_comment(
&crate::content::compute_post_id(&post), &unlock,
&built.slot_binder_nonce, &stranger_id, &stranger_seed,
"hello, stranger here", None, 4000, 4_000_000_000_000,
).unwrap();
assert!(verify_fof_group_sig(&comment, &built.gating));
}
/// Open-slot declaration bounds are enforced on receive.
#[test]
fn open_slot_validation_bounds() {
use crate::types::{OpenSlotDecl, OpenSlotKind};
// slot_index out of bounds.
let mut g = dummy_gating(8);
g.open_slot = Some(OpenSlotDecl { slot_index: 8, kind: OpenSlotKind::Greeting, body_bucket: 1024 });
let p = dummy_post(Some(g));
let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string();
assert!(err.contains("out of bounds"), "got: {}", err);
// body_bucket too big.
let mut g = dummy_gating(8);
g.open_slot = Some(OpenSlotDecl { slot_index: 0, kind: OpenSlotKind::Registry, body_bucket: 4097 });
let p = dummy_post(Some(g));
let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string();
assert!(err.contains("out of range"), "got: {}", err);
// body_bucket zero.
let mut g = dummy_gating(8);
g.open_slot = Some(OpenSlotDecl { slot_index: 0, kind: OpenSlotKind::Greeting, body_bucket: 0 });
let p = dummy_post(Some(g));
assert!(validate_fof_gating_on_receive(&p).is_err());
// Well-formed decl passes.
let mut g = dummy_gating(8);
g.open_slot = Some(OpenSlotDecl { slot_index: 3, kind: OpenSlotKind::Greeting, body_bucket: 1024 });
let p = dummy_post(Some(g));
validate_fof_gating_on_receive(&p).unwrap();
}
/// No open slot declared → derive_open_slot_unlock returns None even
/// though the gating exists.
#[test]
fn open_slot_unlock_requires_declaration() {
let g = dummy_gating(8);
let p = dummy_post(Some(g));
let (stranger, _) = make_persona(150);
assert!(derive_open_slot_unlock(&p, &stranger).is_none());
}
}

View file

@ -1,15 +1,9 @@
//! Group-key distribution as an encrypted post.
//!
//! v0.6.2 replaces the v0.6.1 `GroupKeyDistribute` wire push (admin →
//! member, uni-stream) with a standard public post that carries the group
//! seed inside `PostVisibility::Encrypted`. Each member is a recipient; the
//! Group-key distribution as an encrypted post: the group seed travels
//! inside `PostVisibility::Encrypted`. Each member is a recipient; the
//! post's CEK is wrapped per member using the admin's posting key. Members
//! receive the post via normal CDN / pull paths, decrypt with their posting
//! secret, and recover the seed + metadata.
//!
//! Removing the direct push eliminates the wire-level signal that a given
//! network endpoint is coordinating group membership with another specific
//! endpoint.
//! secret, and recover the seed + metadata. (No direct wire push — that
//! would signal which endpoints coordinate group membership.)
//!
//! Note: Members are identified by their **posting** NodeIds (the
//! author/recipient namespace since the v0.6.1 identity split), not network
@ -20,7 +14,7 @@ use crate::content::compute_post_id;
use crate::crypto;
use crate::storage::Storage;
use crate::types::{
GroupKeyDistributionContent, GroupKeyRecord, GroupMemberKey, NodeId, Post, PostId,
GroupKeyDistributionContent, GroupKeyRecord, NodeId, Post, PostId,
PostVisibility, PostingIdentity, VisibilityIntent,
};
@ -297,4 +291,40 @@ mod tests {
assert!(!applied2);
assert!(s2.get_group_key(&group_id).unwrap().is_none());
}
/// A1 (multi-persona): a distribution post addressed to our SECOND
/// persona must still be applied — the trial-unwrap iterates ALL held
/// posting identities, not just the default one.
#[test]
fn second_persona_member_applies_distribution() {
let s = temp_storage();
let (admin_sec, admin_id) = make_keypair(1);
let (default_sec, default_id) = make_keypair(2); // default persona (not a member)
let (second_sec, second_id) = make_keypair(3); // second persona (the member)
let group_id = [43u8; 32];
let record = GroupKeyRecord {
group_id,
circle_name: "second".to_string(),
epoch: 1,
group_public_key: [8u8; 32],
admin: admin_id,
created_at: 100,
canonical_root_post_id: None,
};
let group_seed = [11u8; 32];
// Addressed ONLY to the second persona.
let (_pid, post, visibility) = build_distribution_post(
&admin_id, &admin_sec, &record, &group_seed, &[second_id],
).unwrap();
let personas = vec![
mk_persona(default_sec, default_id),
mk_persona(second_sec, second_id),
];
let applied = try_apply_distribution_post(&s, &post, &visibility, &personas).unwrap();
assert!(applied, "second persona must unwrap the group seed");
assert_eq!(s.get_group_seed(&group_id, 1).unwrap().unwrap(), group_seed);
}
}

View file

@ -8,7 +8,6 @@ use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::Mutex;
use tracing::{debug, info};
use crate::blob::BlobStore;
@ -106,7 +105,6 @@ pub async fn run_http_server(
port: u16,
storage: Arc<StoragePool>,
blob_store: Arc<BlobStore>,
downstream_addrs: Arc<Mutex<HashMap<[u8; 32], Vec<SocketAddr>>>>,
) -> anyhow::Result<()> {
let addr: SocketAddr = ([0, 0, 0, 0], port).into();
// Use SO_REUSEADDR + SO_REUSEPORT so TCP punch sockets can share the port
@ -156,10 +154,9 @@ pub async fn run_http_server(
let storage = Arc::clone(&storage);
let blob_store = Arc::clone(&blob_store);
let budget = Arc::clone(&budget);
let downstream_addrs = Arc::clone(&downstream_addrs);
tokio::spawn(async move {
handle_connection(stream, ip, slot, &storage, &blob_store, &downstream_addrs).await;
handle_connection(stream, ip, slot, &storage, &blob_store).await;
let mut b = budget.lock().unwrap();
match slot {
SlotKind::Content => b.release_content(ip),
@ -182,7 +179,6 @@ async fn handle_connection(
slot: SlotKind,
storage: &Arc<StoragePool>,
blob_store: &Arc<BlobStore>,
downstream_addrs: &Arc<Mutex<HashMap<[u8; 32], Vec<SocketAddr>>>>,
) {
// Keep-alive loop: handle sequential requests on the same connection
loop {
@ -222,7 +218,7 @@ async fn handle_connection(
}
}
SlotKind::Redirect => {
if !try_redirect(&mut stream, &post_id, storage, downstream_addrs).await {
if !try_redirect(&mut stream, &post_id, storage).await {
return;
}
}
@ -368,7 +364,6 @@ async fn try_redirect(
stream: &mut TcpStream,
post_id: &[u8; 32],
storage: &Arc<StoragePool>,
_downstream_addrs: &Arc<Mutex<HashMap<[u8; 32], Vec<SocketAddr>>>>,
) -> bool {
// Get downstream peers for this post
let downstream_peers = {
@ -583,114 +578,6 @@ pub fn html_escape(s: &str) -> String {
out
}
// --- Share link generation ---
/// Encode a list of socket addresses as compact binary, then base64url.
/// Per IPv4: [0x04][4 bytes IP][2 bytes port] = 7 bytes
/// Per IPv6: [0x06][16 bytes IP][2 bytes port] = 19 bytes
pub fn encode_hostlist(hosts: &[SocketAddr]) -> String {
let mut buf = Vec::with_capacity(hosts.len() * 19);
for host in hosts.iter().take(5) {
match host {
SocketAddr::V4(v4) => {
buf.push(0x04);
buf.extend_from_slice(&v4.ip().octets());
buf.extend_from_slice(&v4.port().to_be_bytes());
}
SocketAddr::V6(v6) => {
buf.push(0x06);
buf.extend_from_slice(&v6.ip().octets());
buf.extend_from_slice(&v6.port().to_be_bytes());
}
}
}
base64url_encode(&buf)
}
/// Decode a base64url-encoded hostlist back to socket addresses.
pub fn decode_hostlist(encoded: &str) -> Vec<SocketAddr> {
let buf = match base64url_decode(encoded) {
Some(b) => b,
None => return Vec::new(),
};
let mut addrs = Vec::new();
let mut i = 0;
while i < buf.len() {
match buf[i] {
0x04 if i + 7 <= buf.len() => {
let ip = std::net::Ipv4Addr::new(buf[i + 1], buf[i + 2], buf[i + 3], buf[i + 4]);
let port = u16::from_be_bytes([buf[i + 5], buf[i + 6]]);
addrs.push(SocketAddr::new(ip.into(), port));
i += 7;
}
0x06 if i + 19 <= buf.len() => {
let mut octets = [0u8; 16];
octets.copy_from_slice(&buf[i + 1..i + 17]);
let ip = std::net::Ipv6Addr::from(octets);
let port = u16::from_be_bytes([buf[i + 17], buf[i + 18]]);
addrs.push(SocketAddr::new(ip.into(), port));
i += 19;
}
_ => break, // malformed
}
}
addrs
}
// --- Minimal base64url implementation (no external dependency) ---
const B64_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
fn base64url_encode(data: &[u8]) -> String {
let mut out = String::with_capacity((data.len() * 4 + 2) / 3);
let mut i = 0;
while i + 2 < data.len() {
let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8) | data[i + 2] as u32;
out.push(B64_CHARS[((n >> 18) & 0x3F) as usize] as char);
out.push(B64_CHARS[((n >> 12) & 0x3F) as usize] as char);
out.push(B64_CHARS[((n >> 6) & 0x3F) as usize] as char);
out.push(B64_CHARS[(n & 0x3F) as usize] as char);
i += 3;
}
let remaining = data.len() - i;
if remaining == 2 {
let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8);
out.push(B64_CHARS[((n >> 18) & 0x3F) as usize] as char);
out.push(B64_CHARS[((n >> 12) & 0x3F) as usize] as char);
out.push(B64_CHARS[((n >> 6) & 0x3F) as usize] as char);
} else if remaining == 1 {
let n = (data[i] as u32) << 16;
out.push(B64_CHARS[((n >> 18) & 0x3F) as usize] as char);
out.push(B64_CHARS[((n >> 12) & 0x3F) as usize] as char);
}
out // no padding
}
fn base64url_decode(s: &str) -> Option<Vec<u8>> {
let mut buf = Vec::with_capacity(s.len() * 3 / 4);
let mut accum: u32 = 0;
let mut bits: u32 = 0;
for c in s.bytes() {
let val = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'-' => 62,
b'_' => 63,
b'=' => continue, // skip padding
_ => return None,
};
accum = (accum << 6) | val as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
buf.push((accum >> bits) as u8);
accum &= (1 << bits) - 1;
}
}
Some(buf)
}
#[cfg(test)]
mod tests {
use super::*;
@ -713,26 +600,6 @@ mod tests {
assert_eq!(html_escape("a&b"), "a&amp;b");
}
#[test]
fn test_base64url_roundtrip() {
let data = b"hello world";
let encoded = base64url_encode(data);
let decoded = base64url_decode(&encoded).unwrap();
assert_eq!(decoded, data);
}
#[test]
fn test_hostlist_roundtrip() {
use std::net::{Ipv4Addr, Ipv6Addr};
let hosts = vec![
SocketAddr::new(Ipv4Addr::new(192, 168, 1, 1).into(), 4433),
SocketAddr::new(Ipv6Addr::LOCALHOST.into(), 8080),
];
let encoded = encode_hostlist(&hosts);
let decoded = decode_hostlist(&encoded);
assert_eq!(decoded, hosts);
}
#[test]
fn test_parse_request_line() {
let req = b"GET /p/abc123 HTTP/1.1\r\nHost: example.com\r\n\r\n";

View file

@ -1,4 +1,6 @@
pub mod activity;
#[cfg(target_os = "android")]
pub mod android_wifi;
pub mod blob;
pub mod connection;
pub mod content;
@ -15,6 +17,7 @@ pub mod network;
pub mod node;
pub mod profile;
pub mod protocol;
pub mod registry;
pub mod storage;
pub mod stun;
pub mod types;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -68,7 +68,6 @@ pub fn apply_profile_post_if_applicable(
updated_at: content.timestamp_ms,
anchors: vec![],
recent_peers: vec![],
preferred_peers: vec![],
public_visible: true,
avatar_cid: content.avatar_cid,
};
@ -175,6 +174,9 @@ pub fn scan_vouch_grants_for_all_personas(
/// batch distributing the persona's current `V_me` to vouched personas.
/// `bio_epoch` is a monotonic per-persona counter that lets receivers
/// short-circuit re-scanning unchanged bios.
/// v0.8 (A3): `fof_gating` carries the bio's comment gating — including
/// the Greeting open slot when the persona's `greetings_open` consent is
/// on. `None` publishes a bio that accepts no greetings.
pub fn build_profile_post(
author: &NodeId,
author_secret: &[u8; 32],
@ -183,6 +185,7 @@ pub fn build_profile_post(
avatar_cid: Option<[u8; 32]>,
vouch_grants: Option<crate::types::VouchGrantBatch>,
bio_epoch: u32,
fof_gating: Option<crate::types::FoFCommentGating>,
) -> Post {
let timestamp_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -203,7 +206,7 @@ pub fn build_profile_post(
content: serde_json::to_string(&content).unwrap_or_default(),
attachments: vec![],
timestamp_ms,
fof_gating: None,
fof_gating,
supersedes_post_id: None,
}
}
@ -345,7 +348,7 @@ mod tests {
let s = temp_storage();
let (sec, pub_id) = make_keypair(11);
let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None, None, 0);
let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None, None, 0, None);
apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap();
let stored = s.get_profile(&pub_id).unwrap().expect("profile stored");
@ -360,7 +363,7 @@ mod tests {
let (sec_b, _pub_b) = make_keypair(2);
// Build a post claiming `pub_a` but signing with `sec_b`.
let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None, None, 0);
let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None, None, 0, None);
let res = apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile));
assert!(res.is_err());
assert!(s.get_profile(&pub_a).unwrap().is_none());
@ -372,7 +375,7 @@ mod tests {
let (sec, pub_id) = make_keypair(3);
// Seed with a newer profile.
let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None, None, 0);
let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None, None, 0, None);
// Hack the timestamp to make it clearly newer.
let mut content: ProfilePostContent = serde_json::from_str(&newer.content).unwrap();
content.timestamp_ms = 10_000;
@ -382,7 +385,7 @@ mod tests {
apply_profile_post_if_applicable(&s, &newer, Some(&VisibilityIntent::Profile)).unwrap();
// Apply an older profile — should be ignored.
let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None, None, 0);
let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None, None, 0, None);
let mut content_o: ProfilePostContent = serde_json::from_str(&older.content).unwrap();
content_o.timestamp_ms = 5_000;
content_o.signature = crypto::sign_profile(&sec, &content_o.display_name, &content_o.bio, &content_o.avatar_cid, content_o.timestamp_ms);

View file

@ -1,12 +1,15 @@
use serde::{Deserialize, Serialize};
use crate::types::{
BlobHeaderDiffOp, CdnManifest, DeleteRecord, GroupEpoch, GroupId, GroupMemberKey, NodeId,
PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, VisibilityUpdate, WormId,
BlobHeaderDiffOp, CdnManifest, DeleteRecord, GroupEpoch, GroupId, NodeId,
PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, WormId,
};
/// Single ALPN for Discovery Protocol v3 (N1/N2/N3 architecture)
pub const ALPN_V2: &[u8] = b"itsgoin/3";
/// Wire-protocol ALPN. One protocol version per build, no negotiation —
/// mismatched versions are refused at the QUIC handshake. v0.8 = itsgoin/4:
/// clean break from <=v0.7.x (zero-users ruling, design.html Appendix D
/// item 3).
pub const ALPN: &[u8] = b"itsgoin/4";
/// A post bundled with its visibility metadata for sync
#[derive(Debug, Serialize, Deserialize)]
@ -15,9 +18,8 @@ pub struct SyncPost {
pub post: Post,
pub visibility: PostVisibility,
/// Optional originator's intent, so receivers can filter control posts
/// out of the feed and process their ControlOp payload. Absent on
/// pre-v0.6.2 senders; receivers treat as "unknown"/regular post.
#[serde(default)]
/// out of the feed and process their ControlOp payload. None = regular
/// post.
pub intent: Option<crate::types::VisibilityIntent>,
}
@ -25,23 +27,29 @@ pub struct SyncPost {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum MessageType {
NodeListUpdate = 0x01,
UniquesAnnounce = 0x01,
InitialExchange = 0x02,
AddressRequest = 0x03,
AddressResponse = 0x04,
RefuseRedirect = 0x05,
PullSyncRequest = 0x40,
PullSyncResponse = 0x41,
// 0x42 (PostNotification), 0x43 (PostPush), 0x44 (AudienceRequest),
// 0x45 (AudienceResponse) retired in v0.6.2: persona-signed direct pushes
// are gone. Public posts propagate via the CDN; encrypted posts via pull.
/// The uniques-index exchange (design.html §sync). A pull is no longer a
/// post transfer: it is "if you want these IDs, talk to me and I'll help
/// you find them". Symmetric — request carries our pools, response carries
/// theirs, one round trip updates both sides.
UniquesPullRequest = 0x40,
UniquesPullResponse = 0x41,
/// TRANSITIONAL (Iteration D folds this into the update-cadence scheduler +
/// CDN replication). Content still has to move somewhere while
/// PostFetchRequest (0xD4) serves Public posts only: DMs, group posts,
/// Friends and FoFClosed posts have no other carrier. Splitting it off
/// 0x40/0x41 keeps §sync honest — a *pull* is not a post transfer, because
/// post transfer is not a pull.
ContentSyncRequest = 0x46,
ContentSyncResponse = 0x47,
ProfileUpdate = 0x50,
DeleteRecord = 0x51,
VisibilityUpdate = 0x52,
WormQuery = 0x60,
WormResponse = 0x61,
SocialAddressUpdate = 0x70,
SocialDisconnectNotice = 0x71,
SocialCheckin = 0x72,
// 0x80-0x81 reserved
BlobRequest = 0x90,
@ -49,19 +57,15 @@ pub enum MessageType {
ManifestRefreshRequest = 0x92,
ManifestRefreshResponse = 0x93,
ManifestPush = 0x94,
// 0x95 (BlobDeleteNotice) retired in v0.6.2 — remote holders evict via LRU.
// 0xA0 (GroupKeyDistribute) retired in v0.6.2 — group seeds now travel
// as encrypted posts via the CDN. See `group_key_distribution` module.
GroupKeyRequest = 0xA1,
GroupKeyResponse = 0xA2,
RelayIntroduce = 0xB0,
RelayIntroduceResult = 0xB1,
SessionRelay = 0xB2,
MeshPrefer = 0xB3,
CircleProfileUpdate = 0xB4,
AnchorRegister = 0xC0,
AnchorReferralRequest = 0xC1,
AnchorReferralResponse = 0xC2,
// 0xC0 retired in v0.8 — anchors keep a rolling window of recent callers
// fed by the request itself; there is no registration message and no
// registration database.
ConvectionRequest = 0xC1,
ConvectionResponse = 0xC2,
AnchorProbeRequest = 0xC3,
AnchorProbeResult = 0xC4,
PortScanHeartbeat = 0xC5,
@ -83,36 +87,31 @@ pub enum MessageType {
impl MessageType {
pub fn from_byte(b: u8) -> Option<Self> {
match b {
0x01 => Some(Self::NodeListUpdate),
0x01 => Some(Self::UniquesAnnounce),
0x02 => Some(Self::InitialExchange),
0x03 => Some(Self::AddressRequest),
0x04 => Some(Self::AddressResponse),
0x05 => Some(Self::RefuseRedirect),
0x40 => Some(Self::PullSyncRequest),
0x41 => Some(Self::PullSyncResponse),
0x40 => Some(Self::UniquesPullRequest),
0x41 => Some(Self::UniquesPullResponse),
0x46 => Some(Self::ContentSyncRequest),
0x47 => Some(Self::ContentSyncResponse),
0x50 => Some(Self::ProfileUpdate),
0x51 => Some(Self::DeleteRecord),
0x52 => Some(Self::VisibilityUpdate),
0x60 => Some(Self::WormQuery),
0x61 => Some(Self::WormResponse),
0x70 => Some(Self::SocialAddressUpdate),
0x71 => Some(Self::SocialDisconnectNotice),
0x72 => Some(Self::SocialCheckin),
0x90 => Some(Self::BlobRequest),
0x91 => Some(Self::BlobResponse),
0x92 => Some(Self::ManifestRefreshRequest),
0x93 => Some(Self::ManifestRefreshResponse),
0x94 => Some(Self::ManifestPush),
0xA1 => Some(Self::GroupKeyRequest),
0xA2 => Some(Self::GroupKeyResponse),
0xB0 => Some(Self::RelayIntroduce),
0xB1 => Some(Self::RelayIntroduceResult),
0xB2 => Some(Self::SessionRelay),
0xB3 => Some(Self::MeshPrefer),
0xB4 => Some(Self::CircleProfileUpdate),
0xC0 => Some(Self::AnchorRegister),
0xC1 => Some(Self::AnchorReferralRequest),
0xC2 => Some(Self::AnchorReferralResponse),
0xC1 => Some(Self::ConvectionRequest),
0xC2 => Some(Self::ConvectionResponse),
0xC3 => Some(Self::AnchorProbeRequest),
0xC4 => Some(Self::AnchorProbeResult),
0xC5 => Some(Self::PortScanHeartbeat),
@ -140,13 +139,152 @@ impl MessageType {
// --- Payload structs ---
/// Initial exchange: N1/N2 node lists + profile + deletes + post_ids + peer addresses
/// A packed set of 32-byte IDs: base64 of the raw concatenated bytes.
///
/// `NodeId = [u8; 32]` serialised through serde_json becomes a JSON array of 32
/// decimal numbers (~100-130 bytes/ID — a 3-4x blowup). The uniques pools can
/// carry thousands of IDs, so they ride packed instead.
pub type PackedIds = String;
/// Encode a set of IDs into a packed base64 blob.
pub fn pack_ids(ids: &[NodeId]) -> PackedIds {
use base64::Engine;
let mut raw = Vec::with_capacity(ids.len() * 32);
for id in ids {
raw.extend_from_slice(id);
}
base64::engine::general_purpose::STANDARD.encode(raw)
}
/// Decode a packed base64 blob back into IDs. Trailing partial IDs are dropped.
pub fn unpack_ids(packed: &str) -> Vec<NodeId> {
use base64::Engine;
let raw = match base64::engine::general_purpose::STANDARD.decode(packed) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
raw.chunks_exact(32)
.map(|c| {
let mut id = [0u8; 32];
id.copy_from_slice(c);
id
})
.collect()
}
/// How many 32-byte IDs a packed base64 blob holds, WITHOUT decoding it.
///
/// Standard base64 emits 4 chars per 3 input bytes (padded), so decoded length
/// is `chars / 4 * 3` minus padding; integer division by 32 absorbs the padding
/// slack for any non-empty blob. Exact for every blob `pack_ids` produces.
pub fn packed_id_count(packed: &str) -> usize {
(packed.len() / 4) * 3 / 32
}
/// The ONLY entry type in a uniques pool that carries an address.
///
/// design.html §layers: anchor entries carry addresses so the pools double as
/// the anchor directory; every other entry is a bare ID (there is no point
/// sharing an address that needs an introduction anyway). Addresses MUST be
/// filtered through `is_publicly_routable()` before they go on the wire.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnchorEntry {
/// hex node id (anchors are always node-class)
pub i: String,
/// e.g. ["1.2.3.4:4433"]
pub a: Vec<String>,
}
/// One slice of a uniques pool. Node-class and author-class IDs live in
/// separate arrays so a receiver structurally cannot mistake a persona ID for
/// a connectable node ID.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UniquesSlice {
/// Connectable network IDs, bare.
#[serde(default)]
pub nodes: PackedIds,
/// Posting/persona/throwaway IDs, bare. CDN-resolvable only — never a
/// connect target.
#[serde(default)]
pub authors: PackedIds,
/// Address-bearing anchor entries (node-class by definition).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub anchors: Vec<AnchorEntry>,
}
impl UniquesSlice {
pub fn is_empty(&self) -> bool {
self.nodes.is_empty() && self.authors.is_empty() && self.anchors.is_empty()
}
/// Number of IDs carried (for logging / budget checks).
///
/// `pack_ids` concatenates ALL IDs and base64-encodes the blob ONCE, so the
/// encoded length is `4 * ceil(32n/3)` characters — about 42.67 per ID, not
/// 44 (44 is the encoding of exactly ONE 32-byte ID). Counting `len()/44`
/// undercounts by ~3% at scale and by 33% for a 3-ID blob. Since this backs
/// budget checks, it is computed from the DECODED byte length instead.
pub fn len(&self) -> usize {
packed_id_count(&self.nodes) + packed_id_count(&self.authors) + self.anchors.len()
}
}
/// 0x01 — the two-pool uniques announce (design.html §layers).
///
/// POOL 1 (`fwd`) is FORWARDABLE and indexed by OUR bounce:
/// fwd[0] = N0 — ourselves (and our own anchor address, if we are an anchor)
/// fwd[1] = N1 — our own uniques: mesh peers, social directs, CDN file
/// authors, CDN file-holder peers, merged so the receiver
/// cannot tell which source an entry came from
/// fwd[2] = N2 — what our N1 reported to us
/// The receiver stores `fwd[i]` at bounce `i + 1` (their N1..N3), tagged to us
/// as reporter, and MAY re-announce it shifted one deeper.
///
/// POOL 2 (`term`) is TERMINAL: our N3, deduplicated against every fwd slice.
/// The receiver stores it as their N4 and USES it for search / address
/// resolution, but NEVER re-announces it. The structural separation — a
/// distinct field, not a flag in a merged list — is what makes "never
/// re-announce" un-forgettable at the announce builder: the builder simply
/// never reads bounce-4 rows. There is no N5 anywhere.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UniquesAnnouncePayload {
pub seq: u64,
/// Always true in v0.8 — the announce is a full snapshot that replaces this
/// reporter's rows. (Incremental diffing over four layers with anchor
/// metadata was not worth the divergence risk; the sender skips the send
/// entirely when nothing changed, which recovers most of the saving.)
pub full: bool,
/// Pool 1, forwardable. Always length 3 (bounces 0, 1, 2).
pub fwd: Vec<UniquesSlice>,
/// Pool 2, terminal. Empty when the recipient advertised `depth < 4`.
#[serde(default)]
pub term: UniquesSlice,
/// Deepest bounce the SENDER retains (4 desktop, 3 mobile). Lets the peer
/// skip building/sending the terminal pool for us — the single biggest
/// bandwidth saving on a cellular link.
pub depth: u8,
}
impl UniquesAnnouncePayload {
pub fn empty(seq: u64, depth: u8) -> Self {
Self {
seq,
full: true,
fwd: vec![UniquesSlice::default(), UniquesSlice::default(), UniquesSlice::default()],
term: UniquesSlice::default(),
depth,
}
}
}
/// Initial exchange: uniques announce + profile + deletes + post_ids + peer addresses
#[derive(Debug, Serialize, Deserialize)]
pub struct InitialExchangePayload {
/// Our connections + social contacts NodeIds (no addresses)
pub n1_node_ids: Vec<NodeId>,
/// Our deduplicated N2 NodeIds (no addresses)
pub n2_node_ids: Vec<NodeId>,
/// Our uniques pools (full snapshot). `None` means "this connection carries
/// no knowledge" — i.e. the sender placed us in a temporary referral slot.
/// Explicit on the wire rather than a pair of empty vectors so the receiver
/// can tell "nothing to share" from "not sharing".
#[serde(default)]
pub uniques: Option<UniquesAnnouncePayload>,
/// Our profile
pub profile: Option<PublicProfile>,
/// Our delete records
@ -154,68 +292,57 @@ pub struct InitialExchangePayload {
/// Our post IDs (for replica tracking)
pub post_ids: Vec<PostId>,
/// Our N+10:Addresses (connected peers with addresses) for social routing
#[serde(default)]
pub peer_addresses: Vec<PeerWithAddress>,
/// If sender is an anchor, their stable advertised address (e.g. "174.127.120.52:4433")
#[serde(default)]
pub anchor_addr: Option<String>,
/// What the sender sees as the receiver's address (STUN-like observed addr)
#[serde(default)]
pub your_observed_addr: Option<String>,
/// Sender's NAT type ("public", "easy", "hard", "unknown")
#[serde(default)]
pub nat_type: Option<String>,
/// Sender's NAT mapping behavior ("eim", "edm", "unknown")
#[serde(default)]
pub nat_mapping: Option<String>,
/// Sender's NAT filtering behavior ("open", "port_restricted", "unknown")
#[serde(default)]
pub nat_filtering: Option<String>,
/// Whether the sender is running an HTTP server for direct browser access
#[serde(default)]
pub http_capable: bool,
/// External HTTP address if known (e.g. "1.2.3.4:4433")
#[serde(default)]
pub http_addr: Option<String>,
/// CDN replication device role: "intermittent", "available", "persistent"
#[serde(default)]
pub device_role: Option<String>,
/// CDN cache pressure: 0-255 availability score (255 = lots of capacity)
#[serde(default)]
pub cache_pressure: Option<u8>,
/// Set by anchor when it detects this NodeId is already connected from a different address
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duplicate_active: Option<bool>,
}
/// Incremental N1/N2 changes
/// 0x40/0x41 — the uniques-index exchange, both directions.
///
/// The requester sends its pools; the responder answers with its own. One
/// round trip refreshes both indexes, which is what makes an index *exchange*
/// rather than a fetch. Content does not ride this path.
#[derive(Debug, Serialize, Deserialize)]
pub struct NodeListUpdatePayload {
pub seq: u64,
pub n1_added: Vec<NodeId>,
pub n1_removed: Vec<NodeId>,
pub n2_added: Vec<NodeId>,
pub n2_removed: Vec<NodeId>,
pub struct UniquesPullPayload {
/// The sender's two pools. `None` = "I carry no knowledge on this link"
/// (temp referral slot), mirroring `InitialExchangePayload.uniques`.
#[serde(default)]
pub uniques: Option<UniquesAnnouncePayload>,
}
/// Pull-based post sync request
/// 0x46 — TRANSITIONAL content sync request. Identical in shape to the v0.7
/// pull request; only the opcode moved. See `MessageType::ContentSyncRequest`.
#[derive(Debug, Serialize, Deserialize)]
pub struct PullSyncRequestPayload {
pub struct ContentSyncRequestPayload {
/// Our follows (for the responder to filter)
pub follows: Vec<NodeId>,
/// Post IDs we already have (backward compat — empty for v4 senders)
#[serde(default)]
pub have_post_ids: Vec<PostId>,
/// Protocol v4: per-author timestamps (Vec of tuples for serde compat)
#[serde(default)]
/// Per-author last-sync timestamps (Vec of tuples for serde compat)
pub since_ms: Vec<(NodeId, u64)>,
}
/// Pull-based post sync response
/// 0x47 — TRANSITIONAL content sync response.
#[derive(Debug, Serialize, Deserialize)]
pub struct PullSyncResponsePayload {
pub struct ContentSyncResponsePayload {
pub posts: Vec<SyncPost>,
pub visibility_updates: Vec<VisibilityUpdate>,
}
/// Profile update (pushed via uni-stream)
@ -224,18 +351,6 @@ pub struct ProfileUpdatePayload {
pub profiles: Vec<PublicProfile>,
}
/// Delete record (pushed via uni-stream)
#[derive(Debug, Serialize, Deserialize)]
pub struct DeleteRecordPayload {
pub records: Vec<DeleteRecord>,
}
/// Visibility update (pushed via uni-stream)
#[derive(Debug, Serialize, Deserialize)]
pub struct VisibilityUpdatePayload {
pub updates: Vec<VisibilityUpdate>,
}
/// Address resolution request (bi-stream: ask reporter for a hop-2 peer's address)
#[derive(Debug, Serialize, Deserialize)]
pub struct AddressRequestPayload {
@ -248,10 +363,8 @@ pub struct AddressResponsePayload {
pub target: NodeId,
pub address: Option<String>,
/// Set when the target is known-disconnected (requester registered as watcher)
#[serde(default)]
pub disconnected_at: Option<u64>,
/// Target's N+10:Addresses if known
#[serde(default)]
pub peer_addresses: Vec<PeerWithAddress>,
}
@ -268,15 +381,12 @@ pub struct WormQueryPayload {
pub worm_id: WormId,
pub target: NodeId,
/// Additional IDs to search for (up to 10 recent_peers of target)
#[serde(default)]
pub needle_peers: Vec<NodeId>,
pub ttl: u8,
pub visited: Vec<NodeId>,
/// Optional: also search for a specific post by ID
#[serde(default)]
pub post_id: Option<PostId>,
/// Optional: also search for a specific blob by CID
#[serde(default)]
pub blob_id: Option<[u8; 32]>,
}
@ -286,19 +396,15 @@ pub struct WormResponsePayload {
pub worm_id: WormId,
pub found: bool,
/// Which needle was actually found (target or one of its recent_peers)
#[serde(default)]
pub found_id: Option<NodeId>,
pub addresses: Vec<String>,
pub reporter: Option<NodeId>,
pub hop: Option<u8>,
/// One random wide-peer referral: (node_id, address) for bloom round
#[serde(default)]
pub wide_referral: Option<(NodeId, String)>,
/// Node that holds the requested post (may differ from found_id)
#[serde(default)]
pub post_holder: Option<NodeId>,
/// Node that holds the requested blob
#[serde(default)]
pub blob_holder: Option<NodeId>,
}
@ -312,12 +418,6 @@ pub struct SocialAddressUpdatePayload {
pub peer_addresses: Vec<PeerWithAddress>,
}
/// Disconnect notice: "peer X disconnected"
#[derive(Debug, Serialize, Deserialize)]
pub struct SocialDisconnectNoticePayload {
pub node_id: NodeId,
}
/// Lightweight keepalive checkin (bidirectional)
#[derive(Debug, Serialize, Deserialize)]
pub struct SocialCheckinPayload {
@ -333,7 +433,6 @@ pub struct SocialCheckinPayload {
pub struct BlobRequestPayload {
pub cid: [u8; 32],
/// Requester's addresses so the host can record downstream
#[serde(default)]
pub requester_addresses: Vec<String>,
}
@ -343,16 +442,12 @@ pub struct BlobResponsePayload {
pub cid: [u8; 32],
pub found: bool,
/// Base64-encoded blob bytes (empty if not found)
#[serde(default)]
pub data_b64: String,
/// Author manifest + host info (if available)
#[serde(default)]
pub manifest: Option<CdnManifest>,
/// Whether host accepted requester as downstream
#[serde(default)]
pub cdn_registered: bool,
/// If not registered (host full), try these peers
#[serde(default)]
pub cdn_redirect_peers: Vec<PeerWithAddress>,
}
@ -390,23 +485,6 @@ pub struct ManifestPushEntry {
// encrypted posts (`VisibilityIntent::GroupKeyDistribute`). See
// `crate::group_key_distribution` and `types::GroupKeyDistributionContent`.
/// Member requests current group key (bi-stream request)
#[derive(Debug, Serialize, Deserialize)]
pub struct GroupKeyRequestPayload {
pub group_id: GroupId,
pub known_epoch: GroupEpoch,
}
/// Admin responds with wrapped key (bi-stream response)
#[derive(Debug, Serialize, Deserialize)]
pub struct GroupKeyResponsePayload {
pub group_id: GroupId,
pub epoch: GroupEpoch,
pub group_public_key: [u8; 32],
pub admin: NodeId,
pub member_key: Option<GroupMemberKey>,
}
// --- Relay introduction payloads ---
/// Relay introduction identifier for deduplication
@ -453,18 +531,6 @@ pub struct SessionRelayPayload {
pub target: NodeId,
}
/// Mesh preference negotiation (bi-stream: request + response)
#[derive(Debug, Serialize, Deserialize)]
pub struct MeshPreferPayload {
/// true = "I want us to be preferred peers" (request)
pub requesting: bool,
/// true = "I agree to be preferred peers" (response only)
pub accepted: bool,
/// Reason for rejection (response only, when accepted=false)
#[serde(default)]
pub reject_reason: Option<String>,
}
/// Circle profile update: encrypted profile variant for a circle (uni-stream push)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CircleProfileUpdatePayload {
@ -479,33 +545,63 @@ pub struct CircleProfileUpdatePayload {
pub updated_at: u64,
}
// --- Anchor referral payloads ---
// --- Anchor convection payloads (design.html §anchors) ---
/// Node registers its address with an anchor (uni-stream)
#[derive(Debug, Serialize, Deserialize)]
pub struct AnchorRegisterPayload {
pub node_id: NodeId,
pub addresses: Vec<String>,
/// The one request-class bit (round-5 ruling).
///
/// ENTRY = bootstrap or recovery, i.e. the caller has fewer than 2 mesh
/// connections. Always served — a node that cannot get in cannot come back.
/// TOP_UP = growth with a working mesh. Served capacity-permitting and refused
/// CHEAPLY (a single response message, no timeout burned). Top-ups are the
/// convection medium — they are what keeps referral windows fresh — so they
/// are not throttled aggressively; the refusal doubles as the adaptive load
/// signal the caller feeds back into its action weights.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConvectionClass {
Entry,
TopUp,
}
/// Node requests peer referrals from an anchor (bi-stream request)
impl ConvectionClass {
/// Entry class is decided purely by "can this node function right now".
pub fn for_mesh_count(mesh_count: usize) -> Self {
if mesh_count < 2 { Self::Entry } else { Self::TopUp }
}
pub fn is_entry(self) -> bool {
matches!(self, Self::Entry)
}
}
/// 0xC1 — "I'm joining / I need peers". The request itself is what enrols the
/// caller in the anchor's rolling window; there is no separate registration.
#[derive(Debug, Serialize, Deserialize)]
pub struct AnchorReferralRequestPayload {
pub struct ConvectionRequestPayload {
pub requester: NodeId,
pub requester_addresses: Vec<String>,
pub class: ConvectionClass,
}
/// Anchor responds with peer referrals (bi-stream response)
/// 0xC2 — up to 2 recent prior callers, or a cheap refusal.
#[derive(Debug, Serialize, Deserialize)]
pub struct AnchorReferralResponsePayload {
pub referrals: Vec<AnchorReferral>,
pub struct ConvectionResponsePayload {
pub referrals: Vec<ConvectionReferral>,
/// Cheap refusal of a TOP_UP request: one message, immediately, so the
/// caller never burns a timeout. Never set for ENTRY-class requests.
#[serde(default)]
pub refused: bool,
}
/// A single peer referral from an anchor
/// A single referral out of the anchor's rolling caller window.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnchorReferral {
pub struct ConvectionReferral {
pub node_id: NodeId,
pub addresses: Vec<String>,
/// The anchor had BOTH ends live and has pushed a RelayIntroduce toward
/// this peer naming the caller as requester — so the peer is already
/// punching outward. Dial straight through instead of paying for a second
/// introduction round trip.
#[serde(default)]
pub introduced: bool,
}
// --- Anchor probe payloads ---
@ -533,7 +629,9 @@ pub struct AnchorProbeResultPayload {
pub observed_addr: Option<String>,
}
/// Port scan heartbeat during scanning hole punch (informational)
/// Port scan heartbeat during scanning hole punch (informational).
/// Reserved wire surface for the EDM scanner (`edm_port_scan_disabled_v0_7_3`
/// in connection.rs) — no sender/handler until the raw-UDP scan refactor.
#[derive(Debug, Serialize, Deserialize)]
pub struct PortScanHeartbeatPayload {
pub peer: NodeId,
@ -584,7 +682,6 @@ pub struct BlobHeaderResponsePayload {
/// True if the sender has a newer header than requested
pub updated: bool,
/// JSON-serialized BlobHeader (if updated)
#[serde(default)]
pub header_json: Option<String>,
}
@ -725,36 +822,31 @@ mod tests {
#[test]
fn message_type_roundtrip() {
let types = [
MessageType::NodeListUpdate,
MessageType::UniquesAnnounce,
MessageType::InitialExchange,
MessageType::AddressRequest,
MessageType::AddressResponse,
MessageType::RefuseRedirect,
MessageType::PullSyncRequest,
MessageType::PullSyncResponse,
MessageType::UniquesPullRequest,
MessageType::UniquesPullResponse,
MessageType::ContentSyncRequest,
MessageType::ContentSyncResponse,
MessageType::ProfileUpdate,
MessageType::DeleteRecord,
MessageType::VisibilityUpdate,
MessageType::WormQuery,
MessageType::WormResponse,
MessageType::SocialAddressUpdate,
MessageType::SocialDisconnectNotice,
MessageType::SocialCheckin,
MessageType::BlobRequest,
MessageType::BlobResponse,
MessageType::ManifestRefreshRequest,
MessageType::ManifestRefreshResponse,
MessageType::ManifestPush,
MessageType::GroupKeyRequest,
MessageType::GroupKeyResponse,
MessageType::RelayIntroduce,
MessageType::RelayIntroduceResult,
MessageType::SessionRelay,
MessageType::MeshPrefer,
MessageType::CircleProfileUpdate,
MessageType::AnchorRegister,
MessageType::AnchorReferralRequest,
MessageType::AnchorReferralResponse,
MessageType::ConvectionRequest,
MessageType::ConvectionResponse,
MessageType::AnchorProbeRequest,
MessageType::AnchorProbeResult,
MessageType::PortScanHeartbeat,
@ -841,43 +933,6 @@ mod tests {
assert_eq!(decoded2.reject_reason.unwrap(), "target not reachable");
}
#[test]
fn mesh_prefer_payload_roundtrip() {
// Request
let request = MeshPreferPayload {
requesting: true,
accepted: false,
reject_reason: None,
};
let json = serde_json::to_string(&request).unwrap();
let decoded: MeshPreferPayload = serde_json::from_str(&json).unwrap();
assert!(decoded.requesting);
assert!(!decoded.accepted);
assert!(decoded.reject_reason.is_none());
// Accepted response
let accept = MeshPreferPayload {
requesting: false,
accepted: true,
reject_reason: None,
};
let json2 = serde_json::to_string(&accept).unwrap();
let decoded2: MeshPreferPayload = serde_json::from_str(&json2).unwrap();
assert!(!decoded2.requesting);
assert!(decoded2.accepted);
// Rejected response
let reject = MeshPreferPayload {
requesting: false,
accepted: false,
reject_reason: Some("slots full".to_string()),
};
let json3 = serde_json::to_string(&reject).unwrap();
let decoded3: MeshPreferPayload = serde_json::from_str(&json3).unwrap();
assert!(!decoded3.accepted);
assert_eq!(decoded3.reject_reason.unwrap(), "slots full");
}
#[test]
fn session_relay_payload_roundtrip() {
let payload = SessionRelayPayload {
@ -913,56 +968,105 @@ mod tests {
}
#[test]
fn anchor_register_payload_roundtrip() {
let payload = AnchorRegisterPayload {
node_id: [1u8; 32],
addresses: vec!["192.168.1.5:4433".to_string(), "10.0.0.1:4433".to_string()],
};
let json = serde_json::to_string(&payload).unwrap();
let decoded: AnchorRegisterPayload = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.node_id, [1u8; 32]);
assert_eq!(decoded.addresses.len(), 2);
assert_eq!(decoded.addresses[0], "192.168.1.5:4433");
}
#[test]
fn anchor_referral_request_payload_roundtrip() {
let payload = AnchorReferralRequestPayload {
fn convection_request_carries_the_class_bit() {
for class in [ConvectionClass::Entry, ConvectionClass::TopUp] {
let payload = ConvectionRequestPayload {
requester: [2u8; 32],
requester_addresses: vec!["10.0.0.2:4433".to_string()],
class,
};
let json = serde_json::to_string(&payload).unwrap();
let decoded: AnchorReferralRequestPayload = serde_json::from_str(&json).unwrap();
let decoded: ConvectionRequestPayload = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.requester, [2u8; 32]);
assert_eq!(decoded.requester_addresses, vec!["10.0.0.2:4433"]);
assert_eq!(decoded.class, class);
}
// The class is derived purely from "can this node function right now".
assert!(ConvectionClass::for_mesh_count(0).is_entry());
assert!(ConvectionClass::for_mesh_count(1).is_entry());
assert!(!ConvectionClass::for_mesh_count(2).is_entry());
assert!(!ConvectionClass::for_mesh_count(20).is_entry());
}
#[test]
fn anchor_referral_response_payload_roundtrip() {
let payload = AnchorReferralResponsePayload {
fn convection_response_roundtrip_including_cheap_refusal() {
let payload = ConvectionResponsePayload {
referrals: vec![
AnchorReferral {
ConvectionReferral {
node_id: [3u8; 32],
addresses: vec!["10.0.0.3:4433".to_string()],
introduced: false,
},
AnchorReferral {
ConvectionReferral {
node_id: [4u8; 32],
addresses: vec!["10.0.0.4:4433".to_string(), "192.168.1.4:4433".to_string()],
addresses: vec!["10.0.0.4:4433".to_string()],
introduced: true,
},
],
refused: false,
};
let json = serde_json::to_string(&payload).unwrap();
let decoded: AnchorReferralResponsePayload = serde_json::from_str(&json).unwrap();
let decoded: ConvectionResponsePayload = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.referrals.len(), 2);
assert_eq!(decoded.referrals[0].node_id, [3u8; 32]);
assert_eq!(decoded.referrals[0].addresses, vec!["10.0.0.3:4433"]);
assert_eq!(decoded.referrals[1].node_id, [4u8; 32]);
assert_eq!(decoded.referrals[1].addresses.len(), 2);
assert!(!decoded.referrals[0].introduced);
assert!(decoded.referrals[1].introduced);
assert!(!decoded.refused);
// Empty referrals
let empty = AnchorReferralResponsePayload { referrals: vec![] };
let json2 = serde_json::to_string(&empty).unwrap();
let decoded2: AnchorReferralResponsePayload = serde_json::from_str(&json2).unwrap();
// A refusal is one small message with no referrals — the caller reads
// it immediately instead of burning a timeout.
let refusal = ConvectionResponsePayload { referrals: vec![], refused: true };
let json2 = serde_json::to_string(&refusal).unwrap();
assert!(json2.len() < 64, "refusal must stay tiny: {}", json2);
let decoded2: ConvectionResponsePayload = serde_json::from_str(&json2).unwrap();
assert!(decoded2.refused);
assert!(decoded2.referrals.is_empty());
}
#[test]
fn uniques_pull_is_an_index_exchange_not_a_post_transfer() {
let payload = UniquesPullPayload {
uniques: Some(UniquesAnnouncePayload::empty(7, 4)),
};
let json = serde_json::to_string(&payload).unwrap();
let decoded: UniquesPullPayload = serde_json::from_str(&json).unwrap();
let u = decoded.uniques.unwrap();
assert_eq!(u.seq, 7);
assert_eq!(u.depth, 4);
assert_eq!(u.fwd.len(), 3);
// No post field exists on this payload at all — content rides 0x46/0x47.
assert!(!json.contains("posts"));
// A temp referral slot carries no knowledge in either direction.
let none = UniquesPullPayload { uniques: None };
let json2 = serde_json::to_string(&none).unwrap();
let decoded2: UniquesPullPayload = serde_json::from_str(&json2).unwrap();
assert!(decoded2.uniques.is_none());
}
/// `len()` backs budget checks, so it must be EXACT — not the old
/// `bytes / 44` approximation, which assumed one base64 encoding per ID
/// when `pack_ids` encodes the whole concatenated blob once.
#[test]
fn uniques_slice_len_counts_packed_ids_exactly() {
for n in [0usize, 1, 2, 3, 7, 100, 1000] {
let ids: Vec<crate::types::NodeId> = (0..n)
.map(|i| {
let mut id = [0u8; 32];
id[0] = (i % 251) as u8;
id[1] = (i / 251) as u8;
id
})
.collect();
let packed = pack_ids(&ids);
assert_eq!(packed_id_count(&packed), n, "packed_id_count for n={}", n);
assert_eq!(unpack_ids(&packed).len(), n);
let slice = UniquesSlice {
nodes: packed.clone(),
authors: packed.clone(),
anchors: vec![AnchorEntry { i: "aa".into(), a: vec!["1.2.3.4:1".into()] }],
};
assert_eq!(slice.len(), n * 2 + 1, "UniquesSlice::len for n={}", n);
}
}
}

474
crates/core/src/registry.rs Normal file
View file

@ -0,0 +1,474 @@
//! v0.8 (A3): the network registry post — a well-known "registrations
//! here" post whose comment chain is the opt-in, searchable linkage of a
//! public persona name/keywords to a posting-identity author ID
//! (design.html §27 `id="discovery"`).
//!
//! - The post's canonical bytes are FROZEN in [`REGISTRY_POST_JSON`];
//! its id is [`REGISTRY_POST_ID`] = BLAKE3(bytes). The constant is the
//! authority — never re-serialize the `Post` struct at runtime to
//! derive the id (serde field order = declaration order; any future
//! field would silently change the bytes).
//! - Every v0.8 node self-materializes the post at startup
//! ([`materialize_registry_post`]); only the comment-chain pull is
//! network work.
//! - Registration = the SAME open-slot comment implementation as
//! greetings (round-6 ruling), aimed at this post: plaintext JSON
//! entry, self-certified by the standard comment signature under the
//! persona's REAL posting key, fixed 30-day TTL, one active entry per
//! persona (newest wins).
//! - Sharding: `shard(seed, k)` derivation exists from day one via the
//! content string embedding the seed + k. k=1 forever in A3; adding
//! shard k+1 is a new content string = threshold change, not a
//! migration.
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
use crate::storage::Storage;
use crate::types::{NodeId, Post, PostId};
/// Shard seed + index (design ruling: derivable shard identity).
pub const REGISTRY_SHARD_SEED: &str = "itsgoin-registry-v1";
pub const REGISTRY_SHARD_K: u32 = 1;
/// Fixed genesis timestamp baked into the canonical bytes
/// (2026-07-30T00:00:00Z).
pub const REGISTRY_GENESIS_TIMESTAMP_MS: u64 = 1_785_369_600_000;
/// Single padded bucket for registration entries (plaintext JSON).
pub const REGISTRY_BODY_BUCKET: u16 = 512;
/// Registrations live exactly 30 days; re-sign to stay listed.
pub const REGISTRATION_TTL_MS: u64 = 30 * 24 * 3600 * 1000;
/// Holder-side slack when checking the fixed TTL (clock skew).
pub const REGISTRATION_TTL_SLACK_MS: u64 = 5 * 60 * 1000;
/// Entry shape limits (holder-enforced).
pub const MAX_REGISTRY_NAME_CHARS: usize = 64;
pub const MAX_REGISTRY_KEYWORDS: usize = 8;
pub const MAX_REGISTRY_KEYWORD_CHARS: usize = 32;
/// Ordinary comment TTL window: rand(30..=365 days), drawn BEFORE
/// signing (the expiry is inside the signed digest).
pub const ORDINARY_TTL_MIN_MS: u64 = 30 * 24 * 3600 * 1000;
pub const ORDINARY_TTL_MAX_MS: u64 = 365 * 24 * 3600 * 1000;
/// Draw the expiry for an ordinary comment: `now + rand(30..=365 days)`.
/// The randomness serves identity hygiene — throwaway commenter IDs
/// vanish at unpredictable times.
///
/// Debug/test override: `ITSGOIN_TEST_TTL_SECS=<n>` makes every ordinary
/// comment expire in n seconds (integration-test hook; debug builds only).
pub fn draw_ordinary_comment_expiry(now_ms: u64) -> u64 {
#[cfg(debug_assertions)]
{
if let Ok(secs) = std::env::var("ITSGOIN_TEST_TTL_SECS") {
if let Ok(secs) = secs.parse::<u64>() {
return now_ms + secs * 1000;
}
}
}
use rand::Rng;
let ttl = rand::rng().random_range(ORDINARY_TTL_MIN_MS..=ORDINARY_TTL_MAX_MS);
now_ms + ttl
}
/// The frozen canonical serialized bytes of the registry post. Generated
/// once (see `build_canonical_registry_post` + the `frozen_bytes_*`
/// tests) and pasted here as a literal. DO NOT regenerate at runtime.
pub const REGISTRY_POST_JSON: &str = include_str!("registry_post_v1.json");
/// `BLAKE3(REGISTRY_POST_JSON)` — the registry post's id. Asserted
/// against the frozen bytes in CI (`frozen_bytes_triple_assertion`).
pub const REGISTRY_POST_ID: PostId = [
0x95, 0x28, 0x55, 0x78, 0x0d, 0xaa, 0x7c, 0xcc,
0x1c, 0xe1, 0x2d, 0x18, 0x10, 0x3f, 0x77, 0x1d,
0x6b, 0xe0, 0xd8, 0x81, 0x33, 0x18, 0x7b, 0xbf,
0xec, 0x64, 0x60, 0xda, 0x24, 0x21, 0x90, 0xb4,
];
/// Deterministic builder for the canonical registry post. All seal
/// inputs are blake3-derived from the shard seed + k, so the output
/// bytes are reproducible bit-for-bit — this is what generated (and what
/// CI re-checks against) [`REGISTRY_POST_JSON`].
pub fn build_canonical_registry_post() -> Result<Post> {
use crate::types::{FoFCommentGating, OpenSlotDecl, OpenSlotKind, WrapSlot};
use ed25519_dalek::SigningKey;
let shard_input = format!("{}/k={}", REGISTRY_SHARD_SEED, REGISTRY_SHARD_K);
let derive = |ctx: &str| -> [u8; 32] { blake3::derive_key(ctx, shard_input.as_bytes()) };
let slot_binder_nonce = derive("itsgoin/registry/v1/slot-binder");
let cek = derive("itsgoin/registry/v1/cek");
let priv_x_seed = derive("itsgoin/registry/v1/priv-x-seed");
let pub_x = SigningKey::from_bytes(&priv_x_seed).verifying_key().to_bytes();
let v_open = crate::crypto::derive_open_slot_vx(
&crate::DEFAULT_ANCHOR_POSTING_ID,
&slot_binder_nonce,
);
let sealed = crate::crypto::seal_wrap_slot(&v_open, &slot_binder_nonce, &cek, &priv_x_seed)?;
let gating = FoFCommentGating {
slot_binder_nonce,
pub_post_set: vec![pub_x],
wrap_slots: vec![WrapSlot {
prefilter_tag: sealed.prefilter_tag,
read_ciphertext: sealed.read_ciphertext,
sign_ciphertext: sealed.sign_ciphertext,
}],
revocation_list: vec![],
open_slot: Some(OpenSlotDecl {
slot_index: 0,
kind: OpenSlotKind::Registry,
body_bucket: REGISTRY_BODY_BUCKET,
}),
};
Ok(Post {
author: crate::DEFAULT_ANCHOR_POSTING_ID,
content: format!(
"ItsGoin Network Registry — shard({}, k={}). Register here with a signed \
public comment {{name, keywords}} authored by your persona's posting key. \
Entries are self-certifying, expire after 30 days, and are newest-wins per \
persona. Delete = signed DeleteComment.",
REGISTRY_SHARD_SEED, REGISTRY_SHARD_K,
),
attachments: vec![],
timestamp_ms: REGISTRY_GENESIS_TIMESTAMP_MS,
fof_gating: Some(gating),
supersedes_post_id: None,
})
}
/// Parse the frozen constant into a `Post`. `verify_post_id` passes by
/// construction everywhere.
pub fn registry_post() -> Post {
serde_json::from_str(REGISTRY_POST_JSON)
.expect("frozen REGISTRY_POST_JSON must deserialize — build is broken otherwise")
}
/// Self-materialize the registry post (store-if-absent, Public). Every
/// v0.8 node can therefore hold/serve the chain; no fetch needed.
/// Returns `true` if the post was newly stored.
pub fn materialize_registry_post(storage: &Storage) -> Result<bool> {
let post = registry_post();
debug_assert!(crate::content::verify_post_id(&REGISTRY_POST_ID, &post));
storage.store_post_with_intent(
&REGISTRY_POST_ID,
&post,
&crate::types::PostVisibility::Public,
&crate::types::VisibilityIntent::Public,
)
}
/// A registration entry — plaintext JSON in the comment `content`.
/// Public by intent: no encryption, no anonymity.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RegistrationEntry {
pub v: u32,
pub name: String,
#[serde(default)]
pub keywords: Vec<String>,
}
/// Parse + shape-validate a registration entry from comment content.
pub fn parse_registration(content: &str) -> Result<RegistrationEntry> {
if content.len() > REGISTRY_BODY_BUCKET as usize {
bail!("registration entry exceeds body bucket ({} bytes)", content.len());
}
let entry: RegistrationEntry = serde_json::from_str(content)
.map_err(|e| anyhow::anyhow!("registration entry is not valid JSON: {}", e))?;
if entry.v != 1 {
bail!("unsupported registration version {}", entry.v);
}
if entry.name.is_empty() || entry.name.chars().count() > MAX_REGISTRY_NAME_CHARS {
bail!("registration name empty or over {} chars", MAX_REGISTRY_NAME_CHARS);
}
if entry.keywords.len() > MAX_REGISTRY_KEYWORDS {
bail!("more than {} keywords", MAX_REGISTRY_KEYWORDS);
}
for kw in &entry.keywords {
if kw.is_empty() || kw.chars().count() > MAX_REGISTRY_KEYWORD_CHARS {
bail!("keyword empty or over {} chars", MAX_REGISTRY_KEYWORD_CHARS);
}
}
Ok(entry)
}
/// Fixed-30d TTL check for registrations: `expires_at_ms timestamp_ms`
/// must equal 30 days within ±5 minutes of slack.
pub fn registration_ttl_ok(timestamp_ms: u64, expires_at_ms: u64) -> bool {
let Some(ttl) = expires_at_ms.checked_sub(timestamp_ms) else { return false; };
ttl.abs_diff(REGISTRATION_TTL_MS) <= REGISTRATION_TTL_SLACK_MS
}
/// One search hit from the local registry chain.
#[derive(Debug, Clone)]
pub struct RegistryMatch {
pub author: NodeId,
pub name: String,
pub keywords: Vec<String>,
pub timestamp_ms: u64,
pub expires_at_ms: u64,
}
/// Case-insensitive substring search over name + keywords of the
/// locally-held, unexpired, newest-wins registry entries. Search cost
/// lands on the searcher (design §27) — LOCAL query only; chain refresh
/// is the caller's job (`Node::search_registry`).
///
/// An empty query returns every live entry (browse mode).
pub fn search_entries(storage: &Storage, query: &str) -> Result<Vec<RegistryMatch>> {
let comments = storage.get_comments(&REGISTRY_POST_ID)?;
let needle = query.trim().to_lowercase();
// Newest-wins per author (storage enforces at ingest; dedupe
// defensively for locally-authored duplicates).
let mut newest: std::collections::HashMap<NodeId, RegistryMatch> =
std::collections::HashMap::new();
for c in &comments {
let Ok(entry) = parse_registration(&c.content) else { continue; };
let m = RegistryMatch {
author: c.author,
name: entry.name,
keywords: entry.keywords,
timestamp_ms: c.timestamp_ms,
expires_at_ms: c.expires_at_ms,
};
match newest.get(&c.author) {
Some(existing) if existing.timestamp_ms >= m.timestamp_ms => {}
_ => {
newest.insert(c.author, m);
}
}
}
let mut hits: Vec<RegistryMatch> = newest
.into_values()
.filter(|m| {
if needle.is_empty() {
return true;
}
m.name.to_lowercase().contains(&needle)
|| m.keywords.iter().any(|k| k.to_lowercase().contains(&needle))
})
.collect();
hits.sort_by(|a, b| b.timestamp_ms.cmp(&a.timestamp_ms));
Ok(hits)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::InlineComment;
fn temp_storage() -> Storage {
Storage::open(":memory:").unwrap()
}
/// Generator (dev-time): rebuild the canonical bytes and print them.
/// Run with `cargo test -p itsgoin-core registry_generator -- --ignored --nocapture`
/// when (and only when) the canonical definition intentionally changes,
/// then paste the output into `registry_post_v1.json` + REGISTRY_POST_ID.
#[test]
#[ignore]
fn registry_generator() {
let post = build_canonical_registry_post().unwrap();
let json = serde_json::to_string(&post).unwrap();
let id = blake3::hash(json.as_bytes());
println!("JSON:\n{}", json);
println!("ID: {:?}", id.as_bytes());
println!("ID hex: {}", hex::encode(id.as_bytes()));
}
/// CI: the frozen literal, its BLAKE3, the shipped constant, and the
/// deterministic builder must all agree — forever.
#[test]
fn frozen_bytes_triple_assertion() {
// 1. blake3(frozen bytes) == REGISTRY_POST_ID.
let hash = blake3::hash(REGISTRY_POST_JSON.as_bytes());
assert_eq!(
hash.as_bytes(),
&REGISTRY_POST_ID,
"REGISTRY_POST_ID must equal blake3 of the frozen bytes",
);
// 2. compute_post_id(parse(frozen bytes)) == REGISTRY_POST_ID —
// i.e., re-serialization is currently byte-identical, so
// verify_post_id passes everywhere.
let post = registry_post();
assert_eq!(
crate::content::compute_post_id(&post),
REGISTRY_POST_ID,
"round-trip serialization must reproduce the frozen bytes",
);
// 3. The deterministic builder still reproduces the constant
// (all seal inputs derived — no drift in crypto derivations).
let rebuilt = build_canonical_registry_post().unwrap();
assert_eq!(
serde_json::to_string(&rebuilt).unwrap(),
REGISTRY_POST_JSON,
"deterministic builder must reproduce the frozen bytes",
);
// Sanity: canonical fields.
assert_eq!(post.author, crate::DEFAULT_ANCHOR_POSTING_ID);
assert_eq!(post.timestamp_ms, REGISTRY_GENESIS_TIMESTAMP_MS);
let gating = post.fof_gating.as_ref().expect("gating");
let decl = gating.open_slot.as_ref().expect("open slot");
assert_eq!(decl.slot_index, 0);
assert_eq!(decl.kind, crate::types::OpenSlotKind::Registry);
assert_eq!(decl.body_bucket, REGISTRY_BODY_BUCKET);
assert_eq!(gating.wrap_slots.len(), 1);
}
/// The registry open slot must actually open under the derivable key
/// and yield the signing seed matching pub_post_set[0].
#[test]
fn registry_open_slot_derivable() {
use ed25519_dalek::SigningKey;
let post = registry_post();
let unlock = crate::fof::derive_open_slot_unlock(&post, &[0u8; 32])
.expect("registry open slot must open under the derived key");
assert_eq!(unlock.slot_index, 0);
let derived_pub = SigningKey::from_bytes(&unlock.priv_x_seed).verifying_key().to_bytes();
assert_eq!(post.fof_gating.unwrap().pub_post_set[0], derived_pub);
}
#[test]
fn materialize_is_idempotent() {
let s = temp_storage();
assert!(materialize_registry_post(&s).unwrap(), "first store inserts");
assert!(!materialize_registry_post(&s).unwrap(), "second store no-ops");
let (post, vis) = s.get_post_with_visibility(&REGISTRY_POST_ID).unwrap().unwrap();
assert_eq!(post.author, crate::DEFAULT_ANCHOR_POSTING_ID);
assert!(matches!(vis, crate::types::PostVisibility::Public));
}
#[test]
fn registration_shape_limits() {
// Good.
parse_registration(r#"{"v":1,"name":"Alice","keywords":["rust","p2p"]}"#).unwrap();
// No keywords is fine.
parse_registration(r#"{"v":1,"name":"Bob"}"#).unwrap();
// Wrong version.
assert!(parse_registration(r#"{"v":2,"name":"X"}"#).is_err());
// Empty name.
assert!(parse_registration(r#"{"v":1,"name":""}"#).is_err());
// Name too long.
let long_name = "x".repeat(65);
assert!(parse_registration(&format!(r#"{{"v":1,"name":"{}"}}"#, long_name)).is_err());
// Too many keywords.
let kws: Vec<String> = (0..9).map(|i| format!("k{}", i)).collect();
let json = serde_json::json!({"v":1,"name":"A","keywords":kws}).to_string();
assert!(parse_registration(&json).is_err());
// Keyword too long.
let json = serde_json::json!({"v":1,"name":"A","keywords":["x".repeat(33)]}).to_string();
assert!(parse_registration(&json).is_err());
// Not JSON.
assert!(parse_registration("hello").is_err());
}
#[test]
fn registration_ttl_fixed_30d() {
let ts = 1_000_000u64;
// Exact 30d.
assert!(registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS));
// Inside slack.
assert!(registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS + REGISTRATION_TTL_SLACK_MS));
assert!(registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS - REGISTRATION_TTL_SLACK_MS));
// Outside slack.
assert!(!registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS + REGISTRATION_TTL_SLACK_MS + 1));
assert!(!registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS - REGISTRATION_TTL_SLACK_MS - 1));
// Degenerate.
assert!(!registration_ttl_ok(ts, ts));
assert!(!registration_ttl_ok(ts, 0));
}
fn reg_comment(author: NodeId, ts: u64, name: &str, kws: &[&str]) -> InlineComment {
InlineComment {
author,
post_id: REGISTRY_POST_ID,
content: serde_json::json!({"v":1,"name":name,"keywords":kws}).to_string(),
timestamp_ms: ts,
signature: vec![0u8; 64],
deleted_at: None,
ref_post_id: None,
pub_x_index: Some(0),
group_sig: None,
encrypted_payload: None,
expires_at_ms: ts + REGISTRATION_TTL_MS,
}
}
fn test_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64
}
#[test]
fn newest_wins_upsert() {
let s = temp_storage();
let author = [7u8; 32];
let t0 = test_now();
// First entry at t0.
assert!(s.upsert_registry_entry_newest_wins(&REGISTRY_POST_ID, &author, t0).unwrap());
s.store_comment(&reg_comment(author, t0, "Old", &["a"])).unwrap();
// Newer entry at t0+1000: accepted, older row hard-deleted.
assert!(s.upsert_registry_entry_newest_wins(&REGISTRY_POST_ID, &author, t0 + 1000).unwrap());
s.store_comment(&reg_comment(author, t0 + 1000, "New", &["b"])).unwrap();
let comments = s.get_comments(&REGISTRY_POST_ID).unwrap();
assert_eq!(comments.len(), 1, "older row hard-deleted");
assert_eq!(comments[0].timestamp_ms, t0 + 1000);
// Older-than-stored entry: rejected, nothing changes.
assert!(!s.upsert_registry_entry_newest_wins(&REGISTRY_POST_ID, &author, t0 + 500).unwrap());
assert_eq!(s.get_comments(&REGISTRY_POST_ID).unwrap().len(), 1);
// Same-timestamp re-ingest: idempotent accept.
assert!(s.upsert_registry_entry_newest_wins(&REGISTRY_POST_ID, &author, t0 + 1000).unwrap());
}
#[test]
fn search_matches_name_and_keywords() {
let s = temp_storage();
let alice = [1u8; 32];
let bob = [2u8; 32];
let t0 = test_now();
s.store_comment(&reg_comment(alice, t0, "Alice", &["rust", "p2p"])).unwrap();
s.store_comment(&reg_comment(bob, t0 + 1, "Bob", &["gardening"])).unwrap();
// Keyword hit (case-insensitive).
let hits = search_entries(&s, "RUST").unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].author, alice);
assert_eq!(hits[0].name, "Alice");
// Name substring hit.
let hits = search_entries(&s, "bo").unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].author, bob);
// Empty query = browse all.
assert_eq!(search_entries(&s, "").unwrap().len(), 2);
// No hit.
assert!(search_entries(&s, "zzz").unwrap().is_empty());
}
#[test]
fn search_skips_expired_entries() {
let s = temp_storage();
let alice = [1u8; 32];
let mut c = reg_comment(alice, 1000, "Alice", &["rust"]);
c.expires_at_ms = 2000; // long past
s.store_comment(&c).unwrap();
assert!(search_entries(&s, "rust").unwrap().is_empty(), "expired entries filtered");
}
}

View file

@ -0,0 +1 @@
{"author":[23,175,20,25,86,174,11,80,220,28,185,36,140,173,245,252,163,113,234,45,133,49,172,154,221,60,3,202,255,198,20,65],"content":"ItsGoin Network Registry — shard(itsgoin-registry-v1, k=1). Register here with a signed public comment {name, keywords} authored by your persona's posting key. Entries are self-certifying, expire after 30 days, and are newest-wins per persona. Delete = signed DeleteComment.","attachments":[],"timestamp_ms":1785369600000,"fof_gating":{"slot_binder_nonce":[194,138,144,176,11,137,185,140,117,108,61,163,60,83,166,44,35,228,74,53,76,175,67,116,175,175,49,54,140,206,82,147],"pub_post_set":[[247,219,138,10,17,157,246,133,230,232,228,97,55,195,255,125,108,212,226,71,102,202,26,31,78,233,76,47,236,66,71,144]],"wrap_slots":[{"prefilter_tag":[95,128],"read_ciphertext":[216,233,169,245,192,77,2,116,19,155,45,0,222,219,59,237,100,86,247,42,2,101,141,2,131,40,39,236,29,158,172,66,8,45,135,137,219,4,159,255,148,101,61,120,3,253,1,220],"sign_ciphertext":[42,203,235,211,8,208,10,246,77,37,158,207,8,214,152,49,15,110,220,5,190,177,105,164,146,117,190,211,12,13,32,36,109,105,156,33,42,133,118,213,248,33,74,182,178,235,176,204]}],"revocation_list":[],"open_slot":{"slot_index":0,"kind":"Registry","body_bucket":512}}}

File diff suppressed because it is too large Load diff

View file

@ -68,7 +68,22 @@ pub struct Attachment {
pub size_bytes: u64,
}
/// Public profile — plaintext, synced to all peers
/// Public profile — plaintext, synced to all peers.
///
/// v0.8 keying (dual-purpose struct — the `node_id` decides which class):
/// - NETWORK-id rows carry TOPOLOGY ONLY (anchors, recent_peers);
/// persona fields stay empty. They travel via
/// ProfileUpdate / InitialExchange, always through
/// `sanitized_for_network_broadcast()`.
/// - POSTING-id rows carry PERSONA fields (display_name, bio, avatar_cid,
/// public_visible); topology fields stay empty. They travel ONLY as
/// signed profile posts (`ProfilePostContent`), which have no topology
/// fields at all — so posting identities never advertise device
/// neighborhoods on the wire.
/// The topology NodeIds on network-id rows are the designed 11-needle
/// findability mechanism (device-to-device, no posting-id linkage) — an
/// accepted tradeoff, not a leak; see design.html v0.8 location-anonymity
/// ruling before reflagging in audits.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PublicProfile {
pub node_id: NodeId,
@ -84,9 +99,6 @@ pub struct PublicProfile {
/// Up to 10 currently-connected peer NodeIds (for 11-needle worm search)
#[serde(default)]
pub recent_peers: Vec<NodeId>,
/// Bilateral preferred peer NodeIds (stable relay hubs)
#[serde(default)]
pub preferred_peers: Vec<NodeId>,
/// Whether display_name/bio are visible to non-circle peers
#[serde(default = "default_true")]
pub public_visible: bool,
@ -98,7 +110,7 @@ pub struct PublicProfile {
impl PublicProfile {
/// Return a copy with persona-level display data (display_name, bio,
/// avatar_cid) stripped, leaving only the routing metadata (anchors,
/// recent_peers, preferred_peers). v0.6.1 broadcasts the profile under
/// recent_peers). v0.6.1 broadcasts the profile under
/// the network NodeId; attaching a human-readable name to that key would
/// correlate the network endpoint to a specific person. Persona display
/// data will travel via signed posts from v0.6.2 onward.
@ -450,14 +462,6 @@ pub struct DeleteRecord {
pub signature: Vec<u8>,
}
/// An update to a post's visibility (new wrapped keys after revocation)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisibilityUpdate {
pub post_id: PostId,
pub author: NodeId,
pub visibility: PostVisibility,
}
/// How to handle revoking a recipient's access to past encrypted posts
#[derive(Debug, Clone, Copy)]
pub enum RevocationMode {
@ -645,33 +649,67 @@ impl NatProfile {
}
/// Device profile — determines connection slot budget
///
/// v0.8 (Iteration C): the Local/Wide split is gone. There is ONE mesh pool
/// (`mesh_slots`) plus a strictly-additional band of temporary referral slots
/// (`temp_referral_slots`) that live ABOVE the mesh cap and carry no knowledge
/// exchange. See design.html §connections.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceProfile {
/// Desktop: 81 local + 20 wide = 101 mesh connections
/// Desktop: 20 mesh + up to 10 temp referral
Desktop,
/// Mobile: 10 local + 5 wide = 15 mesh connections
/// Mobile: 15 mesh + up to 4 temp referral
Mobile,
}
impl DeviceProfile {
pub fn preferred_slots(&self) -> usize {
match self {
DeviceProfile::Desktop => 10,
DeviceProfile::Mobile => 3,
/// The single mesh pool size (v0.8 narrow mesh, deep knowledge).
///
/// `ITSGOIN_TEST_MESH_SLOTS` overrides it so integration tests can reach
/// the temp-referral boundary with a handful of nodes instead of 21. Test
/// gate only — same pattern as `ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS`.
///
/// Read ONCE into a `OnceLock`: this is called from `ConnectionManager`
/// construction and from every path that wants a fresh cap, and an env
/// lookup per call is a syscall-shaped cost on a hot path (and lets the
/// mesh cap change under a running node, which nothing expects).
pub fn mesh_slots(&self) -> usize {
static OVERRIDE: std::sync::OnceLock<Option<usize>> = std::sync::OnceLock::new();
let over = *OVERRIDE.get_or_init(|| {
std::env::var("ITSGOIN_TEST_MESH_SLOTS")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|n| *n > 0)
});
if let Some(n) = over {
return n;
}
}
pub fn local_slots(&self) -> usize {
match self {
DeviceProfile::Desktop => 71,
DeviceProfile::Mobile => 7,
}
}
pub fn wide_slots(&self) -> usize {
match self {
DeviceProfile::Desktop => 20,
DeviceProfile::Mobile => 5,
DeviceProfile::Mobile => 15,
}
}
/// Temporary referral slots, allocated ABOVE `mesh_slots`. These exist only
/// to facilitate introductions: they carry no uniques exchange, are never
/// persisted to `mesh_peers`, and either graduate into a freed mesh slot or
/// expire. Ruling: +4..+10 above the cap.
pub fn temp_referral_slots(&self) -> usize {
match self {
DeviceProfile::Desktop => 10,
DeviceProfile::Mobile => 4,
}
}
/// Deepest bounce this device stores in the `reachable` index.
/// Desktop keeps the full 4-bounce horizon; mobile caps at 3 (it drops the
/// terminal pool on receipt and advertises `depth = 3` so senders skip
/// building it). Alternative considered and NOT implemented: Bloom-compress
/// N4 on mobile (see `apply_uniques_announce` doc comment).
pub fn max_bounce(&self) -> u8 {
match self {
DeviceProfile::Desktop => 4,
DeviceProfile::Mobile => 3,
}
}
@ -711,39 +749,103 @@ impl std::fmt::Display for SessionReachMethod {
}
}
/// Slot kind for mesh connections
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PeerSlotKind {
/// Bilateral preferred connections (Desktop: 10, Mobile: 3)
Preferred,
/// Diverse local connections (Desktop: 71, Mobile: 7)
Local,
/// Bloom-sourced random distant connections (20 slots)
Wide,
/// Which class of slot a live connection occupies.
///
/// v0.8: `Mesh` is the real pool (capped at `DeviceProfile::mesh_slots`) and is
/// the ONLY class that participates in the uniques announce. `TempReferral`
/// slots sit above the cap, exist purely to broker introductions, and are never
/// written to `mesh_peers` — which is what structurally keeps them out of our
/// own announcements. They graduate into a freed mesh slot or expire.
///
/// NOT on the wire — purely local accounting.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MeshSlot {
Mesh,
TempReferral { expires_at_ms: u64 },
}
impl std::fmt::Display for PeerSlotKind {
impl MeshSlot {
pub fn is_mesh(&self) -> bool {
matches!(self, MeshSlot::Mesh)
}
pub fn is_temp(&self) -> bool {
!self.is_mesh()
}
}
impl std::fmt::Display for MeshSlot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PeerSlotKind::Preferred => write!(f, "preferred"),
PeerSlotKind::Local => write!(f, "local"),
PeerSlotKind::Wide => write!(f, "wide"),
MeshSlot::Mesh => write!(f, "mesh"),
MeshSlot::TempReferral { .. } => write!(f, "temp"),
}
}
}
impl std::str::FromStr for PeerSlotKind {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"preferred" => Ok(PeerSlotKind::Preferred),
"local" | "social" => Ok(PeerSlotKind::Local),
"wide" => Ok(PeerSlotKind::Wide),
_ => Err(anyhow::anyhow!("unknown slot kind: {}", s)),
/// Class of an ID carried in a uniques pool.
///
/// A `Node` ID is a network identity we may connect to / hole-punch / relay to.
/// An `Author` ID is a posting identity (persona, throwaway, greeting ID). It
/// has NO address and NO device linkage (Iteration A stripped
/// `AuthorManifest.author_addresses`), so it must NEVER enter the address
/// resolution cascade or the growth-candidate scorer — it resolves only through
/// CDN holder search. The pools keep the two classes in separate arrays so a
/// receiver structurally cannot confuse them.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IdClass {
Node = 0,
Author = 1,
}
impl IdClass {
pub fn as_i64(self) -> i64 {
self as i64
}
pub fn from_i64(v: i64) -> Self {
if v == 1 { IdClass::Author } else { IdClass::Node }
}
}
/// One entry in the uniques index / announce pools.
///
/// `addresses` is non-empty ONLY when `is_anchor` — that is the §layers privacy
/// invariant, enforced at the store (`add_reach` blanks the column otherwise)
/// and at the wire builder (only `AnchorEntry` has an address field at all).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReachEntry {
pub id: NodeId,
pub id_class: IdClass,
pub is_anchor: bool,
pub addresses: Vec<String>,
}
impl ReachEntry {
/// A bare node-class entry (no address).
pub fn node(id: NodeId) -> Self {
Self { id, id_class: IdClass::Node, is_anchor: false, addresses: Vec::new() }
}
/// A bare author-class entry (no address, never a connect target).
pub fn author(id: NodeId) -> Self {
Self { id, id_class: IdClass::Author, is_anchor: false, addresses: Vec::new() }
}
/// An anchor entry — the only kind that carries an address.
pub fn anchor(id: NodeId, addresses: Vec<String>) -> Self {
Self { id, id_class: IdClass::Node, is_anchor: true, addresses }
}
}
/// The two announce pools, pre-wire.
///
/// `fwd` is always length 3 (our N0, N1, N2) and is FORWARDABLE — the receiver
/// stores `fwd[i]` at bounce `i + 1` and may re-announce it shifted.
/// `term` is our N3 and is TERMINAL — the receiver stores it at bounce 4, uses
/// it, and never re-announces it.
#[derive(Debug, Clone, Default)]
pub struct UniquesPools {
pub fwd: Vec<Vec<ReachEntry>>,
pub term: Vec<ReachEntry>,
}
// --- Social Routing Cache ---
/// How we last reached a social contact
@ -856,8 +958,6 @@ pub struct AuthorManifest {
/// The post this manifest is anchored to
pub post_id: PostId,
pub author: NodeId,
/// Author's N+10:A (ip:port strings)
pub author_addresses: Vec<String>,
/// Original post creation time (ms)
pub created_at: u64,
/// When manifest was last updated (ms)
@ -870,20 +970,16 @@ pub struct AuthorManifest {
pub signature: Vec<u8>,
}
/// CDN manifest traveling with blobs (author-signed part + host metadata)
/// CDN manifest traveling with blobs (author-signed part + serving host).
/// v0.8: ALL device-address fields removed (host_addresses, source,
/// source_addresses, downstream_count) — posting-identity manifests must not
/// carry device addresses (location anonymity). Holder resolution goes via
/// file_holders / worm lookup / redirect peers instead.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CdnManifest {
pub author_manifest: AuthorManifest,
/// Serving host's NodeId
/// Serving host's NodeId (the QUIC-authenticated device serving the blob)
pub host: NodeId,
/// Serving host's N+10:A
pub host_addresses: Vec<String>,
/// Who the host got it from
pub source: NodeId,
/// Source's N+10:A
pub source_addresses: Vec<String>,
/// How many downstream this host has
pub downstream_count: u32,
}
/// Cached routing info for a social contact
@ -897,8 +993,6 @@ pub struct SocialRouteEntry {
pub last_connected_ms: u64,
pub last_seen_ms: u64,
pub reach_method: ReachMethod,
/// 2-layer preferred peer tree (~100 nodes) for fast relay candidate search
pub preferred_tree: Vec<NodeId>,
}
// --- Engagement System ---
@ -978,6 +1072,38 @@ pub struct InlineComment {
/// Non-FoF observers see only ciphertext + sigs — body is opaque.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encrypted_payload: Option<Vec<u8>>,
/// v0.8 (A3): absolute expiry, ms since epoch. Fixed at creation and
/// covered by the comment signature (digest v2) — no silent extension
/// is possible. Ordinary comments: created_at + rand(30..=365 days).
/// Registry entries: created_at + exactly 30 days. `0` means "no TTL"
/// (pre-v0.8 comment); v0.8 receivers REJECT `0` at ingest.
#[serde(default)]
pub expires_at_ms: u64,
}
/// v0.8 (A3): plaintext JSON inside a sealed greeting blob (see
/// `crypto::seal_greeting_body`). Only the recipient (bio author for an
/// original greeting; holder of the per-greeting `reply_pubkey` private
/// half for a reply) can read this.
///
/// Messaging-first (rounds 89): `return_path` names the post whose
/// Greeting open slot the recipient replies into (v1: the sender's own
/// bio post), and `reply_pubkey` is a FRESH per-greeting x25519 key —
/// replies are sealed to it, never to a long-term key. No vouch is
/// involved anywhere in this flow.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GreetingBody {
pub v: u32,
/// hex32 — the sender's REAL posting pubkey (inside the seal only).
pub sender_persona: String,
/// <= 64 chars.
pub sender_name: String,
/// <= 600 chars.
pub text: String,
/// hex32 post id — where the sender watches for a reply.
pub return_path: String,
/// hex32 fresh x25519 pubkey — seal replies to THIS.
pub reply_pubkey: String,
}
/// Permission level for comments on a post
@ -1076,6 +1202,34 @@ pub struct RevocationEntry {
pub author_sig: Vec<u8>,
}
/// v0.8 (A3): what an open slot on a post is FOR. One mechanism — a wrap
/// slot whose V_x is derivable by anyone — two features aimed at
/// different parent posts (round-6 ruling).
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum OpenSlotKind {
/// Sealed first-contact greetings on a bio post.
Greeting,
/// Plaintext self-certifying registration entries on the registry post.
Registry,
}
/// v0.8 (A3): author-signed declaration that one of the post's wrap
/// slots is OPEN — its V_x is derivable from the post alone via
/// `crypto::derive_open_slot_vx(author, slot_binder_nonce)`. Covered by
/// the PostId hash (lives inside `FoFCommentGating`), so it is immutable
/// per publish; a bio republish (new slot_binder_nonce) is the only way
/// to change it.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OpenSlotDecl {
/// Index into `wrap_slots`; its pub_x is a normal member of
/// `pub_post_set`. Observable-by-design: greetings are identifiable
/// as a class, never by sender.
pub slot_index: u32,
pub kind: OpenSlotKind,
/// Padded plaintext bucket size in bytes (single bucket, design §27).
pub body_bucket: u16,
}
/// FoF Layer 2: the author-published gating block embedded in a
/// FoF-comment-policy post. Carries the wrap slots + the matching
/// pub_post_set + the slot_binder_nonce. The `revocation_list` is
@ -1097,6 +1251,10 @@ pub struct FoFCommentGating {
/// arrive; the on-wire t=0 snapshot is empty.
#[serde(default)]
pub revocation_list: Vec<RevocationEntry>,
/// v0.8 (A3): optional open-slot declaration (greeting / registry).
/// `None` on ordinary FoF-gated posts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub open_slot: Option<OpenSlotDecl>,
}
/// Author-controlled engagement policy for a post
@ -1128,7 +1286,19 @@ pub enum BlobHeaderDiffOp {
RemoveReaction { reactor: NodeId, emoji: String, post_id: PostId },
AddComment(InlineComment),
EditComment { author: NodeId, post_id: PostId, timestamp_ms: u64, new_content: String },
DeleteComment { author: NodeId, post_id: PostId, timestamp_ms: u64 },
/// v0.8 (A3): self-certifying delete. `signature` is
/// `crypto::sign_comment_delete` by the comment author's posting key
/// over (author || post_id || timestamp_ms). Receivers accept when the
/// signature verifies OR when the diff sender is the parent post's
/// author (moderation override). Empty signature = legacy shape,
/// only the sender-override path can accept it.
DeleteComment {
author: NodeId,
post_id: PostId,
timestamp_ms: u64,
#[serde(default)]
signature: Vec<u8>,
},
SetPolicy(CommentPolicy),
ThreadSplit { new_post_id: PostId },
/// Write an encrypted receipt slot (64 bytes encrypted data)

View file

@ -1,243 +1,181 @@
//! Best-effort UPnP port mapping for NAT traversal.
//! Skipped entirely on mobile platforms where UPnP is unsupported.
//! NAT port mapping via UPnP-IGD, NAT-PMP, and PCP.
//!
//! Wraps the `portmapper` crate (also used internally by iroh) which runs
//! all three protocols in parallel and auto-renews the lease in a background
//! task. This module is named `upnp` for historical reasons — by v0.7.2 it
//! covers more than UPnP.
//!
//! ## Protocols
//! - **UPnP-IGD** — long-standing consumer-router default. Discovery uses
//! SSDP multicast on 239.255.255.250:1900. Behavior on Hold harbor routers
//! varies; many ship with UPnP disabled by default.
//! - **NAT-PMP** (RFC 6886) — Apple lineage; widespread on routers that
//! ever shipped Bonjour. Unicast to the gateway on UDP/5351.
//! - **PCP** (RFC 6887) — modern IETF-track successor to NAT-PMP. Unicast
//! on UDP/5351. Supports both IPv4 NAT mapping and IPv6 firewall pinholes
//! (the latter via `AddPinhole`-shaped requests). Increasingly common in
//! modern routers.
//!
//! All three are attempted in parallel by portmapper; the first one to
//! respond wins. PCP responses arrive sub-second when present; SSDP wakes
//! up in 13s.
//!
//! ## Per-platform contract
//! | Platform | UPnP-IGD | NAT-PMP | PCP | TCP for HTTP |
//! |---|---|---|---|---|
//! | Linux/macOS/Windows | ✓ | ✓ | ✓ | ✓ |
//! | Android (WiFi/Ethernet) | ✓ (with MulticastLock) | ✓ | ✓ | ✓ |
//! | Android (cellular) | ✗ (skipped early) | ✗ | ✗ | ✗ |
//! | iOS | ✗ (without `com.apple.developer.networking.multicast` entitlement) | ✓ | ✓ | ✓ |
//!
//! ## Android specifics
//! `try_udp` / `try_tcp` first check `crate::android_wifi::is_on_wifi()`.
//! If not on WiFi/Ethernet (cellular), returns `None` immediately — cellular
//! networks almost never expose any of these protocols, and a discovery
//! timeout would waste ~3s every startup.
//!
//! When on WiFi, a `WifiManager.MulticastLock` is acquired and held for the
//! lifetime of the resulting `PortMapping`. Without the lock Android filters
//! the SSDP multicast responses; PCP/NAT-PMP work without it but UPnP-IGD
//! would never complete. The lock is released on Drop.
//!
//! ## iOS specifics
//! Until Apple grants the multicast entitlement, UPnP-IGD will silently fail
//! (no SSDP responses delivered to the socket). The unicast protocols PCP
//! and NAT-PMP succeed without any entitlement and cover most modern home
//! routers, so iOS gets reasonable coverage today.
//!
//! ## Renewal model
//! Auto-renewal happens inside `portmapper::Client`'s internal task — there
//! is **no** external renewal cycle to schedule. The `PortMapping` value
//! owns the client; while it's held, the mapping stays alive. Dropping it
//! aborts the renewal task (via `AbortOnDropHandle`) and stops keeping the
//! mapping alive at the gateway. `release(&self)` triggers an explicit
//! deactivation message before drop for cleaner shutdown.
//!
//! ## Anchor reachability watcher (bidirectional)
//! `Network::start` spawns a task that observes the UDP mapping's
//! `watch_external()` channel and adjusts anchor mode at runtime:
//! - **Mapping lost** for >5 min → clear `is_anchor`. The node stops
//! advertising itself as an anchor at the now-stale external address.
//! - **Mapping restored** (None → Some) → re-evaluate auto-anchor. On
//! non-mobile devices the anchor flag is set back on so the node
//! re-joins the anchor set without a restart.
//!
//! Network roams (e.g., leaving a UPnP-capable WiFi and joining a new one)
//! self-heal. Mobile devices never auto-anchor regardless — cellular IPs
//! look public but sit behind CGNAT.
use std::net::SocketAddr;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use tracing::{info, debug};
use std::num::NonZeroU16;
use std::time::Duration;
use tracing::{debug, info};
/// Result of a successful UPnP port mapping.
pub struct UpnpMapping {
/// An active port mapping. While this value is held, the underlying
/// `portmapper::Client` keeps the lease alive in a background task.
/// Dropping it releases the mapping.
pub struct PortMapping {
pub external_addr: SocketAddr,
pub lease_secs: u32,
pub local_port: u16,
#[allow(dead_code)] // service kept alive by holding the client
client: portmapper::Client,
#[cfg(target_os = "android")]
#[allow(dead_code)] // lock kept alive for the lifetime of the mapping
mcast_lock: Option<crate::android_wifi::MulticastLockGuard>,
}
/// Best-effort UPnP port mapping.
/// 3s gateway discovery timeout, 1800s (30 min) lease, UDP protocol.
/// Returns None on any failure (no router, unsupported, timeout, port conflict).
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub async fn try_upnp_mapping(local_port: u16) -> Option<UpnpMapping> {
use igd_next::SearchOptions;
let search_opts = SearchOptions {
timeout: Some(std::time::Duration::from_secs(3)),
..Default::default()
};
let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await {
Ok(gw) => gw,
Err(e) => {
debug!("UPnP gateway discovery failed (expected behind non-UPnP router): {}", e);
return None;
impl PortMapping {
/// Best-effort UDP port mapping for the given local QUIC port.
/// On Android, requires an active WiFi/Ethernet connection — returns
/// `None` immediately on cellular. Waits up to 3 seconds for any of the
/// three protocols (PCP / NAT-PMP / UPnP-IGD) to return a mapping.
pub async fn try_udp(local_port: u16) -> Option<Self> {
Self::try_for_protocol(local_port, portmapper::Protocol::Udp).await
}
};
let external_ip = match gateway.get_external_ip().await {
Ok(ip) => ip,
Err(e) => {
debug!("UPnP: could not get external IP: {}", e);
return None;
/// Best-effort TCP port mapping for HTTP serving on the given local port.
/// Same platform gating as UDP. Used to make this node HTTP-reachable
/// for browser-shaped fetches of public posts.
pub async fn try_tcp(local_port: u16) -> Option<Self> {
Self::try_for_protocol(local_port, portmapper::Protocol::Tcp).await
}
};
// Local address for the mapping — bind to all interfaces
let local_addr = SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), local_port);
let lease_secs: u32 = 1800; // 30 minutes
async fn try_for_protocol(local_port: u16, protocol: portmapper::Protocol) -> Option<Self> {
let port = NonZeroU16::new(local_port)?;
// Try mapping the same external port first
let result = gateway.add_port(
igd_next::PortMappingProtocol::UDP,
local_port,
local_addr,
lease_secs,
"itsgoin",
).await;
let external_port = match result {
Ok(()) => local_port,
Err(_) => {
// Port taken — try any available port
match gateway.add_any_port(
igd_next::PortMappingProtocol::UDP,
local_addr,
lease_secs,
"itsgoin",
).await {
Ok(port) => port,
Err(e) => {
debug!("UPnP: port mapping failed: {}", e);
#[cfg(target_os = "android")]
{
if !crate::android_wifi::is_on_wifi() {
debug!("Port mapping: skipping (not on WiFi/Ethernet)");
return None;
}
}
}
#[cfg(target_os = "android")]
let mcast_lock = crate::android_wifi::MulticastLockGuard::acquire("itsgoin-ssdp");
let config = portmapper::Config {
enable_upnp: true,
enable_pcp: true,
enable_nat_pmp: true,
protocol,
};
let client = portmapper::Client::new(config);
client.update_local_port(port);
let external_addr = SocketAddr::new(external_ip, external_port);
info!("UPnP: mapped {}:{} → :{}", external_ip, external_port, local_port);
Some(UpnpMapping {
external_addr,
lease_secs,
local_port,
// Wait up to 3 seconds for any protocol to produce a mapping.
let mut watch = client.watch_external_address();
let external = match tokio::time::timeout(Duration::from_secs(3), async {
loop {
if let Some(addr) = *watch.borrow_and_update() {
return Some(addr);
}
if watch.changed().await.is_err() {
return None;
}
}
})
}
#[cfg(any(target_os = "android", target_os = "ios"))]
pub async fn try_upnp_mapping(_local_port: u16) -> Option<UpnpMapping> {
None
}
/// Renew an existing UPnP lease. Returns true on success.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub async fn renew_upnp_mapping(local_port: u16, external_port: u16) -> bool {
use igd_next::SearchOptions;
let search_opts = SearchOptions {
timeout: Some(std::time::Duration::from_secs(3)),
..Default::default()
};
let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await {
Ok(gw) => gw,
Err(_) => return false,
};
let local_addr = SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), local_port);
gateway.add_port(
igd_next::PortMappingProtocol::UDP,
external_port,
local_addr,
1800,
"itsgoin",
).await.is_ok()
}
#[cfg(any(target_os = "android", target_os = "ios"))]
pub async fn renew_upnp_mapping(_local_port: u16, _external_port: u16) -> bool {
false
}
/// Remove UPnP mapping on shutdown. Best-effort, errors are silently ignored.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub async fn remove_upnp_mapping(external_port: u16) {
use igd_next::SearchOptions;
let search_opts = SearchOptions {
timeout: Some(std::time::Duration::from_secs(3)),
..Default::default()
};
if let Ok(gateway) = igd_next::aio::tokio::search_gateway(search_opts).await {
let _ = gateway.remove_port(igd_next::PortMappingProtocol::UDP, external_port).await;
info!("UPnP: removed port mapping for external port {}", external_port);
}
}
#[cfg(any(target_os = "android", target_os = "ios"))]
pub async fn remove_upnp_mapping(_external_port: u16) {}
// --- TCP port mapping (for HTTP post delivery) ---
/// Best-effort UPnP TCP port mapping on the same port as QUIC UDP.
/// Returns true on success. Reuses the already-discovered gateway.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub async fn try_upnp_tcp_mapping(local_port: u16, external_port: u16) -> bool {
use igd_next::SearchOptions;
let search_opts = SearchOptions {
timeout: Some(std::time::Duration::from_secs(3)),
..Default::default()
};
let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await {
Ok(gw) => gw,
Err(_) => return false,
};
let local_addr = SocketAddr::new(
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
local_port,
);
match gateway
.add_port(
igd_next::PortMappingProtocol::TCP,
external_port,
local_addr,
1800,
"itsgoin-http",
)
.await
{
Ok(()) => {
info!("UPnP: TCP port {} mapped for HTTP serving", external_port);
true
}
Err(e) => {
debug!("UPnP: TCP port mapping failed (non-fatal): {}", e);
false
}
}
}
#[cfg(any(target_os = "android", target_os = "ios"))]
pub async fn try_upnp_tcp_mapping(_local_port: u16, _external_port: u16) -> bool {
false
}
/// Renew an existing UPnP TCP lease. Returns true on success.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub async fn renew_upnp_tcp_mapping(local_port: u16, external_port: u16) -> bool {
use igd_next::SearchOptions;
let search_opts = SearchOptions {
timeout: Some(std::time::Duration::from_secs(3)),
..Default::default()
};
let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await {
Ok(gw) => gw,
Err(_) => return false,
};
let local_addr = SocketAddr::new(
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
Ok(Some(addr)) => addr,
_ => {
debug!(
protocol = ?protocol,
local_port,
"Port mapping: no protocol responded within 3s (no UPnP/PCP/NAT-PMP gateway?)"
);
gateway
.add_port(
igd_next::PortMappingProtocol::TCP,
external_port,
local_addr,
1800,
"itsgoin-http",
)
.await
.is_ok()
}
#[cfg(any(target_os = "android", target_os = "ios"))]
pub async fn renew_upnp_tcp_mapping(_local_port: u16, _external_port: u16) -> bool {
false
}
/// Remove UPnP TCP mapping on shutdown.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub async fn remove_upnp_tcp_mapping(external_port: u16) {
use igd_next::SearchOptions;
let search_opts = SearchOptions {
timeout: Some(std::time::Duration::from_secs(3)),
..Default::default()
return None;
}
};
if let Ok(gateway) = igd_next::aio::tokio::search_gateway(search_opts).await {
let _ = gateway
.remove_port(igd_next::PortMappingProtocol::TCP, external_port)
.await;
info!("UPnP: removed TCP port mapping for port {}", external_port);
info!(
external = %external,
local_port,
protocol = ?protocol,
"Port mapping established"
);
Some(PortMapping {
external_addr: SocketAddr::V4(external),
local_port,
client,
#[cfg(target_os = "android")]
mcast_lock,
})
}
/// Watch for changes to the external address. Useful when the underlying
/// network changes (e.g., mobile WiFi roam) — the mapping may move to a
/// new public IP/port.
pub fn watch_external(&self) -> tokio::sync::watch::Receiver<Option<std::net::SocketAddrV4>> {
self.client.watch_external_address()
}
/// Explicitly request the portmapper service to release the gateway
/// mapping. The actual cleanup completes when the `PortMapping` is
/// dropped (the internal service handle is abort-on-drop). Allows
/// callers to signal "release now" before the value goes out of scope.
pub fn release(&self) {
self.client.deactivate();
}
}
#[cfg(any(target_os = "android", target_os = "ios"))]
pub async fn remove_upnp_tcp_mapping(_external_port: u16) {}

View file

@ -3,7 +3,8 @@
//! renders HTML, serves blobs. No permanent storage of fetched content.
//!
//! Routes (behind Apache reverse proxy):
//! GET /p/<postid_hex>/<author_hex> → render post HTML (fetched on-demand)
//! GET /p/<postid_hex> → render post HTML (fetched on-demand; anchor
//! resolves holders itself)
//! GET /b/<blobid_hex> → serve blob (images/videos)
use std::net::SocketAddr;
@ -89,7 +90,7 @@ fn extract_header<'a>(buf: &'a [u8], name: &str) -> Option<&'a str> {
None
}
/// Handle GET /p/<postid_hex>/<author_hex>
/// Handle GET /p/<postid_hex>
///
/// Three-tier serving:
/// 1. Redirect to a CDN holder with a public/punchable HTTP server
@ -112,26 +113,11 @@ async fn serve_post(stream: &mut TcpStream, path: &str, node: &Arc<Node>, browse
_ => return,
};
// Parse optional author_id (after the slash)
let author_id: Option<NodeId> = if rest.len() > 65 {
let author_hex = &rest[65..];
if author_hex.len() == 64 && author_hex.chars().all(|c| c.is_ascii_hexdigit()) {
hex::decode(author_hex).ok().and_then(|b| b.try_into().ok())
} else {
None
}
} else {
None
};
// Single lock: gather holders, local post, AND author name if local
let (holders, local_post, local_author_name) = {
let store = node.storage.get().await;
let mut holders = Vec::new();
if let Some(author) = author_id {
holders.push(author);
}
if let Ok(file_holders) = store.get_file_holders(&post_id) {
for (peer, _addrs) in file_holders {
if !holders.contains(&peer) {
@ -177,8 +163,9 @@ async fn serve_post(stream: &mut TcpStream, path: &str, node: &Arc<Node>, browse
}
}
// Fetch via content search + PostFetch
let author = author_id.unwrap_or([0u8; 32]);
// Fetch via content search + PostFetch (no author hint — holders resolved
// from file_holders / worm search)
let author: NodeId = [0u8; 32];
info!("Web: proxying post {} via QUIC (no redirect candidate found)", post_hex);
let search_result = tokio::time::timeout(

View file

@ -1,6 +1,6 @@
[package]
name = "itsgoin-desktop"
version = "0.7.0"
version = "0.8.0-alpha"
edition = "2021"
[lib]

View file

@ -5,6 +5,7 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
@ -16,6 +17,17 @@ class NodeService : Service() {
companion object {
const val CHANNEL_ID = "itsgoin_node"
const val NOTIFICATION_ID = 1
// Called via JNI from Rust when the user taps the in-app close
// button. Foreground services survive Activity exit by design
// (keeps connections alive when backgrounded). When the user
// explicitly wants to stop networking, we need to stop the
// service in addition to ending the Activity.
@JvmStatic
fun stopFromNative(context: Context) {
val intent = Intent(context, NodeService::class.java)
context.stopService(intent)
}
}
private var wakeLock: PowerManager.WakeLock? = null

View file

@ -6,7 +6,7 @@ use tracing::info;
use itsgoin_core::identity::IdentityManager;
use itsgoin_core::node::Node;
use itsgoin_core::types::{NodeId, PeerSlotKind, Post, PostId, PostVisibility, VisibilityIntent};
use itsgoin_core::types::{NodeId, Post, PostId, PostVisibility, VisibilityIntent};
/// The active Node, swappable on identity switch.
type AppNode = Arc<tokio::sync::RwLock<Arc<Node>>>;
@ -145,6 +145,8 @@ struct BadgeCountsDto {
unread_messages: usize,
new_reacts: usize,
new_comments: usize,
/// v0.8 (A3): undismissed greetings in the inbox (Messages badge adds this).
greetings: usize,
}
#[derive(Serialize)]
@ -279,7 +281,11 @@ async fn post_to_dto(
// Engagement data
let reaction_counts = {
let storage = node.storage.get().await;
storage.get_reaction_counts(id, &node.node_id).unwrap_or_default()
// Reactors are posting ids — "reacted_by_me" = any of our personas.
let our_ids: Vec<itsgoin_core::types::NodeId> = storage.list_posting_identities()
.unwrap_or_default()
.into_iter().map(|p| p.node_id).collect();
storage.get_reaction_counts(id, &our_ids).unwrap_or_default()
.into_iter()
.map(|(emoji, count, reacted_by_me)| ReactionCountDto { emoji, count, reacted_by_me })
.collect()
@ -555,9 +561,13 @@ async fn list_posting_identities(
async fn create_posting_identity(
state: State<'_, AppNode>,
display_name: String,
greetings_open: Option<bool>,
) -> Result<PostingIdentityDto, String> {
let node = get_node(&state).await;
let id = node.create_posting_identity(display_name).await.map_err(|e| e.to_string())?;
let id = node
.create_posting_identity(display_name, greetings_open)
.await
.map_err(|e| e.to_string())?;
Ok(PostingIdentityDto {
node_id: hex::encode(id.node_id),
display_name: id.display_name,
@ -863,10 +873,12 @@ async fn post_to_dto_batch(
// Batch queries — 3 queries total instead of 4 × N
let (reaction_map, comment_map, intent_map, posting_identities) = {
let storage = node.storage.get().await;
let reactions = storage.get_reaction_counts_batch(&post_ids, &node.node_id).unwrap_or_default();
let identities = storage.list_posting_identities().unwrap_or_default();
// Reactors are posting ids — "reacted_by_me" = any of our personas.
let our_ids: Vec<itsgoin_core::types::NodeId> = identities.iter().map(|p| p.node_id).collect();
let reactions = storage.get_reaction_counts_batch(&post_ids, &our_ids).unwrap_or_default();
let comments = storage.get_comment_counts_batch(&post_ids).unwrap_or_default();
let intents = storage.get_post_intents_batch(&post_ids).unwrap_or_default();
let identities = storage.list_posting_identities().unwrap_or_default();
(reactions, comments, intents, identities)
};
// Map posting-id -> display-name so we can tag author=persona posts.
@ -1142,6 +1154,18 @@ async fn list_vouches_given(state: State<'_, AppNode>) -> Result<Vec<VouchGivenD
}).collect())
}
#[tauri::command]
async fn exit_app(app: tauri::AppHandle) {
// On Android, the foreground NodeService survives Activity exit by
// design (keeps network alive when backgrounded). When the user
// explicitly hits the in-app close button, also stop the service
// so we actually free the device's network/wakelock.
#[cfg(target_os = "android")]
itsgoin_core::android_wifi::stop_node_service();
app.exit(0);
}
#[tauri::command]
async fn list_vouches_received(state: State<'_, AppNode>) -> Result<Vec<VouchReceivedDto>, String> {
let node = get_node(&state).await;
@ -1339,7 +1363,7 @@ async fn list_peers(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
.into_iter()
.map(|(nid, _, _)| nid)
.collect();
let (social_ids, n2_ids, n3_ids) = {
let (social_ids, n2_ids, n3_ids, n4_ids) = {
let storage = node.storage.get().await;
let social: std::collections::HashSet<_> = storage
.list_social_routes()
@ -1348,7 +1372,7 @@ async fn list_peers(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
.map(|r| r.node_id)
.collect();
let n2: std::collections::HashSet<_> = storage
.build_n2_share()
.list_reach_ids_at(2)
.unwrap_or_default()
.into_iter()
.collect();
@ -1357,7 +1381,12 @@ async fn list_peers(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
.unwrap_or_default()
.into_iter()
.collect();
(social, n2, n3)
let n4: std::collections::HashSet<_> = storage
.list_distinct_n4()
.unwrap_or_default()
.into_iter()
.collect();
(social, n2, n3, n4)
};
let mut dtos = Vec::with_capacity(records.len());
@ -1382,6 +1411,8 @@ async fn list_peers(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
"n2"
} else if n3_ids.contains(&rec.node_id) {
"n3"
} else if n4_ids.contains(&rec.node_id) {
"n4"
} else {
"known"
};
@ -1698,6 +1729,283 @@ async fn set_update_channel(state: State<'_, AppNode>, channel: String) -> Resul
storage.set_setting("ui_update_channel", &channel).map_err(|e| e.to_string())
}
#[tauri::command]
async fn get_session_relay_enabled(state: State<'_, AppNode>) -> Result<bool, String> {
let node = get_node(&state).await;
Ok(node.network.conn_handle().is_session_relay_enabled().await)
}
#[tauri::command]
async fn set_session_relay_enabled(state: State<'_, AppNode>, enabled: bool) -> Result<(), String> {
let node = get_node(&state).await;
{
let storage = node.storage.get().await;
storage
.set_setting("relay.session_relay_enabled", if enabled { "true" } else { "false" })
.map_err(|e| e.to_string())?;
}
node.network.conn_handle().set_session_relay_enabled(enabled).await;
Ok(())
}
// --- v0.8 (A3): registry search + greetings ---
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RegistryEntryDto {
author_id: String,
display_name: String,
keywords: Vec<String>,
updated_at_ms: u64,
expires_at_ms: u64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RegistryStatusDto {
listed: bool,
keywords: Vec<String>,
expires_at_ms: u64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GreetingDto {
/// Comment key part 1 (throwaway outer identity) — opaque to the UI,
/// passed back verbatim for reply/dismiss.
comment_author: String,
/// Comment key part 2.
post_id: String,
/// Comment key part 3.
timestamp_ms: u64,
/// The sender's REAL persona (recovered from inside the seal).
sender_persona_id: String,
sender_name: String,
content: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GreetingSlotDto {
bio_post_id: String,
accepts_greetings: bool,
}
/// Register the persona in the network registry (30d entry, auto-renews
/// while listed — the core eviction cycle re-signs ~every 25 days).
#[tauri::command]
async fn register_persona(
state: State<'_, AppNode>,
posting_id_hex: String,
name: String,
keywords: Vec<String>,
) -> Result<(), String> {
let node = get_node(&state).await;
let posting_id = parse_node_id(&posting_id_hex)?;
node.register_persona(&posting_id, &name, &keywords)
.await
.map_err(|e| e.to_string())
}
/// Remove the persona's registry entry (self-certifying signed delete).
#[tauri::command]
async fn unregister_persona(
state: State<'_, AppNode>,
posting_id_hex: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let posting_id = parse_node_id(&posting_id_hex)?;
node.unregister_persona(&posting_id).await.map_err(|e| e.to_string())
}
/// Current "Listed" state for the consent panel: listed flag + the
/// keywords used at last registration + entry expiry (0 if none held).
#[tauri::command]
async fn get_registry_status(
state: State<'_, AppNode>,
posting_id_hex: String,
) -> Result<RegistryStatusDto, String> {
let node = get_node(&state).await;
let posting_id = parse_node_id(&posting_id_hex)?;
let storage = node.storage.get().await;
let listed = storage
.get_setting(&format!("registry_listed.{}", posting_id_hex))
.ok()
.flatten()
.map(|v| v == "1")
.unwrap_or(false);
let keywords = storage
.get_setting(&format!("registry_keywords.{}", posting_id_hex))
.ok()
.flatten()
.map(|v| {
v.split(',')
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty())
.collect()
})
.unwrap_or_default();
let expires_at_ms = storage
.get_newest_registry_entry(&itsgoin_core::registry::REGISTRY_POST_ID, &posting_id)
.ok()
.flatten()
.map(|(_ts, exp)| exp)
.unwrap_or(0);
Ok(RegistryStatusDto { listed, keywords, expires_at_ms })
}
/// Search the registry: refreshes the comment chain from peers, then
/// queries locally (search cost lands on the searcher, design §27).
#[tauri::command]
async fn search_registry(
state: State<'_, AppNode>,
query: String,
) -> Result<Vec<RegistryEntryDto>, String> {
let node = get_node(&state).await;
let matches = node.search_registry(&query).await.map_err(|e| e.to_string())?;
Ok(matches
.into_iter()
.map(|m| RegistryEntryDto {
author_id: hex::encode(m.author),
display_name: m.name,
keywords: m.keywords,
updated_at_ms: m.timestamp_ms,
expires_at_ms: m.expires_at_ms,
})
.collect())
}
/// Locally-held greeting-slot info for an author's latest bio post.
async fn greeting_slot_local(node: &Node, author: &NodeId) -> Option<GreetingSlotDto> {
let storage = node.storage.get().await;
let bio_post_id = storage
.get_latest_profile_post_id_by_author(author)
.ok()
.flatten()?;
let post = storage.get_post(&bio_post_id).ok().flatten()?;
let accepts = post
.fof_gating
.as_ref()
.and_then(|g| g.open_slot.as_ref())
.map(|d| d.kind == itsgoin_core::types::OpenSlotKind::Greeting)
.unwrap_or(false);
Some(GreetingSlotDto {
bio_post_id: hex::encode(bio_post_id),
accepts_greetings: accepts,
})
}
/// Does this author's bio declare a Greeting open slot? Drives the
/// "Say hi" button in the bio modal. On-demand (A3 §3.2): if the bio
/// isn't local yet (e.g. a registry search result), worm-locate the
/// author and sync their posts once before answering.
#[tauri::command]
async fn get_greeting_slot(
state: State<'_, AppNode>,
node_id_hex: String,
) -> Result<Option<GreetingSlotDto>, String> {
let node = get_node(&state).await;
let nid = parse_node_id(&node_id_hex)?;
if let Some(dto) = greeting_slot_local(&node, &nid).await {
return Ok(Some(dto));
}
// Fetch-on-demand: existing worm lookup + one-shot sync, then retry.
if let Ok(Some(wr)) = node.worm_lookup(&nid).await {
let _ = node.sync_with(wr.node_id).await;
return Ok(greeting_slot_local(&node, &nid).await);
}
Ok(None)
}
/// Send a sealed first-contact greeting to a bio post's author.
/// Messaging-first: no vouch is involved anywhere in this flow.
#[tauri::command]
async fn send_greeting(
state: State<'_, AppNode>,
bio_post_id_hex: String,
content: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let bio_post_id = hex_to_postid(&bio_post_id_hex)?;
node.send_greeting(bio_post_id, content).await.map_err(|e| e.to_string())
}
/// Reply to a received greeting — sealed to the greeting's fresh reply
/// key and dropped into its declared return path (never a long-term key).
#[tauri::command]
async fn reply_to_greeting(
state: State<'_, AppNode>,
comment_author_hex: String,
post_id_hex: String,
timestamp_ms: u64,
content: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let comment_author = parse_node_id(&comment_author_hex)?;
let post_id = hex_to_postid(&post_id_hex)?;
node.reply_to_greeting(comment_author, post_id, timestamp_ms, content)
.await
.map_err(|e| e.to_string())
}
/// Unsealed greetings (and replies) across all personas' bio posts,
/// newest first. Return-path/reply-key internals stay inside core.
#[tauri::command]
async fn list_greetings(state: State<'_, AppNode>) -> Result<Vec<GreetingDto>, String> {
let node = get_node(&state).await;
let records = node.list_greetings().await.map_err(|e| e.to_string())?;
Ok(records
.into_iter()
.map(|g| GreetingDto {
comment_author: hex::encode(g.comment_author),
post_id: hex::encode(g.post_id),
timestamp_ms: g.timestamp_ms,
sender_persona_id: hex::encode(g.sender_persona),
sender_name: g.sender_name,
content: g.text,
})
.collect())
}
/// Dismiss a greeting (local only — nothing propagates).
#[tauri::command]
async fn dismiss_greeting(
state: State<'_, AppNode>,
comment_author_hex: String,
post_id_hex: String,
timestamp_ms: u64,
) -> Result<(), String> {
let node = get_node(&state).await;
let comment_author = parse_node_id(&comment_author_hex)?;
let post_id = hex_to_postid(&post_id_hex)?;
node.dismiss_greeting(comment_author, post_id, timestamp_ms)
.await
.map_err(|e| e.to_string())
}
/// Per-persona greeting consent (republishes the bio on change).
#[tauri::command]
async fn set_greetings_open(
state: State<'_, AppNode>,
posting_id_hex: String,
open: bool,
) -> Result<(), String> {
let node = get_node(&state).await;
let posting_id = parse_node_id(&posting_id_hex)?;
node.set_greetings_open(&posting_id, open).await.map_err(|e| e.to_string())
}
/// Current greeting-consent state (unset = ON, the pre-checked default).
#[tauri::command]
async fn get_greetings_open(
state: State<'_, AppNode>,
posting_id_hex: String,
) -> Result<bool, String> {
let node = get_node(&state).await;
let posting_id = parse_node_id(&posting_id_hex)?;
node.get_greetings_open(&posting_id).await.map_err(|e| e.to_string())
}
/// Open a URL in the user's default system browser.
/// Desktop: spawns the platform opener (xdg-open / open / cmd start).
/// Only https:// URLs are accepted to avoid being a generic command exec.
@ -1765,12 +2073,12 @@ async fn list_connections(state: State<'_, AppNode>) -> Result<Vec<ConnectionDto
let node = get_node(&state).await;
let conns = node.list_connections().await;
let mut dtos = Vec::with_capacity(conns.len());
for (nid, slot_kind, connected_at) in conns {
for (nid, slot, connected_at) in conns {
let display_name = node.get_display_name(&nid).await.unwrap_or(None);
dtos.push(ConnectionDto {
node_id: hex::encode(nid),
display_name,
slot_kind: format!("{:?}", slot_kind),
slot_kind: slot.to_string(),
connected_at,
});
}
@ -2032,6 +2340,28 @@ async fn get_seen_engagement(
}))
}
/// Comment count for the engagement badges, EXCLUDING greeting open-slot
/// comments: greetings have their own dedicated badge/inbox, and counting
/// them here would double-count every greeting into "My Posts" while
/// pointing the user at a bio post where the comment renders as sealed
/// ciphertext instead of at the Greetings inbox.
fn non_greeting_comment_count(
storage: &itsgoin_core::storage::Storage,
post_id: &itsgoin_core::types::PostId,
post: &itsgoin_core::types::Post,
) -> u64 {
let mut count = storage.get_comment_count(post_id).unwrap_or(0);
if let Some(decl) = post.fof_gating.as_ref().and_then(|g| g.open_slot.as_ref()) {
if matches!(decl.kind, itsgoin_core::types::OpenSlotKind::Greeting) {
let greetings = storage
.count_unexpired_open_slot_comments(post_id, decl.slot_index)
.unwrap_or(0);
count = count.saturating_sub(greetings);
}
}
count
}
#[tauri::command]
async fn get_badge_counts(
state: State<'_, AppNode>,
@ -2040,11 +2370,17 @@ async fn get_badge_counts(
let node = get_node(&state).await;
let storage = node.storage.get().await;
// "Own post" checks below are posting-class: posts are authored by our
// posting identities (personas), never the network NodeId.
let own_ids: Vec<itsgoin_core::types::NodeId> = storage.list_posting_identities()
.unwrap_or_default()
.into_iter().map(|p| p.node_id).collect();
// Feed badge: count non-DM posts from others newer than last_feed_view_ms
let feed_posts = storage.get_feed().map_err(|e| e.to_string())?;
let new_feed = feed_posts.iter()
.filter(|(id, p, _vis)| {
p.author != node.node_id
!own_ids.contains(&p.author)
&& p.timestamp_ms > last_feed_view_ms
&& !matches!(
storage.get_post_intent(id).ok().flatten(),
@ -2057,18 +2393,20 @@ async fn get_badge_counts(
let all_posts = storage.list_posts_reverse_chron().map_err(|e| e.to_string())?;
let mut new_engagement = 0usize;
for (id, post, _vis) in &all_posts {
if post.author != node.node_id { continue; }
if !own_ids.contains(&post.author) { continue; }
// Skip DMs
if matches!(
storage.get_post_intent(id).ok().flatten(),
Some(VisibilityIntent::Direct(_))
) { continue; }
let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id)
let total_reacts: u64 = storage.get_reaction_counts(id, &own_ids)
.unwrap_or_default()
.iter()
.map(|(_, count, _)| *count)
.sum();
let total_comments = storage.get_comment_count(id).unwrap_or(0);
// Greeting open-slot comments are excluded — they have their own
// dedicated Greetings badge (see non_greeting_comment_count).
let total_comments = non_greeting_comment_count(&storage, id, post);
if total_reacts > 0 || total_comments > 0 {
let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0));
if total_reacts > seen_r as u64 || total_comments > seen_c as u64 {
@ -2083,14 +2421,14 @@ async fn get_badge_counts(
matches!(
storage.get_post_intent(id).ok().flatten(),
Some(VisibilityIntent::Direct(_))
) || (p.author != node.node_id && matches!(
) || (!own_ids.contains(&p.author) && matches!(
storage.get_post_with_visibility(id).ok().flatten(),
Some((_, PostVisibility::Encrypted { .. }))
))
});
let mut seen_partners = std::collections::HashSet::new();
for (_id, post, _vis) in dm_posts {
let partner = if post.author == node.node_id {
let partner = if own_ids.contains(&post.author) {
// sent DM — skip for unread count
continue;
} else {
@ -2108,16 +2446,22 @@ async fn get_badge_counts(
let mut new_reacts = 0usize;
let mut new_comments = 0usize;
for (id, post, _vis) in &all_posts {
if post.author != node.node_id { continue; }
let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id)
if !own_ids.contains(&post.author) { continue; }
let total_reacts: u64 = storage.get_reaction_counts(id, &own_ids)
.unwrap_or_default().iter().map(|(_, c, _)| *c).sum();
let total_comments = storage.get_comment_count(id).unwrap_or(0);
// Greeting open-slot comments excluded here too (dedicated badge).
let total_comments = non_greeting_comment_count(&storage, id, post);
let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0));
if total_reacts > seen_r as u64 { new_reacts += (total_reacts - seen_r as u64) as usize; }
if total_comments > seen_c as u64 { new_comments += (total_comments - seen_c as u64) as usize; }
}
Ok(BadgeCountsDto { new_feed, new_engagement, unread_messages, new_reacts, new_comments })
// v0.8 (A3): greetings inbox size. Release the storage slot first —
// list_greetings takes its own guard internally.
drop(storage);
let greetings = node.list_greetings().await.map(|g| g.len()).unwrap_or(0);
Ok(BadgeCountsDto { new_feed, new_engagement, unread_messages, new_reacts, new_comments, greetings })
}
#[tauri::command]
@ -2156,12 +2500,13 @@ async fn sync_from_peer(state: State<'_, AppNode>, node_id_hex: String) -> Resul
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct NetworkSummaryDto {
preferred_count: usize,
local_count: usize,
wide_count: usize,
mesh_count: usize,
temp_count: usize,
mesh_cap: usize,
total_connections: usize,
n2_distinct: usize,
n3_distinct: usize,
n4_distinct: usize,
has_public_v6: bool,
has_public_v4: bool,
has_upnp: bool,
@ -2171,29 +2516,24 @@ struct NetworkSummaryDto {
async fn get_network_summary(state: State<'_, AppNode>) -> Result<NetworkSummaryDto, String> {
let node = get_node(&state).await;
let conns = node.list_connections().await;
let mut preferred = 0usize;
let mut local = 0usize;
let mut wide = 0usize;
for (_nid, slot_kind, _at) in &conns {
match slot_kind {
PeerSlotKind::Preferred => preferred += 1,
PeerSlotKind::Local => local += 1,
PeerSlotKind::Wide => wide += 1,
}
}
let (n2, n3) = {
let mesh = conns.iter().filter(|(_, slot, _)| slot.is_mesh()).count();
let temp = conns.len() - mesh;
let (n2, n3, n4) = {
let storage = node.storage.get().await;
let n2 = storage.count_distinct_n2().unwrap_or(0);
let n3 = storage.count_distinct_n3().unwrap_or(0);
(n2, n3)
(
storage.count_distinct_n2().unwrap_or(0),
storage.count_distinct_n3().unwrap_or(0),
storage.count_distinct_n4().unwrap_or(0),
)
};
Ok(NetworkSummaryDto {
preferred_count: preferred,
local_count: local,
wide_count: wide,
mesh_count: mesh,
temp_count: temp,
mesh_cap: node.device_profile().mesh_slots(),
total_connections: conns.len(),
n2_distinct: n2,
n3_distinct: n3,
n4_distinct: n4,
has_public_v6: node.network.has_public_v6(),
has_public_v4: node.network.is_anchor(),
has_upnp: node.network.has_upnp(),
@ -2415,15 +2755,14 @@ struct ActivityLogDto {
events: Vec<ActivityEventDto>,
rebalance_last_ms: u64,
rebalance_interval_secs: u64,
anchor_register_last_ms: u64,
anchor_register_interval_secs: u64,
convection_last_ms: u64,
}
#[tauri::command]
async fn get_activity_log(state: State<'_, AppNode>) -> Result<ActivityLogDto, String> {
let node = get_node(&state).await;
let events = node.get_activity_log(200);
let (rebalance_last, anchor_last) = node.timer_state();
let (rebalance_last, convection_last) = node.timer_state();
let dto_events: Vec<ActivityEventDto> = events.into_iter().map(|e| {
ActivityEventDto {
timestamp_ms: e.timestamp_ms,
@ -2437,8 +2776,7 @@ async fn get_activity_log(state: State<'_, AppNode>) -> Result<ActivityLogDto, S
events: dto_events,
rebalance_last_ms: rebalance_last,
rebalance_interval_secs: 600,
anchor_register_last_ms: anchor_last,
anchor_register_interval_secs: 600,
convection_last_ms: convection_last,
})
}
@ -2454,31 +2792,47 @@ async fn trigger_rebalance(state: State<'_, AppNode>) -> Result<String, String>
async fn request_referrals(state: State<'_, AppNode>) -> Result<String, String> {
let node = get_node(&state).await;
let node_id = node.node_id;
// Try known_anchors table first (populated by anchor register cycle),
// fall back to anchor peers from the peers table (is_anchor = true)
let mesh = node.network.conn_handle().mesh_count().await;
// Manual trigger uses the same class rule as the automatic path: below 2
// mesh peers this is an ENTRY request and is always served.
let class = itsgoin_core::protocol::ConvectionClass::for_mesh_count(mesh);
// Pool-mined anchors first (the uniques pools ARE the anchor directory),
// then the known_anchors bootstrap cache, then anchor-flagged peers.
let anchors: Vec<(NodeId, Vec<std::net::SocketAddr>)> = {
let storage = node.storage.get().await;
let known = storage.list_known_anchors().unwrap_or_default();
if !known.is_empty() {
known
} else {
storage.list_anchor_peers().unwrap_or_default()
.into_iter()
.map(|r| (r.node_id, r.addresses))
.collect()
let mut out: Vec<(NodeId, Vec<std::net::SocketAddr>)> = Vec::new();
let mut seen: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
for (nid, addrs) in storage.list_pool_anchors(16).unwrap_or_default() {
let socks: Vec<std::net::SocketAddr> = addrs.iter().filter_map(|a| a.parse().ok()).collect();
if !socks.is_empty() && seen.insert(nid) {
out.push((nid, socks));
}
}
for (nid, addrs) in storage.list_known_anchors().unwrap_or_default() {
if seen.insert(nid) {
out.push((nid, addrs));
}
}
for r in storage.list_anchor_peers().unwrap_or_default() {
if seen.insert(r.node_id) {
out.push((r.node_id, r.addresses));
}
}
out
};
if anchors.is_empty() {
return Ok("No known anchors".to_string());
}
let mut total = 0usize;
let mut reachable = 0usize;
let mut connected = 0usize;
let mut asked = 0usize;
let mut refused = 0usize;
for (anchor_nid, anchor_addrs) in &anchors {
if *anchor_nid == node_id {
continue;
}
// Connect to anchor if not already connected
if !node.network.is_peer_connected(anchor_nid).await {
if !node.network.is_peer_connected_or_session(anchor_nid).await {
let endpoint_id = match itsgoin_core::EndpointId::from_bytes(anchor_nid) {
Ok(eid) => eid,
Err(_) => continue,
@ -2487,40 +2841,26 @@ async fn request_referrals(state: State<'_, AppNode>) -> Result<String, String>
for sa in anchor_addrs {
addr = addr.with_ip_addr(*sa);
}
if let Err(_) = node.network.connect_to_peer(*anchor_nid, addr).await {
if node.network.connect_to_anchor(*anchor_nid, addr).await.is_err() {
continue;
}
}
match node.network.request_anchor_referrals(anchor_nid).await {
Ok(referrals) => {
reachable += 1;
for referral in &referrals {
if referral.node_id == node_id {
continue;
}
if let Some(addr_str) = referral.addresses.first() {
let connect_str = format!(
"{}@{}",
hex::encode(referral.node_id),
addr_str,
);
if let Ok((rid, raddr)) = itsgoin_core::parse_connect_string(&connect_str) {
match node.network.connect_to_peer(rid, raddr).await {
Ok(()) => {}
Err(_) => {
// Direct connect failed (NAT) — try hole punch via anchor
let _ = node.network.connect_via_introduction(rid, *anchor_nid).await;
}
}
total += 1;
}
}
match node.network.request_convection(anchor_nid, class).await {
Ok(response) => {
asked += 1;
if response.refused {
refused += 1;
} else {
connected += node.network.act_on_convection(anchor_nid, &response).await;
}
}
Err(_) => {}
}
}
Ok(format!("Got {} referrals from {} anchors", total, reachable))
Ok(format!(
"Convection: {} new peers from {} anchors ({} refused)",
connected, asked, refused
))
}
#[tauri::command]
@ -2864,14 +3204,13 @@ async fn switch_identity(
// Start background tasks on the new node
new_node.start_accept_loop();
new_node.start_pull_cycle(300);
new_node.start_sync_cycle();
new_node.start_diff_cycle(120);
new_node.start_rebalance_cycle(600);
new_node.start_growth_loop();
new_node.start_recovery_loop();
new_node.start_social_checkin_cycle(3600);
new_node.start_anchor_register_cycle(600);
new_node.start_upnp_renewal_cycle();
new_node.start_convection_loop();
new_node.start_upnp_tcp_renewal_cycle();
new_node.start_http_server();
new_node.start_bootstrap_connectivity_check();
@ -2963,6 +3302,8 @@ async fn export_data(
}
}
};
// node_id here is only used for the export file name; the post filter
// inside export_data now iterates all posting identities itself.
let result = itsgoin_core::export::export_data(
&node.data_dir,
&node.storage,
@ -2987,14 +3328,13 @@ async fn clear_duplicate_flag(state: State<'_, AppNode>) -> Result<(), String> {
node.network.duplicate_detected.store(false, std::sync::atomic::Ordering::Relaxed);
// Start the sync tasks that were skipped during bootstrap
node.start_accept_loop();
node.start_pull_cycle(300);
node.start_sync_cycle();
node.start_diff_cycle(120);
node.start_rebalance_cycle(600);
node.start_growth_loop();
node.start_recovery_loop();
node.start_social_checkin_cycle(3600);
node.start_anchor_register_cycle(600);
node.start_upnp_renewal_cycle();
node.start_convection_loop();
node.start_upnp_tcp_renewal_cycle();
node.start_http_server();
node.start_bootstrap_connectivity_check();
@ -3052,11 +3392,13 @@ async fn import_public_posts(
zip_path: String,
) -> Result<String, String> {
let node = get_node(&state).await;
// Imported posts are re-authored as US — that must be a POSTING identity
// (the network NodeId never authors posts).
let result = itsgoin_core::import::import_public_posts(
std::path::Path::new(&zip_path),
&node.storage,
&node.blob_store,
&node.node_id,
&node.default_posting_id,
).await.map_err(|e| e.to_string())?;
Ok(result.message)
}
@ -3110,12 +3452,14 @@ async fn import_merge_with_key(
key_hex: String,
) -> Result<String, String> {
let node = get_node(&state).await;
// Merged content is authored under a POSTING identity, never the
// network NodeId.
let result = itsgoin_core::import::merge_with_key(
std::path::Path::new(&zip_path),
&key_hex,
&node.storage,
&node.blob_store,
&node.node_id,
&node.default_posting_id,
&node.secret_seed(),
).await.map_err(|e| e.to_string())?;
Ok(result.message)
@ -3232,14 +3576,13 @@ pub fn run() {
// Start all background networking tasks
boot_node.start_accept_loop();
boot_node.start_pull_cycle(300);
boot_node.start_sync_cycle();
boot_node.start_diff_cycle(120);
boot_node.start_rebalance_cycle(600);
boot_node.start_growth_loop();
boot_node.start_recovery_loop();
boot_node.start_social_checkin_cycle(3600);
boot_node.start_anchor_register_cycle(600);
boot_node.start_upnp_renewal_cycle();
boot_node.start_convection_loop();
boot_node.start_upnp_tcp_renewal_cycle();
boot_node.start_http_server();
boot_node.start_bootstrap_connectivity_check();
@ -3380,6 +3723,20 @@ pub fn run() {
import_as_new_identity,
import_as_personas_cmd,
import_merge_with_key,
exit_app,
get_session_relay_enabled,
set_session_relay_enabled,
register_persona,
unregister_persona,
get_registry_status,
search_registry,
get_greeting_slot,
send_greeting,
reply_to_greeting,
list_greetings,
dismiss_greeting,
set_greetings_open,
get_greetings_open,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")

View file

@ -1,6 +1,6 @@
{
"productName": "itsgoin",
"version": "0.7.0",
"version": "0.8.0-alpha",
"identifier": "com.itsgoin.app",
"build": {
"frontendDist": "../../frontend",

View file

@ -18,26 +18,34 @@ SSH_OPTS="-o StrictHostKeyChecking=no"
KEYSTORE="itsgoin.keystore"
KS_ALIAS="itsgoin"
VERSION=$(grep '"version"' crates/tauri-app/tauri.conf.json | head -1 | sed 's/.*"\([0-9.]*\)".*/\1/')
echo "=== Deploying v${VERSION} ==="
# Captures the FULL version string including any pre-release suffix
# (e.g. 0.8.0-alpha). The old digits-and-dots-only pattern silently
# truncated at the dash, so every ${VERSION} filename below missed the
# artifacts Tauri had actually produced.
VERSION=$(grep '"version"' crates/tauri-app/tauri.conf.json | head -1 | sed 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
# Pre-release versions announce on their own channel so stable clients
# are not told an alpha is "the" current release.
case "$VERSION" in
*-*) ANN_CHANNEL="${VERSION#*-}" ;;
*) ANN_CHANNEL="stable" ;;
esac
# Build CLI
echo "=== Building CLI ==="
cargo build -p itsgoin-cli --release &
CLI_PID=$!
echo "=== Deploying v${VERSION} (announce channel: ${ANN_CHANNEL}) ==="
# Build APK
echo "=== Building APK ==="
cargo tauri android build --apk &
APK_PID=$!
# Builds run SERIALLY — parallel cargo invocations write to the same
# target/ directory, which causes intermittent failures (linuxdeploy
# blowing up mid-AppImage was the v0.7.0 release symptom). The extra
# wall time vs. the parallel version is small because cargo's
# incremental cache deduplicates the shared core crate compilation.
# Build AppImage (includes GStreamer patch)
echo "=== Building AppImage ==="
echo "=== Building AppImage (includes GStreamer patch) ==="
./build-appimage.sh
wait $CLI_PID
echo "=== CLI build complete ==="
wait $APK_PID
echo "=== APK build complete ==="
echo "=== Building APK ==="
cargo tauri android build --apk
echo "=== Building CLI ==="
cargo build -p itsgoin-cli --release
# Sign APK
echo "=== Signing APK ==="
@ -79,10 +87,11 @@ sshpass -p "$SSH_PASS" ssh $SSH_OPTS "$SSH_HOST" "
kill \$(cat ~/itsgoin-anchor.pid 2>/dev/null) 2>/dev/null
sleep 1
mv ~/bin/itsgoin.new ~/bin/itsgoin && chmod +x ~/bin/itsgoin
~/bin/itsgoin ~/itsgoin-anchor-data --publish-registry 2>&1 | tail -3
~/bin/itsgoin ~/itsgoin-anchor-data \
--announce \
--ann-category release \
--ann-channel stable \
--ann-channel ${ANN_CHANNEL} \
--ann-version ${VERSION} \
--ann-url https://itsgoin.com/download.html \
--ann-title 'ItsGoin v${VERSION} available' \

View file

@ -449,6 +449,12 @@ $('#popover-overlay').addEventListener('click', (e) => {
if (e.target === $('#popover-overlay')) closePopover();
});
$('#close-app-btn').addEventListener('click', async () => {
if (confirm('Close ItsGoin?\n\nStops all network connections to save battery. Reopen the app any time to resume.')) {
try { await invoke('exit_app'); } catch (_) {}
}
});
function relativeTime(timestampMs) {
const now = Date.now();
const diff = now - timestampMs;
@ -1084,7 +1090,117 @@ function setupMyPostsMediaObserver() {
mutObs.observe(myPostsList, { childList: true });
}
// v0.8 (A3): greetings inbox. Rows offer [Reply] (primary) and [Dismiss]
// ONLY — vouching is People-tab relationship management and must never be
// reachable from a Messages surface (round 9). The sender's name opens
// the ordinary bio modal, where the existing Friend flow lives.
let _greetingsCount = 0;
let _lastDmUnread = 0;
async function loadGreetings() {
const container = document.getElementById('greetings-list');
if (!container) return;
try {
const greetings = await invoke('list_greetings');
_greetingsCount = greetings.length;
// Notify once per greeting (localStorage-backed seen set, tag
// prefix `greet-` mirroring `msg-`).
try {
let notified = [];
try { notified = JSON.parse(localStorage.getItem('greetNotified') || '[]'); } catch (_) {}
const notifSet = new Set(notified);
const keys = [];
for (const g of greetings) {
const key = `greet-${g.commentAuthor}-${g.timestampMs}`;
keys.push(key);
if (_notifReady && !notifSet.has(key)) {
const who = g.senderName || g.senderPersonaId.slice(0, 8);
maybeNotify(`Greeting from ${who}`, (g.content || '').slice(0, 100), key);
}
}
// Keep only keys still in the inbox (dismissed/expired drop out).
localStorage.setItem('greetNotified', JSON.stringify(keys));
} catch (_) {}
if (greetings.length === 0) {
container.innerHTML = `<p class="empty-hint">No greetings yet</p>`;
} else {
container.innerHTML = greetings.map(g => {
const icon = generateIdenticon(g.senderPersonaId, 18);
const label = escapeHtml(g.senderName || g.senderPersonaId.slice(0, 12));
return `<div class="peer-card greeting-item" data-comment-author="${g.commentAuthor}" data-post-id="${g.postId}" data-ts="${g.timestampMs}">
<div class="peer-card-row">${icon} <a class="peer-name-link greet-bio-link" data-node-id="${g.senderPersonaId}" data-name="${label}">${label}</a> <span class="last-seen">${formatTimeAgo(g.timestampMs)}</span></div>
<div style="font-size:0.85rem;line-height:1.4;margin-top:0.25rem;white-space:pre-wrap;word-break:break-word">${escapeHtml(g.content)}</div>
<div class="peer-card-actions">
<button class="btn btn-primary btn-sm greet-reply-btn">Reply</button>
<button class="btn btn-danger btn-sm greet-dismiss-btn">Dismiss</button>
</div>
<div class="greet-reply-box hidden" style="margin-top:0.4rem;display:flex;gap:0.4rem;align-items:flex-end">
<textarea class="conv-reply-input" rows="2" maxlength="600" placeholder="Sealed reply..." style="flex:1"></textarea>
<button class="btn btn-primary btn-sm greet-reply-send">Send</button>
</div>
</div>`;
}).join('');
container.querySelectorAll('.greet-bio-link').forEach(el => {
el.addEventListener('click', (e) => {
e.preventDefault();
openBioModal(el.dataset.nodeId, el.dataset.name);
});
});
container.querySelectorAll('.greet-reply-btn').forEach(btn => {
btn.addEventListener('click', () => {
const box = btn.closest('.greeting-item').querySelector('.greet-reply-box');
box.classList.toggle('hidden');
if (!box.classList.contains('hidden')) box.querySelector('textarea').focus();
});
});
container.querySelectorAll('.greet-reply-send').forEach(btn => {
btn.addEventListener('click', async () => {
const item = btn.closest('.greeting-item');
const input = item.querySelector('.greet-reply-box textarea');
const content = input.value.trim();
if (!content) return;
btn.disabled = true;
try {
await invoke('reply_to_greeting', {
commentAuthorHex: item.dataset.commentAuthor,
postIdHex: item.dataset.postId,
timestampMs: Number(item.dataset.ts),
content,
});
toast('Reply sent (sealed)');
input.value = '';
item.querySelector('.greet-reply-box').classList.add('hidden');
} catch (e) { toast('Error: ' + e); }
finally { btn.disabled = false; }
});
});
container.querySelectorAll('.greet-dismiss-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const item = btn.closest('.greeting-item');
btn.disabled = true;
try {
await invoke('dismiss_greeting', {
commentAuthorHex: item.dataset.commentAuthor,
postIdHex: item.dataset.postId,
timestampMs: Number(item.dataset.ts),
});
loadGreetings();
} catch (e) { toast('Error: ' + e); btn.disabled = false; }
});
});
}
updateTabBadge('messages', _lastDmUnread + _greetingsCount);
} catch (e) {
container.innerHTML = `<p class="status-err">Error: ${e}</p>`;
}
}
async function loadMessages(force) {
// v0.8 (A3): refresh the greetings inbox alongside DMs (own IPC call,
// fire-and-forget — it updates the shared Messages badge itself).
loadGreetings().catch(() => {});
try {
const [posts, follows] = await Promise.all([
invoke('get_all_posts'),
@ -1330,7 +1446,9 @@ async function loadMessages(force) {
const hasUnread = thread.posts.some(p => !p.isMe && p.timestampMs > lastReadMs);
if (hasUnread) unreadCount++;
}
updateTabBadge('messages', unreadCount);
// v0.8 (A3): the Messages badge = unread DMs + undismissed greetings.
_lastDmUnread = unreadCount;
updateTabBadge('messages', unreadCount + _greetingsCount);
} catch (e) {
conversationsList.innerHTML = `<div class="section-card"><p class="status-err">Error: ${e}</p></div>`;
}
@ -1432,6 +1550,7 @@ async function loadPeers() {
else if (p.reach === 'n1') reachBadge = '<span class="reach-badge reach-n1">N1</span>';
else if (p.reach === 'n2') reachBadge = '<span class="reach-badge reach-n2">N2</span>';
else if (p.reach === 'n3') reachBadge = '<span class="reach-badge reach-n3">N3</span>';
else if (p.reach === 'n4') reachBadge = '<span class="reach-badge reach-n4">N4</span>';
let actions = '';
if (p.nodeId === myNodeId) {
@ -1663,7 +1782,7 @@ async function openBioModal(nodeId, preloadedName) {
overlay.classList.remove('hidden');
try {
// `resolve_display` returns {name, bio, avatarCid} for any NodeId.
// `resolve_display` returns {displayName, bio, avatarCid} for any NodeId.
const resolved = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null);
const follows = await invoke('list_follows').catch(() => []);
const ignored = await invoke('list_ignored_peers').catch(() => []);
@ -1671,7 +1790,7 @@ async function openBioModal(nodeId, preloadedName) {
const following = follows.some(f => f.nodeId === nodeId);
const isIgnored = ignored.some(i => i.nodeId === nodeId);
const isVouched = vouches.some(v => v.nodeId === nodeId);
const name = (resolved && resolved.name) || preloadedName || nodeId.slice(0, 12);
const name = (resolved && resolved.displayName) || preloadedName || nodeId.slice(0, 12);
const bio = (resolved && resolved.bio) || '';
const icon = generateIdenticon(nodeId, 48);
@ -1680,19 +1799,20 @@ async function openBioModal(nodeId, preloadedName) {
<div style="display:flex;gap:0.75rem;align-items:flex-start;margin-bottom:0.75rem">
${icon}
<div style="flex:1;min-width:0">
<div style="font-size:1.05rem;font-weight:600">${escapeHtml(name)}</div>
<div id="bio-modal-name" style="font-size:1.05rem;font-weight:600">${escapeHtml(name)}</div>
<div style="font-size:0.75rem;color:#888;word-break:break-all">${nodeId}</div>
</div>
</div>
${bio ? `<p style="font-size:0.9rem;line-height:1.45;margin:0 0 0.75rem">${escapeHtml(bio)}</p>` : '<p class="empty-hint" style="margin:0 0 0.75rem">No bio.</p>'}
<div style="display:flex;gap:0.4rem;flex-wrap:wrap">
${bio ? `<p id="bio-modal-bio" style="font-size:0.9rem;line-height:1.45;margin:0 0 0.75rem">${escapeHtml(bio)}</p>` : '<p id="bio-modal-bio" class="empty-hint" style="margin:0 0 0.75rem">No bio.</p>'}
<div id="bio-actions" style="display:flex;gap:0.4rem;flex-wrap:wrap">
<button id="bio-view-posts" class="btn btn-primary btn-sm">View Posts</button>
${following
? `<button id="bio-unfollow" class="btn btn-ghost btn-sm">Unfollow</button>`
: `<button id="bio-follow" class="btn btn-primary btn-sm">Follow</button>`}
${isVouched
? `<button id="bio-revoke-vouch" class="btn btn-ghost btn-sm">Revoke Vouch</button>`
: `<button id="bio-vouch" class="btn btn-ghost btn-sm">Vouch</button>`}
${(following && isVouched)
? `<button id="bio-unfriend" class="btn btn-ghost btn-sm">Unfriend</button>`
: (following
? `<button id="bio-vouch" class="btn btn-primary btn-sm">Add Vouch</button>
<button id="bio-unfollow" class="btn btn-ghost btn-sm">Unfollow</button>`
: `<button id="bio-friend" class="btn btn-primary btn-sm">Friend</button>
<button id="bio-follow" class="btn btn-ghost btn-sm">Follow only</button>`)}
<button id="bio-message" class="btn btn-ghost btn-sm">Message</button>
${isIgnored
? `<button id="bio-unignore" class="btn btn-ghost btn-sm">Unignore</button>`
@ -1749,22 +1869,119 @@ async function openBioModal(nodeId, preloadedName) {
} catch (e) { toast('Error: ' + e); }
finally { vouch.disabled = false; }
};
const revokeVouch = document.getElementById('bio-revoke-vouch');
if (revokeVouch) revokeVouch.onclick = async () => {
if (!confirm(`Revoke vouch for ${name}? This rotates your vouch key — they keep access to existing posts but not future ones.`)) return;
revokeVouch.disabled = true;
// Friend = follow + vouch in one click. Default action per v0.7.x UX.
const friend = document.getElementById('bio-friend');
if (friend) friend.onclick = async () => {
friend.disabled = true;
try {
await invoke('follow_node', { nodeIdHex: nodeId });
await invoke('vouch_for_peer', { nodeIdHex: nodeId });
toast(`Friended ${name}`);
close();
loadFollows();
loadFeed(true);
} catch (e) { toast('Error: ' + e); }
finally { friend.disabled = false; }
};
// Unfriend = revoke vouch + unfollow. Rotation cost is real; confirm.
const unfriend = document.getElementById('bio-unfriend');
if (unfriend) unfriend.onclick = async () => {
if (!confirm(`Unfriend ${name}? This revokes your vouch (rotates your vouch key — they keep access to existing posts but not future ones) AND unfollows them.`)) return;
unfriend.disabled = true;
try {
await invoke('revoke_vouch_for_peer', { nodeIdHex: nodeId });
toast('Revoked and rotated');
await invoke('unfollow_node', { nodeIdHex: nodeId });
toast(`Unfriended ${name}`);
close();
loadFollows();
loadFeed(true);
} catch (e) { toast('Error: ' + e); }
finally { revokeVouch.disabled = false; }
finally { unfriend.disabled = false; }
};
// v0.8 (A3): greeting affordance for strangers only (no existing
// relationship). The slot check may fetch the bio on-demand, so
// the button arrives asynchronously. Established peers keep the
// ordinary Message button.
if (!following && !isVouched) {
invoke('get_greeting_slot', { nodeIdHex: nodeId }).then(async slot => {
const row = document.getElementById('bio-actions');
if (!row || !bodyEl.contains(row)) return; // modal replaced/closed
// The slot check may have pulled the author's posts on-demand
// (registry search results start non-local) — if the first
// resolve came back empty, patch name/bio in place now.
if (!resolved || (!resolved.displayName && !resolved.bio)) {
const again = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null);
if (again && bodyEl.contains(row)) {
if (again.displayName) {
titleEl.textContent = again.displayName;
const nm = document.getElementById('bio-modal-name');
if (nm) nm.textContent = again.displayName;
}
if (again.bio) {
const bp = document.getElementById('bio-modal-bio');
if (bp) {
bp.textContent = again.bio;
bp.className = '';
bp.style.cssText = 'font-size:0.9rem;line-height:1.45;margin:0 0 0.75rem';
}
}
}
}
if (slot && slot.acceptsGreetings) {
const hi = document.createElement('button');
hi.id = 'bio-say-hi';
hi.className = 'btn btn-primary btn-sm';
hi.textContent = 'Say hi';
row.prepend(hi);
hi.onclick = () => openGreetingCompose(slot.bioPostId, titleEl.textContent || name);
} else {
const na = document.createElement('button');
na.className = 'btn btn-ghost btn-sm';
na.disabled = true;
na.textContent = 'Not accepting greetings';
row.appendChild(na);
}
}).catch(() => {});
}
} catch (e) {
bodyEl.innerHTML = `<p class="status-err">Error: ${e}</p>`;
}
}
// v0.8 (A3): greeting compose — swaps the bio modal body for a sealed
// one-shot compose aimed at the bio post's Greeting open slot.
function openGreetingCompose(bioPostIdHex, name) {
const titleEl = document.getElementById('popover-title');
const bodyEl = document.getElementById('popover-body');
if (!titleEl || !bodyEl) return;
titleEl.textContent = `Say hi to ${name}`;
bodyEl.innerHTML = `
<textarea id="greeting-text" rows="4" maxlength="600" placeholder="Introduce yourself..." style="width:100%;padding:0.4rem;background:#1a1a2e;color:#e0e0e0;border:1px solid #333;border-radius:4px;resize:vertical;font-family:inherit;font-size:0.9rem"></textarea>
<div style="display:flex;justify-content:space-between;align-items:center;gap:0.5rem;margin-top:0.5rem">
<span class="hint">Sealed &mdash; only ${escapeHtml(name)} can read this</span>
<button id="greeting-send-btn" class="btn btn-primary btn-sm">Send</button>
</div>`;
const input = document.getElementById('greeting-text');
setTimeout(() => input.focus(), 100);
const sendBtn = document.getElementById('greeting-send-btn');
const send = async () => {
const content = input.value.trim();
if (!content) return;
sendBtn.disabled = true;
try {
await invoke('send_greeting', { bioPostIdHex, content });
toast('Greeting sent');
closePopover();
} catch (e) {
toast('Error: ' + e);
sendBtn.disabled = false;
}
};
sendBtn.addEventListener('click', send);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.ctrlKey) send(); });
}
function openAuthorFeed(nodeId, name) {
authorFilterNodeId = nodeId;
authorFilterName = name || nodeId.slice(0, 12);
@ -1862,11 +2079,11 @@ async function loadNetworkSummary() {
const s = await invoke('get_network_summary');
networkSummaryEl.innerHTML = `<div class="diag-grid">
<div class="diag-item"><span class="diag-value">${s.totalConnections}</span><span class="diag-label">Connections</span></div>
<div class="diag-item"><span class="diag-value">${s.preferredCount}</span><span class="diag-label">Preferred</span></div>
<div class="diag-item"><span class="diag-value">${s.localCount}</span><span class="diag-label">Mesh</span></div>
<div class="diag-item"><span class="diag-value">${s.wideCount}</span><span class="diag-label">Non-mesh N1</span></div>
<div class="diag-item"><span class="diag-value">${s.meshCount}/${s.meshCap}</span><span class="diag-label">Mesh</span></div>
<div class="diag-item"><span class="diag-value">${s.tempCount}</span><span class="diag-label">Temp referral</span></div>
<div class="diag-item"><span class="diag-value">${s.n2Distinct}</span><span class="diag-label">N2 Reach</span></div>
<div class="diag-item"><span class="diag-value">${s.n3Distinct}</span><span class="diag-label">N3 Reach</span></div>
<div class="diag-item"><span class="diag-value">${s.n4Distinct}</span><span class="diag-label">N4 Reach</span></div>
</div>`;
} catch (e) {
networkSummaryEl.innerHTML = `<p class="empty-hint">Could not load network summary</p>`;
@ -1883,10 +2100,9 @@ async function loadConnections() {
connectionsList.innerHTML = conns.map(c => {
const label = escapeHtml(peerLabel(c.nodeId, c.displayName));
const icon = generateIdenticon(c.nodeId, 18);
const slotClass = c.slotKind === 'Preferred' ? 'slot-preferred'
: c.slotKind === 'Wide' ? 'slot-wide' : 'slot-local';
const slotLabel = c.slotKind === 'Local' ? 'Mesh'
: c.slotKind === 'Wide' ? 'Non-mesh N1' : c.slotKind;
const slotClass = c.slotKind === 'temp' ? 'slot-temp' : 'slot-mesh';
const slotLabel = c.slotKind === 'mesh' ? 'Mesh'
: c.slotKind === 'temp' ? 'Temp referral' : c.slotKind;
const duration = c.connectedAt ? relativeTime(c.connectedAt) : '';
return `<div class="peer-card">
<div class="peer-card-row">${icon} ${label} <span class="slot-badge ${slotClass}">${slotLabel}</span></div>
@ -1978,7 +2194,13 @@ function renderTimer(label, lastMs, intervalSecs, now) {
}
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Quotes MUST be escaped too: escaped output is interpolated into
// double-quoted HTML attributes (data-name="..."), and registry
// entries / greeting sender names are attacker-controlled — a name
// like `x" onclick="..."` would otherwise break out of the attribute
// and inject an inline handler (stored XSS).
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function formatTimeAgo(timestampMs) {
@ -2643,7 +2865,7 @@ async function doPost() {
try {
const vis = visibilitySelect.value;
const params = { content: content || '' };
if (vis !== 'public') {
if (vis !== 'public' && vis !== 'fof_closed') {
params.visibility = vis;
}
if (vis === 'circle') {
@ -2667,28 +2889,11 @@ async function doPost() {
const reactPerm = document.getElementById('react-perm-select').value;
let result;
if (commentPerm === 'friends_of_friends') {
// FoF Layer 2: body is still public (Mode 2) but the post
// carries a fof_gating block built from the author's
// keyring. Routed through a dedicated command because the
// gating block is signed at publish time (can't be added
// via SetPolicy after the fact).
if (vis === 'fof_closed') {
// Visibility = Extended Friends (FoF). Body + comments are
// encrypted under the FoF gating CEK. Mode 1.
if (selectedFiles.length > 0 || params.postingIdHex) {
toast('FoF posts with attachments or non-default persona not yet supported.');
postBtn.disabled = false;
return;
}
const created = await invoke('create_post_with_fof_comments', {
content: params.content,
});
result = { id: created.postId };
} else if (commentPerm === 'fof_closed') {
// FoF Layer 3 / Mode 1: body itself encrypted under the
// gating CEK. Non-FoF observers see only ciphertext;
// FoF readers unlock + decrypt on render via
// read_fof_closed_body.
if (selectedFiles.length > 0 || params.postingIdHex) {
toast('FoFClosed posts with attachments or non-default persona not yet supported.');
toast('FoF (Extended Friends) posts with attachments or non-default persona not yet supported.');
postBtn.disabled = false;
return;
}
@ -2696,6 +2901,17 @@ async function doPost() {
content: params.content,
});
result = { id: created.postId };
} else if (vis === 'public' && commentPerm === 'friends_of_friends') {
// Public body, FoF-gated comments. Mode 2.
if (selectedFiles.length > 0 || params.postingIdHex) {
toast('FoF-comment posts with attachments or non-default persona not yet supported.');
postBtn.disabled = false;
return;
}
const created = await invoke('create_post_with_fof_comments', {
content: params.content,
});
result = { id: created.postId };
} else if (selectedFiles.length > 0) {
// Convert ArrayBuffers to base64 strings
const files = selectedFiles.map(f => {
@ -2728,7 +2944,7 @@ async function doPost() {
selectedFiles = [];
renderAttachmentPreview();
updateCharCount();
visibilitySelect.value = 'public';
visibilitySelect.value = 'fof_closed';
updateVisibilityUI();
toast('Posted!');
loadFeed(true);
@ -2808,11 +3024,41 @@ async function doSyncAll() {
}
}
// v0.8 (A3): default persona id (hex) — the persona the Profiles lightbox
// and first-run consent act on. Falls back to the first-run auto persona.
async function getDefaultPersonaId() {
try {
const ids = await invoke('list_posting_identities');
const def = ids.find(p => p.isDefault) || ids[0];
if (def) return def.nodeId;
} catch (_) {}
try {
const auto = await invoke('get_first_run_auto_persona_id');
if (auto) return auto;
} catch (_) {}
return null;
}
async function doSetupName() {
// Name is optional — users who want to stay anonymous can proceed with a blank field.
const name = setupName.value.trim();
setupBtn.disabled = true;
try {
// v0.8 (A3): active choice at first publish — if the pre-checked
// "Accept greetings" box was unticked, record the opt-out BEFORE
// the first bio publish so no greeting slot ever ships (the
// republish inside set_display_name reads this setting).
const greetCheck = document.getElementById('setup-greetings-check');
if (greetCheck && !greetCheck.checked) {
// The opt-out write is LOAD-BEARING (round 8: an explicit
// opt-out must never silently fail and ship the slot anyway).
// Any failure here aborts the publish; the overlay stays up.
const personaId = await getDefaultPersonaId();
if (!personaId) {
throw new Error('could not resolve your persona to record the greeting opt-out — please try again');
}
await invoke('set_setting', { key: 'greetings_open.' + personaId, value: '0' });
}
await invoke('set_display_name', { name });
setupOverlay.classList.add('hidden');
toast(name ? 'Welcome, ' + name + '!' : 'Welcome!');
@ -2982,6 +3228,11 @@ async function loadCircleProfiles() {
function updateVisibilityUI() {
const vis = visibilitySelect.value;
circleSelect.classList.toggle('hidden', vis !== 'circle');
// Hide the comment-permission picker for FoF (Extended Friends) — the
// visibility already implies comments-restricted-to-FoF. Show it
// again when audience is public / friends / circle.
const commentPerm = document.getElementById('comment-perm-select');
if (commentPerm) commentPerm.classList.toggle('hidden', vis === 'fof_closed');
}
async function loadCircleOptions() {
@ -2999,6 +3250,9 @@ visibilitySelect.addEventListener('change', () => {
updateVisibilityUI();
if (visibilitySelect.value === 'circle') loadCircleOptions();
});
// Run once on load so the comment-perm picker is hidden for the
// default FoF visibility (matches the dropdown's `selected` option).
updateVisibilityUI();
// --- Circles management ---
async function loadCircles() {
@ -3153,6 +3407,7 @@ document.querySelectorAll('.tab').forEach(tab => {
if (!conversationsList.children.length) conversationsList.innerHTML = renderLoading();
loadMessages(true); loadDmRecipientOptions();
clearNotifications('msg-');
clearNotifications('greet-');
}
if (target === 'settings') {
loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible();
@ -3189,6 +3444,16 @@ $('#profile-lightbox-btn').addEventListener('click', () => {
<input type="checkbox" id="lb-public-visible" ${currentVisible ? 'checked' : ''} />
Show my profile to non-circle peers
</label>
<label class="checkbox-label" style="margin:0.5rem 0 0.25rem;display:block;font-size:0.8rem">
<input type="checkbox" id="lb-registry-listed" />
List me in the network registry (searchable by anyone)
</label>
<input id="lb-registry-keywords" type="text" placeholder="Keywords, comma-separated (up to 8)" maxlength="280" style="width:100%;margin-bottom:0.25rem" />
<div id="lb-registry-status" class="empty-hint" style="font-size:0.72rem;margin-bottom:0.5rem">Not listed</div>
<label class="checkbox-label" style="margin:0.5rem 0;display:block;font-size:0.8rem">
<input type="checkbox" id="lb-greetings-open" checked />
Accept greetings (sealed hellos from people outside your circles)
</label>
<div style="display:flex;gap:0.5rem;justify-content:center;margin-top:0.75rem">
<button class="btn btn-primary btn-sm" id="lb-profile-save">Save</button>
</div>
@ -3201,14 +3466,72 @@ $('#profile-lightbox-btn').addEventListener('click', () => {
</div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#lb-profile-save').addEventListener('click', async () => {
// v0.8 (A3): prefill the consent panel (registry listing + greeting
// consent) for the default persona. Status line mirrors auto-renew.
// The Save button stays DISABLED until the prefill settles: a listed
// user clicking Save against the unfilled (unchecked) checkbox would
// otherwise be treated as never-listed — their unlist intent dropped
// and auto-renew silently continuing.
let _lbWasListed = false;
const _lbSaveBtn = overlay.querySelector('#lb-profile-save');
_lbSaveBtn.disabled = true;
(async () => {
try {
const personaId = await getDefaultPersonaId();
if (!personaId || !document.body.contains(overlay)) return;
try {
const status = await invoke('get_registry_status', { postingIdHex: personaId });
_lbWasListed = !!status.listed;
const listedCheck = overlay.querySelector('#lb-registry-listed');
const kwInput = overlay.querySelector('#lb-registry-keywords');
const statusEl = overlay.querySelector('#lb-registry-status');
if (listedCheck) listedCheck.checked = _lbWasListed;
if (kwInput && status.keywords.length) kwInput.value = status.keywords.join(', ');
if (statusEl) statusEl.textContent = _lbWasListed
? 'Listed — auto-renews every 30 days while checked'
: 'Not listed';
} catch (_) {}
try {
const open = await invoke('get_greetings_open', { postingIdHex: personaId });
const greetCheck = overlay.querySelector('#lb-greetings-open');
if (greetCheck) greetCheck.checked = !!open;
} catch (_) {}
} finally {
_lbSaveBtn.disabled = false;
}
})();
_lbSaveBtn.addEventListener('click', async () => {
const name = overlay.querySelector('#lb-profile-name').value.trim();
const bio = overlay.querySelector('#lb-profile-bio').value.trim();
if (!name) { toast('Display name is required'); return; }
try {
const personaId = await getDefaultPersonaId();
// v0.8 (A3): write greeting consent BEFORE set_profile so the
// single bio republish below carries the right slot state
// (avoids a second republish via set_greetings_open). The
// write is LOAD-BEARING (round 8): if it fails, abort the
// publish rather than republishing with the wrong slot state.
const greetOpen = overlay.querySelector('#lb-greetings-open').checked;
if (!personaId) {
throw new Error('could not resolve your persona — profile not saved, please try again');
}
await invoke('set_setting', { key: 'greetings_open.' + personaId, value: greetOpen ? '1' : '0' });
await invoke('set_profile', { name, bio });
const visible = overlay.querySelector('#lb-public-visible').checked;
await invoke('set_public_visible', { visible });
// v0.8 (A3): registry listing. Checked = (re-)register — the
// core renews automatically every ~25 days while listed.
// Unchecking sends the self-certifying signed delete.
if (personaId) {
const listed = overlay.querySelector('#lb-registry-listed').checked;
const keywords = overlay.querySelector('#lb-registry-keywords').value
.split(',').map(k => k.trim()).filter(k => k).slice(0, 8);
if (listed) {
await invoke('register_persona', { postingIdHex: personaId, name, keywords });
} else if (_lbWasListed) {
await invoke('unregister_persona', { postingIdHex: personaId });
}
}
// Sync back to settings fields
profileNameInput.value = name;
profileBioInput.value = bio;
@ -3270,6 +3593,58 @@ $('#discover-toggle').addEventListener('click', () => {
if (!body.classList.contains('hidden')) loadDiscoverPeople();
});
// v0.8 (A3): registry search — pulls the registry comment chain from
// peers, then queries locally. Results show name/keywords only; the bio
// is fetched on-demand when a result is clicked (openBioModal).
async function runRegistrySearch() {
const input = $('#registry-search-input');
const results = $('#registry-results');
const btn = $('#registry-search-btn');
const query = input.value.trim();
if (!query) { results.innerHTML = ''; return; }
btn.disabled = true;
results.innerHTML = renderLoading();
try {
const entries = await invoke('search_registry', { query });
if (!entries || entries.length === 0) {
results.innerHTML = renderEmptyState(
'No registry matches',
'Entries expire after 30 days — try different keywords.'
);
return;
}
results.innerHTML = `<h4 class="subsection-title" style="margin:0 0 0.4rem">Registry results</h4>` + entries.map(e => {
const icon = generateIdenticon(e.authorId, 18);
const label = escapeHtml(e.displayName);
const kwLine = e.keywords && e.keywords.length
? `<div class="peer-card-meta">${e.keywords.map(k => escapeHtml(k)).join(' &middot; ')}</div>`
: '';
const ageLine = e.updatedAtMs
? `<div class="peer-card-lastseen"><span class="last-seen">Listed ${formatTimeAgo(e.updatedAtMs)}</span></div>`
: '';
return `<div class="peer-card" data-node-id="${e.authorId}">
<div class="peer-card-row">${icon} <a class="peer-name-link bio-link" data-node-id="${e.authorId}" data-name="${label}">${label}</a></div>
${kwLine}
${ageLine}
</div>`;
}).join('');
results.querySelectorAll('.bio-link').forEach(el => {
el.addEventListener('click', (ev) => {
ev.preventDefault();
openBioModal(el.dataset.nodeId, el.dataset.name);
});
});
} catch (e) {
results.innerHTML = `<p class="status-err">Error: ${e}</p>`;
} finally {
btn.disabled = false;
}
}
$('#registry-search-btn').addEventListener('click', runRegistrySearch);
$('#registry-search-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') runRegistrySearch();
});
// "See new activity" button in Following: applies staged data and
// re-renders so the user picks when to rearrange the list.
{
@ -3305,7 +3680,7 @@ function openDiagnostics() {
</div>
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;flex-wrap:wrap;justify-content:center">
<button id="rebalance-btn" class="btn btn-ghost btn-sm">Rebalance Now</button>
<button id="request-referrals-btn" class="btn btn-ghost btn-sm">Request Referrals</button>
<button id="request-referrals-btn" class="btn btn-ghost btn-sm">Find Peers (Convection)</button>
</div>
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;flex-wrap:wrap;justify-content:center">
<button id="show-ourinfo-btn" class="btn btn-ghost btn-sm">Our Info</button>
@ -3441,11 +3816,11 @@ function openDiagnostics() {
try {
const r = await invoke('request_referrals');
btn.textContent = r;
setTimeout(() => { btn.textContent = 'Request Referrals'; btn.disabled = false; }, 3000);
setTimeout(() => { btn.textContent = 'Find Peers (Convection)'; btn.disabled = false; }, 3000);
loadAllDiagnostics();
} catch (e) {
btn.textContent = 'Failed';
setTimeout(() => { btn.textContent = 'Request Referrals'; btn.disabled = false; }, 3000);
setTimeout(() => { btn.textContent = 'Find Peers (Convection)'; btn.disabled = false; }, 3000);
}
});
loadAllDiagnostics();
@ -3522,11 +3897,11 @@ if (exportKeyBtn) exportKeyBtn.addEventListener('click', async () => {
const key = await invoke('export_identity');
try {
await navigator.clipboard.writeText(key);
toast('Identity key copied to clipboard. KEEP IT SECRET!');
toast('Device address key copied to clipboard. KEEP IT SECRET!');
} catch (clipErr) {
// Clipboard API may fail in some webview contexts — show the key instead
console.error('Clipboard write failed:', clipErr);
prompt('Copy your identity key (KEEP IT SECRET!):', key);
prompt('Copy your device address key (KEEP IT SECRET!):', key);
}
} catch (e) {
console.error('export_identity failed:', e);
@ -3565,14 +3940,16 @@ document.querySelectorAll('.text-size-opt').forEach(btn => {
});
});
// --- Identity management ---
// --- Device address management (formerly "Identity") ---
// The underlying Tauri commands keep their `identity` names for now —
// only the user-facing labels rename. Backend rename can follow.
async function loadIdentities() {
const list = $('#identities-list');
if (!list) return;
try {
const identities = await invoke('list_identities');
if (identities.length === 0) {
list.innerHTML = '<p class="empty-hint">No identities</p>';
list.innerHTML = '<p class="empty-hint">No device addresses</p>';
return;
}
list.innerHTML = identities.map(id => {
@ -3595,7 +3972,7 @@ async function loadIdentities() {
btn.textContent = 'Switching...';
try {
await invoke('switch_identity', { nodeIdHex: btn.dataset.id });
toast('Identity switched — reloading...');
toast('Device address switched — reloading...');
setTimeout(() => location.reload(), 1000);
} catch (e) { toast('Error: ' + e); btn.disabled = false; btn.textContent = 'Switch'; }
});
@ -3604,10 +3981,10 @@ async function loadIdentities() {
// Wire delete buttons
list.querySelectorAll('.delete-id-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Delete this identity? This cannot be undone.')) return;
if (!confirm('Delete this device address? This cannot be undone.')) return;
try {
await invoke('delete_identity', { nodeIdHex: btn.dataset.id });
toast('Identity deleted');
toast('Device address deleted');
loadIdentities();
} catch (e) { toast('Error: ' + e); }
});
@ -3623,8 +4000,9 @@ $('#create-identity-btn').addEventListener('click', () => {
overlay.style.cursor = 'default';
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:350px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.75rem">New Identity</h3>
<input id="new-id-name" type="text" placeholder="Display name" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
<h3 style="color:#7fdbca;margin:0 0 0.5rem">New Device Address</h3>
<p style="font-size:0.72rem;color:#888;margin-bottom:0.75rem">A new QUIC network endpoint for this device. Your personas are unaffected.</p>
<input id="new-id-name" type="text" placeholder="Label (your reference, not shared)" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
<div style="display:flex;gap:0.5rem;justify-content:center">
<button class="btn btn-primary btn-sm" id="new-id-create">Create</button>
<button class="btn btn-ghost btn-sm" id="new-id-cancel">Cancel</button>
@ -3633,10 +4011,10 @@ $('#create-identity-btn').addEventListener('click', () => {
document.body.appendChild(overlay);
overlay.querySelector('#new-id-create').addEventListener('click', async () => {
const name = overlay.querySelector('#new-id-name').value.trim();
if (!name) { toast('Name is required'); return; }
if (!name) { toast('Label is required'); return; }
try {
const nodeId = await invoke('create_identity', { name });
toast(`Identity created: ${nodeId.substring(0, 12)}`);
toast(`Device address created: ${nodeId.substring(0, 12)}`);
overlay.remove();
loadIdentities();
} catch (e) { toast('Error: ' + e); }
@ -3651,10 +4029,10 @@ $('#import-identity-btn').addEventListener('click', () => {
overlay.style.cursor = 'default';
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:400px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Import Identity</h3>
<p style="font-size:0.75rem;color:#888;margin-bottom:0.5rem">Paste the 64-character hex key from an identity export.</p>
<input id="import-id-key" type="text" placeholder="Identity key (64 hex chars)" maxlength="64" style="width:100%;margin-bottom:0.5rem;font-family:monospace;font-size:0.7rem" />
<input id="import-id-name" type="text" placeholder="Display name" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
<h3 style="color:#7fdbca;margin:0 0 0.5rem">Import Device Address Key</h3>
<p style="font-size:0.75rem;color:#888;margin-bottom:0.5rem">Paste a 64-character hex key from a previous device-address export. This is NOT how you move your personas &mdash; use Export/Import personas above for that.</p>
<input id="import-id-key" type="text" placeholder="Device address key (64 hex chars)" maxlength="64" style="width:100%;margin-bottom:0.5rem;font-family:monospace;font-size:0.7rem" />
<input id="import-id-name" type="text" placeholder="Label (your reference, not shared)" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
<div style="display:flex;gap:0.5rem;justify-content:center">
<button class="btn btn-primary btn-sm" id="import-id-go">Import</button>
<button class="btn btn-ghost btn-sm" id="import-id-cancel">Cancel</button>
@ -3667,7 +4045,7 @@ $('#import-identity-btn').addEventListener('click', () => {
if (keyHex.length !== 64) { toast('Key must be 64 hex characters'); return; }
try {
const nodeId = await invoke('import_identity_key', { keyHex, name });
toast(`Identity imported: ${nodeId.substring(0, 12)}`);
toast(`Device address imported: ${nodeId.substring(0, 12)}`);
overlay.remove();
loadIdentities();
} catch (e) { toast('Error: ' + e); }
@ -3947,15 +4325,46 @@ function renderPersonasList() {
const deleteBtn = p.isDefault
? ''
: `<button class="btn btn-ghost btn-sm delete-persona-btn" data-id="${p.nodeId}" style="font-size:0.65rem;color:#e74c3c">Delete</button>`;
// Round 8: greeting consent is per-persona REVOCABLE. This toggle
// is the revocation path for non-default personas (the Profiles
// lightbox only manages the default persona). Label filled async.
const greetBtn = `<button class="btn btn-ghost btn-sm persona-greetings-btn" data-id="${p.nodeId}" style="font-size:0.65rem" disabled>Greetings: ...</button>`;
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:0.3rem 0;border-bottom:1px solid #222;gap:0.5rem">
<div style="min-width:0;flex:1">
<div style="font-weight:600">${escapeHtml(label)}${defaultTag}</div>
<div style="font-size:0.6rem;color:#666;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${p.nodeId.substring(0, 16)}...</div>
</div>
<div style="display:flex;gap:0.3rem;flex-wrap:wrap;justify-content:flex-end">${setDefaultBtn}${deleteBtn}</div>
<div style="display:flex;gap:0.3rem;flex-wrap:wrap;justify-content:flex-end">${greetBtn}${setDefaultBtn}${deleteBtn}</div>
</div>`;
}).join('');
list.querySelectorAll('.persona-greetings-btn').forEach(btn => {
// Fill the current consent state, then enable the toggle.
(async () => {
try {
const open = await invoke('get_greetings_open', { postingIdHex: btn.dataset.id });
btn.dataset.open = open ? '1' : '0';
btn.textContent = open ? 'Greetings: on' : 'Greetings: off';
btn.disabled = false;
} catch (_) {
btn.textContent = 'Greetings: ?';
}
})();
btn.addEventListener('click', async () => {
const next = btn.dataset.open !== '1';
btn.disabled = true;
try {
await invoke('set_greetings_open', { postingIdHex: btn.dataset.id, open: next });
btn.dataset.open = next ? '1' : '0';
btn.textContent = next ? 'Greetings: on' : 'Greetings: off';
toast(next
? 'Greetings enabled — bio republished with a greeting slot'
: 'Greetings disabled — slot revoked on published bios');
} catch (e) { toast('Error: ' + e); }
finally { btn.disabled = false; }
});
});
list.querySelectorAll('.set-default-persona-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
@ -4010,6 +4419,10 @@ $('#create-persona-btn').addEventListener('click', () => {
<h3 style="color:#7fdbca;margin:0 0 0.75rem">New Persona</h3>
<p style="font-size:0.7rem;color:#888;margin-bottom:0.75rem">Peers will see this persona as a distinct author. No one can tell which personas belong to the same device.</p>
<input id="new-persona-name" type="text" placeholder="Display name (e.g. Work, Garden Club)" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
<label class="checkbox-label" style="display:block;text-align:left;font-size:0.75rem;margin-bottom:0.75rem">
<input type="checkbox" id="new-persona-greetings" checked />
Accept greetings (sealed hellos from people outside your circles)
</label>
<div style="display:flex;gap:0.5rem;justify-content:center">
<button class="btn btn-primary btn-sm" id="new-persona-create">Create</button>
<button class="btn btn-ghost btn-sm" id="new-persona-cancel">Cancel</button>
@ -4022,7 +4435,12 @@ $('#create-persona-btn').addEventListener('click', () => {
const name = nameInput.value.trim();
if (!name) { toast('Enter a name'); return; }
try {
await invoke('create_posting_identity', { displayName: name });
// Round 8: greeting consent is an ACTIVE pre-checked choice at
// first publish. The choice rides the create call itself so the
// consent setting is written BEFORE the initial bio publish —
// an opted-out persona never ships a greeting slot at all.
const greetingsOpen = overlay.querySelector('#new-persona-greetings').checked;
await invoke('create_posting_identity', { displayName: name, greetingsOpen });
toast(`Persona created: ${name}`);
overlay.remove();
loadPersonas();
@ -4041,14 +4459,14 @@ $('#export-btn').addEventListener('click', () => {
overlay.className = 'image-lightbox';
overlay.style.cursor = 'default';
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:400px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Export Data</h3>
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Choose what to include in the export ZIP.</p>
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:420px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.5rem">Export your personas</h3>
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Save your personas + (optionally) your posts to a ZIP file so you can import them on another device.</p>
<div style="display:flex;flex-direction:column;gap:0.5rem;text-align:left;margin-bottom:1rem">
<label class="checkbox-label"><input type="radio" name="export-scope" value="identity_only" /> Identity key only (tiny backup)</label>
<label class="checkbox-label"><input type="radio" name="export-scope" value="posts_only" /> Posts + media (no key safe to share)</label>
<label class="checkbox-label"><input type="radio" name="export-scope" value="posts_with_identity" checked /> Posts + media + identity key</label>
<label class="checkbox-label"><input type="radio" name="export-scope" value="everything" /> Everything (posts, key, follows, settings)</label>
<label class="checkbox-label"><input type="radio" name="export-scope" value="identity_only" /> Persona keys only (tiny backup &mdash; restores your identity but not your posts)</label>
<label class="checkbox-label"><input type="radio" name="export-scope" value="posts_only" /> Posts + media only (no keys &mdash; safe to share publicly)</label>
<label class="checkbox-label"><input type="radio" name="export-scope" value="posts_with_identity" checked /> Posts + media + persona keys (typical &ldquo;move to new device&rdquo;)</label>
<label class="checkbox-label"><input type="radio" name="export-scope" value="everything" /> Everything (posts, keys, follows, settings)</label>
</div>
<div style="margin-bottom:0.75rem">
<label style="font-size:0.75rem;color:#888">Save to folder:</label>
@ -4118,8 +4536,8 @@ $('#import-btn').addEventListener('click', () => {
overlay.style.cursor = 'default';
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:420px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Import Data</h3>
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Select an ItsGoin export ZIP file.</p>
<h3 style="color:#7fdbca;margin:0 0 0.5rem">Import from another device</h3>
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Select an ItsGoin export ZIP. Default action restores the exported personas onto this device so you can post as them.</p>
<div style="display:flex;gap:0.25rem;margin-bottom:0.75rem">
<input id="import-zip-path" type="text" placeholder="/path/to/itsgoin-export.zip" style="flex:1;font-size:0.8rem" />
<button class="btn btn-ghost btn-sm" id="import-browse">Browse</button>
@ -4221,6 +4639,20 @@ $('#import-btn').addEventListener('click', () => {
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
});
(async () => {
const toggle = $('#session-relay-toggle');
if (!toggle) return;
try { toggle.checked = await invoke('get_session_relay_enabled'); } catch (_) {}
toggle.addEventListener('change', async () => {
try {
await invoke('set_session_relay_enabled', { enabled: toggle.checked });
} catch (e) {
toggle.checked = !toggle.checked;
alert('Failed to update session relay setting: ' + e);
}
});
})();
$('#notifications-btn').addEventListener('click', async () => {
// Load current settings
const msgVal = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on';
@ -4302,7 +4734,7 @@ async function init() {
// Update tab badges from welcome screen
updateTabBadge('feed', b.newFeed || 0);
updateTabBadge('myposts', b.newEngagement || 0);
updateTabBadge('messages', b.unreadMessages || 0);
updateTabBadge('messages', (b.unreadMessages || 0) + (b.greetings || 0));
// Ticker + notifications only after user leaves welcome screen
// (welcome page already shows these counts directly)
}).catch(() => {});
@ -4385,7 +4817,7 @@ async function init() {
<p style="color:#888;font-size:0.85rem;margin-bottom:1.5rem">How would you like to get started?</p>
<div style="display:flex;flex-direction:column;gap:0.75rem">
<button id="first-run-new" class="btn btn-primary" style="padding:0.75rem">Start Fresh</button>
<button id="first-run-import" class="btn btn-ghost" style="padding:0.75rem">Import an Identity</button>
<button id="first-run-import" class="btn btn-ghost" style="padding:0.75rem">Import from another device</button>
</div>
</div>`;
document.body.appendChild(chooser);
@ -4456,6 +4888,7 @@ async function init() {
const badges = await invoke('get_badge_counts', { lastFeedViewMs: _lastFeedViewMs });
if (currentTab !== 'feed') updateTabBadge('feed', badges.newFeed);
if (currentTab !== 'myposts') updateTabBadge('myposts', badges.newEngagement);
if (currentTab !== 'messages') updateTabBadge('messages', (badges.unreadMessages || 0) + (badges.greetings || 0));
} catch (_) {}
}, 30000);

View file

@ -13,6 +13,12 @@
<h2>Welcome to ItsGoin</h2>
<p>Pick a display name if you want one &mdash; or leave blank to stay anonymous.</p>
<input id="setup-name" type="text" placeholder="Display name (optional)" maxlength="50" autofocus />
<!-- v0.8 (A3): active choice at first profile publish — pre-checked,
visible, opt-out BEFORE anything ships (never silently defaulted). -->
<label class="checkbox-label" style="display:block;text-align:left;font-size:0.8rem;margin:0.5rem 0 0.75rem;cursor:pointer">
<input type="checkbox" id="setup-greetings-check" checked />
Accept greetings &mdash; people who find you can send a sealed hello. Only you can read them. Turn off any time in Profiles.
</label>
<button id="setup-btn" class="btn btn-primary">Continue</button>
</div>
</div>
@ -25,6 +31,7 @@
<span id="net-dot"></span>
<span id="net-labels"></span>
</div>
<button id="close-app-btn" title="Close app (stops connections to save battery)" aria-label="Close app"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg></button>
</div>
<nav id="tabs">
<button class="tab" data-tab="feed"><span class="tab-icon">&#x1f4f0;</span><span class="tab-label">Feed</span></button>
@ -96,8 +103,9 @@
<div id="visibility-row">
<select id="persona-select" title="Post as" class="hidden"></select>
<select id="visibility-select">
<option value="public">Public</option>
<option value="fof_closed" selected>Extended Friends (FoF)</option>
<option value="friends">Friends</option>
<option value="public">Public</option>
<option value="circle">Circle</option>
</select>
<select id="circle-select" class="hidden"></select>
@ -105,7 +113,6 @@
<option value="public">Comments: All</option>
<option value="followers_only">Comments: Followers</option>
<option value="friends_of_friends">Comments: Friends of Friends</option>
<option value="fof_closed">Body+Comments: FoF only (Mode 1)</option>
<option value="none">Comments: Off</option>
</select>
<select id="react-perm-select" title="React permission">
@ -138,8 +145,14 @@
</div>
<div class="section-card">
<button id="discover-toggle" class="btn btn-ghost btn-sm section-toggle">Discover People</button>
<div id="discover-body" class="hidden">
<button id="discover-toggle" class="btn btn-ghost btn-sm section-toggle">Hide Discover</button>
<div id="discover-body">
<!-- v0.8 (A3): registry search — on-demand chain pull, local query -->
<div class="input-row" style="margin-bottom:0.5rem">
<input id="registry-search-input" placeholder="Search the registry: name or keywords" />
<button id="registry-search-btn" class="btn btn-primary">Search</button>
</div>
<div id="registry-results"></div>
<p class="empty-hint" style="margin-bottom:0.5rem">Named profiles on the network you haven't followed or ignored.</p>
<div id="discover-list"></div>
</div>
@ -180,6 +193,15 @@
<h3>Message Requests</h3>
<div id="message-requests-list"></div>
</div>
<!-- v0.8 (A3): greetings inbox — [Reply] and [Dismiss] ONLY.
Vouching is People-tab relationship management, never a
Messages action (round 9). -->
<div class="section-card" id="greetings-section">
<h3>Greetings</h3>
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.75rem">Sealed hellos from people outside your circles. Replies go back sealed &mdash; only the sender can read them.</p>
<div id="greetings-list"></div>
</div>
</section>
<!-- Settings tab -->
@ -231,13 +253,13 @@
<button id="check-updates-btn" class="btn btn-ghost btn-sm" style="margin-top:0.5rem">Check now</button>
</div>
<div class="section-card" style="text-align:center">
<h3 style="margin-bottom:0.5rem">Identities</h3>
<div id="identities-list" style="margin-bottom:0.5rem"></div>
<div style="display:flex;gap:0.5rem;justify-content:center;flex-wrap:wrap">
<button id="create-identity-btn" class="btn btn-ghost btn-sm">New Identity</button>
<button id="import-identity-btn" class="btn btn-ghost btn-sm">Import Key</button>
</div>
<div class="section-card" style="text-align:left">
<h3 style="margin-bottom:0.4rem;text-align:center">Your data on this device</h3>
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.78rem;line-height:1.5">
<strong style="color:#7fdbca">Personas</strong> are who you are to peers &mdash; the keys you post and message with. Most people only need one. To move your account to a new device, you <em>export your personas</em> from this device and <em>import them</em> on the new one.
<br><br>
<strong style="color:#888">Device Address</strong> below is this device's own network endpoint &mdash; usually not what you want to move. Leave it alone unless you know why you're touching it.
</p>
</div>
<div class="section-card" style="text-align:center">
@ -248,9 +270,25 @@
</div>
<div class="section-card" style="text-align:center">
<h3 style="margin-bottom:0.4rem">Move to another device</h3>
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.78rem">
Export creates a ZIP containing your personas (and optionally posts/follows). Import on the other device's Settings &gt; Move to another device.
</p>
<div style="display:flex;gap:0.5rem;justify-content:center;flex-wrap:wrap">
<button id="export-btn" class="btn btn-ghost btn-sm">Export</button>
<button id="import-btn" class="btn btn-ghost btn-sm">Import</button>
<button id="export-btn" class="btn btn-primary btn-sm">Export personas</button>
<button id="import-btn" class="btn btn-ghost btn-sm">Import from another device</button>
</div>
</div>
<div class="section-card" style="text-align:center">
<h3 style="margin-bottom:0.4rem;font-size:0.85rem;color:#888">Device Address (advanced)</h3>
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.72rem">
This device's network endpoint &mdash; the QUIC address peers use to reach you. Changing this rotates the device's network identifier but does NOT change your posting identity (personas). Rarely useful.
</p>
<div id="identities-list" style="margin-bottom:0.5rem"></div>
<div style="display:flex;gap:0.5rem;justify-content:center;flex-wrap:wrap">
<button id="create-identity-btn" class="btn btn-ghost btn-sm">New Device Address</button>
<button id="import-identity-btn" class="btn btn-ghost btn-sm">Import Address Key</button>
</div>
</div>
@ -258,6 +296,17 @@
<button id="notifications-btn" class="btn btn-ghost btn-full">Notifications</button>
</div>
<div class="section-card">
<h3 style="margin-bottom:0.4rem;font-size:0.85rem;color:#888">Session Relay (off by default)</h3>
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.72rem">
When two peers can't connect directly through their networks, a third peer can pipe their traffic through itself. This burns the relay peer's bandwidth on someone else's connection. Off by default. Enable only if you're OK both <em>using</em> other peers as relays and <em>serving</em> as a relay for others.
</p>
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer">
<input type="checkbox" id="session-relay-toggle">
<span>Enable session relay</span>
</label>
</div>
<!-- Hidden: node-info, anchors, diagnostics btn moved elsewhere -->
<div class="hidden">
<button id="anchors-toggle"></button>
@ -300,7 +349,7 @@
<div class="section-card">
<h3>Danger Zone</h3>
<p class="empty-hint">Delete all local data. Identity key preserved.</p>
<p class="empty-hint">Delete all local data. Device address key preserved.</p>
<button id="reset-data-btn" class="btn btn-danger btn-full">Reset All Data</button>
</div>
</section>

View file

@ -18,6 +18,9 @@ header h1 { font-size: clamp(1.4rem, 2.5vw, 2rem); color: #7fdbca; margin: 0; }
#net-dot.net-green { background: #22c55e; }
#net-labels { font-size: 0.65rem; color: #888; display: flex; gap: 0.3rem; }
.net-label { background: #2a2a40; padding: 0.1rem 0.35rem; border-radius: 3px; color: #aab; }
#close-app-btn { margin-left: 0.6rem; padding: 0.3rem 0.5rem; background: transparent; border: 1px solid #444; border-radius: 4px; color: #999; font-size: 1rem; line-height: 1; cursor: pointer; }
#close-app-btn:hover { color: #e74c3c; border-color: #e74c3c; }
#close-app-btn:active { background: #2a1a1a; }
/* Setup overlay */
.overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(10, 10, 20, 0.92); display: flex; align-items: center; justify-content: center; z-index: 200; }
@ -224,7 +227,8 @@ header h1 { font-size: clamp(1.4rem, 2.5vw, 2rem); color: #7fdbca; margin: 0; }
.identicon { display: inline-block; vertical-align: middle; flex-shrink: 0; border-radius: 2px; }
/* Visibility selector */
#visibility-row { display: flex; gap: 0.4rem; margin-top: 0.35rem; align-items: center; }
#visibility-row { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.35rem; align-items: center; max-width: 100%; }
#visibility-row select { max-width: 100%; }
#visibility-select, #circle-select { background: #1a1a2e; border: 1px solid #444; border-radius: 3px; padding: 0.2rem 0.4rem; font-size: 0.75rem; font-family: inherit; -webkit-appearance: none; appearance: none; }
#visibility-select:focus, #circle-select:focus { outline: none; border-color: #7fdbca; }
#visibility-select option, #circle-select option { background: #fff; color: #000; }
@ -355,9 +359,8 @@ header h1 { font-size: clamp(1.4rem, 2.5vw, 2rem); color: #7fdbca; margin: 0; }
/* Slot kind badges */
.slot-badge { font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 3px; font-family: system-ui, sans-serif; }
.slot-preferred { background: #1a3a2e; color: #7fdbca; }
.slot-local { background: #1e2040; color: #aab; }
.slot-wide { background: #2a2a1e; color: #e2b93d; }
.slot-mesh { background: #1e2040; color: #aab; }
.slot-temp { background: #2a2a1e; color: #e2b93d; }
/* Reach level badges */
.reach-badge { font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 3px; font-family: system-ui, sans-serif; }
@ -365,6 +368,7 @@ header h1 { font-size: clamp(1.4rem, 2.5vw, 2rem); color: #7fdbca; margin: 0; }
.reach-n1 { background: #1a2e3a; color: #7fc4db; }
.reach-n2 { background: #1e2040; color: #aab; }
.reach-n3 { background: #1e2040; color: #667; }
.reach-n4 { background: #1a1a2e; color: #556; }
/* Diagnostics summary grid */
.diag-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.4rem; margin-bottom: 0.75rem; }

142
scripts/a3_integration_test.sh Executable file
View file

@ -0,0 +1,142 @@
#!/usr/bin/env bash
# v0.8 A3 integration test — registry + greetings across 3 local nodes.
#
# Spec: A3-SPEC §6 multi-node scenario. Run from the repo root after
# `cargo build -p itsgoin-cli`. Uses FIFOs to drive the interactive REPL.
#
# 1. All nodes self-materialize the same registry post ID; node1 also
# runs the --publish-registry genesis one-shot (debug gate override).
# 2. node2 registers "Alice rust p2p" → node3 `search rust` finds her.
# node2 `unregister` → node3 re-search finds nothing.
# 3. node2's bio has a greeting slot; node3 greets it; node2 sees the
# unsealed text + real sender persona; node2 replies; node3 sees the
# reply. node1 (holder) stores only ciphertext.
# 5. Newest-wins: node2 re-registers; node3 sees exactly one entry.
# 6. (Optional, ITSGOIN_TEST_TTL_SECS) comment expiry sweep.
#
# Exit code 0 = all checks passed. Logs: /tmp/itsgoin-cli{1,2,3}.log
set -u
cd "$(dirname "$0")/.."
BIN=target/debug/itsgoin
[ -x "$BIN" ] || { echo "build first: cargo build -p itsgoin-cli"; exit 2; }
PASS=0; FAIL=0
check() { # check <desc> <cmd...>
local desc="$1"; shift
if "$@" >/dev/null 2>&1; then PASS=$((PASS+1)); echo "PASS: $desc";
else FAIL=$((FAIL+1)); echo "FAIL: $desc"; fi
}
strip_ansi() { sed 's/\x1b\[[0-9;]*m//g' "$1" | grep -v "WARN.*netlink\|WARN.*buffer_tool"; }
# Same noise filter for the tail-window checks below. The netlink/mDNS watcher
# logs in unpredictable bursts, and a burst landing between a REPL command and
# its assertion pushes the answer out of a raw `tail -N` window — a false
# failure with nothing wrong in the node.
quiet_tail() { grep -av "netlink\|buffer_tool\|iroh_quinn\|swarm_discovery" "$1" | tail -"$2"; }
export -f quiet_tail
sq() { sqlite3 "/tmp/itsgoin-test$1/itsgoin.db" "$2"; }
cleanup() {
{ exec 13>&- 14>&- 15>&-; } 2>/dev/null || true
kill $(pgrep -f 'itsgoin.*itsgoin-test') 2>/dev/null
rm -f /tmp/itsgoin-cmd{1,2,3}
}
trap cleanup EXIT
echo "== setup =="
kill $(pgrep -f 'itsgoin.*itsgoin-test') 2>/dev/null; sleep 1
for i in 1 2 3; do
rm -rf /tmp/itsgoin-test$i /tmp/itsgoin-cmd$i /tmp/itsgoin-cli$i.log
mkdir -p /tmp/itsgoin-test$i
mkfifo /tmp/itsgoin-cmd$i
done
export ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS=1
# Genesis one-shot on node1's data dir (debug-gate override).
"$BIN" /tmp/itsgoin-test1 --bind 127.0.0.1:18411 --publish-registry > /tmp/itsgoin-genesis.log 2>&1
check "genesis prints registry post id matching shipped constant" \
bash -c 'g=$(grep registry_post_id /tmp/itsgoin-genesis.log | awk "{print \$2}");
c=$(grep shipped_constant /tmp/itsgoin-genesis.log | awk "{print \$2}");
[ -n "$g" ] && [ "$g" = "$c" ]'
# Start the three nodes with FIFO-driven stdin.
for i in 1 2 3; do
( exec "$BIN" /tmp/itsgoin-test$i --bind 127.0.0.1:1841$i \
< /tmp/itsgoin-cmd$i > /tmp/itsgoin-cli$i.log 2>&1 ) &
done
# Keep FIFO write ends open for the whole run.
exec 13>/tmp/itsgoin-cmd1 14>/tmp/itsgoin-cmd2 15>/tmp/itsgoin-cmd3
sleep 6
n1_id=$(strip_ansi /tmp/itsgoin-cli1.log | grep -m1 "Node ID:" | awk '{print $3}')
n2_id=$(strip_ansi /tmp/itsgoin-cli2.log | grep -m1 "Node ID:" | awk '{print $3}')
echo "node1=$n1_id node2=$n2_id"
check "all nodes self-materialized the registry post" \
bash -c 'for i in 1 2 3; do sqlite3 /tmp/itsgoin-test$i/itsgoin.db \
"SELECT count(*) FROM posts WHERE hex(id) = upper(\"'"$(grep shipped_constant /tmp/itsgoin-genesis.log | awk '{print $2}')"'\")" \
| grep -q 1 || exit 1; done'
echo "== mesh: 2,3 -> 1; 3 -> 2 =="
echo "connect $n1_id@127.0.0.1:18411" >&14
echo "connect $n1_id@127.0.0.1:18411" >&15
sleep 4
echo "connect $n2_id@127.0.0.1:18412" >&15
sleep 4
echo "== step 2: register / search / unregister =="
echo "name Alice" >&14
sleep 3
echo "register Alice rust p2p" >&14
sleep 3
echo "search rust" >&15
sleep 8
check "node3 search finds Alice" grep -q "Alice" /tmp/itsgoin-cli3.log
echo "unregister" >&14
sleep 3
echo "search rust" >&15
sleep 8
check "node3 re-search finds nothing after signed delete" \
bash -c 'quiet_tail /tmp/itsgoin-cli3.log 20 | grep -q "no registry matches"'
echo "== step 3: greeting roundtrip =="
# node2's bio post id (latest Profile post) from its DB.
bio2=$(sq 2 'SELECT lower(hex(id)) FROM posts WHERE visibility_intent = '"'"'"Profile"'"'"' ORDER BY timestamp_ms DESC LIMIT 1')
check "node2 has a bio post with a greeting slot" \
bash -c '[ -n "'"$bio2"'" ] && sqlite3 /tmp/itsgoin-test2/itsgoin.db \
"SELECT fof_gating_json FROM posts WHERE lower(hex(id))=\"'"$bio2"'\"" | grep -q Greeting'
echo "name Bob" >&15
sleep 3
echo "greet $bio2 hello alice from bob" >&15
sleep 6
echo "greetings" >&14
sleep 4
check "node2 unseals the greeting text" grep -q "hello alice from bob" /tmp/itsgoin-cli2.log
echo "reply 0 hello back bob" >&14
sleep 6
echo "greetings" >&15
sleep 4
check "node3 unseals the reply via its stored fresh reply key" \
grep -q "hello back bob" /tmp/itsgoin-cli3.log
# Holder-side opacity: node1 never sees plaintext greeting bodies.
check "node1 stores only ciphertext (no greeting plaintext in db)" \
bash -c '! sqlite3 /tmp/itsgoin-test1/itsgoin.db \
"SELECT content FROM comments" | grep -q "hello alice"'
echo "== step 5: newest-wins =="
echo "register Alice rust p2p" >&14
sleep 2
echo "register AliceV2 rust mesh" >&14
sleep 4
echo "search rust" >&15
sleep 8
check "node3 sees exactly one (newest) entry for node2's persona" \
bash -c 'quiet_tail /tmp/itsgoin-cli3.log 6 | grep -c "rust" | grep -q "^1$" &&
quiet_tail /tmp/itsgoin-cli3.log 6 | grep -q AliceV2'
echo
echo "== results: $PASS passed, $FAIL failed =="
[ "$FAIL" -eq 0 ]

278
scripts/c_topology_test.sh Executable file
View file

@ -0,0 +1,278 @@
#!/usr/bin/env bash
# v0.8 Iteration C integration test — TOPOLOGY.
#
# Run from the repo root after `cargo build -p itsgoin-cli`, AFTER
# scripts/a3_integration_test.sh (C does not replace A). Drives interactive
# REPLs over FIFOs, asserts by grepping logs and by sqlite3 against node DBs.
#
# SCENARIO 1 Two-pool uniques exchange over a chain: pool 1 shifts one bounce
# deeper per hop, pool 2 lands at N4, and N4 is USED but NEVER
# re-announced (no N5 anywhere). Plus the §layers privacy
# invariant: only anchor entries carry an address.
# SCENARIO 2 Anchor convection: the rolling window hands each caller the 2
# most recent prior callers, produces a REAL connection, rotates
# entries out after 2 hand-outs, coordinates a RelayIntroduce when
# both ends are live, and refuses a top-up CHEAPLY.
# SCENARIO 3 Temp referral slots: allocated ABOVE the mesh cap, carrying no
# knowledge, never evicting an established mesh peer, graduating
# into a freed mesh slot.
# STANDING 0xC0 AnchorRegister is gone; no automatic session relay anywhere.
#
# Exit code 0 = all checks passed. Logs: /tmp/itsgoin-ctop{1..7}.log
set -u
cd "$(dirname "$0")/.."
BIN=target/debug/itsgoin
[ -x "$BIN" ] || { echo "build first: cargo build -p itsgoin-cli"; exit 2; }
PASS=0; FAIL=0
check() { # check <desc> <cmd...>
local desc="$1"; shift
if "$@" >/dev/null 2>&1; then PASS=$((PASS+1)); echo "PASS: $desc";
else FAIL=$((FAIL+1)); echo "FAIL: $desc"; fi
}
strip_ansi() { sed 's/\x1b\[[0-9;]*m//g' "$1" | grep -v "WARN.*netlink\|WARN.*buffer_tool"; }
sq() { sqlite3 "/tmp/itsgoin-ctop$1/itsgoin.db" "$2"; }
# Is `id` (hex) present in node N's uniques index at `bounce`?
at_bounce() { # at_bounce <node> <id_hex> <bounce>
[ "$(sq "$1" "SELECT count(*) FROM reachable WHERE lower(hex(reachable_id))='$2' AND bounce=$3")" != "0" ]
}
anywhere() { # anywhere <node> <id_hex>
[ "$(sq "$1" "SELECT count(*) FROM reachable WHERE lower(hex(reachable_id))='$2'")" != "0" ]
}
# `check` runs its command via `bash -c`, which does not inherit shell functions.
export -f strip_ansi sq at_bounce anywhere
NODES="1 2 3 4 5 6 7"
FDS=""
cleanup() {
for fd in 21 22 23 24 25 26 27; do eval "exec $fd>&-" 2>/dev/null || true; done
kill $(pgrep -f 'itsgoin.*itsgoin-ctop') 2>/dev/null
rm -f /tmp/itsgoin-ctopcmd*
}
trap cleanup EXIT
# Test gates:
# - loopback referrals: the convection address filter is publicly-routable-only
# (bugs-fixed #8), which would make every 127.0.0.1 referral unusable.
# - mesh cap 2: reaches the temp-referral boundary with 4 nodes, not 21.
# - no growth loop: on loopback the growth loop collapses any chain into a
# full mesh in seconds, and a full mesh has no N2 at all (every peer is a
# direct). Topology here is script-controlled on purpose.
export ITSGOIN_TEST_ALLOW_LOOPBACK_REFERRALS=1
export ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS=1
export ITSGOIN_TEST_NO_GROWTH=1
export RUST_LOG="${RUST_LOG:-itsgoin_core=debug,info,iroh=warn,swarm_discovery=warn}"
echo "== setup =="
kill $(pgrep -f 'itsgoin.*itsgoin-ctop') 2>/dev/null; sleep 1
for i in $NODES; do
rm -rf /tmp/itsgoin-ctop$i /tmp/itsgoin-ctopcmd$i /tmp/itsgoin-ctop$i.log
mkdir -p /tmp/itsgoin-ctop$i
mkfifo /tmp/itsgoin-ctopcmd$i
done
start_node() { # start_node <n> <port> [extra env assignments...]
local i="$1" port="$2"; shift 2
( exec env "$@" "$BIN" /tmp/itsgoin-ctop$i --bind 127.0.0.1:$port \
< /tmp/itsgoin-ctopcmd$i > /tmp/itsgoin-ctop$i.log 2>&1 ) &
}
# Nodes 1-6: chain + convection. Node 7 joins late (fresh index).
for i in 1 2 3 4 5 6 7; do start_node $i $((18430 + i)) ITSGOIN_TEST_NO_GROWTH=1; done
exec 21>/tmp/itsgoin-ctopcmd1 22>/tmp/itsgoin-ctopcmd2 23>/tmp/itsgoin-ctopcmd3 \
24>/tmp/itsgoin-ctopcmd4 25>/tmp/itsgoin-ctopcmd5 26>/tmp/itsgoin-ctopcmd6 \
27>/tmp/itsgoin-ctopcmd7
sleep 7
for i in $NODES; do
eval "n${i}=\$(strip_ansi /tmp/itsgoin-ctop$i.log | grep -m1 'Node ID:' | awk '{print \$3}')"
done
echo "n1=$n1 n2=$n2 n3=$n3 n4=$n4 n5=$n5"
[ -n "$n5" ] || { echo "FAIL: nodes did not start"; exit 1; }
##############################################################################
echo
echo "== scenario 1: two-pool uniques exchange, N4 terminal =="
##############################################################################
# Chain 1 - 2 - 3 - 4 - 5. Built inward so each hop's initial exchange already
# carries the deeper knowledge where possible.
echo "connect $n1@127.0.0.1:18431" >&22; sleep 3
echo "connect $n2@127.0.0.1:18432" >&23; sleep 3
echo "connect $n3@127.0.0.1:18433" >&24; sleep 3
echo "connect $n4@127.0.0.1:18434" >&25; sleep 4
# Drive propagation explicitly: each uniques-pull round moves knowledge one
# bounce along the chain. This IS the v0.8 pull — an index exchange, no posts.
for round in 1 2 3 4 5; do
for fd in 21 22 23 24 25; do echo "uniques-pull" >&$fd; done
sleep 3
done
check "uniques pull is an index exchange (peers exchanged, no post transfer)" \
bash -c 'grep -q "uniques-pull: exchanged with [1-9]" /tmp/itsgoin-ctop3.log'
# Pool 1 shifts one bounce deeper per hop, tagged to the reporter.
check "node1 holds node3 at N2 (pool 1 shifted one bounce)" at_bounce 1 "$n3" 2
check "node1 holds node4 at N3" at_bounce 1 "$n4" 3
check "node1 holds node5 at N4 (terminal pool)" at_bounce 1 "$n5" 4
check "node5 holds node1 at N4 from the other end of the chain" at_bounce 5 "$n1" 4
# N4 is USED — address resolution and search read it.
check "node1 can look up its N4 entry (used, not merely stored)" \
bash -c 'grep -q "N4 via" <(echo "uniques '"$n5"'" >&21; sleep 3; tail -40 /tmp/itsgoin-ctop1.log)'
# §layers privacy invariant: ONLY anchor entries carry an address.
check "no non-anchor index row carries an address (on any node)" \
bash -c 'for i in 1 2 3 4 5; do
c=$(sqlite3 /tmp/itsgoin-ctop$i/itsgoin.db \
"SELECT count(*) FROM reachable WHERE is_anchor=0 AND addresses NOT IN ('"''"',\"[]\")");
[ "$c" = "0" ] || exit 1; done'
# Per-reporter dedup: one row per (reporter, id) — an ID can never sit at two
# depths from the same reporter.
check "one index row per (reporter, id) — no duplicate depths" \
bash -c 'for i in 1 2 3 4 5; do
d=$(sqlite3 /tmp/itsgoin-ctop$i/itsgoin.db \
"SELECT count(*) FROM (SELECT reporter_node_id, reachable_id FROM reachable
GROUP BY reporter_node_id, reachable_id HAVING count(*) > 1)");
[ "$d" = "0" ] || exit 1; done'
# Nothing is stored past bounce 4. There is no N5 table and no N5 row.
check "stored horizon never exceeds 4 bounces" \
bash -c 'for i in 1 2 3 4 5; do
m=$(sqlite3 /tmp/itsgoin-ctop$i/itsgoin.db "SELECT ifnull(max(bounce),0) FROM reachable");
[ "$m" -le 4 ] || exit 1; done'
# THE KEY ASSERTION: a fresh node joining node1 must NOT learn node5. node1
# knows node5 only at N4, and the terminal pool is never re-announced.
echo "connect $n1@127.0.0.1:18431" >&27; sleep 4
for round in 1 2 3; do echo "uniques-pull" >&27; echo "uniques-pull" >&21; sleep 3; done
check "N4 is never re-announced: node7 never learns node5" \
bash -c '! /usr/bin/env bash -c "[ \"\$(sqlite3 /tmp/itsgoin-ctop7/itsgoin.db \
\"SELECT count(*) FROM reachable WHERE lower(hex(reachable_id))=\\\"'"$n5"'\\\"\")\" != \"0\" ]"'
check "node7 DID learn node2 (the exchange itself works)" anywhere 7 "$n2"
##############################################################################
echo
echo "== scenario 2: anchor convection =="
##############################################################################
# Node 6 plays the anchor: directly dialable, and it is the one every caller
# asks. Callers are node1 and node5 — the two ENDS of the chain, so they have
# no path to each other and a referral that lands is unambiguous.
echo "connect $n6@127.0.0.1:18436" >&21; sleep 3
echo "convect $n6" >&21; sleep 5 # node1 admitted; window was empty
echo "connect $n6@127.0.0.1:18436" >&25; sleep 3
echo "convect $n6" >&25; sleep 10 # node5 is referred to node1
check "anchor serves referrals out of the rolling window" \
bash -c 'grep -q "Convection: serving referrals" /tmp/itsgoin-ctop6.log'
check "the anchor admitted callers to the window without any registration msg" \
bash -c 'grep -q "Convection: admitted caller to rolling window" /tmp/itsgoin-ctop6.log'
check "the anchor referred the PRIOR caller to the next one" \
bash -c 'grep "Convection: referring peer" /tmp/itsgoin-ctop6.log | grep -q "'"$n1"'"'
check "a convection referral produced a real peer connection" \
bash -c 'grep -q "Convection: connected to referred peer\|Convection: connected via introduction" \
/tmp/itsgoin-ctop5.log'
check "node5 is now actually mesh-connected to node1 (chain ends joined)" \
bash -c 'echo "connections" >&25; sleep 3; strip_ansi /tmp/itsgoin-ctop5.log | tail -20 | grep -q "'"${n1:0:12}"'"'
# Both ends live on the anchor => coordinate an introduction, not a cold address.
check "anchor coordinates RelayIntroduce when both ends are live" \
bash -c 'grep -q "Convection: coordinated introduction" /tmp/itsgoin-ctop6.log'
# Rotation: an entry is handed to exactly 2 callers, then leaves the window.
echo "convect $n6" >&24; sleep 6
echo "convect $n6" >&23; sleep 6
echo "convect $n6" >&27; sleep 6
check "each window entry is handed out at most twice, then rotates out" \
bash -c 'n=$(grep "Convection: referring peer" /tmp/itsgoin-ctop6.log | grep -c "referred=\"\?'"$n1"'"); \
[ "$n" -le 2 ]'
check "later callers are served the more RECENT callers" \
bash -c 'grep "Convection: referring peer" /tmp/itsgoin-ctop6.log | tail -3 | grep -q "'"$n5"'\|'"$n4"'"'
# CHEAP REFUSAL: node3 is an interior chain node (2 mesh peers) so its request
# is TOP-UP class. It asks node5, whose own convection window is empty — there
# is nothing fresh to circulate, so the refusal must be one message, not a
# burned timeout.
echo "connect $n5@127.0.0.1:18435" >&23; sleep 4
echo "convect $n5" >&23; sleep 6
check "top-up against an empty window is refused" \
bash -c 'grep -q "convection: refused" /tmp/itsgoin-ctop3.log'
check "the refusal is CHEAP (single message, well under a timeout)" \
bash -c 'ms=$(grep -o "convection: refused in [0-9]*ms" /tmp/itsgoin-ctop3.log | tail -1 |
grep -o "[0-9]*" | head -1); [ -n "$ms" ] && [ "$ms" -lt 3000 ]'
check "entry class is served even where top-up was refused" \
bash -c 'grep "Convection: serving referrals" /tmp/itsgoin-ctop6.log | grep -q entry'
check "the refusing anchor lands in the caller's penalty box (refusal feedback)" \
bash -c 'echo "convection" >&23; sleep 3; strip_ansi /tmp/itsgoin-ctop3.log | tail -20 |
grep -oE "anchor_bias [0-9.]+" | tail -1 | awk "{ exit !(\$2 < 1.0) }"'
##############################################################################
echo
echo "== scenario 3: temp referral slots (mesh cap 2) =="
##############################################################################
# Restart nodes 1-4 with a mesh cap of 2 so the temp-referral band is reachable.
for fd in 21 22 23 24 25 26 27; do echo "quit" >&$fd 2>/dev/null; done
sleep 3
kill $(pgrep -f 'itsgoin.*itsgoin-ctop') 2>/dev/null; sleep 2
for fd in 21 22 23 24 25 26 27; do eval "exec $fd>&-" 2>/dev/null || true; done
for i in 1 2 3 4; do
rm -rf /tmp/itsgoin-ctop$i /tmp/itsgoin-ctopcmd$i /tmp/itsgoin-ctop$i.log
mkdir -p /tmp/itsgoin-ctop$i; mkfifo /tmp/itsgoin-ctopcmd$i
start_node $i $((18440 + i)) ITSGOIN_TEST_MESH_SLOTS=2 \
ITSGOIN_TEST_ALLOW_LOOPBACK_REFERRALS=1 ITSGOIN_TEST_NO_GROWTH=1
done
exec 21>/tmp/itsgoin-ctopcmd1 22>/tmp/itsgoin-ctopcmd2 23>/tmp/itsgoin-ctopcmd3 \
24>/tmp/itsgoin-ctopcmd4
sleep 7
for i in 1 2 3 4; do
eval "m${i}=\$(strip_ansi /tmp/itsgoin-ctop$i.log | grep -m1 'Node ID:' | awk '{print \$3}')"
done
# Fill node1's 2 mesh slots, then send a third peer at it.
echo "connect $m1@127.0.0.1:18441" >&22; sleep 4
echo "connect $m1@127.0.0.1:18441" >&23; sleep 4
echo "slots" >&21; sleep 3
check "mesh pool fills to the cap" \
bash -c 'strip_ansi /tmp/itsgoin-ctop1.log | grep -q "mesh 2/2"'
echo "connect $m1@127.0.0.1:18441" >&24; sleep 5
echo "slots" >&21; sleep 3
check "the 4th peer lands in a temp referral slot ABOVE the cap" \
bash -c 'strip_ansi /tmp/itsgoin-ctop1.log | tail -30 | grep -q "mesh 2/2 temp-referral 1/"'
check "an established mesh peer was never evicted to make room" \
bash -c 'strip_ansi /tmp/itsgoin-ctop1.log | tail -30 | grep -q "mesh 2/2"'
check "the temp slot carries NO knowledge (never a reporter in our index)" \
bash -c '[ "$(sqlite3 /tmp/itsgoin-ctop1/itsgoin.db \
"SELECT count(*) FROM reachable WHERE lower(hex(reporter_node_id))=\"'"$m4"'\"")" = "0" ]'
check "and a temp peer is never written to mesh_peers" \
bash -c '[ "$(sqlite3 /tmp/itsgoin-ctop1/itsgoin.db \
"SELECT count(*) FROM mesh_peers WHERE lower(hex(node_id))=\"'"$m4"'\"")" = "0" ]'
# Free a mesh slot: the temp peer graduates into it. The wait covers the mesh
# keepalive interval (30s) — an abruptly-gone QUIC peer is noticed when the
# next keepalive write fails, not instantly.
echo "quit" >&22; sleep 45
echo "slots" >&21; sleep 5
check "the temp referral slot GRADUATES into the freed mesh slot" \
bash -c 'strip_ansi /tmp/itsgoin-ctop1.log | tail -20 | grep -q "temp-referral 0/"'
check "the graduated peer now exchanges knowledge (written to mesh_peers)" \
bash -c '[ "$(sqlite3 /tmp/itsgoin-ctop1/itsgoin.db \
"SELECT count(*) FROM mesh_peers WHERE lower(hex(node_id))=\"'"$m4"'\"")" != "0" ]'
##############################################################################
echo
echo "== standing assertions =="
##############################################################################
check "0xC0 AnchorRegister is gone from the tree" \
bash -c '! grep -rn "AnchorRegister" crates/ --include=*.rs'
check "no automatic session relay anywhere in the convection path" \
bash -c '! grep -riE "session relay" /tmp/itsgoin-ctop*.log'
check "no registration cycle writes known_anchors (bootstrap cache only)" \
bash -c '! grep -rn "start_anchor_register_cycle" crates/ --include=*.rs'
echo
echo "== results: $PASS passed, $FAIL failed =="
[ "$FAIL" -eq 0 ]

View file

@ -6,6 +6,74 @@ See `CONTRIBUTING.md` for the protocol. See `AGENTS.md` for the Claude-specific
---
## 2026-05-13 to 2026-05-15 — primary Claude (Lead) — `docs/fof-spec-layer1-bio-grants` → master
**Started**: May 13 UTC. Released v0.7.0 stable on May 15 UTC.
**Instance**: Scott's primary Claude (Lead)
**Issue**: implement FoF spec Layers 15 end-to-end
**Branch**: `docs/fof-spec-layer1-bio-grants` (continued from prior spec-drafting session; merged to master at d46fcb4 on May 15)
**Scope**: Friend-of-Friend post gating: per-persona vouch keys, anonymous bio-post wrapper distribution, FoF-gated comments with CDN verification, FoF-closed encrypted bodies, V_me lifecycle (rotation/cascade/key-burn), unlock cache + retry sweep. Pre-deploy hardening pass. Version bump to 0.7.0 stable.
**Commits landed on master** (34 total, `1fdf9a9..d46fcb4`):
Layer 1 (vouch primitive):
- `8a53d83` schema + storage API + HPKE-sealed vouch-grant crypto + 3 tests
- `bc008c5` wire types (VouchGrantBatch) + V_me auto-gen on persona create
- `3ee5c30` publish path: bucketed-padding VouchGrantBatch in bio posts
- `d1afcec` receive-path scan + follow-gating + scan cache (2 e2e tests)
- `34c5b60` Tauri commands + Settings UI for vouching
Layer 2 (Mode 2 + CDN verify + revocation + access-grant):
- `74fec3b` wrap-slot dual-derivation seal/open primitives + 4 tests
- `0f5147a` wire types: WrapSlot, FoFCommentGating, CommentPermission::FriendsOfFriends, RevocationEntry
- `bdcd214` fof.rs: build_fof_comment_gating with bucketed padding
- `673f9e2` wired FoF gating into post-create path
- `00522f4` reader unlock + commenter authoring + sig verify (1 roundtrip test)
- `63ff5ad` CDN four-check verification on AddComment receive
- `583033e` persist FoF fields + fof_revocations table
- `6a76ade` FoFRevocation diff + sign/verify/apply + retroactive cascade-delete (2 tests)
- `96118d7` FoFAccessGrant diff + retroactive read widening (1 test)
- `10de3f6` Tauri commands + frontend compose-picker for Mode 2
Layer 3 (Mode 1 FoFClosed):
- `856f386` PostVisibility::FoFClosed variant + body encrypt/decrypt + body-size bucket padding (3 tests)
- `66b7804` create_post_fof_closed + read_fof_closed_body + frontend hooks for locked/unlocked posts (1 e2e test)
Layer 4 (V_me lifecycle + cascade + key-burn):
- `c0de21d` own_post_slot_provenance + Node::rotate_v_me + cascade_revoke_v_me_epoch (1 test)
- `c2f2203` FoFKeyBurn primitive (1 test)
- `fdbf97f` supersedes_post_id field for re-issue path
- `ce710a6` Tauri commands + Settings "Rotate my vouch key" UI
Layer 5 (perf):
- `12a3058` unlock cache + unreadable-posts queue + author-direct fast path + sweep on V_x arrival (2 tests)
Pre-deploy hardening (audit pass):
- `aa190db` wire-shape validation on incoming FoF posts; unreadable-queue per-persona cap of 4096 (7 tests)
- `4ec3a80` key-burn replay rejection (monotonic timestamps); MAX_SWEEP_PER_CALL=256 (1 test)
Release prep:
- `d46fcb4` version bump 0.6.2 → 0.7.0; download page updated with FoF release notes
**Test count**: 158 passing on master (added ~24 new fof:: integration tests across Layers 15 + hardening).
**Build state**: full-pipeline deploy initiated on May 15 (`./deploy.sh` from this Linux host: CLI + AppImage + APK in parallel, sign APK, sequential SCP uploads, anchor swap with signed release announcement). Windows installer separate (uploaded by Windows host team).
**Key design decisions worth knowing**:
- `slot_binder_nonce` (32B random per post) replaces the spec's "post_id in HKDF info" — PostId = BLAKE3(post) was circular here. Same anti-replay property.
- Per-V_x signing keypair (`pub_x`/`priv_x`) generated per-post (not per-V_x-genesis). Comment signing is asymmetric Ed25519; PQ-migration deferred. Body + comment-payload encryption is symmetric ChaCha20-Poly1305 (PQ-safe).
- `vouch_keys_received` keyed by `(holder, owner, epoch)` — multi-epoch retention is the receiver-chain mechanism. New V_me from a voucher appends; old key isn't deleted.
- Revocation is per-post per-pub_x with retroactive cascade-delete. V_me rotation is grandfather-by-default; cascade is opt-in via `cascade_revoke_v_me_epoch`. Key-burn swaps slots in-place for leaked-key scenarios.
**Pending after deploy succeeds**:
- Live shakedown on real devices (Scott has been looking forward to this).
- Per-post Revoke / Grant Access UI surfaces (Tauri commands exist; only Rotate has a Settings button so far).
- Update `MEMORY.md` "Current Status" to v0.7.0 once anchor swap confirms healthy.
**Stopping point**: deploy script running in background; master at `d46fcb4`. Awaiting deploy completion notification.
---
## 2026-04-24 — primary Claude (Lead) — `docs/fof-spec-layer1-bio-grants`
**Started**: April 24 UTC

File diff suppressed because it is too large Load diff

View file

@ -26,26 +26,148 @@
<h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 0.25rem;">Download ItsGoin</h1>
<p>Available for Android, Linux, and Windows. Free and open source.</p>
<div class="note" style="margin-top: 1rem; border-left: 3px solid var(--accent); padding-left: 0.9rem;">
<strong>Upgrading from v0.5.1 or newer?</strong> v0.6 is a hard network fork &mdash; older versions can no longer reach the network. Bring your account over in three steps:
<ol style="margin: 0.5rem 0 0.25rem 1.25rem; padding: 0;">
<li>On your current v0.5 install, go to <strong>Settings &rarr; Export</strong>, tick <em>&ldquo;Posts + media + identity key&rdquo;</em>, and save the .zip somewhere safe.</li>
<li>Download v0.6 below (APK, AppImage, or Windows installer).</li>
<li>On first launch, choose <strong>&ldquo;Import an Identity&rdquo;</strong> and point it at the .zip.</li>
</ol>
<p style="margin: 0.5rem 0 0 0; font-size: 0.8rem; color: var(--text-muted);">Your posts, media, follows, and identity key all come across. Encrypted posts stay decryptable under the same key.</p>
<h2 style="margin-top: 2rem;">v0.8.0-alpha &mdash; July 30, 2026</h2>
<p style="color: var(--text-muted); font-size: 0.85rem;"><strong>Clean protocol break.</strong> ALPN moves to <code>itsgoin/4</code> &mdash; v0.8 nodes and v0.7.x nodes refuse each other at the QUIC handshake rather than half-interoperating. Alpha: testers only. See <a href="design.html">design.html</a> for the full v0.8 architecture.</p>
<div class="downloads">
<a href="itsgoin-0.8.0-alpha.apk" class="download-btn btn-android">
Android APK
<span class="sub">v0.8.0-alpha</span>
</a>
<a href="itsgoin_0.8.0-alpha_amd64.AppImage" class="download-btn btn-linux">
Linux AppImage
<span class="sub">v0.8.0-alpha</span>
</a>
<a href="itsgoin-cli-0.8.0-alpha-linux-amd64" class="download-btn btn-linux">
Linux CLI / Anchor
<span class="sub">v0.8.0-alpha</span>
</a>
<a href="itsgoin-0.8.0-alpha-windows-x64-setup.exe" class="download-btn btn-windows">
Windows Installer
<span class="sub">v0.8.0-alpha</span>
</a>
</div>
<div class="note" style="margin-top: 0.75rem; border-left: 3px solid var(--text-muted); padding-left: 0.9rem;">
<strong>Upgrading from v0.5.0 or older?</strong> The v0.5 export format matured in v0.5.1, so a direct export from 0.5.0 won't import cleanly into v0.6. Do a two-hop upgrade:
<ol style="margin: 0.5rem 0 0.25rem 1.25rem; padding: 0;">
<li>Install <a href="itsgoin_0.5.3_amd64.AppImage">v0.5.3 Linux AppImage</a> / <a href="itsgoin-0.5.3.apk">Android APK</a> / <a href="itsgoin-0.5.3-windows-x64-setup.exe">Windows installer</a> and open it. It reads your existing data in place.</li>
<li>In v0.5.3, go to <strong>Settings &rarr; Export</strong> and save the bundle.</li>
<li>Install v0.6.0 below and import that bundle on first launch.</li>
</ol>
<p style="margin: 0.5rem 0 0 0; font-size: 0.8rem; color: var(--text-muted);">v0.5.3 is kept online only as an upgrade bridge &mdash; it no longer connects to the live network.</p>
<h3 style="margin-top: 1.25rem; font-size: 1rem;">Finding people &mdash; the headline feature</h3>
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 0.5rem;">
<li><strong>Registry &mdash; opt-in discoverability.</strong> A well-known "registrations here" post whose comment chain <em>is</em> the directory. Registering publishes a signed comment carrying your display name, keywords, and public author ID &mdash; self-certifying, so any holder can verify it against the key it names, with no authority involved. Entries carry a fixed 30-day expiry and auto-renew while you stay listed, so the chain self-cleans and is never more than a month stale. Unlisting sends a signed delete every holder honors. Your author ID stays location-anonymous: it maps to content through ordinary CDN holder search, never to an address.</li>
<li><strong>Greeting comments &mdash; talking to strangers.</strong> Messaging with no prior key relationship. A greeting is an opaque comment on a bio post, signed with a published open-slot key so it passes the CDN's comment-verification gate, with its body sealed to the recipient's posting key. Inside: who you really are and a return path. Outside: ciphertext authored by a throwaway ID. Replies carry a fresh key and the next rendezvous, so a conversation hops temporary identities and no two messages share an outer identity. Since encrypted comment blobs are already commonplace on public posts, greetings blend into ordinary traffic &mdash; if everyone's anonymous, anonymity doesn't stand out.</li>
<li><strong>Vouching is deliberately separate.</strong> No vouch control exists on any messaging surface. Talking to someone never grants them anything; vouching is a considered relationship act that lives in the People tab.</li>
<li><strong>Comment expiry.</strong> Ordinary comments now carry a randomized 30&ndash;365 day lifetime fixed at creation and covered by the signature, so every holder expires them at the same moment. Throwaway conversation identities eventually vanish from the network entirely.</li>
<li><strong>No proof-of-work.</strong> Considered and rejected: its cost lands inverted &mdash; a phone registrant pays seconds of battery while a botnet rig pays effectively nothing. Flood limits are holder-side size and count caps; the durable answer is vouch-gated registration once the graph supports it.</li>
</ul>
<h3 style="margin-top: 1.25rem; font-size: 1rem;">Privacy &amp; correctness fixes</h3>
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 0.5rem;">
<li><strong>Manifests no longer carry device addresses.</strong> <code>AuthorManifest</code> shipped your device's network addresses under a posting-identity author &mdash; a direct link between a persona and the machine hosting it. The field is gone from the struct <em>and</em> the signature digest, with startup migrations that re-sign your own stored manifests and purge unverifiable foreign copies. This is what makes a registered author ID safe to publish.</li>
<li><strong>Persona-split bug family fixed.</strong> The v0.6.1 network-key/posting-key split left roughly fourteen places comparing a network NodeId where posting identities now live. Consequences included: <em>visibility revocation silently failed on every post</em>, group-key distribution to later-added circle members never worked, encrypted attachments wouldn't decrypt, the replication cycle never found under-replicated content, and your own blobs missed their eviction protection tier. Every "is this mine?" check now tests membership across all of your posting personas.</li>
<li><strong>Comment ingest is verified everywhere.</strong> Two pull paths stored incoming comments with no signature verification at all. All three ingest sites now share one acceptance gate. Also fixed during review: forged tombstones could delete comments through the ingest path, a delete path trusted the sender rather than a signature, a forged policy message could poison a post's comment intake remotely, and the frontend's HTML escaper didn't escape quotes (an attribute-breakout XSS reachable through registry names, which also affected seven pre-existing sites).</li>
</ul>
<h3 style="margin-top: 1.25rem; font-size: 1rem;">Protocol slimming</h3>
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 0.5rem;">
<li><strong>Message types 46 &rarr; 40, about 1,400 lines of Rust removed.</strong> Deleted: <code>DeleteRecord</code> (0x51) and <code>VisibilityUpdate</code> (0x52), both superseded by signed control posts; <code>SocialDisconnectNotice</code> (0x71) and <code>MeshPrefer</code> (0xB3), which nothing had sent in generations; <code>GroupKeyRequest</code>/<code>Response</code> (0xA1/0xA2), an orphaned pair that could never have granted a key after the persona split; and the legacy half of the dual pull-matching path.</li>
<li><strong>Preferred peers eliminated.</strong> The preferred tier existed to serve direct N+10 push/pull that the CDN's holder model replaced. Gone: the slot tier, the <code>preferred_peers</code> table, preferred-tree routing semantics, the rebalance priority pass, the relay candidate tiers, and the 7-day and 30-day watchers.</li>
</ul>
<p style="color: var(--text-muted); font-size: 0.85rem;"><strong>Upgrade note:</strong> v0.8.0-alpha cannot talk to v0.7.x at all &mdash; that is intentional, and the anchor moves with this release. Existing data directories migrate in place on first run (manifest re-signing, legacy profile cleanup, registry materialization).</p>
<h2 style="margin-top: 2rem;">v0.7.3 &mdash; May 15, 2026</h2>
<p style="color: var(--text-muted); font-size: 0.85rem;">Bandwidth + bootstrap hardening on top of v0.7.2. Wire-compatible with v0.7.0/v0.7.1/v0.7.2.</p>
<div class="downloads">
<a href="itsgoin-0.7.3.apk" class="download-btn btn-android">
Android APK
<span class="sub">v0.7.3</span>
</a>
<a href="itsgoin_0.7.3_amd64.AppImage" class="download-btn btn-linux">
Linux AppImage
<span class="sub">v0.7.3</span>
</a>
<a href="itsgoin-cli-0.7.3-linux-amd64" class="download-btn btn-linux">
Linux CLI / Anchor
<span class="sub">v0.7.3</span>
</a>
<a href="itsgoin-0.7.3-windows-x64-setup.exe" class="download-btn btn-windows">
Windows Installer
<span class="sub">v0.7.3</span>
</a>
</div>
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 1rem;">
<li><strong>EDM port scanner disabled.</strong> The "advanced NAT traversal" port-scanner (Hard NAT &harr; Hard NAT) used <code>endpoint.connect()</code> as its probe primitive; iroh accumulates every connect target into its per-endpoint path store and probes them all in the background under QUIC NAT-traversal. A 5-min scan inserted ~30k paths; iroh then probed all of them &mdash; observed at 22MB/s outbound from a single client. DoS-grade at any scale. Disabled until we replace per-probe <code>connect()</code> with raw UDP sends. The scanner source is preserved as <code>edm_port_scan_disabled_v0_7_3</code> to refactor against.</li>
<li><strong>Bootstrap anchor probing batched.</strong> Discovered anchors are now probed 3 at a time with a 2s stagger between batches and a 10s per-anchor timeout. First success unblocks the bootstrap flow immediately; remaining probes continue in background and naturally fill peer connections. Phase 2 (bootstrap fallback) still only fires when every discovered anchor has failed &mdash; preserves the load-distribution intent for when the network scales.</li>
<li><strong>Stale-anchor self-pruning.</strong> When a probe fails AND the anchor's <code>last_seen_ms</code> is more than 3 days old, the entry is deleted from <code>known_anchors</code> immediately. Recoverable anchors (failed once, succeeded recently) are preserved. Users with old data dirs whose discovered anchors point to keypairs that rotated months ago no longer carry stale baggage forward.</li>
<li><strong>Close button kills the Android NodeService.</strong> The in-app close button now calls <code>NodeService.stopFromNative()</code> via JNI before exiting the Activity, so the foreground service actually stops &mdash; previously the button ended the UI but networking kept running.</li>
<li><strong>Power-icon SVG.</strong> The close-button glyph is now an inline SVG instead of <code>&amp;#x23FB;</code> &mdash; Android webview fonts that lack U+23FB previously rendered the button as a missing-image tofu box.</li>
</ul>
<h2 style="margin-top: 2rem;">v0.7.2 &mdash; May 15, 2026</h2>
<p style="color: var(--text-muted); font-size: 0.85rem;">Network &amp; reachability improvements, plus a relay-privacy fix.</p>
<div class="downloads">
<a href="itsgoin-0.7.2.apk" class="download-btn btn-android">
Android APK
<span class="sub">v0.7.2</span>
</a>
<a href="itsgoin_0.7.2_amd64.AppImage" class="download-btn btn-linux">
Linux AppImage
<span class="sub">v0.7.2</span>
</a>
<a href="itsgoin-cli-0.7.2-linux-amd64" class="download-btn btn-linux">
Linux CLI / Anchor
<span class="sub">v0.7.2</span>
</a>
<a href="itsgoin-0.7.2-windows-x64-setup.exe" class="download-btn btn-windows">
Windows Installer
<span class="sub">v0.7.2</span>
</a>
</div>
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 1rem;">
<li><strong>NAT traversal: UPnP + NAT-PMP + PCP.</strong> Replaced UPnP-only port mapping with the <code>portmapper</code> crate (also used by iroh internally). All three protocols run in parallel; the first router-response wins. PCP adds IPv6 firewall pinholes and works on iOS without a multicast entitlement. Auto-renewal runs in a background task &mdash; no more lease-expiry surprises.</li>
<li><strong>Android NAT mapping on WiFi.</strong> Mobile devices on WiFi now attempt UPnP/PCP/NAT-PMP just like desktops, with the required <code>WifiManager.MulticastLock</code> acquired for the lifetime of the mapping. Cellular is gated off (no UPnP/PCP gateway on carrier nets, so we skip the discovery cost). Reachability for phones-on-home-WiFi should improve noticeably.</li>
<li><strong>Mobile HTTP serving when reachable.</strong> Phones whose router cooperates with TCP port mapping can now serve <code>/p/&lt;post&gt;</code> directly for browser fetches. No mode switch &mdash; this just works when the network allows it.</li>
<li><strong>Anchor-mode self-heals across network changes.</strong> A new watcher observes the live port mapping. Mapping lost &gt;5min &rarr; anchor mode clears (don't keep advertising an unreachable address). Mapping restored &rarr; anchor mode comes back on, no restart needed. Roaming between UPnP-capable WiFi networks no longer requires a reboot.</li>
<li><strong>Shorter share URLs.</strong> Share links now contain only the post ID &mdash; <code>itsgoin.net/p/&lt;post&gt;</code>. The anchor handles holder lookup itself, so URLs stay stable as the holder set changes. Older URLs with the author hex appended continue to work.</li>
<li><strong>Session relay is opt-in, off by default.</strong> Fixed a regression where hole-punch failures could silently pipe a peer-to-peer session through an intermediary's bandwidth. The previous behavior burned random users' bandwidth without consent. A new Settings toggle (<em>Enable session relay</em>) opts in to both serving as a relay and using one. Anchors default-off too &mdash; server bandwidth shouldn't be silently consumed either. Hole-punch coordination (small signaling) is unaffected.</li>
<li><strong>Quick app close from the header.</strong> Power icon next to the network indicator fully terminates the app (with confirm). Useful on mobile to stop network activity between active sessions.</li>
</ul>
<p style="color: var(--text-muted); font-size: 0.85rem;">v0.7.2 is wire-compatible with v0.7.0 and v0.7.1. No protocol changes; all network &amp; UX improvements are local.</p>
<h2 style="margin-top: 2rem;">v0.7.1 &mdash; May 15, 2026</h2>
<p style="color: var(--text-muted); font-size: 0.85rem;">UI polish + bug-fix pass on top of v0.7.0's FoF gating. Default post visibility is now Extended Friends (FoF). New <em>Friend</em> button combines follow + vouch in one click. Network Identity renamed to Device Address (you almost never need to touch it). Settings clearly separates personas from device address with an export/import "Move to another device" flow. Plus three fixes: profile display name now updates everywhere when changed; redundancy panel reads from the correct author set so it no longer shows 0 for all posts; My Posts tab no longer horizontally overflows and breaks the sticky header/tabs.</p>
<div class="downloads">
<a href="itsgoin-0.7.1.apk" class="download-btn btn-android">
Android APK
<span class="sub">v0.7.1</span>
</a>
<a href="itsgoin_0.7.1_amd64.AppImage" class="download-btn btn-linux">
Linux AppImage
<span class="sub">v0.7.1</span>
</a>
<a href="itsgoin-cli-0.7.1-linux-amd64" class="download-btn btn-linux">
Linux CLI / Anchor
<span class="sub">v0.7.1</span>
</a>
<a href="itsgoin-0.7.1-windows-x64-setup.exe" class="download-btn btn-windows">
Windows Installer
<span class="sub">v0.7.1</span>
</a>
</div>
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 1rem;">
<li><strong>Default visibility is Extended Friends (FoF).</strong> Public is one click away when you want it; encrypted-by-default is the new posture.</li>
<li><strong>Friend button</strong> on the bio modal: follow + vouch in one tap. "Follow only" stays available for the watch-but-don't-trust case. "Unfriend" reverses both.</li>
<li><strong>Network Identity &rarr; Device Address.</strong> UI rename for clarity; the device's QUIC endpoint is distinct from your posting personas. Backend command names are unchanged.</li>
<li><strong>Settings &mdash; Move to another device.</strong> Export-personas / import-from-another-device buttons get their own labeled section with a plain-English explainer.</li>
<li><strong>Bug fix: profile display name update.</strong> Renaming the auto-generated first persona now propagates everywhere (the posting-identities table was previously stuck on the original empty name).</li>
<li><strong>Bug fix: redundancy panel.</strong> Was querying posts authored by the device's network NodeId; since v0.6.0 posts are authored by personas, so the query returned 0 of your posts. Now queries every persona on the device.</li>
<li><strong>Bug fix: My Posts horizontal-scroll regression.</strong> The new "FoF only (Mode 1)" compose-policy option pushed the visibility row past viewport width on mobile, triggering browser-chrome auto-hide behavior. Row now wraps; selects cap at container width.</li>
</ul>
<p style="color: var(--text-muted); font-size: 0.85rem;">v0.7.1 is wire-compatible with v0.7.0. UI/UX only.</p>
<h2 style="margin-top: 2rem;">v0.7.0 &mdash; May 15, 2026</h2>
<p style="color: var(--text-muted); font-size: 0.85rem;">Friend-of-Friend gating is live. Posts can be public to readers but FoF-gated for comments (Mode 2), or fully FoF-gated for body + comments (Mode 1, <code>FoFClosed</code>). The CDN verifies comment signatures before propagating, killing the bandwidth-DoS attack a single admitted FoF member could otherwise mount. Vouches distribute via HPKE-sealed wrappers in your bio post &mdash; no DMs, no recipient IDs on the wire.</p>

View file

@ -124,8 +124,8 @@ wrapped_key[i] = X25519_DH(author_ed25519, recipient_ed25519[i]) XOR CEK</code><
<!-- Sync -->
<section>
<h2>Sync protocol (v3)</h2>
<p>The protocol uses a single ALPN (<code>itsgoin/3</code>) with 37+ message types multiplexed over QUIC bi-streams and uni-streams.</p>
<h2>Sync protocol (v4)</h2>
<p>The protocol uses a single ALPN (<code>itsgoin/4</code>) with 40 message types multiplexed over QUIC bi-streams and uni-streams.</p>
<div class="card">
<h3>Pull-based sync</h3>