* Add Persian (fa) localization and an in-app language picker
Persian was the one notable gap in the 29-language catalog. Translate all
381 strings (plus the share extension) with proper plural substitutions,
and register fa in knownRegions.
Settings gains a LANGUAGE section: a picker over every language the bundle
ships (native names via Locale), backed by an AppleLanguages override so
users can run bitchat in a language different from the device's. Localization
resolves at process start, so the picker surfaces a restart note.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Remove unused AppLanguageSettings.currentOverride (Periphery)
AppInfoView reads the override via @AppStorage, so the accessor was dead
code and failed the Periphery CI scan.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
* Bound public rate-limit buckets against attacker-keyed growth.
Keep the NIP-13 PoW sender bypass, skip content-bucket minting on
sender reject, and evict idle/oldest entries at a hard cap.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Stop Cashu-looking text from skipping long-message guards.
Oversized public content always collapses and takes the plain
formatting path so remote tokens cannot force layout/regex DoS.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Cap teleported geohash participant markers.
Bound the set with FIFO eviction, clear it on channel switch, and
prune markers that leave the visible participant list.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Bound untrusted Nostr relay frames and event tags.
Reject oversized inbound messages before JSON parse, cap tag
arrays/values at decode, and stop logging raw tag contents.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fail soft when Noise handshake state is unexpectedly missing.
Replace the initiator startHandshake force unwrap with a guard
that throws invalidState instead of crashing.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Cap geohash nickname cache from remote Nostr events.
FIFO-evict at capacity, clear on channel switch, and prune
nicknames that leave the visible participant list.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Avoid overlapping exclusive access in the rate limiter.
Make bucket helpers static so inout dictionary updates do not
conflict with a mutating call on self.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix rate-limiter tests for mutating allow under #expect.
Call allow outside the macro so Swift Testing does not capture an
immutable copy of the struct.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop unused WebSocket data helper; reset rate limiter on panic wipe.
dataWithinInboundLimit replaced the unbounded path, and panic clear
should not leave public intake buckets behind.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: use country-level resolution for low-precision geohashes (#887)
* fix: skip admin fallback when country exists but is duplicate
Prevents mixed labels like "United Kingdom and Scotland" for
single-country geohashes. Admin fallback now only triggers when
no country is available from the placemark.
* fix: migrate stale low-precision bookmark names so country-first logic applies
Users who bookmarked a <=2-char geohash before the country-first resolver
kept the old administrativeArea cache (e.g. "England" for `gc`) because
resolveBookmarkNameIfNeeded bails when bookmarkNames[gh] is non-nil. Add a
one-shot, versioned migration that drops cached names for <=2-char geohashes
on load; the next LocationChannelsSheet .onAppear re-resolves them via the
fixed logic. Higher-precision entries are untouched.
* Fix low-precision geohash country names
---------
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Centralize PTT and voice audio-session ownership, harden courier/bridge/outbox delivery and recovery, correct location and delivery-state races, add privacy/release metadata, and ship reproducible universal Arti slices with Release CI coverage.
Validated by the full iOS suite, repeated audio/fragment/performance regressions, BitFoundation tests, strict lint and dead-code analysis, universal iOS Release builds, and iOS/macOS archives.
* Consolidate duplicate BLE links after restore; suppress duplicate fragment streams; onChange coalescing
Field evidence (two-phone test after BLE state-restoration relaunches):
both phones held 2-3 simultaneous same-role connections to each other —
one side received every packet from three distinct centrals all bound to
the same peer — so every PTT voice frame arrived 3x, and a 41KB voice
file went out as TWO complete independent 89-fragment streams (different
assembly ids), both fully reassembled and the duplicate only dropped at
the very end by messageID dedup. 2-3x airtime/battery on all traffic
plus doubled reassembly memory.
Root causes and fixes:
- Duplicate links stayed unbound forever: announces (the only packet
that binds a link to a peer) went through the per-peer duplicate-link
collapse, so a peer's second/third link never received the announce it
needed to become bound — and unbound links pass the collapse untouched,
so every broadcast sprayed down all of them. Direct announces now
bypass the collapse and reach every live link (relayed announces keep
it); once bound, the existing collapse dedups all traffic.
- Same-role link retirement: a verified direct announce now consolidates
our central-role connections to that peer — keep the link the announce
arrived on (or the most recently bound one), cancel other connected
links bound to the same peer. One connection per role per peer is the
normal dual-role topology; only same-role duplicates are touched, only
links already announce-bound are retired (never pre-announce links),
at most one retirement per peer per rebind-cooldown window, and the
peer keeps a live link either way. Directness stays forgeable (TTL is
unsigned), so a replay could nominate the survivor — bounded by the
cooldown and by DM routing's existing canDeliverSecurely gate. A
rotation rebind now also cancels other stale links still bound to the
rotated-away ID, so the ghost identity retires promptly.
- Deterministic preferred-link collapse: when several bound links to one
peer are candidates, collapse now keeps the peer's most recently bound
link (the reverse-mapped one) instead of dictionary order; links
without a discovered characteristic are excluded from fanout (they
cannot be written to, and could silently eat a peer's collapsed copy).
links(to:) now reports all bound peripheral links, and removing one
duplicate no longer clobbers the reverse map of the survivor.
- Duplicate fragment streams: a transferId-less resend (gossip-sync
replay, spool) of file content already being fragmented out to a
covering audience is dropped at the outbound scheduler (broadcast
covers everyone; directed covers its recipient). App-initiated sends
carry an explicit transferId the progress UI tracks and always run.
A peer that asks after the stream completes still gets a resend.
- didUnsubscribeFrom no longer flaps a peer that is still live on other
links (the far side retiring its duplicate arrives as an unsubscribe);
didDisconnectPeripheral bookkeeping is skipped for self-retired links
by removing the store entry before cancelling.
Also: guard the DeliveryStatus onChange state write in TextMessageView/
MediaMessageView (unconditional per-row writes under a message storm
tripped SwiftUI's "tried to update multiple times per frame" warning).
The restore-path bgRemaining=∞ log was already fixed on main (#1425
review follow-up 07b5ac31: init-time seed + sampler-routed restore
captures); verified, no change needed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Review fixes: multi-link disconnect guard, characteristic-aware retirement
Adversarial review of the duplicate-link PR (merge-with-nits):
- didDisconnectPeripheral gets the same multi-link guard as the
unsubscribe path: when a duplicate link drops naturally while the peer
stays live on another (dual-role central link, or a second bound link
during the post-restore consolidation window), peer-disconnect
bookkeeping (markDisconnected + disconnect notify) no longer runs — a
UI blip until the next announce re-marked the peer connected. The
reverse map was just repaired onto a connected survivor, so
directLinkState is the accurate probe. Scan restart and connect-slot
refill stay unguarded: they respond to the physical drop regardless of
remaining logical links. (No unit test: driving the CBCentralManager
delegate requires CBPeripheral instances, which cannot be constructed
in tests; the policy pieces backing the guard are covered.)
- Retirement is now characteristic-aware: the policy snapshot carries
characteristic presence, and keptPeripheralUUID selects anchors only
among writable links while any exist — consolidation must not keep a
link mid-service-rediscovery (didModifyServices cleared its
characteristic) and cancel the writable duplicate, stranding outbound
traffic on the central link until rediscovery finishes. When neither
anchor is writable but a writable duplicate exists, consolidation
defers to a later announce instead of guessing. The reverse-map
survivor repair in removePeripheral prefers writable links for the
same reason. Three new policy tests cover charless-vs-writable.
Deferred with code comments per review: central-side collapse keeps the
oldest subscription (no recency signal; remote consolidates within its
cooldown), and the extra preferred-bindings bleQueue hop in
sendOnAllLinks (fold into a combined snapshot if profiling flags it).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Fix restore-path main↔bleQueue deadlock and courier drop amplification
A device froze permanently in a two-phone test. Debugger stacks showed an
ABBA deadlock: the main actor was in bleQueue.sync (delivery-ack send →
broadcastPacket → readLinkState) while bleQueue was in main.sync
(captureBluetoothStatus reading backgroundTimeRemaining). The load that
lined the two edges up came from a courier-drop amplification storm:
drop dedup was in-memory only while the outbox driving 120s re-deposits
is persisted, so every relaunch republished the same undelivered DM as a
fresh 24h relay drop and every gateway relaunch re-fetched the whole
backlog — ~20 copies of one DM delivered in 40ms, each triggering
decrypt + delivery + ack + handshake work.
Fixes, in rank order:
- Edge B (P0): captureBluetoothStatus no longer main.syncs from bleQueue;
backgroundTimeRemaining is sampled on main and cached behind a lock.
Invariant documented: bleQueue must NEVER sync-dispatch to main.
- Edge A (P0, defense in depth): sendDeliveryAck / sendReadReceipt /
sendPrivateMessage / sendNoisePayload / triggerHandshake hop to
messageQueue like sendMessage, so no main-actor call path reaches
readLinkState's bleQueue.sync.
- Drop dedup (P1): publishedDropKeys and seenDropEventIDs persist across
relaunches (new BridgeDropDedupStore, entries expire with the 24h
NIP-40 drop window; wiped on panic) — one drop per message ID per 24h
regardless of relaunch count.
- Receiver dedup (P1): openCourierEnvelope dedups on the inner private
message ID before delivery, so a duplicate copy costs one decrypt and
never re-delivers, re-acks, or re-triggers a handshake.
- Handshake gating (P2): queued acks initiate a Noise handshake only for
reachable peers; mail from absent/rotated identities no longer turns
each copy into a mesh-wide handshake flood (the ack stays queued and
flushes when a session eventually establishes).
- Outbox (P3): re-enqueueing a queued message ID carries over its
depositedCourierKeys so resends stop re-burning the same courier slots.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Review fixes: offline-drop durability, gateway handoff retry, restore-log freshness, coalesced persist
Adversarial review of the storm/deadlock PR surfaced four issues:
- Offline blackhole (must-fix): a deposit made while relays were down
persisted its dedup key even though the drop only sat in the in-memory
pending queue — app killed before reconnect meant the relaunch lost the
drop but the persisted key blocked every re-deposit for 24h. The
persisted snapshot now excludes keys still pending; they become durable
only when flushPendingDrops actually publishes them.
- Gateway handoff: seen-event IDs were consumed before the deliverToPeer
handoff; a failed handoff (peer walked away) permanently dropped the
event for a single-gateway island. deliverToPeer now reports whether
the handoff was attempted, and a failure releases the seen slot so a
relaunch or backlog redelivery retries.
- Restore-path logs: central/peripheral-restore captures logged the init
sentinel bgRemaining=∞. The cache is now seeded in init's main-thread
branch and restore captures route through the sampler, which refreshes
the cached budget before logging.
- Persist cost: the dedup record was a full JSON encode + atomic write on
the main actor per mutation (once per event during a backlog re-fetch).
Writes now coalesce behind a 1s window, flushed immediately on
background/terminate; panic wipe stays immediate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Remove BoundedIDSet.remove, orphaned by the ExpiringIDSet migration
The drop-dedup sets that needed slot release moved to ExpiringIDSet;
remaining BoundedIDSet users only insert and check. Periphery caught it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Nearby notes: tap-to-reveal before any relay REQ, plus shared subscription pool
The nearby-notes counter used to open a live building-precision (precision-8)
geohash REQ to the closest geo relays whenever the mesh public timeline was
visible — a passive location side-channel with no opt-in. Now nothing
subscribes until one explicit act reveals the counter for the session: tapping
the new "check for notes left here" line on the empty mesh timeline (static,
no network), opening the notices sheet's geo tab, or a successful /drop. The
app-info setting stays the default-ON kill switch and still gates /drop.
Subscription hygiene alongside:
- LocationNotesManager.deinit now unsubscribes its live REQ (hopping to the
main actor like the timer teardown) instead of only invalidating timers.
- New refcounted LocationNotesPool dedupes the counter's and the notices
sheet's identical 9-cell kind-1 REQs into one shared manager per geohash;
both callers release-and-reacquire instead of retargeting in place, and the
sheet releases its ref on dismissal.
The reveal affordance is localized across all 29 catalog locales, and new
NearbyNotesCounterTests cover the no-REQ-before-reveal contract, the 9-cell
filter, NIP-40 expiry handling, single unsubscribe on deactivate, and pool
refcounting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Review fixes: permission-gate the hint, exclude building cell from pre-reveal sampling, explicit-act reveal only
Fixes the verified review findings on the tap-to-reveal PR:
- Permission dead-end: the "check for notes left here" hint now renders
only when location permission is already authorized (it never prompts).
Previously a location-denied install could tap it, flip the sticky
revealed flag, and get nothing for the rest of the session because
retarget() guards on authorization. Predicate lives on
NearbyNotesCounter.offersRevealHint(permissionState:) and is reactive
to the published permission state.
- Building-cell sampling: background geohash sampling subscribed
geo-sample-<gh> for every regional level including the precision-8
building cell, pre-reveal — contradicting the claim that nothing
building-precision hits a relay before the explicit act.
GeoChannelCoordinator now excludes the building level until
NearbyNotesCounter.revealed (injectable publisher for tests); coarser
levels keep the nearby-conversation hint and participant counts
working, and bookmarks stay exempt (bookmarking is explicit).
- Implicit reveal: opening the notices sheet no longer reveals — the
sheet auto-lands on the geo tab whenever a location channel is
selected, so browsing a remote geohash and opening notices revealed
the LOCAL building subscription. reveal() now fires only on the
person actively picking the geo segment, and only when the sheet has
a geo scope (the empty-mesh hint tap and /drop stay as before).
- Subscription hygiene: switching the sheet geo → mesh releases the
pooled notes manager (the REQ was left streaming behind the mesh
board); switching back re-acquires from the pool. The dismissal
release stays balanced — liveGeoManager is nil after the tab-switch
release.
- VoiceOver: the hint button exposes the plain localized action text
instead of the decorated "* 📍 … *" label.
- Removed LocationNotesManager.setGeohash: zero callers, and calling it
on a pooled instance would corrupt the pool's keying and refcounts.
New tests: hint permission gate, explicit-geo-tab reveal contract,
building-cell sampling exclusion before/after reveal, pool
release/re-acquire round trip. Full suite (1467) green; iOS simulator
build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Deflake peer-snapshot binding waits: longTimeout for the multi-hop pipeline
CI failed once on initialization_bindsPeerSnapshotsIntoAllPeers: the
snapshot -> allPeers binding crosses the mock transport's unstructured
Task, UnifiedPeerService.updatePeers, a receive(on: main), and another
Task { @MainActor } in bindPeerService — all contending with every
parallel worker. On the failing runner the whole suite took 10.1s
(usually ~4.4s locally), so the positive 5s defaultTimeout wait lost
the race. That's exactly the case TestConstants.longTimeout documents;
passing runs return as soon as the condition holds and never pay it.
Not caused by the tap-to-reveal changes: nothing on that pipeline was
touched, and 20 full parallel-suite loops each on the branch and on
origin/main reproduce zero failures locally. The two sibling waits on
the same pipeline in this file get the same timeout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Gate connected-link trust on a secure session; deny forged direct announces the connected shortcut
Residual gap after the rotation-heal containment (#1401): "verified
direct" announces prove the signature but not directness — TTL is
unsigned — so a malicious connected peer can replay a victim's fresh
announce with its TTL restored. When the victim has no live link, the
replayer's link rebinds to the victim's ID and reads as "connected",
and MessageRouter's connected fast-path then trusts it outright: every
DM stalls on a Noise handshake the replayer can never complete and is
silently lost while showing "sent".
Router-level trust gate (no wire change):
- Transport gains canDeliverSecurely(to:) — BLE answers with an
established Noise session; Nostr keeps its prompt-delivery predicate;
the protocol default forwards to canDeliverPromptly for transports
without a forgeable link layer.
- MessageRouter.sendPrivate only trusts a connected link outright when
it can deliver securely; otherwise it still sends (kicking the
handshake on a genuine link) but retains a copy and hands a sealed
copy to couriers, like the reachable path. flushOutbox gets the same
gate so a flush over an insecure link resends instead of dropping the
retained copy.
Presence hardening (defense in depth): a "direct" announce arriving on
a link already bound to a different peer no longer shortcuts the
claimed peer into "connected" — only real link state does. Genuine
first-contact and direct announces are unaffected; only the ambiguous
heal path loses the forgeable shortcut.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Harden the insecure-link outbox bound; document the second-replay presence gap and the courier metadata tradeoff
Review follow-ups on the canDeliverSecurely gate:
- flushOutbox no longer counts connected-but-insecure flushes toward the
maxSendAttempts drop: the message was actually transmitted over a live
link, so a peer whose Noise handshake stalls across reconnect flapping
must not burn through the cap and lose the store-and-forward copy the
gate exists to preserve. Retention stays bounded by the 24h outbox TTL
and the per-peer FIFO cap; acks still clear it. Attempt-counting stays
for reachable-only (heuristic) sends. Regression test: >8 connected-
insecure flushes keep the retained copy, drop callback never fires.
- Document the known second-replay presence gap: linkBoundToOtherPeer
reads the binding before rebindLinkAfterVerifiedDirectAnnounce steals
the link, so once a first replay has rebound a link to an absent
victim's ID, a second replay marks the victim connected. Presence
display only — DMs stay on the retain+courier path via the router
gate. Not closed at the announce layer because the post-rebind state
is indistinguishable from a legitimate rotation/reconnect heal (a
supported, field-verified flow). Covered by a two-announce test.
- Note the accepted courier-spray metadata tradeoff at the connected-
insecure send: nearby verified peers receive a sealed copy (they learn
a DM exists, never its content), cleared on ack.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Promote a healed rotation to connected; normalize the secure-delivery probe; retain Codable properties in Periphery
Codex review follow-ups on 5017c232:
- P1: a legitimate rotation announce necessarily arrives on a link still
bound to the OLD peer ID, so the linkBoundToOtherPeer denial stored the
new identity as disconnected — and the rebind only fixed link state,
never the registry. A healed rotation then read as disconnected until
the peer happened to announce again. rebindLinkAfterVerifiedDirect-
Announce now promotes the rebound identity to connected after the
containment checks pass (registry markConnected + topology/snapshot/
peer-list refresh; the .peerConnected UI event already fired from the
announce path). This consciously moves the forged-presence residue
from second-replay to first-successful-rebind — bounded by the rebind
containment (never steals a live identity, one rebind per link per
cooldown) and still display-only: DMs stay gated on canDeliverSecurely.
Comments and the pinned tests updated; new regression test covers
rotate-on-open-link -> rebind -> isPeerConnected(new) == true.
- P2: BLEService.canDeliverSecurely probed the Noise session with the
peer ID as given, but sessions are keyed by the short wire ID — a send
keyed by the full 64-hex Noise key (favorites resolution) misread an
established session as insecure and needlessly retained + couriered
every DM until ack. Normalize with toShort() like isPeerConnected.
Test drives a real XX handshake and asserts both ID forms pass.
- Periphery: the PrekeyBundleStore.StoredBundle.noiseKey "assign-only"
flake fired again and slipped past its baselined USR (read exists in
loadFromDisk; the indexer intermittently misses it). Replace the
baseline entry with retain_codable_properties: true — deterministic,
and a truly-dead Codable field is a persisted-format change anyway.
Local periphery scan --strict: no unused code detected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* PTT hardening: burst-ID collision hijack + live-capture quota bypass
Fix C — burst-ID collision hijack: inbound live-voice assemblies and the
finished-burst registry were keyed by the sender-chosen burst ID alone, so
an attacker who observed a public burst ID could race a START and capture
the real talker's frames (packets from the true sender were dropped as
"collisions"). Assemblies now key on (peerID, scope, burstID): a colliding
START from another peer opens its own capped assembly and can never divert
the victim's frames; finalized-note absorption matches on the same triple,
preserving the sender/scope binding. Live capture files also gain the peer
ID in their name so colliding bursts land on distinct paths (still rejected
by burstID(fromVoiceFileName:), so live names remain unabsorbable).
Fix B — live-burst files bypassed the incoming-media quota: progressive
voice_live_*.aac captures were written via raw FileHandle without ever
touching BLEIncomingFileStore.enforceQuota, growing disk unbounded outside
the 100 MB LRU accounting. The coordinator now takes an injected
BLEIncomingFileStore, reserves pttMaxBurstBytes before opening a capture,
and sweeps orphaned voice_live_* partials from previous sessions at
startup. enforceQuota gains an `excluding:` set so in-flight partials are
never LRU-evicted mid-stream; existing callers are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Review fixes: pattern-guard live captures in quota, scope in capture names
Quota-eviction gap: BLEFileTransferHandler enforces the quota for every
finalized arrival via enforceQuota(reservingBytes:) with no exclusion set,
so a file landing at quota could LRU-evict an in-flight live capture —
unlinking the inode under the coordinator's open FileHandle and leaving a
dead bubble. Protection is now layer-independent: enforceQuota itself skips
voice_live_* names (they still count toward usage), and the excluding:
parameter is gone since the pattern guard covers its only caller. Orphaned
partials are still reclaimed by the coordinator's startup sweep, which the
quota deliberately never touches.
Same-peer cross-scope truncation: makeIncomingURL omitted the scope, so one
peer running a DM burst and a public burst with the same burst ID mapped
both assemblies onto one path — and FileManager.createFile truncates, so
each START corrupted the other capture. Names now mirror the assembly key:
voice_live_<burstHex>_<peerID>_<dm|mesh>.aac. The sweep and quota guard
match on the voice_live_ prefix and burstID(fromVoiceFileName:) still
rejects every live name, so live captures remain unabsorbable;
sameBurstIDCoexistsAcrossScopes now asserts both files survive with intact
contents.
Nits: the coordinator's file operations route through the store's
injectable FileManager instead of FileManager.default, and the
TestEnvironment.isRunningTests branch in init is replaced by a
sweepsOnInit parameter (tests sharing the real application-support
directory pass false for hermeticity).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Promote kept live captures off the voice_live_ name at finalize
Codex P1 follow-up: when a burst finalizes with frames but its .m4a note
never arrives, the voice_live_*.aac capture stays behind as the bubble's
replayable audio — yet the startup sweep deletes every voice_live_* file
and the quota guard skips them forever, so a kept fallback was both
quota-immune for the rest of the session and doomed at the next launch.
finalize now promotes the capture to a plain voice_ name (same suffix) and
republishes the row pointing at it; the finished-burst registry tracks the
promoted URL so a late note still absorbs and deletes it. voice_live_ is
thereby scoped to genuinely in-flight captures: the sweep never touches a
referenced fallback, and promoted files age out of the quota like any
finalized media. If the move fails the live name is kept — exactly the
pre-promotion behavior.
Premise correction, verified while tracing the reference model: chat rows
are NOT persisted across restarts (ConversationStore is in-memory; the
gossip archive replays MessageType.message packets only), so today's sweep
never orphaned a persisted row — the fix removes the in-session quota
immunity and makes the invariant hold if row persistence ever lands. Crash
case stays deliberate and documented: a mid-burst partial from a dead
process is swept because no surviving row can reference it.
Tests: finalize-as-fallback promotes the file, repoints the row, and
survives a later coordinator's startup sweep; promoted fallbacks are
LRU-evictable by the quota; the sweep still removes true orphans while
sparing promoted names; existing burst tests updated for the promoted
paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Bridge dedup keys on a content-derived stable mesh message ID
Public mesh messages carry no message ID on the BLE wire, so every
non-origin device minted a fresh UUID and the bridge's m-tag dedup only
matched on the origin device: duplicate bridged rows, misattributed
"across the bridge" counts, redundant downlink rebroadcasts, and an
m-tag spoof vector (an attacker could claim a victim's message ID).
Every device now derives the same stable ID from the signed wire fields
(sender ID + ms timestamp + trimmed content, SHA256/32 hex) via the new
MeshMessageIdentity — zero BLE wire change. The bridge event's m tag
carries the origin coordinates ["m", senderIDHex, timestampMs] and
receivers recompute the key from those plus the event's own content
instead of trusting a claimed ID; old-format/absent tags fall back to
the event ID as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Fix mixed-version message loss: m tag leads with the derived stable ID
The previous layout (["m", senderIDHex, timestampMs]) broke v1.7.0
receivers: their parser takes m[1] unconditionally as the timeline
dedup key whenever the tag has >= 2 elements, so every bridged message
from a new-version sender keyed on the CONSTANT sender hex and
inject-dedup dropped all but the first. The tag is now
["m", <derived stable ID>, senderIDHex, timestampMs]: old parsers get a
per-message-unique m[1] (exactly today's semantics), while the new
parser recomputes the ID from elements 2-3 plus the event's own content
and never trusts element 1, keeping the recompute-don't-trust property.
Also:
- Soften the overstated security claim in MeshMessageIdentity and the
BridgeService classify comment: forging a chosen ID onto different
content is infeasible, but all three hash inputs are cleartext on the
radio, so identical-content front-running by a radio-local attacker
remains possible (no worse than the unbridged mesh).
- Fix the stale archivedEchoKeys rationale: re-synced copies of others'
messages now carry the derived stable ID (insert-by-ID catches them);
the content key remains for echo--prefixed archive rows + self echoes.
- Tests: old-parser semantics on the new tag (m[1] per-message-unique
and equal to the derived ID) and a forged-m[1] event that cannot
pre-poison a genuine message's dedup slot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Quality pass on the 1.7.0 batch: fix confirmed bugs, bump to 1.7.1
Post-merge review of PRs #1400–#1417 (push-to-talk, mesh bridging, DM
store-and-forward, empty-mesh liveliness, geo-notes). Fixes the confirmed,
well-scoped findings; deeper architectural/security items are tracked
separately.
- PTT hot-mic leak: releasing the mic during VoiceCaptureSession.start()'s
150ms retry pause left the mic live and streaming for up to 120s, because
cancel() no-op'd once `completed` was set. Bail after the sleep if the hold
was released, and make cancel() always tear down a late-started capture.
- Bridge courier depositDrop reported success and burned the dedup slot before
the drop was actually published (evicted/compose-fail = lying 📦 "carried"
with no retry). Only consume publishedDropKeys on durable accept; add
BoundedIDSet.remove() to release evicted/failed slots (uses the dead dedupKey).
- Blocked senders resurfaced via archived "heard here earlier" echoes, the one
path that bypassed the live block filter — filter at seed time.
- A late optimistic .sent clobbered the router's .carried state; extend
ConversationStore.shouldSkipStatusUpdate to a full precedence guard
(sending < sent < carried < delivered < read).
- Read receipts were permanently burned when the router dropped them (marked
sent then dropped). sendReadReceipt/routeReadReceipt now return Bool; only
record as sent on a successful route, else retry on the next read scan.
- MessageRouter.cleanupExpiredMessages() had no production caller, so DMs to a
peer that never reconnects sat on .sending until relaunch — run it in the
120s bridge sweep.
- Sightings tally now rolls over at midnight while idle; wave notification
action localized across all 29 locales; bridged anon#tag uses suffix(4) like
everything else; makeThrowawayIdentity delegates to NostrIdentity.generate();
.swiftlint.yml excludes .claude worktrees.
- Add regression tests: carried→sent no-downgrade, carried→delivered upgrade,
evicted pending drop stays retryable.
- Bump MARKETING_VERSION to 1.7.1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Fix CI: adjust delivery-status benchmark and drop now-dead addSystemMessage
The stricter no-downgrade guard (delivered/carried never regress to sent) broke
two things the earlier commit didn't catch locally (perf tests are skipped in
the default run, and Periphery runs only in CI):
- PerformanceBaselineTests delivery benchmarks alternated sent <-> delivered
assuming both directions apply; the delivered -> sent half is now correctly
skipped, so the pass measured 0 updates. Alternate two delivered timestamps
instead — every update is real, no downgrade.
- Routing the geoDM "not in a location channel" error into the thread removed
the only caller of ChatPrivateConversationContext.addSystemMessage, leaving
it (and its mock) dead per Periphery. Drop the protocol requirement, the mock
impl, and the now-vacuous systemMessages.isEmpty assertions (the invariant is
compile-time enforced: the context can no longer emit a public system line).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address review findings: read-receipt dedup, carried-vs-sending, block-time echo purge
- Read receipts: claim the receipt in sentReadReceipts synchronously
before spawning the routing task (chat open runs two read scans in one
MainActor stretch, so the async insert let every unread message route
twice), and release the claim when the route fails so the
retry-on-failed-route behavior is preserved.
- Delivery status: extend the no-downgrade guard so the `.sending`
stamp a pre-handshake resend emits can no longer clobber
carried/delivered/read (the 📦 indicator survived `.sent` but not
`.sending`).
- Archived echoes: blocking a peer now purges their carried public
messages from the gossip archive at block time (UnifiedPeerService
and /block), while the fingerprint-to-peerID mapping is still known —
the seed-time filter can't resolve offline non-favorite strangers and
stays only as defense-in-depth. New Transport hook (default no-op) +
GossipSyncManager.removePublicMessages with immediate persist.
- Bridge courier: an envelope that can't encode within the drop size
caps fails identically on every attempt; consume the dedup slot so
the 120s retry sweep stops re-running Noise sealing on it.
- MeshSightingsTracker: cache the day-key DateFormatter instead of
building one per call.
Tests: double-markAsRead dedup + failed-route retry, carried→sending
no-downgrade matrix, block-time purge (manager + service wiring),
oversize-drop slot consumption.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Also skip the sent→sending downgrade in the delivery-status guard
Codex review follow-up: sendPrivateMessage without an established Noise
session emits `.sending` asynchronously, so it can land after the
message already reached `.sent` and visibly walk "Sent" back to
"Sending...". Treat `.sending` as weaker than `.sent` too — the status
was already truthful. `.failed` → `.sending` stays allowed so a retry
after a real failure remains visible.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Retain Codable properties in Periphery scan (same fix as #1421)
The noiseKey assign-only false positive fired persistently on this branch
(twice, including a rerun) despite the baselined USR. Byte-identical to the
fix on fix/announce-replay-link-steal so the branches merge cleanly in
either order.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>